| 1 | package docs_test |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | |
| 8 | "github.com/carlosframework/docs/internal/docs" |
| 9 | ) |
| 10 | |
| 11 | // anon is a request with no cookies at all — nobody, arriving from |
| 12 | // somewhere else. The whole point of a public link is that this works, so |
| 13 | // the helper exists to make every test below say "signed out" in one word. |
| 14 | func anon(t *testing.T, app *docs.App, path string) *http.Response { |
| 15 | t.Helper() |
| 16 | rec := get(t, app, nil, path) |
| 17 | return rec.Result() |
| 18 | } |
| 19 | |
| 20 | // linkFor publishes a document and returns its public path, collected the |
| 21 | // one way the app offers it: the share page rendered immediately after the |
| 22 | // publish. Under ruling 16 the database holds a SHA-256 and cannot produce |
| 23 | // a URL, so there is no second chance and no store-level shortcut — the |
| 24 | // test has to take the same one shot a person does. |
| 25 | func linkFor(t *testing.T, app *docs.App, cookies []*http.Cookie, sqid string) string { |
| 26 | t.Helper() |
| 27 | if rec := post(t, app, cookies, "/d/"+sqid+"/share/link", "on=1"); rec.Code != http.StatusSeeOther { |
| 28 | t.Fatalf("POST link: status %d, want 303", rec.Code) |
| 29 | } |
| 30 | return readLink(t, get(t, app, cookies, "/d/"+sqid+"/share").Body.String()) |
| 31 | } |
| 32 | |
| 33 | // readLink pulls a /p/... path out of a rendered share page. |
| 34 | func readLink(t *testing.T, body string) string { |
| 35 | t.Helper() |
| 36 | i := strings.Index(body, "/p/") |
| 37 | if i < 0 { |
| 38 | t.Fatal("the share page does not show the public link it just created") |
| 39 | } |
| 40 | rest := body[i:] |
| 41 | end := strings.IndexAny(rest, `"'< `) |
| 42 | if end < 0 { |
| 43 | t.Fatal("could not read the public link off the share page") |
| 44 | } |
| 45 | return rest[:end] |
| 46 | } |
| 47 | |
| 48 | // The feature, end to end: Paul's "being able to share read-only publicly". |
| 49 | // A signed-out stranger with the link reads the document. |
| 50 | func TestAPublicLinkLetsASignedOutStrangerRead(t *testing.T) { |
| 51 | app, _ := docs.NewForTest(t) |
| 52 | alice := signInCookies(t, app, "alice@example.com") |
| 53 | sqid := createDoc(t, app, alice, "Minutes") |
| 54 | |
| 55 | // Before the link exists, the app has no public door at all. |
| 56 | if res := anon(t, app, "/d/"+sqid); res.StatusCode == http.StatusOK { |
| 57 | t.Fatal("a signed-out request read a document through the private route") |
| 58 | } |
| 59 | |
| 60 | path := linkFor(t, app, alice, sqid) |
| 61 | res := anon(t, app, path) |
| 62 | if res.StatusCode != http.StatusOK { |
| 63 | t.Fatalf("GET %s signed out: status %d, want 200", path, res.StatusCode) |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | // Read-only means read-only, and the interesting half is that it holds |
| 68 | // against the routes rather than only against the rendering. Ruling 15: |
| 69 | // every write has a named actor, and a link holds no name. |
| 70 | func TestAPublicLinkCarriesNoWrite(t *testing.T) { |
| 71 | app, _ := docs.NewForTest(t) |
| 72 | alice := signInCookies(t, app, "alice@example.com") |
| 73 | sqid := createDoc(t, app, alice, "Minutes") |
| 74 | linkFor(t, app, alice, sqid) |
| 75 | |
| 76 | // Holding the link does not admit its holder to any authenticated |
| 77 | // route: it is a capability on one document, not a session. |
| 78 | for _, path := range []string{ |
| 79 | "/", |
| 80 | "/d/" + sqid, |
| 81 | "/d/" + sqid + "/share", |
| 82 | "/d/" + sqid + "/updates", |
| 83 | } { |
| 84 | if res := anon(t, app, path); res.StatusCode == http.StatusOK { |
| 85 | t.Errorf("signed out, %s answered 200; a link is not a session", path) |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | // And the public page offers no editor to drive one with. |
| 90 | // |
| 91 | // "editor-entry" used to be in this list and appears on no page in |
| 92 | // this app — a ban that could never fail. The mount element and the |
| 93 | // real served script path are what actually matter. |
| 94 | body := get(t, app, nil, linkFor(t, app, alice, sqid)).Body.String() |
| 95 | for _, banned := range []string{"docs-text docs-editor", "data-sqid", docs.TestEditorScriptPath(), "/updates"} { |
| 96 | if strings.Contains(body, banned) { |
| 97 | t.Errorf("the public page ships %q; it is a reading surface", banned) |
| 98 | } |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | // A wrong token is indistinguishable from a document that does not exist — |
| 103 | // spec §4.2's rule, which the public route has to keep too or it becomes |
| 104 | // an oracle for which documents are published. |
| 105 | func TestAWrongTokenIsANotFound(t *testing.T) { |
| 106 | app, _ := docs.NewForTest(t) |
| 107 | alice := signInCookies(t, app, "alice@example.com") |
| 108 | sqid := createDoc(t, app, alice, "Minutes") |
| 109 | path := linkFor(t, app, alice, sqid) |
| 110 | |
| 111 | for _, bad := range []string{"/p/", "/p/nope", "/p/" + strings.Repeat("a", 43), path + "x"} { |
| 112 | if res := anon(t, app, bad); res.StatusCode != http.StatusNotFound { |
| 113 | t.Errorf("GET %s answered %d, want exactly 404", bad, res.StatusCode) |
| 114 | } |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | // The refusals are byte-identical, which is the invariant the branch |
| 119 | // claims and nothing previously checked. The old test asserted only |
| 120 | // "!= 200", so a 500 from a database error would have passed it while |
| 121 | // telling a stranger that this token means something. |
| 122 | // |
| 123 | // Unknown, revoked, and pointing-at-a-deleted-document must be one answer. |
| 124 | // The differences between them are exactly the oracle §4.2 exists to deny: |
| 125 | // which documents were ever published, and which were withdrawn. |
| 126 | func TestPublicRefusalsAreIndistinguishable(t *testing.T) { |
| 127 | app, _ := docs.NewForTest(t) |
| 128 | alice := signInCookies(t, app, "alice@example.com") |
| 129 | |
| 130 | type answer struct{ status, body, ctype string } |
| 131 | capture := func(path string) answer { |
| 132 | rec := get(t, app, nil, path) |
| 133 | return answer{ |
| 134 | status: rec.Result().Status, |
| 135 | body: rec.Body.String(), |
| 136 | ctype: rec.Header().Get("Content-Type"), |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | // (i) a syntactically valid token that was never minted. |
| 141 | unknown := capture("/p/" + strings.Repeat("A", 22)) |
| 142 | |
| 143 | // (ii) a real token, revoked. |
| 144 | revokedSqid := createDoc(t, app, alice, "Revoked") |
| 145 | revokedPath := linkFor(t, app, alice, revokedSqid) |
| 146 | post(t, app, alice, "/d/"+revokedSqid+"/share/link", "on=0") |
| 147 | revoked := capture(revokedPath) |
| 148 | |
| 149 | // (iii) a live token whose document has been deleted underneath it. |
| 150 | deletedSqid := createDoc(t, app, alice, "Deleted") |
| 151 | deletedPath := linkFor(t, app, alice, deletedSqid) |
| 152 | app.TestSoftDelete(t, deletedSqid) |
| 153 | deleted := capture(deletedPath) |
| 154 | |
| 155 | if unknown.status != "404 Not Found" { |
| 156 | t.Fatalf("an unknown token answered %q, want 404", unknown.status) |
| 157 | } |
| 158 | for name, got := range map[string]answer{"revoked": revoked, "deleted-document": deleted} { |
| 159 | if got != unknown { |
| 160 | t.Errorf("%s answers differently from an unknown token:\n got: %+v\nwant: %+v", name, got, unknown) |
| 161 | } |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | // The published page shows the document's actual content. |
| 166 | // |
| 167 | // Every other test here publishes an EMPTY document, so if ByToken's join |
| 168 | // ever scanned links.id into Document.ID the wrong document would be |
| 169 | // replayed and every one of them would still pass with a 200. GORM emits |
| 170 | // SELECT documents.* here today; nothing held it. |
| 171 | func TestAPublicLinkShowsTheDocumentsContent(t *testing.T) { |
| 172 | app, _ := docs.NewForTest(t) |
| 173 | alice := signInCookies(t, app, "alice@example.com") |
| 174 | sqid := createDoc(t, app, alice, "Minutes") |
| 175 | app.TestWrite(t, alice, sqid, "hello") |
| 176 | |
| 177 | body := get(t, app, nil, linkFor(t, app, alice, sqid)).Body.String() |
| 178 | if !strings.Contains(body, "hello") { |
| 179 | t.Error("the published page does not contain the document's text") |
| 180 | } |
| 181 | if strings.Contains(body, sqid) { |
| 182 | t.Error("the published page names the private sqid") |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | // A published URL is shown once and never again — ruling 16 as a |
| 187 | // behaviour rather than as a column type. Reloading the share page after |
| 188 | // collecting it must show the document as published WITHOUT the URL. |
| 189 | func TestThePublicURLIsShownExactlyOnce(t *testing.T) { |
| 190 | app, _ := docs.NewForTest(t) |
| 191 | alice := signInCookies(t, app, "alice@example.com") |
| 192 | sqid := createDoc(t, app, alice, "Minutes") |
| 193 | |
| 194 | path := linkFor(t, app, alice, sqid) // consumes the one showing |
| 195 | |
| 196 | again := get(t, app, alice, "/d/"+sqid+"/share").Body.String() |
| 197 | if strings.Contains(again, path) { |
| 198 | t.Error("the share page showed the public URL a second time") |
| 199 | } |
| 200 | if strings.Contains(again, "/p/") { |
| 201 | t.Error("the share page still prints a /p/ URL after the one showing") |
| 202 | } |
| 203 | // But it still says the document is published, or the owner cannot |
| 204 | // tell a published document from an unpublished one. |
| 205 | // |
| 206 | // The catalog key, not the word: NewForTest wires no BaseCatalog, so |
| 207 | // strings render as their keys — and "published" matched the key |
| 208 | // "docs.share.public.published" whatever the page actually said, |
| 209 | // which made this assertion unfalsifiable. |
| 210 | if !strings.Contains(again, "docs.share.public.published") { |
| 211 | t.Error("the share page does not say the document is published") |
| 212 | } |
| 213 | // And the link itself still works — shown once, not valid once. |
| 214 | if res := anon(t, app, path); res.StatusCode != http.StatusOK { |
| 215 | t.Errorf("the link stopped working after being shown: %d", res.StatusCode) |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | // Nothing anywhere stores the plaintext token. The point of ruling 16, |
| 220 | // asserted against the database rather than against the code that writes |
| 221 | // to it. |
| 222 | func TestTheTokenIsNotStoredInPlaintext(t *testing.T) { |
| 223 | app, _ := docs.NewForTest(t) |
| 224 | alice := signInCookies(t, app, "alice@example.com") |
| 225 | sqid := createDoc(t, app, alice, "Minutes") |
| 226 | path := linkFor(t, app, alice, sqid) |
| 227 | token := strings.TrimPrefix(path, "/p/") |
| 228 | |
| 229 | if app.TestRowContains(t, "links", token) { |
| 230 | t.Error("the plaintext token is in the links table") |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | // Turning it off is the revoke, and it has to be immediate: a link that |
| 235 | // keeps working after the owner switched it off is the failure that makes |
| 236 | // people distrust the whole control. |
| 237 | func TestTurningThePublicLinkOffClosesIt(t *testing.T) { |
| 238 | app, _ := docs.NewForTest(t) |
| 239 | alice := signInCookies(t, app, "alice@example.com") |
| 240 | sqid := createDoc(t, app, alice, "Minutes") |
| 241 | path := linkFor(t, app, alice, sqid) |
| 242 | |
| 243 | if res := anon(t, app, path); res.StatusCode != http.StatusOK { |
| 244 | t.Fatalf("precondition: the link does not work (%d)", res.StatusCode) |
| 245 | } |
| 246 | if rec := post(t, app, alice, "/d/"+sqid+"/share/link", "on=0"); rec.Code != http.StatusSeeOther { |
| 247 | t.Fatalf("POST link off: status %d, want 303", rec.Code) |
| 248 | } |
| 249 | if res := anon(t, app, path); res.StatusCode == http.StatusOK { |
| 250 | t.Error("the link still reads the document after being turned off") |
| 251 | } |
| 252 | |
| 253 | // Turning it back on mints a NEW token: the old one stays dead, so |
| 254 | // "off" is a revocation rather than a pause somebody can undo for a |
| 255 | // stranger who kept the URL. |
| 256 | again := linkFor(t, app, alice, sqid) |
| 257 | if again == path { |
| 258 | t.Error("re-enabling reissued the same token; the revoked link came back") |
| 259 | } |
| 260 | if res := anon(t, app, path); res.StatusCode == http.StatusOK { |
| 261 | t.Error("the old token works again after the link was re-enabled") |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | // Publishing over a live link rotates it. This is the property the store |
| 266 | // promises ("a fresh token every time") and the one the off/on test above |
| 267 | // cannot see: with the row deleted in between, any implementation mints a |
| 268 | // new token, so that test passes on a Publish that reuses an existing one. |
| 269 | // A mutation that made Publish return the existing row survived it, which |
| 270 | // is why this test exists. |
| 271 | // |
| 272 | // It is the rotation an owner needs when a link has gone somewhere they |
| 273 | // did not intend: republish, and the copy that escaped stops working. |
| 274 | func TestPublishingAgainRotatesALiveLink(t *testing.T) { |
| 275 | app, _ := docs.NewForTest(t) |
| 276 | alice := signInCookies(t, app, "alice@example.com") |
| 277 | sqid := createDoc(t, app, alice, "Minutes") |
| 278 | |
| 279 | first := linkFor(t, app, alice, sqid) |
| 280 | second := linkFor(t, app, alice, sqid) // no Unpublish in between |
| 281 | if second == first { |
| 282 | t.Fatal("publishing over a live link reissued the same token") |
| 283 | } |
| 284 | if res := anon(t, app, first); res.StatusCode == http.StatusOK { |
| 285 | t.Error("the rotated-away token still reads the document") |
| 286 | } |
| 287 | if res := anon(t, app, second); res.StatusCode != http.StatusOK { |
| 288 | t.Errorf("the new token does not work: status %d", res.StatusCode) |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | // Only the owner controls it. A writer grantee cannot publish somebody |
| 293 | // else's document to the whole internet — the same rule that stops them |
| 294 | // re-sharing it to one person, and it matters far more here. |
| 295 | func TestOnlyTheOwnerMayPublish(t *testing.T) { |
| 296 | app, _ := docs.NewForTest(t) |
| 297 | alice := signInCookies(t, app, "alice@example.com") |
| 298 | sqid := createDoc(t, app, alice, "Minutes") |
| 299 | bob := signInCookies(t, app, "bob@example.com") |
| 300 | post(t, app, alice, "/d/"+sqid+"/share", "address=bob@example.com&role=writer") |
| 301 | |
| 302 | for _, c := range []struct{ path, body string }{ |
| 303 | {"/d/" + sqid + "/share/link", "on=1"}, |
| 304 | {"/d/" + sqid + "/share/link", "on=0"}, |
| 305 | {"/d/" + sqid + "/share/revoke", "address=alice@example.com"}, |
| 306 | {"/d/" + sqid + "/share", "address=eve@example.com&role=reader"}, |
| 307 | } { |
| 308 | if rec := post(t, app, bob, c.path, c.body); rec.Code != http.StatusNotFound { |
| 309 | t.Errorf("a writer grantee reached POST %s (%s): status %d, want 404", c.path, c.body, rec.Code) |
| 310 | } |
| 311 | } |
| 312 | } |
| 313 | |
| 314 | // The public page must not carry who else can reach the document. The |
| 315 | // share list is the owner's alone, and a published page is the widest |
| 316 | // audience this app has. |
| 317 | func TestThePublicPageLeaksNoAudience(t *testing.T) { |
| 318 | app, _ := docs.NewForTest(t) |
| 319 | alice := signInCookies(t, app, "alice@example.com") |
| 320 | sqid := createDoc(t, app, alice, "Minutes") |
| 321 | post(t, app, alice, "/d/"+sqid+"/share", "address=bob@example.com&role=writer") |
| 322 | path := linkFor(t, app, alice, sqid) |
| 323 | |
| 324 | body := get(t, app, nil, path).Body.String() |
| 325 | for _, secret := range []string{"alice@example.com", "bob@example.com", sqid} { |
| 326 | if strings.Contains(body, secret) { |
| 327 | t.Errorf("the public page names %q", secret) |
| 328 | } |
| 329 | } |
| 330 | } |
| 331 | |