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 "bytes"
5 "fmt"
6 "net/http"
7 "net/http/httptest"
8 "net/url"
9 "regexp"
10 "strings"
11 "testing"
12)
13
14func commentsPath(s SheetSummary) string { return fmt.Sprintf("/sheets/%d/comments", s.ID) }
15
16func 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
22func 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
27func 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.
45func 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.
66func 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
84func 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
101func 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.
120func 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
147func 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.
174func 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
200func 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
215func 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.
235func 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.
253func 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