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 "encoding/json"
5 "fmt"
6 "net/http"
7 "net/http/httptest"
8 "net/url"
9 "strconv"
10 "strings"
11 "testing"
12)
13
14// Formatting from the scriptless table view.
15//
16// PRODUCT.md's second principle: the scriptless path is the product,
17// not the fallback, and spec §14 recorded the gap in its own words —
18// "an affordance in the grid and not in the table view means the table
19// view is unfinished". Bold, italic, alignment, number format, fill and
20// text colour landed in the grid on 2026-09-02 and none of them was
21// reachable here.
22//
23// The tests below are written against the ROUTE the grid already uses,
24// because that is the design (this slice's decision D): one route, two
25// decoders, converging on one formatPatch before anything is validated.
26// A test that exercised a form-only endpoint would pass while the two
27// paths drifted, which is the failure the decision exists to prevent.
28
29func formatURL(s SheetSummary) string {
30 return fmt.Sprintf("/api/sheets/%d/tabs/%d/format", s.ID, s.TabID)
31}
32
33// postFormat sends a form-encoded patch, the way the table view does.
34func postFormatForm(t *testing.T, app *App, cookies []*http.Cookie, s SheetSummary, fields map[string]string) *httptest.ResponseRecorder {
35 t.Helper()
36 form := url.Values{}
37 for k, v := range fields {
38 form.Set(k, v)
39 }
40 req := httptest.NewRequest(http.MethodPost, formatURL(s), strings.NewReader(form.Encode()))
41 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
42 return do(t, app, as(req, cookies))
43}
44
45func tablePage(t *testing.T, app *App, cookies []*http.Cookie, s SheetSummary, page int) string {
46 t.Helper()
47 u := fmt.Sprintf("/sheets/%d/table?page=%d", s.ID, page)
48 rec := do(t, app, as(httptest.NewRequest(http.MethodGet, u, nil), cookies))
49 if rec.Code != http.StatusOK {
50 t.Fatalf("table page = %d", rec.Code)
51 }
52 return rec.Body.String()
53}
54
55func TestFormattingFromTheTableViewReachesTheCell(t *testing.T) {
56 app, cookies := signIn(t)
57 s, cells := newSheet(t, app, cookies, "Scriptless")
58 postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"total"}`)
59
60 rec := postFormatForm(t, app, cookies, s, map[string]string{
61 "r1": "0", "c1": "0", "r2": "0", "c2": "0",
62 "bold": "set", "align": "right", "fill": "yellow", "page": "1",
63 })
64 if rec.Code != http.StatusSeeOther {
65 t.Fatalf("a form post = %d, want 303: %s", rec.Code, rec.Body.String())
66 }
67
68 // The grid's own snapshot is the check, not the table's HTML: if
69 // the two disagree the bug is that the form wrote something the
70 // grid cannot read, and asserting against the page that produced it
71 // would hide exactly that.
72 read := decodeGrid(t, do(t, app, as(httptest.NewRequest(http.MethodGet, cells, nil), cookies)))
73 if len(read.Cells) == 0 {
74 t.Fatal("no cells came back")
75 }
76 style, ok := read.Formats[read.Cells[0].F]
77 if !ok {
78 t.Fatalf("the cell carries format key %q and the response has no such style", read.Cells[0].F)
79 }
80 if !style.Bold {
81 t.Error("bold did not reach the cell")
82 }
83 if style.Align != "right" {
84 t.Errorf("align = %q, want right", style.Align)
85 }
86 if style.FillH == nil {
87 t.Error("the fill did not reach the cell")
88 }
89}
90
91// TestTheTableViewPaintsWhatItSets is the other half, and the half that
92// makes this a feature rather than a control.
93//
94// A screen that can set bold and cannot show it is the worse half: you
95// press the button, nothing visible happens, and the only way to check
96// is to open the other screen.
97func TestTheTableViewPaintsWhatItSets(t *testing.T) {
98 app, cookies := signIn(t)
99 s, cells := newSheet(t, app, cookies, "Painted")
100 postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"heading"}`)
101 postFormatForm(t, app, cookies, s, map[string]string{
102 "r1": "0", "c1": "0", "r2": "0", "c2": "0",
103 "bold": "set", "italic": "set", "align": "center", "fill": "green", "page": "1",
104 })
105
106 page := tablePage(t, app, cookies, s, 1)
107 for _, want := range []string{"data-b", "data-i", `data-a="center"`, "data-fill", "--sheet-h:"} {
108 if !strings.Contains(page, want) {
109 t.Errorf("the table view does not paint %s — it can set the format and not show it", want)
110 }
111 }
112}
113
114// TestAToggleCanBeTurnedOffFromAForm is the tri-state check, and the
115// reason the controls are selects rather than checkboxes.
116//
117// formatPatch distinguishes absent ("leave alone") from false ("clear
118// it") by pointer presence, and an unchecked checkbox is absent from a
119// submission. A checkbox-shaped bold control can therefore make a cell
120// bold and can NEVER make it not bold — half a control, and the half
121// that fails silently.
122func TestAToggleCanBeTurnedOffFromAForm(t *testing.T) {
123 app, cookies := signIn(t)
124 s, cells := newSheet(t, app, cookies, "Toggle")
125 postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"x"}`)
126
127 postFormatForm(t, app, cookies, s, map[string]string{
128 "r1": "0", "c1": "0", "r2": "0", "c2": "0", "bold": "set", "page": "1",
129 })
130 if !boldAt(t, app, cookies, cells) {
131 t.Fatal("setup failed: the cell is not bold")
132 }
133
134 postFormatForm(t, app, cookies, s, map[string]string{
135 "r1": "0", "c1": "0", "r2": "0", "c2": "0", "bold": "clear", "page": "1",
136 })
137 if boldAt(t, app, cookies, cells) {
138 t.Error("a form could not turn bold off — the toggle is write-only")
139 }
140}
141
142// TestKeepLeavesAFieldAlone: the other half of tri-state. A form posts
143// every one of its selects on every submit, so "I am changing the fill"
144// has to be distinguishable from "I am not touching bold" — otherwise
145// choosing a fill would silently un-bold the selection, which is
146// formatPatch's own documented nightmare arriving through a new door.
147func TestKeepLeavesAFieldAlone(t *testing.T) {
148 app, cookies := signIn(t)
149 s, cells := newSheet(t, app, cookies, "Keep")
150 postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"x"}`)
151
152 postFormatForm(t, app, cookies, s, map[string]string{
153 "r1": "0", "c1": "0", "r2": "0", "c2": "0", "bold": "set", "page": "1",
154 })
155 // Exactly what the rendered form sends when somebody picks a fill
156 // and touches nothing else: every control present, five of them
157 // still on "keep".
158 rec := postFormatForm(t, app, cookies, s, map[string]string{
159 "r1": "0", "c1": "0", "r2": "0", "c2": "0",
160 "bold": "keep", "italic": "keep", "align": "keep",
161 "numfmt": "keep", "fill": "blue", "ink": "keep", "page": "1",
162 })
163 // The status check is load-bearing, and its absence made this test
164 // pass for the wrong reason. Without the "keep" guard in
165 // formatFromForm, `align=keep` reaches validate as an unknown
166 // alignment and the whole post 400s — so the cell stays bold
167 // because NOTHING was applied, and an assertion on boldness alone
168 // reads that as success. Caught by planting the mutation.
169 if rec.Code != http.StatusSeeOther {
170 t.Fatalf("the form every control posts was refused (%d) — a submission "+
171 "where five controls say \"keep\" is the ordinary case, not an error",
172 rec.Code)
173 }
174 if !boldAt(t, app, cookies, cells) {
175 t.Error("choosing a fill un-bolded the cell — every control a form posts " +
176 "is being read as a change, which is the exact bug formatPatch's " +
177 "pointers exist to prevent")
178 }
179}
180
181// TestTheFormRedirectIsBuiltByTheServer: an open redirect is the one
182// thing a POST-then-redirect shape gets wrong, and this repo has
183// already shipped one fix in that family.
184func TestTheFormRedirectIsBuiltByTheServer(t *testing.T) {
185 app, cookies := signIn(t)
186 s, _ := newSheet(t, app, cookies, "Return")
187
188 for _, page := range []string{"https://evil.example/", "../../..", "999999", "-1", "", "notanumber"} {
189 rec := postFormatForm(t, app, cookies, s, map[string]string{
190 "r1": "0", "c1": "0", "r2": "0", "c2": "0", "bold": "set", "page": page,
191 })
192 loc := rec.Header().Get("Location")
193 want := fmt.Sprintf("/sheets/%d/table?page=", s.ID)
194 if !strings.HasPrefix(loc, want) {
195 t.Errorf("page=%q redirected to %q — the target must be built from the "+
196 "sheet id and a bounded page, never from the request", page, loc)
197 }
198 }
199}
200
201// TestAFormPostStillObeysEveryBound: the decoders converge before
202// validation, so the form path must inherit the allowlist, the
203// rectangle bounds and the cell cap rather than restate them.
204func TestAFormPostStillObeysEveryBound(t *testing.T) {
205 app, cookies := signIn(t)
206 s, _ := newSheet(t, app, cookies, "Bounds")
207
208 for name, fields := range map[string]map[string]string{
209 "an alignment that is not one": {"r1": "0", "c1": "0", "r2": "0", "c2": "0", "align": "diagonal"},
210 "a colour that is not ours": {"r1": "0", "c1": "0", "r2": "0", "c2": "0", "fill": "#ff0000"},
211 "a number format we do not accept": {"r1": "0", "c1": "0", "r2": "0", "c2": "0",
212 "numfmt": `"><script>`},
213 "a patch that changes nothing": {"r1": "0", "c1": "0", "r2": "0", "c2": "0"},
214 "a rectangle out of bounds": {"r1": "0", "c1": "0", "r2": strconv.Itoa(maxWindowRows * 100),
215 "c2": "0", "bold": "set"},
216 } {
217 fields["page"] = "1"
218 rec := postFormatForm(t, app, cookies, s, fields)
219 if rec.Code == http.StatusSeeOther {
220 t.Errorf("%s was accepted (303) — the form decoder is not reaching "+
221 "the same validation the JSON one does", name)
222 }
223 }
224}
225
226// TestAHugeRectangleFromAFormIsRefused is separate because its bound is
227// a different one — maxFormatCells rather than the coordinate range —
228// and because a form is the easiest place to submit one by hand.
229func TestAHugeRectangleFromAFormIsRefused(t *testing.T) {
230 app, cookies := signIn(t)
231 s, _ := newSheet(t, app, cookies, "Huge")
232 rec := postFormatForm(t, app, cookies, s, map[string]string{
233 "r1": "0", "c1": "0", "r2": strconv.Itoa(maxWindowRows * 9),
234 "c2": strconv.Itoa(maxWindowCols * 9), "bold": "set", "page": "1",
235 })
236 if rec.Code != http.StatusRequestEntityTooLarge {
237 t.Errorf("a giant rectangle = %d, want 413", rec.Code)
238 }
239}
240
241// TestJSONStillAnswersJSON: the grid must not start receiving redirects.
242func TestJSONStillAnswersJSON(t *testing.T) {
243 app, cookies := signIn(t)
244 s, cells := newSheet(t, app, cookies, "StillJSON")
245 postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"x"}`)
246
247 req := httptest.NewRequest(http.MethodPost, formatURL(s),
248 strings.NewReader(`{"r1":0,"c1":0,"r2":0,"c2":0,"bold":true}`))
249 req.Header.Set("Content-Type", "application/json")
250 rec := do(t, app, as(req, cookies))
251 if rec.Code != http.StatusOK {
252 t.Fatalf("the grid's own post = %d, want 200", rec.Code)
253 }
254 var g gridResponse
255 if err := json.Unmarshal(rec.Body.Bytes(), &g); err != nil {
256 t.Fatalf("the grid no longer receives JSON: %v", err)
257 }
258}
259
260func boldAt(t *testing.T, app *App, cookies []*http.Cookie, cells string) bool {
261 t.Helper()
262 read := decodeGrid(t, do(t, app, as(httptest.NewRequest(http.MethodGet, cells, nil), cookies)))
263 if len(read.Cells) == 0 {
264 return false
265 }
266 return read.Formats[read.Cells[0].F].Bold
267}
268