| 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 sharePath(s SheetSummary) string { return fmt.Sprintf("/sheets/%d/share", s.ID) } |
| 13 | |
| 14 | func 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. |
| 42 | func 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. |
| 61 | func 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 | |
| 84 | func 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 | |
| 101 | func 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. |
| 119 | func 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. |
| 139 | func 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 | |
| 157 | func 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. |
| 175 | func 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. |
| 192 | func 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. |
| 225 | func 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. |
| 241 | func 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 | |