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 "strconv"
9 "strings"
10 "testing"
11)
12
13// newSheet creates a sheet through the real HTTP route and returns the
14// cells endpoint for its first tab.
15//
16// It reads the new sheet out of the redirect rather than by listing.
17// Listing needs a viewer, and a helper that picked its own would be
18// asserting visibility as a side effect of setup — which is how the
19// grants change first broke this: it listed as a member the caller had
20// never signed in as, who was correctly a guest and correctly saw
21// nothing.
22func newSheet(t *testing.T, app *App, cookies []*http.Cookie, title string) (SheetSummary, string) {
23 t.Helper()
24 rec := do(t, app, as(postForm("/sheets", map[string][]string{"title": {title}}), cookies))
25 if rec.Code != http.StatusSeeOther {
26 t.Fatalf("creating a sheet = %d: %s", rec.Code, rec.Body.String())
27 }
28 loc := rec.Header().Get("Location")
29 id, err := strconv.ParseUint(strings.TrimPrefix(loc, "/sheets/"), 10, 64)
30 if err != nil {
31 t.Fatalf("Location %q is not a sheet URL: %v", loc, err)
32 }
33 s, err := app.sheetSummary(id, signedInMember(t, app))
34 if err != nil {
35 t.Fatalf("sheetSummary(%d): %v", id, err)
36 }
37 return s, fmt.Sprintf("/api/sheets/%d/tabs/%d/cells", s.ID, s.TabID)
38}
39
40func postCell(t *testing.T, app *App, cookies []*http.Cookie, path, body string) *httptest.ResponseRecorder {
41 t.Helper()
42 req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
43 req.Header.Set("Content-Type", "application/json")
44 return do(t, app, as(req, cookies))
45}
46
47func decodeGrid(t *testing.T, rec *httptest.ResponseRecorder) gridResponse {
48 t.Helper()
49 var g gridResponse
50 if err := json.Unmarshal(rec.Body.Bytes(), &g); err != nil {
51 t.Fatalf("decode grid response: %v (body %q)", err, rec.Body.String())
52 }
53 return g
54}
55
56func TestPostACellThenGetItBack(t *testing.T) {
57 app, cookies := signIn(t)
58 _, cells := newSheet(t, app, cookies, "Budget")
59
60 rec := postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"=1+1"}`)
61 if rec.Code != http.StatusOK {
62 t.Fatalf("POST cell = %d: %s", rec.Code, rec.Body.String())
63 }
64 wrote := decodeGrid(t, rec)
65 if len(wrote.Cells) != 1 {
66 t.Fatalf("POST returned %d cells, want 1: %+v", len(wrote.Cells), wrote.Cells)
67 }
68 if wrote.Cells[0].Display != "2" {
69 t.Errorf("display = %q, want %q — the server owns every painted string", wrote.Cells[0].Display, "2")
70 }
71
72 read := decodeGrid(t, do(t, app, as(httptest.NewRequest(http.MethodGet, cells, nil), cookies)))
73 if len(read.Cells) != 1 {
74 t.Fatalf("GET returned %d cells, want 1", len(read.Cells))
75 }
76 // Both must survive a reload: the editor shows Raw, the grid paints
77 // Display, and a client with only one of them is broken in a way
78 // that only shows up when somebody clicks the cell.
79 if read.Cells[0].Raw != "=1+1" || read.Cells[0].Display != "2" {
80 t.Fatalf("after reload: %+v", read.Cells[0])
81 }
82}
83
84// A formula's dependents must come back with the edit that changed
85// them, or the other tab paints a stale number forever.
86func TestPostACellReturnsItsDependents(t *testing.T) {
87 app, cookies := signIn(t)
88 _, cells := newSheet(t, app, cookies, "Budget")
89
90 postCell(t, app, cookies, cells, `{"r":1,"c":0,"raw":"=A1*2"}`)
91 rec := postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"21"}`)
92 got := decodeGrid(t, rec)
93
94 if len(got.Cells) != 2 {
95 t.Fatalf("writing a feeder returned %d cells, want 2: %+v", len(got.Cells), got.Cells)
96 }
97 var derived *gridCell
98 for i := range got.Cells {
99 if got.Cells[i].Derived {
100 derived = &got.Cells[i]
101 }
102 }
103 if derived == nil {
104 t.Fatal("no cell is flagged derived")
105 }
106 if derived.Display != "42" {
107 t.Errorf("the dependent displays %q, want %q", derived.Display, "42")
108 }
109}
110
111func TestPostACellClearsIt(t *testing.T) {
112 app, cookies := signIn(t)
113 _, cells := newSheet(t, app, cookies, "Budget")
114
115 postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"7"}`)
116 postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":""}`)
117
118 read := decodeGrid(t, do(t, app, as(httptest.NewRequest(http.MethodGet, cells, nil), cookies)))
119 for _, c := range read.Cells {
120 if c.R == 0 && c.C == 0 && c.Display != "" {
121 t.Fatalf("a cleared cell still reads %q", c.Display)
122 }
123 }
124}
125
126// The version is what a client orders paints by. A snapshot taken after
127// an edit must not carry a version below that edit's, or the client
128// discards the edit it just fetched.
129func TestVersionAdvancesAcrossWrites(t *testing.T) {
130 app, cookies := signIn(t)
131 _, cells := newSheet(t, app, cookies, "Budget")
132
133 first := decodeGrid(t, postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"1"}`))
134 second := decodeGrid(t, postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"2"}`))
135 if second.Version <= first.Version {
136 t.Fatalf("version did not advance: %d then %d", first.Version, second.Version)
137 }
138 snap := decodeGrid(t, do(t, app, as(httptest.NewRequest(http.MethodGet, cells, nil), cookies)))
139 if snap.Version <= second.Version {
140 t.Fatalf("a snapshot after the last write carries version %d, not above %d — "+
141 "a client would discard the edit it just fetched", snap.Version, second.Version)
142 }
143}
144
145// Spec §6's cap reasoning, enforced at the boundary rather than
146// trusted: a request for a million rows must clamp, not allocate.
147func TestGridWindowIsClampedNotRefused(t *testing.T) {
148 app, cookies := signIn(t)
149 _, cells := newSheet(t, app, cookies, "Budget")
150
151 rec := do(t, app, as(httptest.NewRequest(http.MethodGet, cells+"?rows=999999&cols=999999", nil), cookies))
152 if rec.Code != http.StatusOK {
153 t.Fatalf("a too-wide window = %d, want 200 — clamping beats refusing", rec.Code)
154 }
155 g := decodeGrid(t, rec)
156 if g.Rows != maxWindowRows || g.Cols != maxWindowCols {
157 t.Fatalf("window = %dx%d, want the caps %dx%d", g.Rows, g.Cols, maxWindowRows, maxWindowCols)
158 }
159}
160
161func TestGridWindowDefaultsWhenUnasked(t *testing.T) {
162 app, cookies := signIn(t)
163 _, cells := newSheet(t, app, cookies, "Budget")
164 g := decodeGrid(t, do(t, app, as(httptest.NewRequest(http.MethodGet, cells, nil), cookies)))
165 if g.Rows != defaultWindowRows || g.Cols != defaultWindowCols {
166 t.Fatalf("window = %dx%d, want the defaults %dx%d",
167 g.Rows, g.Cols, defaultWindowRows, defaultWindowCols)
168 }
169}
170
171// A cell outside the window must not be sent. Without the filter the
172// endpoint would hand back a whole tab whenever anybody asked for a
173// corner of it.
174func TestCellsOutsideTheWindowAreNotSent(t *testing.T) {
175 app, cookies := signIn(t)
176 _, cells := newSheet(t, app, cookies, "Budget")
177 postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"near"}`)
178 postCell(t, app, cookies, cells, `{"r":80,"c":0,"raw":"far"}`)
179
180 g := decodeGrid(t, do(t, app, as(httptest.NewRequest(http.MethodGet, cells+"?rows=10&cols=5", nil), cookies)))
181 for _, c := range g.Cells {
182 if c.R >= 10 || c.C >= 5 {
183 t.Fatalf("a cell at (%d,%d) escaped a 10x5 window", c.R, c.C)
184 }
185 }
186 if len(g.Cells) != 1 {
187 t.Fatalf("window holds %d cells, want 1", len(g.Cells))
188 }
189}
190
191// The one rule, at the last layer that could break it: an error must
192// never quote what somebody typed.
193func TestNoCellContentInAnErrorResponse(t *testing.T) {
194 app, cookies := signIn(t)
195 _, cells := newSheet(t, app, cookies, "Budget")
196
197 const secret = "SalariesAreConfidential"
198 rec := postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"=SUM(("`+secret+`"}`)
199 if rec.Code == http.StatusOK {
200 t.Fatalf("an unparseable body was accepted: %s", rec.Body.String())
201 }
202 if strings.Contains(rec.Body.String(), secret) {
203 t.Fatalf("the error response quotes the cell's contents: %s", rec.Body.String())
204 }
205}
206
207func TestUnparseableFormulaIsRefusedWithoutQuotingIt(t *testing.T) {
208 app, cookies := signIn(t)
209 _, cells := newSheet(t, app, cookies, "Budget")
210
211 const secret = "PayrollNumbers"
212 body, _ := json.Marshal(cellWrite{R: 0, C: 0, Raw: "=SUM(" + secret})
213 rec := postCell(t, app, cookies, cells, string(body))
214 if rec.Code == http.StatusOK {
215 t.Fatal("an unparseable formula was accepted")
216 }
217 if strings.Contains(rec.Body.String(), secret) {
218 t.Fatalf("the refusal quotes the formula: %s", rec.Body.String())
219 }
220}
221
222// Both ids come from the URL. Without the pairing check, any tab could
223// be read through any sheet and the sheet id would mean nothing.
224func TestATabFromAnotherSheetIs404(t *testing.T) {
225 app, cookies := signIn(t)
226 one, _ := newSheet(t, app, cookies, "One")
227 two, _ := newSheet(t, app, cookies, "Two")
228
229 path := fmt.Sprintf("/api/sheets/%d/tabs/%d/cells", one.ID, two.TabID)
230 if rec := do(t, app, as(httptest.NewRequest(http.MethodGet, path, nil), cookies)); rec.Code != http.StatusNotFound {
231 t.Fatalf("reading a tab through the wrong sheet = %d, want 404", rec.Code)
232 }
233}
234
235func TestGridAPIRequiresASession(t *testing.T) {
236 app, cookies := signIn(t)
237 _, cells := newSheet(t, app, cookies, "Budget")
238
239 if rec := do(t, app, httptest.NewRequest(http.MethodGet, cells, nil)); rec.Code == http.StatusOK {
240 t.Error("the grid API served a window with no session")
241 }
242 if rec := postCell(t, app, nil, cells, `{"r":0,"c":0,"raw":"1"}`); rec.Code == http.StatusOK {
243 t.Error("the grid API accepted a write with no session")
244 }
245}
246
247func TestOverlongCellInputIsRefused(t *testing.T) {
248 app, cookies := signIn(t)
249 _, cells := newSheet(t, app, cookies, "Budget")
250
251 body, _ := json.Marshal(cellWrite{R: 0, C: 0, Raw: strings.Repeat("x", maxRawBytes+1)})
252 if rec := postCell(t, app, cookies, cells, string(body)); rec.Code == http.StatusOK {
253 t.Fatal("a cell larger than the cap was accepted")
254 }
255}
256
257func TestNegativeCoordinatesAreRefused(t *testing.T) {
258 app, cookies := signIn(t)
259 _, cells := newSheet(t, app, cookies, "Budget")
260 if rec := postCell(t, app, cookies, cells, `{"r":-1,"c":0,"raw":"1"}`); rec.Code != http.StatusBadRequest {
261 t.Fatalf("a negative row = %d, want 400", rec.Code)
262 }
263}
264
265// The poster's client id comes back so its own edit arriving over the
266// stream can be skipped. Without it the tab you typed in repaints the
267// cell you are still editing.
268func TestClientIDIsEchoed(t *testing.T) {
269 app, cookies := signIn(t)
270 _, cells := newSheet(t, app, cookies, "Budget")
271 g := decodeGrid(t, postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"1","client":"tab-abc"}`))
272 if g.Client != "tab-abc" {
273 t.Fatalf("client = %q, want %q", g.Client, "tab-abc")
274 }
275}
276
277// A write must reach every other open tab. This is the whole point of
278// the feature, asserted at the layer below the browser.
279func TestAWriteReachesASubscriber(t *testing.T) {
280 app, cookies := signIn(t)
281 s, cells := newSheet(t, app, cookies, "Budget")
282
283 sub := app.hub.Subscribe(s.TabID)
284 defer sub.Close()
285
286 postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"=6*7"}`)
287
288 select {
289 case m := <-sub.C():
290 var g gridResponse
291 if err := json.Unmarshal(m.Data, &g); err != nil {
292 t.Fatalf("the fanned-out payload does not decode: %v", err)
293 }
294 if len(g.Cells) != 1 || g.Cells[0].Display != "42" {
295 t.Fatalf("fanned out %+v, want one cell displaying 42", g.Cells)
296 }
297 default:
298 t.Fatal("a committed write reached no subscriber; nothing would ever live-update")
299 }
300}
301