| 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. |
| 7 | package slidestest |
| 8 | |
| 9 | import ( |
| 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. |
| 31 | const 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. |
| 37 | const ( |
| 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). |
| 46 | const testInstanceKey = "slidestest-instance-key-not-a-secret" |
| 47 | |
| 48 | // sentMail is one message the fake mailer captured. |
| 49 | type 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. |
| 56 | type 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 | |
| 64 | func (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. |
| 72 | func (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. |
| 85 | var 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. |
| 89 | type 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. |
| 117 | func 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. |
| 131 | func 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. |
| 139 | func 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. |
| 159 | func 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. |
| 178 | func 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 | |
| 187 | func 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. |
| 200 | func 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 | |
| 212 | func 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. |
| 237 | func 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). |
| 249 | var 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. |
| 255 | func 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. |
| 276 | func 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. |
| 282 | func 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. |
| 295 | func 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 | |