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 sharePath(s SheetSummary) string { return fmt.Sprintf("/sheets/%d/share", s.ID) }
13
14func TestSharingAddsAGrantAndRedirects(t *testing.T) {
15 app, cookies := signIn(t)
16 s, _ := newSheet(t, app, cookies, "Budget")
17
18 form := url.Values{"address": {"ada@elsewhere.example"}, "role": {string(SheetEditor)}}
19 rec := do(t, app, as(postForm(sharePath(s), form), cookies))
20 if rec.Code != http.StatusSeeOther {
21 t.Fatalf("POST share = %d, want 303: %s", rec.Code, rec.Body.String())
22 }
23
24 ada, err := app.memberFor("ada@elsewhere.example")
25 if err != nil {
26 t.Fatalf("memberFor: %v", err)
27 }
28 role, ok := app.roleFor(s.ID, ada)
29 if !ok || role != SheetEditor {
30 t.Fatalf("after sharing, role = %q (%v), want editor", role, ok)
31 }
32
33 body := do(t, app, as(httptest.NewRequest(http.MethodGet, sharePath(s), nil), cookies)).Body.String()
34 if !strings.Contains(body, "ada@elsewhere.example") {
35 t.Error("the share screen does not list the person it just shared with")
36 }
37}
38
39// Spec §9.5, ruled: the behaviour is kept and the obligation is to
40// disclose it in words. A guest grant discloses the granting team's
41// name, and the screen has to say so before somebody uses it.
42func TestTheShareScreenDisclosesTheTeamNameLeak(t *testing.T) {
43 app, cookies := signIn(t)
44 s, _ := newSheet(t, app, cookies, "Budget")
45
46 body := do(t, app, as(httptest.NewRequest(http.MethodGet, sharePath(s), nil), cookies)).Body.String()
47 // Matched without the apostrophe: html/template escapes it to
48 // ', so asserting on the prose as typed fails on copy that is
49 // actually there — a test failing for a reason unrelated to what it
50 // is checking.
51 for _, want := range []string{"see your team", "name"} {
52 if !strings.Contains(body, want) {
53 t.Fatalf("the share screen does not disclose that sharing reveals the "+
54 "team's name (spec §9.5): missing %q", want)
55 }
56 }
57}
58
59// Only a sheet owner may change who else can reach it, and everybody
60// else gets a 404 rather than a 403.
61func TestOnlyAnOwnerCanReachTheShareScreen(t *testing.T) {
62 app, cookies := signIn(t)
63 s, _ := newSheet(t, app, cookies, "Budget")
64 guestOn(t, app, s.ID, "ada@elsewhere.example", SheetEditor)
65 guestCookies := signInAs(t, app, "ada@elsewhere.example")
66
67 if rec := do(t, app, as(httptest.NewRequest(http.MethodGet, sharePath(s), nil), guestCookies)); rec.Code != http.StatusNotFound {
68 t.Errorf("an editor reached the share screen: %d", rec.Code)
69 }
70 form := url.Values{"address": {"eve@elsewhere.example"}, "role": {string(SheetEditor)}}
71 if rec := do(t, app, as(postForm(sharePath(s), form), guestCookies)); rec.Code != http.StatusNotFound {
72 t.Errorf("an editor shared a sheet they do not own: %d", rec.Code)
73 }
74 // And the grant must not exist.
75 eve, err := app.memberFor("eve@elsewhere.example")
76 if err != nil {
77 t.Fatalf("memberFor: %v", err)
78 }
79 if _, ok := app.roleFor(s.ID, eve); ok {
80 t.Fatal("an editor's share attempt created a grant anyway")
81 }
82}
83
84func TestTheSheetPageOffersShareOnlyToAnOwner(t *testing.T) {
85 app, cookies := signIn(t)
86 s, _ := newSheet(t, app, cookies, "Budget")
87 guestOn(t, app, s.ID, "ada@elsewhere.example", SheetEditor)
88 guestCookies := signInAs(t, app, "ada@elsewhere.example")
89
90 page := fmt.Sprintf("/sheets/%d", s.ID)
91 owner := do(t, app, as(httptest.NewRequest(http.MethodGet, page, nil), cookies)).Body.String()
92 if !strings.Contains(owner, sharePath(s)) {
93 t.Error("the owner is not offered a way to share")
94 }
95 editor := do(t, app, as(httptest.NewRequest(http.MethodGet, page, nil), guestCookies)).Body.String()
96 if strings.Contains(editor, sharePath(s)) {
97 t.Error("an editor is offered a Share link that would 404")
98 }
99}
100
101func TestRevokingThroughTheScreen(t *testing.T) {
102 app, cookies := signIn(t)
103 s, _ := newSheet(t, app, cookies, "Budget")
104 ada := guestOn(t, app, s.ID, "ada@elsewhere.example", SheetViewer)
105
106 form := url.Values{"member": {fmt.Sprint(ada.ID)}}
107 rec := do(t, app, as(postForm(sharePath(s)+"/revoke", form), cookies))
108 if rec.Code != http.StatusSeeOther {
109 t.Fatalf("revoke = %d, want 303", rec.Code)
110 }
111 if app.mayRead(s.ID, ada) {
112 t.Fatal("the revoked guest can still read")
113 }
114}
115
116// A sheet with no owner can never be shared again and there is no admin
117// path to repair it. Refusing the last one is the difference between a
118// mistake and an unrecoverable mistake.
119func TestTheLastOwnerCannotBeRemoved(t *testing.T) {
120 app, cookies := signIn(t)
121 s, _ := newSheet(t, app, cookies, "Budget")
122 me := signedInMember(t, app)
123
124 form := url.Values{"member": {fmt.Sprint(me.ID)}}
125 rec := do(t, app, as(postForm(sharePath(s)+"/revoke", form), cookies))
126 if rec.Code != http.StatusSeeOther {
127 t.Fatalf("revoke = %d", rec.Code)
128 }
129 if loc := rec.Header().Get("Location"); !strings.Contains(loc, "last-owner") {
130 t.Errorf("Location = %q, want the last-owner refusal", loc)
131 }
132 if !app.mayShare(s.ID, me) {
133 t.Fatal("the last owner was removed; the sheet can never be shared again")
134 }
135}
136
137// Handing a sheet on has to be possible, or the only way to transfer
138// one is a database edit.
139func TestOwnershipCanBeHandedOn(t *testing.T) {
140 app, cookies := signIn(t)
141 s, _ := newSheet(t, app, cookies, "Budget")
142 me := signedInMember(t, app)
143
144 form := url.Values{"address": {"ada@elsewhere.example"}, "role": {string(SheetOwner)}}
145 if rec := do(t, app, as(postForm(sharePath(s), form), cookies)); rec.Code != http.StatusSeeOther {
146 t.Fatalf("granting ownership = %d", rec.Code)
147 }
148 // Now the original owner can step back, because they are no longer
149 // the last one.
150 revoke := url.Values{"member": {fmt.Sprint(me.ID)}}
151 rec := do(t, app, as(postForm(sharePath(s)+"/revoke", revoke), cookies))
152 if loc := rec.Header().Get("Location"); strings.Contains(loc, "last-owner") {
153 t.Fatal("stepping back was refused even though a second owner exists")
154 }
155}
156
157func TestShareRefusesAnUnknownRole(t *testing.T) {
158 app, cookies := signIn(t)
159 s, _ := newSheet(t, app, cookies, "Budget")
160
161 form := url.Values{"address": {"ada@elsewhere.example"}, "role": {"admin"}}
162 rec := do(t, app, as(postForm(sharePath(s), form), cookies))
163 if loc := rec.Header().Get("Location"); !strings.Contains(loc, "bad-role") {
164 t.Fatalf("Location = %q, want the bad-role refusal", loc)
165 }
166 ada, _ := app.memberFor("ada@elsewhere.example")
167 if _, ok := app.roleFor(s.ID, ada); ok {
168 t.Fatal("an unknown role still created a grant")
169 }
170}
171
172// The notice is a code that selects a fixed sentence, never text from
173// the query. A page that echoed it would take a redirect parameter
174// straight into the document.
175func TestTheNoticeIsNeverEchoed(t *testing.T) {
176 app, cookies := signIn(t)
177 s, _ := newSheet(t, app, cookies, "Budget")
178
179 const probe = "<script>alert(1)</script>"
180 body := do(t, app, as(httptest.NewRequest(http.MethodGet,
181 sharePath(s)+"?notice="+url.QueryEscape(probe), nil), cookies)).Body.String()
182 if strings.Contains(body, "alert(1)") {
183 t.Fatal("the share screen echoed its notice parameter")
184 }
185 if noticeText(probe) != "" {
186 t.Fatal("noticeText returned something for an unknown code")
187 }
188}
189
190// Sharing is chrome that must work with scripting off: an invite is
191// exactly the thing somebody does from a locked-down machine.
192func TestTheShareScreenIsScriptless(t *testing.T) {
193 app, cookies := signIn(t)
194 s, _ := newSheet(t, app, cookies, "Budget")
195
196 body := withoutScript(do(t, app,
197 as(httptest.NewRequest(http.MethodGet, sharePath(s), nil), cookies)).Body.String())
198 for _, want := range []string{
199 fmt.Sprintf(`action="/sheets/%d/share"`, s.ID),
200 `name="address"`,
201 `name="role"`,
202 } {
203 if !strings.Contains(body, want) {
204 t.Errorf("with scripts stripped, %q is gone", want)
205 }
206 }
207}
208
209// The share screen's default role is the least able one, and this test
210// is the whole enforcement of that.
211//
212// The screen renders shareableRoles straight into a <select> with no
213// option marked selected, so the browser selects the first. That makes
214// the ORDER of this slice a security control rather than a
215// presentational choice: whatever leads it is what a person grants when
216// they fill in an address, press Share, and never touch the dropdown —
217// which is the fastest and therefore most common path through the
218// screen.
219//
220// It led with owner once. The quickest route through the sharing screen
221// handed the sheet away, and the only repair is the new owner giving it
222// back. A reordering that reads like tidying restores that, silently and
223// with every test still green, which is exactly why the assertion is on
224// the first element and not on the set.
225func TestSharingDefaultsToTheLeastAbleRole(t *testing.T) {
226 if shareableRoles[0] != SheetViewer {
227 t.Fatalf("shareableRoles[0] = %q, want %q: the first entry is the "+
228 "<select>'s default, so it is what somebody grants without "+
229 "choosing", shareableRoles[0], SheetViewer)
230 }
231 if shareableRoles[len(shareableRoles)-1] != SheetOwner {
232 t.Errorf("shareableRoles ends with %q, want %q: owner is offered, "+
233 "but it belongs furthest from the default",
234 shareableRoles[len(shareableRoles)-1], SheetOwner)
235 }
236}
237
238// The rendered markup has to agree with the slice above: a <select>
239// whose first <option> is owner is the bug, however the slice is
240// ordered.
241func TestTheShareFormOffersViewerFirst(t *testing.T) {
242 app, cookies := signIn(t)
243 s, _ := newSheet(t, app, cookies, "Budget")
244
245 body := do(t, app,
246 as(httptest.NewRequest(http.MethodGet, sharePath(s), nil), cookies)).Body.String()
247
248 // id="role", NOT name="role". There are two selects named "role" on
249 // this page — the person share and the link share — and the link
250 // one also leads with viewer. Anchored on the name, a reordering of
251 // the two forms would silently retarget this test at the select
252 // that happens to be right, and it would keep passing while the
253 // person default regressed to owner.
254 i := strings.Index(body, `id="role"`)
255 if i < 0 {
256 t.Fatal("no person-share role select on the share screen")
257 }
258 first := strings.Index(body[i:], "<option")
259 if first < 0 {
260 t.Fatal("the role select has no options")
261 }
262 opt := body[i+first:]
263 if end := strings.Index(opt, "</option>"); end >= 0 {
264 opt = opt[:end]
265 }
266 if !strings.Contains(opt, string(SheetViewer)) {
267 t.Errorf("the first role option is %q, want the viewer one: the "+
268 "browser selects the first option, so this is the default grant", opt)
269 }
270
271 // The other way the default moves. First-option-wins only holds
272 // while nothing is marked selected; one `selected` on a later
273 // option overrides it, and every assertion above would still pass.
274 sel := body[i:]
275 if end := strings.Index(sel, "</select>"); end >= 0 {
276 sel = sel[:end]
277 }
278 if strings.Contains(sel, "selected") {
279 t.Errorf("the role select marks an option selected: that beats "+
280 "first-option-wins, so the default is no longer the first "+
281 "entry of shareableRoles.\n%s", sel)
282 }
283}
284