oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1package docs_test
2
3import (
4 "net/http"
5 "strings"
6 "testing"
7
8 "github.com/carlosframework/docs/internal/docs"
9)
10
11// members builds an app whose front door is a real allowlist, so the
12// difference between a configured member and somebody admitted only by a
13// grant actually exists. NewForTest's empty allowlist means "admit
14// everyone" in development, which is why none of this was reachable before.
15func members(t *testing.T, addresses string) *docs.App {
16 t.Helper()
17 app, err := docs.NewForTestWith(t, docs.Config{AllowedAddresses: addresses})
18 if err != nil {
19 t.Fatalf("NewForTestWith: %v", err)
20 }
21 return app
22}
23
24// A grant admits its holder as a GUEST, not as a member.
25//
26// Suite §1 ruling 1: "Sharing is membership; an outside guest is a guest
27// member." Docs §8.2: "Outside guests are granted a specific document and
28// become guest members." The branch had no guest kind at all, so a single
29// reader grant was an unbounded, transitive key to the instance's front
30// door: Bob is admitted by Alice's grant, creates his own documents, shares
31// them to Mallory, who is now admitted and can repeat it. Alice cannot
32// undo any of it, and $DOCS_ALLOWED_ADDRESSES silently stopped being the
33// membership list the operator configured. (Branch review, finding 1.)
34//
35// A guest reads and writes what they were granted, and nothing else.
36func TestAGuestCannotCreateOrShare(t *testing.T) {
37 app := members(t, "alice@example.com")
38 alice := signInCookies(t, app, "alice@example.com")
39 sqid := createDoc(t, app, alice, "Minutes")
40 post(t, app, alice, "/d/"+sqid+"/share", "address=bob@example.com&role=writer")
41
42 // Bob gets in — the invitation is redeemable, which is the whole
43 // reason grant-admission exists.
44 bob := signInCookies(t, app, "bob@example.com")
45 if rec := get(t, app, bob, "/d/"+sqid); rec.Code != http.StatusOK {
46 t.Fatalf("the guest cannot read the document he was granted: %d", rec.Code)
47 }
48
49 // But he is a guest. He cannot mint documents of his own — 403
50 // exactly, so a 500 cannot pass for a refusal.
51 if rec := post(t, app, bob, "/documents", "title=Bob%27s+own"); rec.Code != http.StatusForbidden {
52 t.Errorf("guest create: status %d, want 403", rec.Code)
53 }
54 // ...and he cannot admit anybody else, which is the transitive half
55 // and the one that makes the first unbounded.
56 //
57 // Asserting 403 specifically, not merely "not 303". The first version
58 // of this test accepted any non-303, which the OWNER check already
59 // produces for Bob on Alice's document — so deleting the member guard
60 // from sharePost left it green. The re-review caught that by mutation:
61 // three of the four guarded routes had never been exercised at all.
62 for _, c := range []struct{ what, path, body string }{
63 {"share", "/d/" + sqid + "/share", "address=mallory@example.com&role=writer"},
64 {"revoke", "/d/" + sqid + "/share/revoke", "address=alice@example.com"},
65 {"publish", "/d/" + sqid + "/share/link", "on=1"},
66 } {
67 if rec := post(t, app, bob, c.path, c.body); rec.Code != http.StatusForbidden {
68 t.Errorf("guest %s: status %d, want 403 from the member guard", c.what, rec.Code)
69 }
70 }
71}
72
73// A guest is not shown a control they cannot use. Two of them: the create
74// form in the list header, and File → New document in the editor's menu
75// bar. Both post /documents, which answers a guest 403 with a bare text
76// page. (Re-review, finding 3.)
77func TestAGuestIsNotShownCreateControls(t *testing.T) {
78 app := members(t, "alice@example.com")
79 alice := signInCookies(t, app, "alice@example.com")
80 sqid := createDoc(t, app, alice, "Minutes")
81 post(t, app, alice, "/d/"+sqid+"/share", "address=bob@example.com&role=writer")
82 bob := signInCookies(t, app, "bob@example.com")
83
84 for _, page := range []string{"/", "/d/" + sqid} {
85 rec := get(t, app, bob, page)
86 // The status first: an absent control and an unrendered page look
87 // identical to a substring check, so without this the test passes
88 // on a 404 — which is the opposite of what it claims to show.
89 if rec.Code != http.StatusOK {
90 t.Fatalf("the guest's %s answered %d, want 200", page, rec.Code)
91 }
92 if strings.Contains(rec.Body.String(), `action="/documents"`) {
93 t.Errorf("the guest's %s offers a create control they are refused", page)
94 }
95 }
96
97 // Alice keeps both, or the check above passes on an app that offers
98 // nobody a way to create a document.
99 for _, page := range []string{"/", "/d/" + sqid} {
100 if body := get(t, app, alice, page).Body.String(); !strings.Contains(body, `action="/documents"`) {
101 t.Errorf("the member's %s lost its create control", page)
102 }
103 }
104}
105
106// A configured member keeps everything. Without this the rule above passes
107// on an app where nobody can create anything.
108func TestAMemberStillCreatesAndShares(t *testing.T) {
109 app := members(t, "alice@example.com,carol@example.com")
110 carol := signInCookies(t, app, "carol@example.com")
111
112 sqid := createDoc(t, app, carol, "Carol's")
113 if rec := post(t, app, carol, "/d/"+sqid+"/share", "address=dave@example.com&role=reader"); rec.Code != http.StatusSeeOther {
114 t.Errorf("a configured member could not share: status %d", rec.Code)
115 }
116}
117
118// Revoking the last grant ends admission, not just the document.
119//
120// Before this, `allows()` ran only at sign-in, so a revoked guest kept a
121// valid session for up to 30 days — and, per the finding above, kept
122// creating and sharing with it. "Stop sharing" was complete for the
123// document and said nothing about the instance. (Branch review, finding 3.)
124func TestRevokingTheLastGrantEndsAdmission(t *testing.T) {
125 app := members(t, "alice@example.com")
126 alice := signInCookies(t, app, "alice@example.com")
127 sqid := createDoc(t, app, alice, "Minutes")
128 post(t, app, alice, "/d/"+sqid+"/share", "address=bob@example.com&role=reader")
129
130 bob := signInCookies(t, app, "bob@example.com")
131 if rec := get(t, app, bob, "/d/"+sqid); rec.Code != http.StatusOK {
132 t.Fatalf("precondition: the guest cannot read (%d)", rec.Code)
133 }
134
135 post(t, app, alice, "/d/"+sqid+"/share/revoke", "address=bob@example.com")
136
137 // The same cookies, still cryptographically valid, now reach nothing.
138 // 303 exactly: "not 200" would let a 500 pass for a refusal.
139 for _, path := range []string{"/", "/d/" + sqid} {
140 if rec := get(t, app, bob, path); rec.Code != http.StatusSeeOther {
141 t.Errorf("a revoked guest's GET %s answered %d, want 303", path, rec.Code)
142 }
143 }
144
145 // A member is not touched by any of this: the recheck must key on
146 // admission, not on holding a grant.
147 if rec := get(t, app, alice, "/"); rec.Code != http.StatusOK {
148 t.Errorf("the owner lost access to her own list: %d", rec.Code)
149 }
150}
151
152// A revoked guest's writes are REFUSED, not redirected.
153//
154// The worst defect on this branch, and I introduced it while fixing the
155// last one. requireAdmission answered every method with 303 → /signin.
156// fetch() follows redirects by default, so the editor's POST became a GET
157// of the sign-in page, came back 200 with HTML, and save-queue.mjs read
158// res.ok and reported the batch saved. Alice revokes Bob mid-sentence and
159// Bob types for an hour into a page that says everything is fine.
160//
161// That is strictly worse than the reader-editor bug this branch already
162// fixed: that one at least lit the "save failed" indicator. rastrillo's
163// own refuseSession redirects GET/HEAD and 403s everything else, for
164// exactly this reason, and requireAdmission now does the same.
165//
166// A redirect is a fine answer to "show me a page". It is never an answer
167// to "store this". (Re-review, finding 1.)
168func TestARevokedGuestsWritesAreRefusedNotRedirected(t *testing.T) {
169 app := members(t, "alice@example.com")
170 alice := signInCookies(t, app, "alice@example.com")
171 sqid := createDoc(t, app, alice, "Minutes")
172 post(t, app, alice, "/d/"+sqid+"/share", "address=bob@example.com&role=writer")
173 bob := signInCookies(t, app, "bob@example.com")
174
175 // He really can write, first — or the assertion below passes on an
176 // app where nobody can.
177 if rec := postBytes(t, app, bob, "/d/"+sqid+"/updates", docs.ReadFragmentFixture(t, "hello")); rec.Code != http.StatusNoContent {
178 t.Fatalf("precondition: the guest writer cannot write (%d)", rec.Code)
179 }
180
181 post(t, app, alice, "/d/"+sqid+"/share/revoke", "address=bob@example.com")
182
183 // Now every write must be refused with a status the browser reports
184 // as a failure. A 3xx here is the bug: fetch follows it and the save
185 // queue reads the sign-in page's 200 as success.
186 rec := postBytes(t, app, bob, "/d/"+sqid+"/updates", docs.ReadFragmentFixture(t, "hello"))
187 if rec.Code >= 300 && rec.Code < 400 {
188 t.Errorf("a revoked guest's write was redirected (%d → %q); fetch follows it and the save reports ok",
189 rec.Code, rec.Header().Get("Location"))
190 }
191 if rec.Code != http.StatusForbidden {
192 t.Errorf("a revoked guest's write answered %d; want 403", rec.Code)
193 }
194
195 // GETs still redirect — that is what a redirect is for, and a person
196 // following a link deserves the sign-in page rather than a bare 403.
197 if rec := get(t, app, bob, "/"); rec.Code != http.StatusSeeOther {
198 t.Errorf("a revoked guest's GET / answered %d, want 303 to sign-in", rec.Code)
199 }
200}
201
202// The wiring, end to end through the real sign-in, rather than allows()
203// and HasGrant each tested against a stand-in for the other.
204func TestAGrantAdmitsThroughTheRealSignIn(t *testing.T) {
205 app := members(t, "alice@example.com")
206 alice := signInCookies(t, app, "alice@example.com")
207 sqid := createDoc(t, app, alice, "Minutes")
208
209 // Before any grant, Bob is refused — and refused invisibly, since
210 // gateBegin answers a refused address exactly as it answers a real
211 // one so the endpoint is not a membership oracle.
212 if _, err := app.TestSignIn(t, "bob@example.com"); err == nil {
213 t.Fatal("an address on no list and holding no grant was mailed a link")
214 }
215
216 post(t, app, alice, "/d/"+sqid+"/share", "address=bob@example.com&role=reader")
217
218 link, err := app.TestSignIn(t, "bob@example.com")
219 if err != nil {
220 t.Fatalf("a granted address was not mailed a link: %v", err)
221 }
222 if !strings.Contains(link, "/auth/verify") {
223 t.Fatalf("not a verify link: %q", link)
224 }
225}
226