| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 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 | |
| 29 | func 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. |
| 34 | func 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 | |
| 45 | func 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 | |
| 55 | func 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. |
| 97 | func 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. |
| 122 | func 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. |
| 147 | func 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. |
| 184 | func 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. |
| 204 | func 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. |
| 229 | func 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. |
| 242 | func 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 | |
| 260 | func 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 | |