oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package slidestest tests the app from the outside: real HTTP
2// requests against the real router, wired exactly as
3// cmd/slides/main.go wires it.
4//
5// The TDD loop: write a failing test here, make it pass in
6// internal/slides, repeat.
7package slidestest
8
9import (
10 "context"
11 "html"
12 "io"
13 "log/slog"
14 "net/http"
15 "net/http/httptest"
16 "net/url"
17 "path/filepath"
18 "regexp"
19 "strings"
20 "sync"
21 "testing"
22
23 "amadan.net/rastrillo/rastrillo"
24 "amadan.net/rastrillo/rastrillo/db"
25
26 slides "amadan.net/oficina/slides/internal/slides"
27)
28
29// testOrigin is the origin newApp wires and post presents as its
30// same-origin evidence — the pair a browser form submission would be.
31const testOrigin = "http://app.test"
32
33// allowedAddress is on newConfig's allowlist; deniedAddress is not.
34// Named constants rather than literals per test, so a test that means
35// "the allowlist let this through" cannot silently become a test about
36// a typo.
37const (
38 allowedAddress = "paul@example.test"
39 deniedAddress = "someone@else.test"
40)
41
42// testInstanceKey seals rastrillo/auth's pending blob for a test app.
43// Explicit, so the suite exercises the same fully-configured path a
44// real deployment takes rather than the development fallbacks — those
45// get tests of their own (config_test's refusals).
46const testInstanceKey = "slidestest-instance-key-not-a-secret"
47
48// sentMail is one message the fake mailer captured.
49type sentMail struct{ To, Subject, Body string }
50
51// fakeMailer is the test suite's mail.Sender: it delivers nothing and
52// remembers everything, so a test can assert both that a link was sent
53// and — the case the allowlist exists for — that one was not. The app
54// itself carries no recorder of its own; this is wired in through
55// slides.Config.Mailer like any other sender.
56type fakeMailer struct {
57 mu sync.Mutex
58 sent []sentMail
59 // flush waits for the app's mail queue: links are sent after the
60 // response (mailqueue.go), so messages reads only after it.
61 flush func()
62}
63
64func (m *fakeMailer) Send(_ context.Context, to, subject, body string) error {
65 m.mu.Lock()
66 defer m.mu.Unlock()
67 m.sent = append(m.sent, sentMail{To: to, Subject: subject, Body: body})
68 return nil
69}
70
71// messages returns a copy of everything sent so far.
72func (m *fakeMailer) messages() []sentMail {
73 if m.flush != nil {
74 m.flush()
75 }
76 m.mu.Lock()
77 defer m.mu.Unlock()
78 return append([]sentMail(nil), m.sent...)
79}
80
81// magicLinkPattern finds the emailed link inside the mail body
82// rastrillo/auth writes. Anchored to the app's own verify path so it
83// cannot mistake some other URL the body might someday carry for the
84// sign-in link itself.
85var magicLinkPattern = regexp.MustCompile(`https?://\S+/auth/verify\?token=\S+`)
86
87// testApp is the wired app plus the two seams a test needs to look
88// behind it: the mail the app tried to send, and the database it wrote.
89type testApp struct {
90 http.Handler
91 mailer *fakeMailer
92 db *db.DB
93 // mux is slides.New's own mux, before the locale middleware
94 // localised puts around it: what production hands rastrillo.Serve,
95 // and what productionHandler builds the real serving stack over.
96 mux *http.ServeMux
97 // app is the built app itself, reachable only through its exported
98 // methods — slides.New returns an unexported type on purpose, so
99 // this names the shape rather than the type. Today: Close; the
100 // export caps, which a test moves so it can prove the refusal
101 // without first making a 50 MiB PDF; and an empty job registry, so
102 // a test can reach the fifty minutes in which the file outlives the
103 // job record without waiting ten.
104 app interface {
105 Close() error
106 SetExportCaps(perDeck, perInstance int64)
107 ForgetExportJobs() error
108 FlushMail()
109 ServeOptions(rastrillo.Options, *http.ServeMux) rastrillo.Options
110 }
111}
112
113// newConfig is the fully-configured Config every test starts from:
114// nothing left to a development fallback, so a test that means to
115// exercise one has to ask for it. Tweak the returned value and hand it
116// to newAppWith.
117func newConfig(t *testing.T) (slides.Config, *fakeMailer) {
118 t.Helper()
119 mailer := &fakeMailer{}
120 return slides.Config{
121 Origin: testOrigin,
122 InstanceKey: testInstanceKey,
123 Mailer: mailer,
124 AllowedAddresses: []string{allowedAddress},
125 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
126 }, mailer
127}
128
129// newApp builds the whole app per test over a fresh temp database,
130// exactly as main.go does.
131func newApp(t *testing.T) *testApp {
132 t.Helper()
133 cfg, mailer := newConfig(t)
134 return newAppWith(t, cfg, mailer)
135}
136
137// newAppWith is newApp with the config spelled out — for the tests
138// whose subject is a config decision rather than a screen.
139func newAppWith(t *testing.T, cfg slides.Config, mailer *fakeMailer) *testApp {
140 t.Helper()
141 d := openTestDB(t, cfg)
142 mux, a, err := slides.New(d, cfg)
143 if err != nil {
144 t.Fatalf("slides.New: %v", err)
145 }
146 // Releases the compiled deck core's wazero runtime; every test app
147 // built over slides.New leaks one without this (task 3 fix round 1).
148 t.Cleanup(func() { a.Close() })
149 if mailer != nil {
150 mailer.flush = a.FlushMail
151 }
152 return &testApp{Handler: localised(t, mux), mailer: mailer, db: d, mux: mux, app: a}
153}
154
155// openTestDB opens a fresh SQLite file for one test, closed on
156// cleanup. Separate from newAppWith so a test whose subject is
157// slides.New's own refusals can open a database without also expecting
158// New to succeed over it.
159func openTestDB(t *testing.T, cfg slides.Config) *db.DB {
160 t.Helper()
161 logger := cfg.Logger
162 if logger == nil {
163 logger = slog.New(slog.NewTextHandler(io.Discard, nil))
164 }
165 d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger)
166 if err != nil {
167 t.Fatalf("db.Open: %v", err)
168 }
169 t.Cleanup(func() { d.Close() })
170 return d
171}
172
173// localised wraps the app's mux in the same locale middleware
174// rastrillo.Serve mounts around it in production (cmd/slides/main.go
175// wires Options.Locales/LocaleFS; Serve builds the middleware from
176// them). Without it rastrillo.T returns the key verbatim, so every
177// assertion about a rendered string would be an assertion about a key.
178func localised(t *testing.T, mux http.Handler) http.Handler {
179 t.Helper()
180 loc, err := rastrillo.NewLocales(slides.Locales, slides.DefaultLocale, rastrillo.BaseCatalog(), slides.LocaleFS)
181 if err != nil {
182 t.Fatalf("rastrillo.NewLocales: %v", err)
183 }
184 return loc.Middleware(mux)
185}
186
187func get(t *testing.T, h http.Handler, target string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
188 t.Helper()
189 req := httptest.NewRequest(http.MethodGet, target, nil)
190 for _, c := range cookies {
191 req.AddCookie(c)
192 }
193 rec := httptest.NewRecorder()
194 h.ServeHTTP(rec, req)
195 return rec
196}
197
198// getHTML is a browser navigation. Error pages deliberately distinguish this
199// from a client fetch without Accept: text/html.
200func getHTML(t *testing.T, h http.Handler, target string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
201 t.Helper()
202 req := httptest.NewRequest(http.MethodGet, target, nil)
203 req.Header.Set("Accept", "text/html")
204 for _, c := range cookies {
205 req.AddCookie(c)
206 }
207 rec := httptest.NewRecorder()
208 h.ServeHTTP(rec, req)
209 return rec
210}
211
212func post(t *testing.T, h http.Handler, target string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder {
213 t.Helper()
214 req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode()))
215 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
216 // The same-origin evidence csrf.Protect requires — every current
217 // browser sends it on a form POST; a test client must too.
218 req.Header.Set("Origin", testOrigin)
219 req.Header.Set("Sec-Fetch-Site", "same-origin")
220 for _, c := range cookies {
221 req.AddCookie(c)
222 }
223 rec := httptest.NewRecorder()
224 h.ServeHTTP(rec, req)
225 return rec
226}
227
228// postLegacy is post without the Sec-Fetch-Site header, with origin as
229// the only same-origin evidence.
230//
231// It exists because post sends both, and csrf.Protect checks
232// Sec-Fetch-Site FIRST and never consults Origin at all when it is
233// present — so every ordinary test here leaves the app's configured
234// Origin entirely unexercised, and would pass with a wrong one. This is
235// the shape a non-browser client (or an old browser) sends, and the
236// only shape that puts the app's own resolved origin under test.
237func postLegacy(t *testing.T, h http.Handler, target, origin string, form url.Values) *httptest.ResponseRecorder {
238 t.Helper()
239 req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode()))
240 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
241 req.Header.Set("Origin", origin)
242 rec := httptest.NewRecorder()
243 h.ServeHTTP(rec, req)
244 return rec
245}
246
247// confirmForm is the confirm door's one form, as a browser would read it
248// (signin.html's Confirm branch, auth.go's renderConfirm).
249var confirmForm = regexp.MustCompile(`<form[^>]*method="post" action="([^"]+)">\s*<input type="hidden" name="token" value="([^"]+)">`)
250
251// openLink is opening an emailed link: a GET that must spend nothing and
252// answer with the confirm door. It returns the door's form action and the
253// token it carries, read off the page rather than the link, so a door
254// that dropped or mangled the token fails here.
255func openLink(t *testing.T, h http.Handler, link string, cookies ...*http.Cookie) (action, token string) {
256 t.Helper()
257 u, err := url.Parse(link)
258 if err != nil {
259 t.Fatalf("parse emailed link %q: %v", link, err)
260 }
261 rec := get(t, h, u.RequestURI(), cookies...)
262 if rec.Code != http.StatusOK || len(rec.Result().Cookies()) != 0 {
263 t.Fatalf("GET %s = %d with %d cookies, want 200 and the confirm door, minting nothing", u.Path, rec.Code, len(rec.Result().Cookies()))
264 }
265 m := confirmForm.FindStringSubmatch(rec.Body.String())
266 if m == nil {
267 t.Fatalf("GET %s: no confirm form on the page:\n%s", u.Path, rec.Body.String())
268 }
269 return html.UnescapeString(m[1]), html.UnescapeString(m[2])
270}
271
272// postLink is pressing the confirm door's Sign in button: the token,
273// POSTed back to the door's action with a browser's same-origin
274// evidence (post sends Origin and Sec-Fetch-Site: same-origin). This is
275// the request that spends the link.
276func postLink(t *testing.T, h http.Handler, action, token string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
277 t.Helper()
278 return post(t, h, action, url.Values{"token": {token}}, cookies...)
279}
280
281// followLink is both: open the emailed link, then press Sign in.
282func followLink(t *testing.T, h http.Handler, link string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
283 t.Helper()
284 action, token := openLink(t, h, link, cookies...)
285 return postLink(t, h, action, token, cookies...)
286}
287
288// signedIn runs the whole magic-link flow for address against the fake
289// mailer — POST /signin, read the link out of the captured message,
290// open it and press the confirm door's Sign in — and returns the
291// cookies the verify step set. Every later
292// test that needs a signed-in request builds on this rather than
293// minting a session by hand, so the suite always signs in the way a
294// person does.
295func signedIn(t *testing.T, a *testApp, address string) []*http.Cookie {
296 t.Helper()
297
298 before := len(a.mailer.messages())
299 rec := post(t, a, "/signin", url.Values{"address": {address}})
300 if rec.Code != http.StatusSeeOther {
301 t.Fatalf("POST /signin as %q: status %d, want %d", address, rec.Code, http.StatusSeeOther)
302 }
303 msgs := a.mailer.messages()
304 if len(msgs) != before+1 {
305 t.Fatalf("POST /signin as %q: mailer captured %d messages, want %d", address, len(msgs), before+1)
306 }
307 link := magicLinkPattern.FindString(msgs[len(msgs)-1].Body)
308 if link == "" {
309 t.Fatalf("no magic link in the mailed body for %q:\n%s", address, msgs[len(msgs)-1].Body)
310 }
311 verify := followLink(t, a, link)
312 cookies := verify.Result().Cookies()
313 if len(cookies) == 0 {
314 t.Fatalf("following the magic link for %q set no session cookie (status %d)", address, verify.Code)
315 }
316 return cookies
317}
318