| 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 | // The HTTP half of the two-user isolation suite: the same questions as |
| 13 | // grants_test.go, asked through the real routes with a real session, |
| 14 | // because a service-level check that passes and a route that forgot to |
| 15 | // call it look identical from inside Go. |
| 16 | |
| 17 | // The HTTP surface, end to end: a guest signs in for real and is told |
| 18 | // nothing about the sheets they were not given. |
| 19 | func TestAGuestGetsA404NotA403(t *testing.T) { |
| 20 | app, cookies := signIn(t) |
| 21 | shared, _ := newSheet(t, app, cookies, "Shared") |
| 22 | private, _ := newSheet(t, app, cookies, "Private") |
| 23 | guestOn(t, app, shared.ID, "ada@elsewhere.example", SheetViewer) |
| 24 | |
| 25 | guestCookies := signInAs(t, app, "ada@elsewhere.example") |
| 26 | |
| 27 | for _, tc := range []struct { |
| 28 | path string |
| 29 | want int |
| 30 | }{ |
| 31 | {fmt.Sprintf("/sheets/%d", shared.ID), http.StatusOK}, |
| 32 | {fmt.Sprintf("/sheets/%d", private.ID), http.StatusNotFound}, |
| 33 | {fmt.Sprintf("/sheets/%d/table", private.ID), http.StatusNotFound}, |
| 34 | {fmt.Sprintf("/api/sheets/%d/tabs/%d/cells", private.ID, private.TabID), http.StatusNotFound}, |
| 35 | {fmt.Sprintf("/api/sheets/%d/tabs/%d/stream", private.ID, private.TabID), http.StatusNotFound}, |
| 36 | } { |
| 37 | rec := do(t, app, as(httptest.NewRequest(http.MethodGet, tc.path, nil), guestCookies)) |
| 38 | if rec.Code != tc.want { |
| 39 | t.Errorf("GET %s as a guest = %d, want %d", tc.path, rec.Code, tc.want) |
| 40 | } |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | // A viewer may look and must not write. This is the one that would fail |
| 45 | // silently if a route checked "has any grant" instead of "has enough". |
| 46 | func TestAViewerCannotWrite(t *testing.T) { |
| 47 | app, cookies := signIn(t) |
| 48 | s, cells := newSheet(t, app, cookies, "Read only") |
| 49 | guestOn(t, app, s.ID, "ada@elsewhere.example", SheetViewer) |
| 50 | guestCookies := signInAs(t, app, "ada@elsewhere.example") |
| 51 | |
| 52 | if rec := do(t, app, as(httptest.NewRequest(http.MethodGet, cells, nil), guestCookies)); rec.Code != http.StatusOK { |
| 53 | t.Fatalf("a viewer cannot read the grid: %d", rec.Code) |
| 54 | } |
| 55 | if rec := postCell(t, app, guestCookies, cells, `{"r":0,"c":0,"raw":"sneaky"}`); rec.Code != http.StatusNotFound { |
| 56 | t.Errorf("a viewer wrote a cell: %d", rec.Code) |
| 57 | } |
| 58 | form := url.Values{"r": {"0"}, "c": {"0"}, "raw": {"sneaky"}, "page": {"1"}} |
| 59 | if rec := do(t, app, as(postForm(fmt.Sprintf("/sheets/%d/table", s.ID), form), guestCookies)); rec.Code == http.StatusSeeOther { |
| 60 | t.Error("a viewer wrote a cell through the scriptless table") |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | // A commenter may read and must not write cells. Commenting itself is |
| 65 | // a later slice; what this pins is that the role does not leak edit |
| 66 | // rights on the way there. |
| 67 | func TestACommenterCannotWriteCells(t *testing.T) { |
| 68 | app, cookies := signIn(t) |
| 69 | s, cells := newSheet(t, app, cookies, "Comment only") |
| 70 | guestOn(t, app, s.ID, "ada@elsewhere.example", SheetCommenter) |
| 71 | guestCookies := signInAs(t, app, "ada@elsewhere.example") |
| 72 | |
| 73 | if rec := do(t, app, as(httptest.NewRequest(http.MethodGet, cells, nil), guestCookies)); rec.Code != http.StatusOK { |
| 74 | t.Fatalf("a commenter cannot read the grid: %d", rec.Code) |
| 75 | } |
| 76 | if rec := postCell(t, app, guestCookies, cells, `{"r":0,"c":0,"raw":"sneaky"}`); rec.Code != http.StatusNotFound { |
| 77 | t.Errorf("a commenter wrote a cell: %d", rec.Code) |
| 78 | } |
| 79 | _ = s |
| 80 | } |
| 81 | |
| 82 | func TestAnEditorCanWrite(t *testing.T) { |
| 83 | app, cookies := signIn(t) |
| 84 | s, cells := newSheet(t, app, cookies, "Editable") |
| 85 | guestOn(t, app, s.ID, "ada@elsewhere.example", SheetEditor) |
| 86 | guestCookies := signInAs(t, app, "ada@elsewhere.example") |
| 87 | |
| 88 | rec := postCell(t, app, guestCookies, cells, `{"r":0,"c":0,"raw":"=2*3"}`) |
| 89 | if rec.Code != http.StatusOK { |
| 90 | t.Fatalf("an editor could not write: %d %s", rec.Code, rec.Body.String()) |
| 91 | } |
| 92 | if g := decodeGrid(t, rec); len(g.Cells) != 1 || g.Cells[0].Display != "6" { |
| 93 | t.Fatalf("the editor's write produced %+v", g.Cells) |
| 94 | } |
| 95 | _ = s |
| 96 | } |
| 97 | |
| 98 | // The stream carries every cell of every edit, so it needs the same |
| 99 | // grant a read of the grid does. Without the check it is the quietest |
| 100 | // possible leak, because nothing renders it. |
| 101 | func TestTheStreamIsGated(t *testing.T) { |
| 102 | app, cookies := signIn(t) |
| 103 | private, _ := newSheet(t, app, cookies, "Private") |
| 104 | other, _ := newSheet(t, app, cookies, "Shared") |
| 105 | guestOn(t, app, other.ID, "ada@elsewhere.example", SheetViewer) |
| 106 | guestCookies := signInAs(t, app, "ada@elsewhere.example") |
| 107 | |
| 108 | path := fmt.Sprintf("/api/sheets/%d/tabs/%d/stream", private.ID, private.TabID) |
| 109 | if rec := do(t, app, as(httptest.NewRequest(http.MethodGet, path, nil), guestCookies)); rec.Code != http.StatusNotFound { |
| 110 | t.Fatalf("a guest opened the stream of a sheet they cannot read: %d", rec.Code) |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | // signInAs drives the real magic-link flow for an arbitrary admitted |
| 115 | // address, so a guest's session is minted the way a person's is rather |
| 116 | // than fabricated. |
| 117 | func signInAs(t *testing.T, app *App, address string) []*http.Cookie { |
| 118 | t.Helper() |
| 119 | mail := app.testMailer(t) |
| 120 | before := len(mail.addresses()) |
| 121 | |
| 122 | req := httptest.NewRequest(http.MethodPost, "/signin", strings.NewReader("address="+url.QueryEscape(address))) |
| 123 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 124 | req.Header.Set("Origin", testOrigin) |
| 125 | if rec := do(t, app, req); rec.Code != http.StatusSeeOther { |
| 126 | t.Fatalf("POST /signin for %s = %d", address, rec.Code) |
| 127 | } |
| 128 | if len(mail.addresses()) == before { |
| 129 | t.Fatalf("no link was emailed to %s; is that address admitted?", address) |
| 130 | } |
| 131 | rec := followLink(t, app.Router(), mail.lastLink(t)) |
| 132 | cookies := rec.Result().Cookies() |
| 133 | if len(cookies) == 0 { |
| 134 | t.Fatalf("verify minted no session for %s (status %d, location %q)", |
| 135 | address, rec.Code, rec.Header().Get("Location")) |
| 136 | } |
| 137 | return cookies |
| 138 | } |
| 139 | |