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 "fmt"
5 "net/http"
6 "net/http/httptest"
7 "net/url"
8 "strings"
9 "testing"
10)
11
12func historyPath(s SheetSummary) string { return fmt.Sprintf("/sheets/%d/history", s.ID) }
13
14func historyBody(t *testing.T, app *App, s SheetSummary, cookies []*http.Cookie) string {
15 t.Helper()
16 return do(t, app, as(httptest.NewRequest(http.MethodGet, historyPath(s), nil), cookies)).Body.String()
17}
18
19func TestHistoryListsEveryEdit(t *testing.T) {
20 app, cookies := signIn(t)
21 s, cells := newSheet(t, app, cookies, "Budget")
22
23 postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"first"}`)
24 postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"second"}`)
25 postCell(t, app, cookies, cells, `{"r":1,"c":2,"raw":"elsewhere"}`)
26
27 body := historyBody(t, app, s, cookies)
28 for _, want := range []string{"first", "second", "elsewhere", "A1", "C2"} {
29 if !strings.Contains(body, want) {
30 t.Errorf("history is missing %q", want)
31 }
32 }
33}
34
35// The log holds inputs, never derived values (ruling 16). An edit that
36// recalculates four hundred cells is one row here, not four hundred.
37func TestHistoryRecordsEditsNotDerivedCells(t *testing.T) {
38 app, cookies := signIn(t)
39 s, cells := newSheet(t, app, cookies, "Budget")
40
41 postCell(t, app, cookies, cells, `{"r":1,"c":0,"raw":"=A1*2"}`)
42 postCell(t, app, cookies, cells, `{"r":2,"c":0,"raw":"=A1*3"}`)
43 postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"7"}`)
44
45 body := historyBody(t, app, s, cookies)
46 // Three edits, so three rows. If derived values were logged, the
47 // last edit alone would add two more.
48 if n := strings.Count(body, `name="event"`); n != 3 {
49 t.Fatalf("history shows %d restorable edits, want 3 — derived cells are not logged", n)
50 }
51 // And the computed value never appears as an edit.
52 if strings.Contains(body, "<code>14</code>") {
53 t.Error("history shows a derived value as though somebody typed it")
54 }
55}
56
57// Restoring is a fresh last-write-wins write, not a rewind: it appears
58// in history as an edit and is itself undoable. A rewind would make the
59// log disagree with the sheet.
60func TestRestorePutsACellBackAndIsItselfAnEdit(t *testing.T) {
61 app, cookies := signIn(t)
62 s, cells := newSheet(t, app, cookies, "Budget")
63
64 postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"original"}`)
65 postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"replaced"}`)
66
67 before := strings.Count(historyBody(t, app, s, cookies), `name="event"`)
68
69 // The newest event is the one that replaced it; restoring that
70 // event's OldRaw puts "original" back.
71 eventID := newestEventID(t, app, s)
72 form := url.Values{"event": {fmt.Sprint(eventID)}}
73 rec := do(t, app, as(postForm(fmt.Sprintf("/sheets/%d/restore", s.ID), form), cookies))
74 if rec.Code != http.StatusSeeOther {
75 t.Fatalf("restore = %d, want 303: %s", rec.Code, rec.Body.String())
76 }
77
78 grid := decodeGrid(t, do(t, app, as(httptest.NewRequest(http.MethodGet, cells, nil), cookies)))
79 if len(grid.Cells) != 1 || grid.Cells[0].Display != "original" {
80 t.Fatalf("after restore the cell holds %+v, want original", grid.Cells)
81 }
82 after := strings.Count(historyBody(t, app, s, cookies), `name="event"`)
83 if after != before+1 {
84 t.Fatalf("history went from %d to %d rows; a restore must appear as an edit", before, after)
85 }
86}
87
88// Own-edits-only (spec §7). Undo must never reach somebody else's work.
89func TestUndoTouchesOnlyYourOwnEdits(t *testing.T) {
90 app, cookies := signIn(t)
91 s, cells := newSheet(t, app, cookies, "Budget")
92 guestOn(t, app, s.ID, "ada@elsewhere.example", SheetEditor)
93 adaCookies := signInAs(t, app, "ada@elsewhere.example")
94
95 // Paul writes, then Ada writes over it.
96 postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"paul"}`)
97 postCell(t, app, adaCookies, cells, `{"r":0,"c":0,"raw":"ada"}`)
98
99 // Paul undoes. His last edit set the cell to "paul" from empty, so
100 // putting it back empties the cell — taking Ada's value with it,
101 // which is exactly what last-write-wins undo means and is itself
102 // undoable.
103 rec := do(t, app, as(postForm(fmt.Sprintf("/sheets/%d/undo", s.ID), url.Values{}), cookies))
104 if rec.Code != http.StatusSeeOther {
105 t.Fatalf("undo = %d", rec.Code)
106 }
107 grid := decodeGrid(t, do(t, app, as(httptest.NewRequest(http.MethodGet, cells, nil), cookies)))
108 for _, c := range grid.Cells {
109 if c.R == 0 && c.C == 0 && c.Display == "ada" {
110 t.Fatal("undo did nothing; it found somebody else's edit and stopped")
111 }
112 }
113}
114
115func TestUndoWithNothingToUndoSaysSo(t *testing.T) {
116 app, cookies := signIn(t)
117 s, _ := newSheet(t, app, cookies, "Budget")
118
119 rec := do(t, app, as(postForm(fmt.Sprintf("/sheets/%d/undo", s.ID), url.Values{}), cookies))
120 if loc := rec.Header().Get("Location"); !strings.Contains(loc, "nothing") {
121 t.Fatalf("Location = %q, want the nothing-to-undo notice", loc)
122 }
123}
124
125// History skips an event this key cannot open rather than failing the
126// whole read — one unreadable row must not take the log with it. Undo
127// asked for exactly one row and then skipped it, so a single unreadable
128// newest edit reported that the person had nothing to undo at all,
129// while older edits of theirs sat right there.
130//
131// The shape is what makes it worth a test: it does not read as an
132// error. It reads as a calm, correct answer about their own work, given
133// to somebody who has just made a mistake and is trying to take it
134// back.
135func TestUndoLooksPastAnEventItCannotUnseal(t *testing.T) {
136 app, cookies := signIn(t)
137 s, cells := newSheet(t, app, cookies, "Budget")
138
139 postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"first"}`)
140 postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"second"}`)
141 spoil(t, app, newestEventID(t, app, s))
142
143 rec := do(t, app, as(postForm(fmt.Sprintf("/sheets/%d/undo", s.ID), url.Values{}), cookies))
144 if loc := rec.Header().Get("Location"); strings.Contains(loc, "nothing") {
145 t.Fatal("undo said there was nothing to undo while a readable older edit " +
146 "of the viewer's own was in the log")
147 }
148
149 // The readable edit set A1 to "first" from empty, so undoing it
150 // empties A1 — and in particular leaves it not showing "second".
151 grid := decodeGrid(t, do(t, app, as(httptest.NewRequest(http.MethodGet, cells, nil), cookies)))
152 for _, c := range grid.Cells {
153 if c.R == 0 && c.C == 0 && c.Display == "second" {
154 t.Fatal("undo changed nothing; it stopped at the event it could not open")
155 }
156 }
157}
158
159// spoil makes one event's payload undecryptable, which is what an event
160// sealed under a key this instance no longer holds looks like from
161// here: AEAD rejects it and openInput reports not-ok.
162func spoil(t *testing.T, app *App, id uint64) {
163 t.Helper()
164 if err := app.db.Exec("UPDATE events SET payload_sealed = ? WHERE id = ?",
165 []byte("not a sealed payload"), id).Error; err != nil {
166 t.Fatalf("spoil event %d: %v", id, err)
167 }
168}
169
170// An event id from another sheet must not be applicable here — it comes
171// from a URL, and without the tab in the lookup a caller could name any
172// event on the instance.
173func TestRestoreCannotReachAnotherSheetsEvent(t *testing.T) {
174 app, cookies := signIn(t)
175 one, oneCells := newSheet(t, app, cookies, "One")
176 two, _ := newSheet(t, app, cookies, "Two")
177
178 postCell(t, app, cookies, oneCells, `{"r":0,"c":0,"raw":"one"}`)
179 eventID := newestEventID(t, app, one)
180
181 form := url.Values{"event": {fmt.Sprint(eventID)}}
182 rec := do(t, app, as(postForm(fmt.Sprintf("/sheets/%d/restore", two.ID), form), cookies))
183 if loc := rec.Header().Get("Location"); !strings.Contains(loc, "gone") {
184 t.Fatalf("Location = %q, want the not-found notice", loc)
185 }
186}
187
188// A viewer may read history and must not restore or undo.
189func TestAViewerCannotRestoreOrUndo(t *testing.T) {
190 app, cookies := signIn(t)
191 s, cells := newSheet(t, app, cookies, "Budget")
192 postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"value"}`)
193 guestOn(t, app, s.ID, "ada@elsewhere.example", SheetViewer)
194 adaCookies := signInAs(t, app, "ada@elsewhere.example")
195
196 body := historyBody(t, app, s, adaCookies)
197 if !strings.Contains(body, "value") {
198 t.Error("a viewer cannot read the history of a sheet they can read")
199 }
200 if strings.Contains(body, `action="/sheets/`+fmt.Sprint(s.ID)+`/undo"`) {
201 t.Error("a viewer is offered an undo button that would 404")
202 }
203
204 form := url.Values{"event": {fmt.Sprint(newestEventID(t, app, s))}}
205 if rec := do(t, app, as(postForm(fmt.Sprintf("/sheets/%d/restore", s.ID), form), adaCookies)); rec.Code != http.StatusNotFound {
206 t.Errorf("a viewer restored a cell: %d", rec.Code)
207 }
208 if rec := do(t, app, as(postForm(fmt.Sprintf("/sheets/%d/undo", s.ID), url.Values{}), adaCookies)); rec.Code != http.StatusNotFound {
209 t.Errorf("a viewer undid an edit: %d", rec.Code)
210 }
211}
212
213// A guest must not read the history of a sheet nobody gave them, and
214// history is a place cell content is very much on display.
215func TestHistoryIsGated(t *testing.T) {
216 app, cookies := signIn(t)
217 private, cells := newSheet(t, app, cookies, "Private")
218 postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"confidential"}`)
219 other, _ := newSheet(t, app, cookies, "Shared")
220 guestOn(t, app, other.ID, "ada@elsewhere.example", SheetViewer)
221 adaCookies := signInAs(t, app, "ada@elsewhere.example")
222
223 rec := do(t, app, as(httptest.NewRequest(http.MethodGet, historyPath(private), nil), adaCookies))
224 if rec.Code != http.StatusNotFound {
225 t.Fatalf("a guest read another sheet's history: %d", rec.Code)
226 }
227 if strings.Contains(rec.Body.String(), "confidential") {
228 t.Fatal("the refusal leaked a cell's contents")
229 }
230}
231
232func TestHistoryIsScriptless(t *testing.T) {
233 app, cookies := signIn(t)
234 s, cells := newSheet(t, app, cookies, "Budget")
235 postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"value"}`)
236
237 body := withoutScript(historyBody(t, app, s, cookies))
238 for _, want := range []string{
239 fmt.Sprintf(`action="/sheets/%d/undo"`, s.ID),
240 fmt.Sprintf(`action="/sheets/%d/restore"`, s.ID),
241 `name="event"`,
242 } {
243 if !strings.Contains(body, want) {
244 t.Errorf("with scripts stripped, %q is gone", want)
245 }
246 }
247}
248
249// newestEventID reads the most recent input event's id straight from
250// the log.
251func newestEventID(t *testing.T, app *App, s SheetSummary) uint64 {
252 t.Helper()
253 var id uint64
254 err := app.db.Raw(
255 "SELECT id FROM events WHERE kind = 'input' AND tab_id = ? ORDER BY id DESC LIMIT 1",
256 s.TabID).Row().Scan(&id)
257 if err != nil {
258 t.Fatalf("read newest event: %v", err)
259 }
260 return id
261}
262