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
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.
19func 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".
46func 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.
67func 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
82func 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.
101func 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.
117func 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