| 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 one rule, through a side door. Spec §8.3: stored plaintext, the |
| 13 | // replicated database becomes a bundle of live read capabilities to |
| 14 | // sealed sheets. |
| 15 | func TestALinkTokenIsNeverStored(t *testing.T) { |
| 16 | app, cookies := signIn(t) |
| 17 | s, _ := newSheet(t, app, cookies, "Budget") |
| 18 | |
| 19 | token, err := app.createLink(s.ID, SheetViewer, signedInMember(t, app)) |
| 20 | if err != nil { |
| 21 | t.Fatalf("createLink: %v", err) |
| 22 | } |
| 23 | if len(token) < 40 { |
| 24 | t.Fatalf("token is %d characters; 32 random bytes should be longer", len(token)) |
| 25 | } |
| 26 | |
| 27 | var stored string |
| 28 | if err := app.db.Raw("SELECT token_hash FROM share_links LIMIT 1").Row().Scan(&stored); err != nil { |
| 29 | t.Fatalf("read back: %v", err) |
| 30 | } |
| 31 | if stored == token { |
| 32 | t.Fatal("the token itself is in the database") |
| 33 | } |
| 34 | if strings.Contains(stored, token) || strings.Contains(token, stored) { |
| 35 | t.Fatal("the stored value contains the token") |
| 36 | } |
| 37 | if stored != hashLinkToken(token) { |
| 38 | t.Fatal("the stored value is not the token's hash") |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | func TestTwoLinksNeverCollide(t *testing.T) { |
| 43 | app, cookies := signIn(t) |
| 44 | s, _ := newSheet(t, app, cookies, "Budget") |
| 45 | me := signedInMember(t, app) |
| 46 | |
| 47 | seen := map[string]bool{} |
| 48 | for i := 0; i < 20; i++ { |
| 49 | token, err := app.createLink(s.ID, SheetViewer, me) |
| 50 | if err != nil { |
| 51 | t.Fatalf("createLink: %v", err) |
| 52 | } |
| 53 | if seen[token] { |
| 54 | t.Fatal("crypto/rand produced the same token twice") |
| 55 | } |
| 56 | seen[token] = true |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | // Ruling 12: never editor. Every version row carries an actor, and an |
| 61 | // anonymous editor would write unattributed rows into version history. |
| 62 | func TestALinkCanNeverEdit(t *testing.T) { |
| 63 | app, cookies := signIn(t) |
| 64 | s, _ := newSheet(t, app, cookies, "Budget") |
| 65 | me := signedInMember(t, app) |
| 66 | |
| 67 | for _, role := range []SheetRole{SheetEditor, SheetOwner, SheetRole("admin"), SheetRole("")} { |
| 68 | if _, err := app.createLink(s.ID, role, me); err == nil { |
| 69 | t.Errorf("a %q link was created", role) |
| 70 | } |
| 71 | } |
| 72 | for _, role := range []SheetRole{SheetViewer, SheetCommenter} { |
| 73 | if _, err := app.createLink(s.ID, role, me); err != nil { |
| 74 | t.Errorf("a %q link was refused: %v", role, err) |
| 75 | } |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | // A view link mints nothing: no session, no member, no directory entry. |
| 80 | func TestALinkOpensTheSheetWithNoAccount(t *testing.T) { |
| 81 | app, cookies := signIn(t) |
| 82 | s, cells := newSheet(t, app, cookies, "Budget") |
| 83 | postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"=6*7"}`) |
| 84 | |
| 85 | token, err := app.createLink(s.ID, SheetViewer, signedInMember(t, app)) |
| 86 | if err != nil { |
| 87 | t.Fatalf("createLink: %v", err) |
| 88 | } |
| 89 | |
| 90 | // No cookies at all. |
| 91 | rec := do(t, app, httptest.NewRequest(http.MethodGet, "/l/"+token, nil)) |
| 92 | if rec.Code != http.StatusOK { |
| 93 | t.Fatalf("GET /l/<token> = %d, want 200", rec.Code) |
| 94 | } |
| 95 | body := rec.Body.String() |
| 96 | if !strings.Contains(body, "Budget") { |
| 97 | t.Error("the link page does not show the sheet's title") |
| 98 | } |
| 99 | if !strings.Contains(body, ">42<") { |
| 100 | t.Error("the link page does not show the sheet's computed values") |
| 101 | } |
| 102 | |
| 103 | var members int64 |
| 104 | app.db.Model(&Member{}).Count(&members) |
| 105 | // Only the signed-in owner. Opening a link must not mint anybody. |
| 106 | if members != 1 { |
| 107 | t.Fatalf("opening a link left %d members, want 1 — a view link mints nothing", members) |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | // The link page must offer no way to write, and the write routes must |
| 112 | // refuse a link holder, who has no session at all. |
| 113 | func TestALinkHolderCannotWrite(t *testing.T) { |
| 114 | app, cookies := signIn(t) |
| 115 | s, cells := newSheet(t, app, cookies, "Budget") |
| 116 | token, err := app.createLink(s.ID, SheetCommenter, signedInMember(t, app)) |
| 117 | if err != nil { |
| 118 | t.Fatalf("createLink: %v", err) |
| 119 | } |
| 120 | |
| 121 | body := do(t, app, httptest.NewRequest(http.MethodGet, "/l/"+token, nil)).Body.String() |
| 122 | if strings.Contains(body, "<form") { |
| 123 | t.Error("the link page carries a form; a link is read-only") |
| 124 | } |
| 125 | if strings.Contains(body, "edit=") { |
| 126 | t.Error("the link page offers an edit link") |
| 127 | } |
| 128 | |
| 129 | // And the real write routes, with no session. |
| 130 | if rec := postCell(t, app, nil, cells, `{"r":0,"c":0,"raw":"sneaky"}`); rec.Code == http.StatusOK { |
| 131 | t.Error("a cell was written with no session") |
| 132 | } |
| 133 | form := url.Values{"r": {"0"}, "c": {"0"}, "raw": {"sneaky"}, "page": {"1"}} |
| 134 | if rec := do(t, app, postForm(fmt.Sprintf("/sheets/%d/table", s.ID), form)); rec.Code == http.StatusSeeOther { |
| 135 | t.Error("the scriptless table accepted a write with no session") |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | func TestARevokedLinkGivesNothing(t *testing.T) { |
| 140 | app, cookies := signIn(t) |
| 141 | s, _ := newSheet(t, app, cookies, "Budget") |
| 142 | token, err := app.createLink(s.ID, SheetViewer, signedInMember(t, app)) |
| 143 | if err != nil { |
| 144 | t.Fatalf("createLink: %v", err) |
| 145 | } |
| 146 | if rec := do(t, app, httptest.NewRequest(http.MethodGet, "/l/"+token, nil)); rec.Code != http.StatusOK { |
| 147 | t.Fatalf("the link did not work before revoking: %d", rec.Code) |
| 148 | } |
| 149 | |
| 150 | links, err := app.linksOn(s.ID) |
| 151 | if err != nil || len(links) != 1 { |
| 152 | t.Fatalf("linksOn: %v (%d links)", err, len(links)) |
| 153 | } |
| 154 | if err := app.revokeLink(s.ID, links[0].ID); err != nil { |
| 155 | t.Fatalf("revokeLink: %v", err) |
| 156 | } |
| 157 | if rec := do(t, app, httptest.NewRequest(http.MethodGet, "/l/"+token, nil)); rec.Code != http.StatusNotFound { |
| 158 | t.Fatalf("a revoked link still serves: %d", rec.Code) |
| 159 | } |
| 160 | // And it disappears from the share screen's live list. |
| 161 | live, err := app.linksOn(s.ID) |
| 162 | if err != nil { |
| 163 | t.Fatalf("linksOn: %v", err) |
| 164 | } |
| 165 | if len(live) != 0 { |
| 166 | t.Fatalf("a revoked link is still listed as live") |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | // One answer for every failure, so the route is not an oracle for which |
| 171 | // tokens were ever real. |
| 172 | func TestABadTokenIs404(t *testing.T) { |
| 173 | app, _ := signIn(t) |
| 174 | for _, token := range []string{"nope", "", strings.Repeat("A", 43), "../../etc/passwd"} { |
| 175 | path := "/l/" + url.PathEscape(token) |
| 176 | rec := do(t, app, httptest.NewRequest(http.MethodGet, path, nil)) |
| 177 | if rec.Code != http.StatusNotFound { |
| 178 | t.Errorf("GET %s = %d, want 404", path, rec.Code) |
| 179 | } |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | // The token is shown once because only its hash is kept. That is a |
| 184 | // property of the design, so it is asserted rather than described. |
| 185 | func TestTheTokenIsShownOnceAndNotAgain(t *testing.T) { |
| 186 | app, cookies := signIn(t) |
| 187 | s, _ := newSheet(t, app, cookies, "Budget") |
| 188 | |
| 189 | form := url.Values{"role": {string(SheetViewer)}} |
| 190 | rec := do(t, app, as(postForm(sharePath(s)+"/link", form), cookies)) |
| 191 | if rec.Code != http.StatusOK { |
| 192 | t.Fatalf("creating a link = %d: %s", rec.Code, rec.Body.String()) |
| 193 | } |
| 194 | |
| 195 | // The response to the POST is the one place it appears. |
| 196 | if !strings.Contains(rec.Body.String(), "/l/") { |
| 197 | t.Fatal("minting a link did not show the new link") |
| 198 | } |
| 199 | |
| 200 | // Loading the screen again cannot. |
| 201 | plain := do(t, app, as(httptest.NewRequest(http.MethodGet, sharePath(s), nil), cookies)).Body.String() |
| 202 | if strings.Contains(plain, "/l/") { |
| 203 | t.Fatal("the share screen showed a link URL without being handed the token — " + |
| 204 | "something is storing it") |
| 205 | } |
| 206 | if !strings.Contains(plain, "link") { |
| 207 | t.Error("the share screen does not mention the live link at all") |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | // TestTheShareScreenOnlyEchoesItsOwnToken is deliberately gone. |
| 212 | // |
| 213 | // It asserted that a token minted for one sheet is not echoed onto |
| 214 | // another sheet's share screen — a real property while the screen read |
| 215 | // a token out of its query string. The screen no longer reads one at |
| 216 | // all, so no production change could make that test fail: it would have |
| 217 | // sat in the suite passing and proving nothing, which is the shape this |
| 218 | // branch has been finding all week. TestTheShareScreenIgnoresATokenInIts |
| 219 | // Query, in sharelink_url_test.go, asserts the stronger property that |
| 220 | // replaced it. |
| 221 | |
| 222 | // Only an owner may mint or revoke a link — it is the same power as |
| 223 | // granting access, in a different shape. |
| 224 | func TestOnlyAnOwnerCanMintALink(t *testing.T) { |
| 225 | app, cookies := signIn(t) |
| 226 | s, _ := newSheet(t, app, cookies, "Budget") |
| 227 | guestOn(t, app, s.ID, "ada@elsewhere.example", SheetEditor) |
| 228 | guestCookies := signInAs(t, app, "ada@elsewhere.example") |
| 229 | |
| 230 | form := url.Values{"role": {string(SheetViewer)}} |
| 231 | if rec := do(t, app, as(postForm(sharePath(s)+"/link", form), guestCookies)); rec.Code != http.StatusNotFound { |
| 232 | t.Errorf("an editor minted a link: %d", rec.Code) |
| 233 | } |
| 234 | links, err := app.linksOn(s.ID) |
| 235 | if err != nil { |
| 236 | t.Fatalf("linksOn: %v", err) |
| 237 | } |
| 238 | if len(links) != 0 { |
| 239 | t.Fatal("an editor's attempt created a link anyway") |
| 240 | } |
| 241 | } |
| 242 | |