oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1package docs
2
3import (
4 "context"
5 "database/sql"
6 "fmt"
7 "io"
8 "io/fs"
9 "net/http"
10 "net/http/httptest"
11 "net/url"
12 "os"
13 "path/filepath"
14 "strings"
15 "testing"
16 "time"
17)
18
19// NewForTest builds an App against a temporary SQLite database. It is
20// the constructor every later task's tests use — deliberately not in a
21// _test.go file, so packages other than this one's own tests can reach
22// it too.
23//
24// Env is fixed at "development": that is what lets InstanceKey and
25// Mailer (auth.go) default rather than refuse to start, and what
26// installs the *recordingMailer TestSignIn (below) reads a minted
27// link back from. It used to also enable a development-only stand-in
28// identity (devauth.go); that identity had zero readers once real
29// sign-in landed and was deleted in fix round 1 on Task 1 — every
30// test built on NewForTest signs in for real via TestSignIn now.
31func NewForTest(t *testing.T) (*App, error) {
32 return NewForTestWith(t, Config{})
33}
34
35// NewForTestWith is NewForTest with the caller's own Config, for the tests
36// that need a real allowlist.
37//
38// It exists because NewForTest fixes an empty AllowedAddresses, which in
39// development means "admit everyone" (resolveAllowlist) — so the whole
40// grant-admission path, and the guest/member distinction built on it, were
41// unreachable from any test in the suite. The branch review found exactly
42// that hole: allows() and HasGrant were each tested in isolation, and the
43// wiring that makes them one thing was not, so moving one line in app.go
44// would have silently recreated the unredeemable invitation its comment
45// warns about, green. (Branch review, finding 7e.)
46//
47// DBPath and Env are filled in when the caller leaves them empty, so a test
48// that only wants an allowlist writes only an allowlist.
49func NewForTestWith(t *testing.T, cfg Config) (*App, error) {
50 t.Helper()
51
52 if cfg.DBPath == "" {
53 cfg.DBPath = filepath.Join(t.TempDir(), "docs.db")
54 }
55 if cfg.Env == "" {
56 cfg.Env = "development"
57 }
58
59 app, err := New(cfg)
60 if err != nil {
61 return nil, err
62 }
63 t.Cleanup(func() {
64 if cerr := app.Close(); cerr != nil {
65 t.Errorf("docs.App.Close: %v", cerr)
66 }
67 })
68 return app, nil
69}
70
71// TestSignIn mints a magic link for address and returns its path
72// (e.g. "/auth/verify?token=..."). GETting it draws the confirm door;
73// confirmRequest(link) is the POST that spends it. It never
74// sends real mail: it POSTs /signin with force=1 — the escape hatch
75// auth.Begin's own doc comment describes ("skips classification and
76// goes straight to the magic link") — so no outbound classification
77// call ever happens, and it reads the link back out of the app's
78// *recordingMailer (auth.go), installed only in development
79// (NewForTest's Env), rather than through any real transport. It is
80// test scaffolding, not a shortcut available in production: an App
81// built with a non-development Env has no recordingMailer and this
82// returns an error.
83//
84// Every later task's tests that need a signed-in request build on
85// this — get its shape right (a path, ready for httptest.NewRequest)
86// and address (a plain email string) is all that changes.
87func (a *App) TestSignIn(t *testing.T, address string) (string, error) {
88 t.Helper()
89
90 if a.mailRecorder == nil {
91 return "", fmt.Errorf("docs: TestSignIn requires Env %q; app was built with Env %q", "development", a.cfg.Env)
92 }
93
94 // Snapshot the send count before the POST: the check below has to see
95 // a NEW mail, not merely a recorder whose last occupant matches.
96 a.mailRecorder.mu.Lock()
97 before := a.mailRecorder.sends
98 a.mailRecorder.mu.Unlock()
99
100 form := url.Values{"address": {address}, "force": {"1"}}
101 req := httptest.NewRequest(http.MethodPost, "/signin", strings.NewReader(form.Encode()))
102 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
103 // auth.Begin CSRF-checks this itself (its own doc comment: an
104 // unauthenticated endpoint that sends mail and makes outbound
105 // calls decided by the submitted address must never accept a
106 // cross-site form) and csrf.Protect guards it again app-wide;
107 // Sec-Fetch-Site: same-origin is exactly what a real same-origin
108 // browser POST sends and satisfies both the same way
109 // list_test.go's TestCreateThenRead does for POST /documents.
110 req.Header.Set("Sec-Fetch-Site", "same-origin")
111
112 rec := httptest.NewRecorder()
113 a.Handler().ServeHTTP(rec, req)
114 if rec.Code != http.StatusSeeOther {
115 return "", fmt.Errorf("docs: TestSignIn: POST /signin: status %d, want %d", rec.Code, http.StatusSeeOther)
116 }
117 if loc := rec.Header().Get("Location"); !strings.Contains(loc, "sent=1") {
118 return "", fmt.Errorf("docs: TestSignIn: POST /signin redirected to %q, want a %q outcome", loc, "?sent=1")
119 }
120
121 a.signinMail.wait() // the link is sent after the response (mailqueue.go)
122 a.mailRecorder.mu.Lock()
123 to, body, after := a.mailRecorder.lastTo, a.mailRecorder.lastBody, a.mailRecorder.sends
124 a.mailRecorder.mu.Unlock()
125
126 // A refused address gets the same 303 ?sent=1 a real one gets
127 // (gateBegin, deliberately, so the endpoint is not a membership
128 // oracle). So the only evidence that this address was admitted is that
129 // a new mail went out, to it. Both halves are needed: the count alone
130 // would accept somebody else's mail, and the recipient alone stays
131 // true for every later refusal of an address that once succeeded.
132 if after == before {
133 return "", fmt.Errorf("docs: TestSignIn: no mail was sent for %q — the address was refused", address)
134 }
135 if normalizeAddress(to) != normalizeAddress(address) {
136 return "", fmt.Errorf("docs: TestSignIn: the mail went to %q, not %q", to, address)
137 }
138
139 link := magicLinkPattern.FindString(body)
140 if link == "" {
141 return "", fmt.Errorf("docs: TestSignIn: no magic link found in the mailed body for %q", address)
142 }
143 return linkPath(link)
144}
145
146// confirmForm is what the confirm door's Sign in button submits for an
147// emailed link: the link's own path, and a form body carrying its token.
148// GET /auth/verify only draws that door and spends nothing, so a test
149// that signs in must POST this, as a browser does.
150func confirmForm(link string) (path, form string) {
151 u, err := url.Parse(link)
152 if err != nil {
153 return link, ""
154 }
155 return u.Path, url.Values{"token": {u.Query().Get("token")}}.Encode()
156}
157
158// confirmRequest is that POST, with the same-origin evidence a browser
159// sends from the door (Sec-Fetch-Site), which both csrf.Protect and
160// rastrillo's own redeem check.
161func confirmRequest(link string) *http.Request {
162 path, form := confirmForm(link)
163 req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form))
164 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
165 req.Header.Set("Sec-Fetch-Site", "same-origin")
166 return req
167}
168
169// TestConfirmRequest is confirmRequest for tests outside the package:
170// the POST that spends a link TestSignIn returned.
171func (a *App) TestConfirmRequest(link string) *http.Request {
172 return confirmRequest(link)
173}
174
175// TestPerson creates (or finds) a person by address and returns this
176// app's own numeric id for them — Store.EnsurePerson, the same
177// address-to-id mapping a real request goes through via handlers.go's
178// owner helper (auth.From(r).Address, never sessions.UserID — see
179// Document.OwnerID's doc comment). It is what Store.Create/List/Get's
180// owner parameter expects, and what Task 2's own tests use directly
181// against the store without any HTTP round trip at all:
182//
183// alice := app.TestPerson(t, "alice@example.com")
184// doc, err := app.Store().Create(ctx, alice, "Alice's private notes")
185//
186// TestPerson does not sign anyone in — no cookie, no session. For a
187// request that carries one, use TestRequest, which calls TestPerson
188// itself so the two always agree on who an address maps to.
189func (a *App) TestPerson(t *testing.T, address string) int64 {
190 t.Helper()
191 id, err := a.store.EnsurePerson(context.Background(), address)
192 if err != nil {
193 t.Fatalf("docs: TestPerson(%q): %v", address, err)
194 }
195 return id
196}
197
198// TestRequest builds an httptest request for method and path, signed
199// in as address: a real magic-link round trip (TestSignIn) mints the
200// session cookies, and TestPerson ensures address's Person row exists
201// first — the same row Store.Create/List/Get's owner id needs to
202// already be there for handlers.go's owner helper to find on the
203// request this returns.
204//
205// For a state-changing method (anything but GET/HEAD) it also sets
206// Sec-Fetch-Site: same-origin, exactly what a real same-origin
207// browser sends and csrf.Protect (app.go) checks first (TestSignIn's
208// own doc comment) — so a later task's signed-in POST does not have
209// to re-derive this to get past CSRF.
210//
211// req := app.TestRequest(t, "bob@example.com", http.MethodGet, "/d/"+doc.Sqid, nil)
212func (a *App) TestRequest(t *testing.T, address, method, path string, body io.Reader) *http.Request {
213 t.Helper()
214
215 // Ensure address's Person row exists before any handler on this
216 // request reads it — EnsurePerson is get-or-create, so this is a
217 // no-op if TestSignIn's own callback already created it via the
218 // exact same call.
219 a.TestPerson(t, address)
220
221 link, err := a.TestSignIn(t, address)
222 if err != nil {
223 t.Fatalf("docs: TestRequest: TestSignIn(%q): %v", address, err)
224 }
225 verifyRec := httptest.NewRecorder()
226 a.Handler().ServeHTTP(verifyRec, confirmRequest(link))
227 cookies := verifyRec.Result().Cookies()
228 if len(cookies) == 0 {
229 t.Fatalf("docs: TestRequest: following the magic link for %q set no session cookie", address)
230 }
231
232 req := httptest.NewRequest(method, path, body)
233 for _, c := range cookies {
234 req.AddCookie(c)
235 }
236 if method != http.MethodGet && method != http.MethodHead {
237 req.Header.Set("Sec-Fetch-Site", "same-origin")
238 }
239 return req
240}
241
242// testHandlers exposes the app's handler struct so a test can reach
243// the two seams that only exist for testing: the stream's heartbeat
244// and TTL overrides (stream_test.go, whose subject is behaviour over
245// time), and the hub, whose subscriber count is the only observable
246// consequence of a stream handler having returned.
247//
248// Unexported, and in this file rather than a _test.go one for the
249// same reason NewForTest is: it is scaffolding this package's own
250// tests share, not an interface the app has.
251func (a *App) testHandlers() *handlers { return a.handlers }
252
253// ReadFixture loads fixtures/data/<name>.update.bin — a real Yjs
254// update produced by browser yjs (fixtures/generate.mjs), never
255// hand-edited. Not in a _test.go file for the same reason as
256// NewForTest: other packages' tests may need it too.
257func ReadFixture(t *testing.T, name string) []byte {
258 t.Helper()
259 b, err := os.ReadFile(filepath.Join("..", "..", "fixtures", "data", name+".update.bin"))
260 if err != nil {
261 t.Fatalf("docs.ReadFixture(%q): %v", name, err)
262 }
263 return b
264}
265
266// ReadFragmentFixture loads fixtures/vectors/fragment/<name>.update.bin
267// — a real Yjs update, produced by real yjs
268// (fixtures/generate-fragment.mjs), that builds a Y.XmlFragment on
269// root "prosemirror" rather than a bare Y.Text on root "content".
270//
271// Distinct from ReadFixture, not a parameter on it: since Store.Text
272// and Store.HTML were repointed to read the "prosemirror" root
273// (core/src/abi.rs's text_string doc comment — fix round 1 on the
274// fragment-migration task), a test that wants to see real content
275// come back through either of them needs an update built on that
276// root. ReadFixture's own fixtures/data updates still apply cleanly
277// (ApplyUpdate/apply_slice decode and merge regardless of which root
278// an update targets) but read back as "" through Text/HTML now — a
279// test reaching for ReadFixture("hello") expecting "hello world" back
280// through Store.Text would be reaching for the wrong fixture set.
281func ReadFragmentFixture(t *testing.T, name string) []byte {
282 t.Helper()
283 b, err := os.ReadFile(filepath.Join("..", "..", "fixtures", "vectors", "fragment", name+".update.bin"))
284 if err != nil {
285 t.Fatalf("docs.ReadFragmentFixture(%q): %v", name, err)
286 }
287 return b
288}
289
290// TestActorFor returns the Actor for a document's own owner.
291//
292// It exists because ApplyUpdate now authorizes the acting person rather than
293// the document, so a test that writes to a document has to say who is
294// writing. Almost every existing one is the owner editing their own document,
295// and this resolves that owner for real — through the people row the document
296// points at — rather than fabricating an Actor that would satisfy the check
297// without exercising it. A test wanting a grantee builds the Actor itself, as
298// grants_write_test.go does.
299func (a *App) TestActorFor(t *testing.T, doc *Document) Actor {
300 t.Helper()
301 return a.TestActorForPerson(t, doc.OwnerID)
302}
303
304// TestActorForPerson is the same lookup keyed on the person directly, for a
305// test that has an id in hand rather than a document.
306func (a *App) TestActorForPerson(t *testing.T, person int64) Actor {
307 t.Helper()
308 var p Person
309 if err := a.Store().db.Where("id = ?", person).First(&p).Error; err != nil {
310 t.Fatalf("TestActorForPerson: resolving person %d: %v", person, err)
311 }
312 return localActor(p.ID, p.Email)
313}
314
315// TestEditorScriptPath is the served path of the editor bundle, exported so
316// an external test can assert a page does or does not ship it.
317//
318// It exists because the branch review found a test banning the string
319// "editor-entry" from the public page — a string that appears on no page in
320// this app, so the ban could never fail. A test that names the real path
321// cannot drift from what staticHandler actually serves, because it is the
322// same function that builds the <script src>.
323func TestEditorScriptPath() string { return editorScriptPath() }
324
325// TestSoftDelete marks a document deleted the way a delete verb would, for
326// tests that need a live link pointing at a document that is no longer
327// there. There is no delete route yet; this is the state the schema
328// already models (documents.deleted_at) and that ByToken filters on.
329func (a *App) TestSoftDelete(t *testing.T, sqid string) {
330 t.Helper()
331 err := a.store.db.Model(&Document{}).
332 Where("sqid = ?", sqid).
333 Update("deleted_at", time.Now().UTC()).Error
334 if err != nil {
335 t.Fatalf("docs: TestSoftDelete(%q): %v", sqid, err)
336 }
337}
338
339// TestWrite applies one update to a document as the signed-in owner,
340// through the same store path a real edit takes, so a test can publish a
341// document that actually has text in it.
342func (a *App) TestWrite(t *testing.T, cookies []*http.Cookie, sqid, fixture string) {
343 t.Helper()
344 var doc Document
345 if err := a.store.db.Where("sqid = ?", sqid).First(&doc).Error; err != nil {
346 t.Fatalf("docs: TestWrite: find %q: %v", sqid, err)
347 }
348 actor := a.TestActorFor(t, &doc)
349 if _, err := a.store.ApplyUpdate(context.Background(), &doc, actor, ReadFragmentFixture(t, fixture)); err != nil {
350 t.Fatalf("docs: TestWrite(%q): %v", fixture, err)
351 }
352}
353
354// TestRowContains reports whether any column of any row in table holds
355// needle as a substring.
356//
357// It exists for the one assertion that cannot be made against behaviour:
358// ruling 16 says the plaintext token is not in the database, and the only
359// honest way to check that is to look in the database. A test that asked
360// the store instead would be asking the code that is supposed to be wrong.
361func (a *App) TestRowContains(t *testing.T, table, needle string) bool {
362 t.Helper()
363 rows, err := a.store.db.Raw("SELECT * FROM " + table).Rows()
364 if err != nil {
365 t.Fatalf("docs: TestRowContains: %v", err)
366 }
367 defer rows.Close()
368
369 cols, err := rows.Columns()
370 if err != nil {
371 t.Fatalf("docs: TestRowContains: columns: %v", err)
372 }
373 for rows.Next() {
374 cells := make([]any, len(cols))
375 for i := range cells {
376 cells[i] = new(sql.RawBytes)
377 }
378 if err := rows.Scan(cells...); err != nil {
379 t.Fatalf("docs: TestRowContains: scan: %v", err)
380 }
381 for _, c := range cells {
382 if strings.Contains(string(*(c.(*sql.RawBytes))), needle) {
383 return true
384 }
385 }
386 }
387 return false
388}
389
390// TestViewSources returns every template this app authors, by path, for
391// the tests that hold its markup to a vocabulary.
392func TestViewSources(t *testing.T) map[string]string {
393 t.Helper()
394 out := map[string]string{}
395 err := fs.WalkDir(viewsFS, ".", func(path string, d fs.DirEntry, err error) error {
396 if err != nil || d.IsDir() {
397 return err
398 }
399 b, err := fs.ReadFile(viewsFS, path)
400 out[path] = string(b)
401 return err
402 })
403 if err != nil {
404 t.Fatal(err)
405 }
406 return out
407}
408