| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "net/http" |
| 6 | "net/http/httptest" |
| 7 | "net/url" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | ) |
| 11 | |
| 12 | func historyPath(s SheetSummary) string { return fmt.Sprintf("/sheets/%d/history", s.ID) } |
| 13 | |
| 14 | func 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 | |
| 19 | func 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. |
| 37 | func 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. |
| 60 | func 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. |
| 89 | func 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 | |
| 115 | func 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. |
| 135 | func 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. |
| 162 | func 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. |
| 173 | func 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. |
| 189 | func 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. |
| 215 | func 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 | |
| 232 | func 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. |
| 251 | func 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 | |