oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1package sheets
2
3import (
4 "bytes"
5 "net/http"
6 "net/url"
7 "strconv"
8 "strings"
9 "testing"
10)
11
12// Rename is the other half of a Create button that stopped asking for a
13// name. These tests are written against that pairing rather than
14// against the route on its own: the failure they exist to catch is a
15// list of sheets all called "Untitled sheet" with no way to fix any of
16// them.
17
18func renamePath(id uint64) string {
19 return "/sheets/" + strconv.FormatUint(id, 10) + "/rename"
20}
21
22func TestRenameChangesTheTitle(t *testing.T) {
23 app, cookies := signIn(t)
24 s, _ := newSheet(t, app, cookies, "Untitled sheet")
25
26 rec := do(t, app, as(postForm(renamePath(s.ID),
27 url.Values{"title": {"Q3 budget"}}), cookies))
28 if rec.Code != http.StatusSeeOther {
29 t.Fatalf("rename = %d, want 303: %s", rec.Code, rec.Body.String())
30 }
31 if loc := rec.Header().Get("Location"); loc != "/sheets/"+strconv.FormatUint(s.ID, 10) {
32 t.Errorf("rename redirected to %q, want the sheet", loc)
33 }
34
35 got, err := app.sheetSummary(s.ID, signedInMember(t, app))
36 if err != nil {
37 t.Fatalf("sheetSummary: %v", err)
38 }
39 if got.Title != "Q3 budget" {
40 t.Errorf("title is %q after rename, want %q", got.Title, "Q3 budget")
41 }
42}
43
44// The one rule reaches the second time a name is set, not just the
45// first. A rename that wrote the new title straight into the column
46// would put a sheet's name in the database file in plaintext, and
47// nothing about createSheet's sealing would have caught it.
48func TestRenamedTitleIsSealedAtRest(t *testing.T) {
49 const secret = "Redundancies Q4"
50 app, cookies := signIn(t)
51 s, _ := newSheet(t, app, cookies, "Untitled sheet")
52
53 if rec := do(t, app, as(postForm(renamePath(s.ID),
54 url.Values{"title": {secret}}), cookies)); rec.Code != http.StatusSeeOther {
55 t.Fatalf("rename = %d", rec.Code)
56 }
57
58 var raw []byte
59 if err := app.db.Raw("SELECT title_sealed FROM sheets WHERE id = ?", s.ID).
60 Row().Scan(&raw); err != nil {
61 t.Fatalf("read back: %v", err)
62 }
63 if len(raw) == 0 {
64 t.Fatal("no sealed title was stored")
65 }
66 if bytes.Contains(raw, []byte(secret)) {
67 t.Fatal("the renamed title reached the database in plaintext")
68 }
69}
70
71// A blank title keeps the name the sheet had. The alternative is a
72// sheet called nothing at all, which the list would render as a row you
73// cannot click by name.
74func TestRenameRefusesABlankTitle(t *testing.T) {
75 app, cookies := signIn(t)
76 s, _ := newSheet(t, app, cookies, "Budget")
77
78 for _, blank := range []string{"", " "} {
79 rec := do(t, app, as(postForm(renamePath(s.ID),
80 url.Values{"title": {blank}}), cookies))
81 if rec.Code != http.StatusSeeOther {
82 t.Fatalf("rename to %q = %d, want 303", blank, rec.Code)
83 }
84 got, err := app.sheetSummary(s.ID, signedInMember(t, app))
85 if err != nil {
86 t.Fatalf("sheetSummary: %v", err)
87 }
88 if got.Title != "Budget" {
89 t.Errorf("a blank rename changed the title to %q", got.Title)
90 }
91 }
92}
93
94// Truncation matches createSheet's, and by runes: cutting a multi-byte
95// title at a byte boundary stores half a character.
96func TestRenameTruncatesToTheSameCapAsCreate(t *testing.T) {
97 app, cookies := signIn(t)
98 s, _ := newSheet(t, app, cookies, "Budget")
99
100 long := strings.Repeat("é", maxTitleRunes+50)
101 if rec := do(t, app, as(postForm(renamePath(s.ID),
102 url.Values{"title": {long}}), cookies)); rec.Code != http.StatusSeeOther {
103 t.Fatalf("rename = %d", rec.Code)
104 }
105 got, err := app.sheetSummary(s.ID, signedInMember(t, app))
106 if err != nil {
107 t.Fatalf("sheetSummary: %v", err)
108 }
109 if n := len([]rune(got.Title)); n != maxTitleRunes {
110 t.Errorf("title is %d runes after rename, want %d", n, maxTitleRunes)
111 }
112}
113
114// The body is capped before it is parsed.
115//
116// An independent review pass found rename reading its form with nothing
117// bounding the request — the only write route in the app doing so.
118// FormValue buffers the WHOLE body to pull one field out of it, so
119// without the cap a megabyte of "title=" is a megabyte held in memory
120// on the way to a value that gets truncated to maxTitleRunes anyway.
121//
122// An oversized body is REFUSED, not quietly ignored. The parse error is
123// checked rather than swallowed, which is the difference between this
124// and the first attempt at the fix — see the query test below for what
125// swallowing it cost.
126func TestRenameCapsTheBodyItWillParse(t *testing.T) {
127 app, cookies := signIn(t)
128 s, _ := newSheet(t, app, cookies, "Budget")
129
130 // Well past the 8 KiB cap, as one enormous field value.
131 huge := url.Values{"title": {strings.Repeat("x", 64<<10)}}
132 rec := do(t, app, as(postForm(renamePath(s.ID), huge), cookies))
133 if rec.Code != http.StatusBadRequest {
134 t.Fatalf("oversized rename = %d, want 400: %s", rec.Code, rec.Body.String())
135 }
136 got, err := app.sheetSummary(s.ID, signedInMember(t, app))
137 if err != nil {
138 t.Fatalf("sheetSummary: %v", err)
139 }
140 if got.Title != "Budget" {
141 t.Errorf("an oversized body renamed the sheet to %q; the cap is not there", got.Title)
142 }
143}
144
145// The title comes from the body, never from the URL.
146//
147// This is the defect the second review pass found in the first fix. Go's
148// r.FormValue merges the query string into the form, and it swallows a
149// parse failure rather than reporting it — so capping the body without
150// checking the error created a NEW way in: POST ?title=Wrong with an
151// oversized body, the cap rejects the body, FormValue falls through to
152// the query, and the sheet is renamed to whatever the URL said. A
153// state-changing value that can travel in a link is exactly what a POST
154// route must not accept.
155//
156// Both halves are asserted, because either alone would pass a broken
157// handler: the query is ignored when the body is fine, and an oversized
158// body is refused outright rather than falling back to it.
159func TestRenameTakesTheTitleFromTheBodyNotTheQuery(t *testing.T) {
160 app, cookies := signIn(t)
161 s, _ := newSheet(t, app, cookies, "Budget")
162
163 // A good body and a hostile query: the body must win.
164 req := postForm(renamePath(s.ID)+"?title=FromTheQuery",
165 url.Values{"title": {"FromTheBody"}})
166 if rec := do(t, app, as(req, cookies)); rec.Code != http.StatusSeeOther {
167 t.Fatalf("rename = %d, want 303: %s", rec.Code, rec.Body.String())
168 }
169 got, err := app.sheetSummary(s.ID, signedInMember(t, app))
170 if err != nil {
171 t.Fatalf("sheetSummary: %v", err)
172 }
173 if got.Title != "FromTheBody" {
174 t.Errorf("title is %q, want the body's value — the query reached the write", got.Title)
175 }
176
177 // A body carrying no title at all, and a query that does. This is
178 // the case that actually distinguishes PostForm from FormValue, and
179 // it exists because the first version of this test did not: with
180 // ParseForm's error checked, a well-formed body wins under either
181 // spelling, so swapping PostForm back for FormValue passed. Here
182 // r.Form merges the query in and FormValue would answer
183 // "FromTheQuery"; PostForm answers "", which is blank, which keeps
184 // the name the sheet had.
185 req = postForm(renamePath(s.ID)+"?title=FromTheQuery",
186 url.Values{"unrelated": {"x"}})
187 if rec := do(t, app, as(req, cookies)); rec.Code != http.StatusSeeOther {
188 t.Fatalf("rename with no body title = %d, want 303", rec.Code)
189 }
190 got, err = app.sheetSummary(s.ID, signedInMember(t, app))
191 if err != nil {
192 t.Fatalf("sheetSummary: %v", err)
193 }
194 if got.Title != "FromTheBody" {
195 t.Errorf("title is %q; the query renamed the sheet without a body field", got.Title)
196 }
197
198 // An oversized body with a query title: refused, and the query must
199 // not stand in for the body it could not read.
200 huge := url.Values{"title": {strings.Repeat("x", 64<<10)}}
201 req = postForm(renamePath(s.ID)+"?title=FromTheQuery", huge)
202 if rec := do(t, app, as(req, cookies)); rec.Code != http.StatusBadRequest {
203 t.Fatalf("oversized rename = %d, want 400", rec.Code)
204 }
205 got, err = app.sheetSummary(s.ID, signedInMember(t, app))
206 if err != nil {
207 t.Fatalf("sheetSummary: %v", err)
208 }
209 if got.Title != "FromTheBody" {
210 t.Errorf("title is %q; a rejected body let the query rename the sheet", got.Title)
211 }
212}
213
214// A viewer may not rename, and is told 404 rather than 403 — AGENTS.md
215// requires it, because a 403 confirms the sheet exists to somebody who
216// was never shown it.
217func TestRenameIsNotForViewers(t *testing.T) {
218 app, cookies := signIn(t)
219 s, _ := newSheet(t, app, cookies, "Budget")
220 guestOn(t, app, s.ID, "ada@elsewhere.example", SheetViewer)
221 guest := signInAs(t, app, "ada@elsewhere.example")
222
223 rec := do(t, app, as(postForm(renamePath(s.ID),
224 url.Values{"title": {"Mine now"}}), guest))
225 if rec.Code != http.StatusNotFound {
226 t.Errorf("a viewer renaming = %d, want 404", rec.Code)
227 }
228
229 got, err := app.sheetSummary(s.ID, signedInMember(t, app))
230 if err != nil {
231 t.Fatalf("sheetSummary: %v", err)
232 }
233 if got.Title != "Budget" {
234 t.Errorf("a viewer renamed the sheet to %q", got.Title)
235 }
236}
237
238// TestCreateFromTheTopBarLandsAnUntitledSheet drives the button the
239// list page now carries: an empty POST, no title field anywhere in it.
240//
241// This is the whole reason rename exists, so it is pinned here rather
242// than left to the create handler's own tests: if an empty create ever
243// starts refusing instead of defaulting, the top bar's only action
244// breaks and the failure should name the button.
245func TestCreateFromTheTopBarLandsAnUntitledSheet(t *testing.T) {
246 app, cookies := signIn(t)
247
248 rec := do(t, app, as(postForm("/sheets", url.Values{}), cookies))
249 if rec.Code != http.StatusSeeOther {
250 t.Fatalf("create with no title = %d, want 303: %s", rec.Code, rec.Body.String())
251 }
252 id, err := strconv.ParseUint(strings.TrimPrefix(rec.Header().Get("Location"), "/sheets/"), 10, 64)
253 if err != nil {
254 t.Fatalf("Location %q is not a sheet URL: %v", rec.Header().Get("Location"), err)
255 }
256 got, err := app.sheetSummary(id, signedInMember(t, app))
257 if err != nil {
258 t.Fatalf("sheetSummary: %v", err)
259 }
260 if got.Title != "Untitled sheet" {
261 t.Errorf("a titleless create landed as %q, want %q", got.Title, "Untitled sheet")
262 }
263}
264