| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "fmt" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "net/url" |
| 9 | "regexp" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | ) |
| 13 | |
| 14 | func commentsPath(s SheetSummary) string { return fmt.Sprintf("/sheets/%d/comments", s.ID) } |
| 15 | |
| 16 | func addComment(t *testing.T, app *App, s SheetSummary, cookies []*http.Cookie, r, c int, body string) *httptest.ResponseRecorder { |
| 17 | t.Helper() |
| 18 | form := url.Values{"r": {fmt.Sprint(r)}, "c": {fmt.Sprint(c)}, "body": {body}} |
| 19 | return do(t, app, as(postForm(commentsPath(s), form), cookies)) |
| 20 | } |
| 21 | |
| 22 | func commentsBody(t *testing.T, app *App, s SheetSummary, cookies []*http.Cookie) string { |
| 23 | t.Helper() |
| 24 | return do(t, app, as(httptest.NewRequest(http.MethodGet, commentsPath(s), nil), cookies)).Body.String() |
| 25 | } |
| 26 | |
| 27 | func TestACommentIsWrittenAndShown(t *testing.T) { |
| 28 | app, cookies := signIn(t) |
| 29 | s, _ := newSheet(t, app, cookies, "Budget") |
| 30 | |
| 31 | if rec := addComment(t, app, s, cookies, 0, 0, "Is this figure current?"); rec.Code != http.StatusSeeOther { |
| 32 | t.Fatalf("adding a comment = %d: %s", rec.Code, rec.Body.String()) |
| 33 | } |
| 34 | body := commentsBody(t, app, s, cookies) |
| 35 | if !strings.Contains(body, "Is this figure current?") { |
| 36 | t.Error("the comment is not shown") |
| 37 | } |
| 38 | if !strings.Contains(body, "A1") { |
| 39 | t.Error("the comment is not anchored to a readable cell reference") |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | // The one rule. Comment text is user content — spec §2.1 lists it |
| 44 | // beside cell values and sheet titles. |
| 45 | func TestCommentTextIsSealedAtRest(t *testing.T) { |
| 46 | app, cookies := signIn(t) |
| 47 | s, _ := newSheet(t, app, cookies, "Budget") |
| 48 | |
| 49 | const secret = "SalaryLooksWrongToMe" |
| 50 | addComment(t, app, s, cookies, 0, 0, secret) |
| 51 | |
| 52 | var raw []byte |
| 53 | if err := app.db.Raw("SELECT body_sealed FROM comments LIMIT 1").Row().Scan(&raw); err != nil { |
| 54 | t.Fatalf("read back: %v", err) |
| 55 | } |
| 56 | if len(raw) == 0 { |
| 57 | t.Fatal("no sealed body was stored") |
| 58 | } |
| 59 | if bytes.Contains(raw, []byte(secret)) { |
| 60 | t.Fatal("the comment reached the database in plaintext") |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | // This is what the commenter role is FOR. Until comments existed it |
| 65 | // granted nothing beyond reading. |
| 66 | func TestACommenterCanCommentButNotEdit(t *testing.T) { |
| 67 | app, cookies := signIn(t) |
| 68 | s, cells := newSheet(t, app, cookies, "Budget") |
| 69 | guestOn(t, app, s.ID, "ada@elsewhere.example", SheetCommenter) |
| 70 | adaCookies := signInAs(t, app, "ada@elsewhere.example") |
| 71 | |
| 72 | if rec := addComment(t, app, s, adaCookies, 0, 0, "a note from Ada"); rec.Code != http.StatusSeeOther { |
| 73 | t.Fatalf("a commenter could not comment: %d", rec.Code) |
| 74 | } |
| 75 | if !strings.Contains(commentsBody(t, app, s, cookies), "a note from Ada") { |
| 76 | t.Error("the commenter's note is not visible to the owner") |
| 77 | } |
| 78 | // And still cannot write a cell. |
| 79 | if rec := postCell(t, app, adaCookies, cells, `{"r":0,"c":0,"raw":"sneaky"}`); rec.Code == http.StatusOK { |
| 80 | t.Error("a commenter wrote a cell") |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | func TestAViewerCannotComment(t *testing.T) { |
| 85 | app, cookies := signIn(t) |
| 86 | s, _ := newSheet(t, app, cookies, "Budget") |
| 87 | guestOn(t, app, s.ID, "ada@elsewhere.example", SheetViewer) |
| 88 | adaCookies := signInAs(t, app, "ada@elsewhere.example") |
| 89 | |
| 90 | if rec := addComment(t, app, s, adaCookies, 0, 0, "sneaky"); rec.Code != http.StatusNotFound { |
| 91 | t.Fatalf("a viewer commented: %d", rec.Code) |
| 92 | } |
| 93 | // A viewer may still READ comments: a comment is about the sheet, |
| 94 | // and somebody who can read the cells can read what was said. |
| 95 | addComment(t, app, s, cookies, 0, 0, "owner's note") |
| 96 | if !strings.Contains(commentsBody(t, app, s, adaCookies), "owner's note") { |
| 97 | t.Error("a viewer cannot read comments on a sheet they can read") |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | func TestCommentsAreGated(t *testing.T) { |
| 102 | app, cookies := signIn(t) |
| 103 | private, _ := newSheet(t, app, cookies, "Private") |
| 104 | addComment(t, app, private, cookies, 0, 0, "confidential note") |
| 105 | other, _ := newSheet(t, app, cookies, "Shared") |
| 106 | guestOn(t, app, other.ID, "ada@elsewhere.example", SheetCommenter) |
| 107 | adaCookies := signInAs(t, app, "ada@elsewhere.example") |
| 108 | |
| 109 | rec := do(t, app, as(httptest.NewRequest(http.MethodGet, commentsPath(private), nil), adaCookies)) |
| 110 | if rec.Code != http.StatusNotFound { |
| 111 | t.Fatalf("a guest read another sheet's comments: %d", rec.Code) |
| 112 | } |
| 113 | if strings.Contains(rec.Body.String(), "confidential") { |
| 114 | t.Fatal("the refusal leaked comment text") |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | // Every comment on a cell is one thread, and resolving settles the |
| 119 | // whole thread rather than one message. |
| 120 | func TestCommentsOnOneCellFormOneThread(t *testing.T) { |
| 121 | app, cookies := signIn(t) |
| 122 | s, _ := newSheet(t, app, cookies, "Budget") |
| 123 | |
| 124 | addComment(t, app, s, cookies, 1, 1, "first") |
| 125 | addComment(t, app, s, cookies, 1, 1, "second") |
| 126 | addComment(t, app, s, cookies, 2, 0, "elsewhere") |
| 127 | |
| 128 | h, err := app.openTabFor(s) |
| 129 | if err != nil { |
| 130 | t.Fatalf("open tab: %v", err) |
| 131 | } |
| 132 | defer h.Release() |
| 133 | threads, err := app.commentThreads(s.TabID, newPosIndex(h)) |
| 134 | if err != nil { |
| 135 | t.Fatalf("commentThreads: %v", err) |
| 136 | } |
| 137 | if len(threads) != 2 { |
| 138 | t.Fatalf("got %d threads, want 2 — comments on one cell are one thread", len(threads)) |
| 139 | } |
| 140 | for _, th := range threads { |
| 141 | if th.Ref == "B2" && len(th.Entries) != 2 { |
| 142 | t.Fatalf("B2's thread holds %d comments, want 2", len(th.Entries)) |
| 143 | } |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | func TestResolvingSettlesTheWholeThread(t *testing.T) { |
| 148 | app, cookies := signIn(t) |
| 149 | s, _ := newSheet(t, app, cookies, "Budget") |
| 150 | addComment(t, app, s, cookies, 0, 0, "one") |
| 151 | addComment(t, app, s, cookies, 0, 0, "two") |
| 152 | |
| 153 | form := url.Values{"r": {"0"}, "c": {"0"}} |
| 154 | rec := do(t, app, as(postForm(commentsPath(s)+"/resolve", form), cookies)) |
| 155 | if rec.Code != http.StatusSeeOther { |
| 156 | t.Fatalf("resolve = %d", rec.Code) |
| 157 | } |
| 158 | |
| 159 | body := commentsBody(t, app, s, cookies) |
| 160 | if !strings.Contains(body, "resolved") { |
| 161 | t.Error("the thread is not marked resolved") |
| 162 | } |
| 163 | // Kept, not deleted: the conversation is worth reading later. |
| 164 | for _, want := range []string{"one", "two"} { |
| 165 | if !strings.Contains(body, want) { |
| 166 | t.Errorf("resolving lost the comment %q", want) |
| 167 | } |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | // Anchored on stable ids, so a comment stays on the cell it was written |
| 172 | // about. This is one of the three things spec §4.2 says stable identity |
| 173 | // is load-bearing for. |
| 174 | func TestACommentAnchorsToTheCellNotTheCoordinate(t *testing.T) { |
| 175 | app, cookies := signIn(t) |
| 176 | s, _ := newSheet(t, app, cookies, "Budget") |
| 177 | addComment(t, app, s, cookies, 3, 0, "about this row") |
| 178 | |
| 179 | var c Comment |
| 180 | if err := app.db.First(&c).Error; err != nil { |
| 181 | t.Fatalf("read comment: %v", err) |
| 182 | } |
| 183 | if c.RowID == 0 || c.ColID == 0 { |
| 184 | t.Fatal("the comment carries no stable ids") |
| 185 | } |
| 186 | // The stored anchor must not be the display position: those are 3 |
| 187 | // and 0, and storing them would slide the comment onto another cell |
| 188 | // the first time a row was inserted above it. |
| 189 | h, err := app.openTabFor(s) |
| 190 | if err != nil { |
| 191 | t.Fatalf("open tab: %v", err) |
| 192 | } |
| 193 | defer h.Release() |
| 194 | idx := newPosIndex(h) |
| 195 | if idx.rowPos[c.RowID] != 3 { |
| 196 | t.Fatalf("the comment's row id resolves to position %d, want 3", idx.rowPos[c.RowID]) |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | func TestAnEmptyCommentIsRefused(t *testing.T) { |
| 201 | app, cookies := signIn(t) |
| 202 | s, _ := newSheet(t, app, cookies, "Budget") |
| 203 | |
| 204 | rec := addComment(t, app, s, cookies, 0, 0, " ") |
| 205 | if loc := rec.Header().Get("Location"); !strings.Contains(loc, "empty") { |
| 206 | t.Fatalf("Location = %q, want the empty refusal", loc) |
| 207 | } |
| 208 | var n int64 |
| 209 | app.db.Model(&Comment{}).Count(&n) |
| 210 | if n != 0 { |
| 211 | t.Fatalf("an empty comment was stored (%d rows)", n) |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | func TestCommentsAreScriptless(t *testing.T) { |
| 216 | app, cookies := signIn(t) |
| 217 | s, _ := newSheet(t, app, cookies, "Budget") |
| 218 | addComment(t, app, s, cookies, 0, 0, "a note") |
| 219 | |
| 220 | body := withoutScript(commentsBody(t, app, s, cookies)) |
| 221 | for _, want := range []string{ |
| 222 | fmt.Sprintf(`action="/sheets/%d/comments"`, s.ID), |
| 223 | `name="body"`, |
| 224 | fmt.Sprintf(`action="/sheets/%d/comments/resolve"`, s.ID), |
| 225 | } { |
| 226 | if !strings.Contains(body, want) { |
| 227 | t.Errorf("with scripts stripped, %q is gone", want) |
| 228 | } |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | // The resolve form has to speak the same language cellIDsFromForm |
| 233 | // reads. Posting stable ids where positions are expected resolves the |
| 234 | // wrong thread, or none. |
| 235 | func TestTheResolveFormPostsPositionsNotIDs(t *testing.T) { |
| 236 | app, cookies := signIn(t) |
| 237 | s, _ := newSheet(t, app, cookies, "Budget") |
| 238 | // Row 5 so the position and the row id cannot coincide. |
| 239 | addComment(t, app, s, cookies, 5, 0, "a note") |
| 240 | |
| 241 | body := commentsBody(t, app, s, cookies) |
| 242 | if !strings.Contains(body, `name="r" value="5"`) { |
| 243 | t.Fatalf("the resolve form does not post the row POSITION:\n%s", |
| 244 | body[max(0, strings.Index(body, "resolve")-300):]) |
| 245 | } |
| 246 | } |
| 247 | |
| 248 | // B6: comment text rendered inside rst-tblock, a toggle-block whose body |
| 249 | // is display:none until a switch in its head is checked. Comments have no |
| 250 | // switch, so every thread showed an author, a time and an empty box. The |
| 251 | // visible-in-a-browser proof is tools/drive/comments.mjs; this keeps the |
| 252 | // markup from drifting back into a collapsed component in make test. |
| 253 | func TestCommentTextIsNotInACollapsedToggleBlock(t *testing.T) { |
| 254 | app, cookies := signIn(t) |
| 255 | s, _ := newSheet(t, app, cookies, "Budget") |
| 256 | addComment(t, app, s, cookies, 0, 0, "Check this total") |
| 257 | body := commentsBody(t, app, s, cookies) |
| 258 | if strings.Contains(body, "rst-tblock") { |
| 259 | t.Error("comment entries render inside rst-tblock, whose body is hidden without a checked switch") |
| 260 | } |
| 261 | if !regexp.MustCompile(`<div data-comment>\s*<p><strong>[^<]+</strong>[\s\S]*?<p>Check this total</p>`).MatchString(body) { |
| 262 | t.Error("the comment text is not rendered as an always-open entry") |
| 263 | } |
| 264 | } |
| 265 | |