| 1 | package docs_test |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "net/http/httptest" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 9 | "github.com/carlosframework/docs/internal/docs" |
| 10 | ) |
| 11 | |
| 12 | // post sends a scriptless form the way a browser would: urlencoded body, a |
| 13 | // real Origin, and the signed-in person's cookies. There is no CSRF token |
| 14 | // field anywhere in this app — csrf.Protect reads Sec-Fetch-Site first and |
| 15 | // falls back to Origin — so a test that forgets the header is testing the |
| 16 | // CSRF layer rather than the handler. |
| 17 | func post(t *testing.T, app *docs.App, cookies []*http.Cookie, path, body string) *httptest.ResponseRecorder { |
| 18 | t.Helper() |
| 19 | req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) |
| 20 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 21 | req.Header.Set("Origin", docs.DefaultOrigin) |
| 22 | for _, c := range cookies { |
| 23 | req.AddCookie(c) |
| 24 | } |
| 25 | rec := httptest.NewRecorder() |
| 26 | app.Handler().ServeHTTP(rec, req) |
| 27 | return rec |
| 28 | } |
| 29 | |
| 30 | func get(t *testing.T, app *docs.App, cookies []*http.Cookie, path string) *httptest.ResponseRecorder { |
| 31 | t.Helper() |
| 32 | req := httptest.NewRequest(http.MethodGet, path, nil) |
| 33 | for _, c := range cookies { |
| 34 | req.AddCookie(c) |
| 35 | } |
| 36 | rec := httptest.NewRecorder() |
| 37 | app.Handler().ServeHTTP(rec, req) |
| 38 | return rec |
| 39 | } |
| 40 | |
| 41 | // createDoc makes a document as the signed-in person and returns its sqid. |
| 42 | func createDoc(t *testing.T, app *docs.App, cookies []*http.Cookie, title string) string { |
| 43 | t.Helper() |
| 44 | rec := post(t, app, cookies, "/documents", "title="+title) |
| 45 | if rec.Code != http.StatusSeeOther { |
| 46 | t.Fatalf("POST /documents: status %d, want 303", rec.Code) |
| 47 | } |
| 48 | return strings.TrimPrefix(rec.Header().Get("Location"), "/d/") |
| 49 | } |
| 50 | |
| 51 | // The whole point of the feature, end to end and scriptless: Alice shares a |
| 52 | // document with Bob by typing his address into a form, and Bob can read it. |
| 53 | func TestShareThenTheGranteeCanRead(t *testing.T) { |
| 54 | app, _ := docs.NewForTest(t) |
| 55 | alice := signInCookies(t, app, "alice@example.com") |
| 56 | sqid := createDoc(t, app, alice, "Minutes") |
| 57 | |
| 58 | // Bob cannot see it yet. |
| 59 | bob := signInCookies(t, app, "bob@example.com") |
| 60 | if rec := get(t, app, bob, "/d/"+sqid); rec.Code != http.StatusNotFound { |
| 61 | t.Fatalf("bob reading before the share: status %d, want 404", rec.Code) |
| 62 | } |
| 63 | |
| 64 | rec := post(t, app, alice, "/d/"+sqid+"/share", "address=bob@example.com&role=reader") |
| 65 | if rec.Code != http.StatusSeeOther { |
| 66 | t.Fatalf("POST share: status %d, want 303", rec.Code) |
| 67 | } |
| 68 | |
| 69 | if rec := get(t, app, bob, "/d/"+sqid); rec.Code != http.StatusOK { |
| 70 | t.Errorf("bob reading after the share: status %d, want 200", rec.Code) |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | // The share page lists who currently holds a grant, or the owner cannot tell |
| 75 | // what they have shared and with whom — which is the question the page exists |
| 76 | // to answer. |
| 77 | func TestSharePageListsGrantees(t *testing.T) { |
| 78 | app, _ := docs.NewForTest(t) |
| 79 | alice := signInCookies(t, app, "alice@example.com") |
| 80 | sqid := createDoc(t, app, alice, "Minutes") |
| 81 | |
| 82 | if rec := post(t, app, alice, "/d/"+sqid+"/share", "address=bob@example.com&role=writer"); rec.Code != http.StatusSeeOther { |
| 83 | t.Fatalf("POST share: status %d", rec.Code) |
| 84 | } |
| 85 | |
| 86 | rec := get(t, app, alice, "/d/"+sqid+"/share") |
| 87 | if rec.Code != http.StatusOK { |
| 88 | t.Fatalf("GET share: status %d, want 200", rec.Code) |
| 89 | } |
| 90 | if !strings.Contains(rec.Body.String(), "bob@example.com") { |
| 91 | t.Error("the share page does not name the person the document is shared with") |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | // Only the owner shares. A grantee — even one who can write — reaches the |
| 96 | // share page exactly as a stranger does, with a 404 rather than a 403: a |
| 97 | // document you may not share does not announce itself as one you may not |
| 98 | // share (spec §4.2). |
| 99 | func TestOnlyTheOwnerReachesTheSharePage(t *testing.T) { |
| 100 | app, _ := docs.NewForTest(t) |
| 101 | alice := signInCookies(t, app, "alice@example.com") |
| 102 | sqid := createDoc(t, app, alice, "Minutes") |
| 103 | |
| 104 | if rec := post(t, app, alice, "/d/"+sqid+"/share", "address=bob@example.com&role=writer"); rec.Code != http.StatusSeeOther { |
| 105 | t.Fatalf("POST share: status %d", rec.Code) |
| 106 | } |
| 107 | |
| 108 | bob := signInCookies(t, app, "bob@example.com") |
| 109 | if rec := get(t, app, bob, "/d/"+sqid+"/share"); rec.Code != http.StatusNotFound { |
| 110 | t.Errorf("a writer reaching the share page: status %d, want 404", rec.Code) |
| 111 | } |
| 112 | if rec := post(t, app, bob, "/d/"+sqid+"/share", "address=carol@example.com&role=reader"); rec.Code != http.StatusNotFound { |
| 113 | t.Errorf("a writer re-sharing: status %d, want 404", rec.Code) |
| 114 | } |
| 115 | |
| 116 | carol := signInCookies(t, app, "carol@example.com") |
| 117 | if rec := get(t, app, carol, "/d/"+sqid); rec.Code != http.StatusNotFound { |
| 118 | t.Error("bob managed to re-share the document with carol") |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | // Revoking takes the access away again. Without this the share page is a |
| 123 | // one-way door and a mistyped address is permanent. |
| 124 | func TestRevokeRemovesAccess(t *testing.T) { |
| 125 | app, _ := docs.NewForTest(t) |
| 126 | alice := signInCookies(t, app, "alice@example.com") |
| 127 | sqid := createDoc(t, app, alice, "Minutes") |
| 128 | bob := signInCookies(t, app, "bob@example.com") |
| 129 | |
| 130 | if rec := post(t, app, alice, "/d/"+sqid+"/share", "address=bob@example.com&role=reader"); rec.Code != http.StatusSeeOther { |
| 131 | t.Fatalf("POST share: status %d", rec.Code) |
| 132 | } |
| 133 | if rec := get(t, app, bob, "/d/"+sqid); rec.Code != http.StatusOK { |
| 134 | t.Fatalf("bob cannot read after the share; the revoke below would prove nothing") |
| 135 | } |
| 136 | |
| 137 | if rec := post(t, app, alice, "/d/"+sqid+"/share/revoke", "address=bob@example.com"); rec.Code != http.StatusSeeOther { |
| 138 | t.Fatalf("POST revoke: status %d, want 303", rec.Code) |
| 139 | } |
| 140 | |
| 141 | if rec := get(t, app, bob, "/d/"+sqid); rec.Code != http.StatusNotFound { |
| 142 | t.Errorf("bob reading after revoke: status %d, want 404", rec.Code) |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | // A reader granted read access cannot write, through the real HTTP path |
| 147 | // rather than only at the store. The 404 is the same one a stranger gets. |
| 148 | func TestAReaderCannotPostUpdates(t *testing.T) { |
| 149 | app, _ := docs.NewForTest(t) |
| 150 | alice := signInCookies(t, app, "alice@example.com") |
| 151 | sqid := createDoc(t, app, alice, "Minutes") |
| 152 | |
| 153 | if rec := post(t, app, alice, "/d/"+sqid+"/share", "address=bob@example.com&role=reader"); rec.Code != http.StatusSeeOther { |
| 154 | t.Fatalf("POST share: status %d", rec.Code) |
| 155 | } |
| 156 | |
| 157 | bob := signInCookies(t, app, "bob@example.com") |
| 158 | rec := post(t, app, bob, "/d/"+sqid+"/updates", "") |
| 159 | if rec.Code != http.StatusNotFound { |
| 160 | t.Errorf("a reader posting an update: status %d, want 404", rec.Code) |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | // An empty address is refused rather than stored, so the page cannot mint a |
| 165 | // grant that matches nobody and lists as a blank row forever. |
| 166 | func TestShareRefusesAnEmptyAddress(t *testing.T) { |
| 167 | app, _ := docs.NewForTest(t) |
| 168 | alice := signInCookies(t, app, "alice@example.com") |
| 169 | sqid := createDoc(t, app, alice, "Minutes") |
| 170 | |
| 171 | rec := post(t, app, alice, "/d/"+sqid+"/share", "address=&role=reader") |
| 172 | if rec.Code == http.StatusSeeOther { |
| 173 | t.Error("an empty address was accepted as a share") |
| 174 | } |
| 175 | } |
| 176 | |