// Package slidestest tests the app from the outside: real HTTP // requests against the real router, wired exactly as // cmd/slides/main.go wires it. // // The TDD loop: write a failing test here, make it pass in // internal/slides, repeat. package slidestest import ( "context" "html" "io" "log/slog" "net/http" "net/http/httptest" "net/url" "path/filepath" "regexp" "strings" "sync" "testing" "amadan.net/rastrillo/rastrillo" "amadan.net/rastrillo/rastrillo/db" slides "amadan.net/oficina/slides/internal/slides" ) // testOrigin is the origin newApp wires and post presents as its // same-origin evidence — the pair a browser form submission would be. const testOrigin = "http://app.test" // allowedAddress is on newConfig's allowlist; deniedAddress is not. // Named constants rather than literals per test, so a test that means // "the allowlist let this through" cannot silently become a test about // a typo. const ( allowedAddress = "paul@example.test" deniedAddress = "someone@else.test" ) // testInstanceKey seals rastrillo/auth's pending blob for a test app. // Explicit, so the suite exercises the same fully-configured path a // real deployment takes rather than the development fallbacks — those // get tests of their own (config_test's refusals). const testInstanceKey = "slidestest-instance-key-not-a-secret" // sentMail is one message the fake mailer captured. type sentMail struct{ To, Subject, Body string } // fakeMailer is the test suite's mail.Sender: it delivers nothing and // remembers everything, so a test can assert both that a link was sent // and — the case the allowlist exists for — that one was not. The app // itself carries no recorder of its own; this is wired in through // slides.Config.Mailer like any other sender. type fakeMailer struct { mu sync.Mutex sent []sentMail // flush waits for the app's mail queue: links are sent after the // response (mailqueue.go), so messages reads only after it. flush func() } func (m *fakeMailer) Send(_ context.Context, to, subject, body string) error { m.mu.Lock() defer m.mu.Unlock() m.sent = append(m.sent, sentMail{To: to, Subject: subject, Body: body}) return nil } // messages returns a copy of everything sent so far. func (m *fakeMailer) messages() []sentMail { if m.flush != nil { m.flush() } m.mu.Lock() defer m.mu.Unlock() return append([]sentMail(nil), m.sent...) } // magicLinkPattern finds the emailed link inside the mail body // rastrillo/auth writes. Anchored to the app's own verify path so it // cannot mistake some other URL the body might someday carry for the // sign-in link itself. var magicLinkPattern = regexp.MustCompile(`https?://\S+/auth/verify\?token=\S+`) // testApp is the wired app plus the two seams a test needs to look // behind it: the mail the app tried to send, and the database it wrote. type testApp struct { http.Handler mailer *fakeMailer db *db.DB // mux is slides.New's own mux, before the locale middleware // localised puts around it: what production hands rastrillo.Serve, // and what productionHandler builds the real serving stack over. mux *http.ServeMux // app is the built app itself, reachable only through its exported // methods — slides.New returns an unexported type on purpose, so // this names the shape rather than the type. Today: Close; the // export caps, which a test moves so it can prove the refusal // without first making a 50 MiB PDF; and an empty job registry, so // a test can reach the fifty minutes in which the file outlives the // job record without waiting ten. app interface { Close() error SetExportCaps(perDeck, perInstance int64) ForgetExportJobs() error FlushMail() ServeOptions(rastrillo.Options, *http.ServeMux) rastrillo.Options } } // newConfig is the fully-configured Config every test starts from: // nothing left to a development fallback, so a test that means to // exercise one has to ask for it. Tweak the returned value and hand it // to newAppWith. func newConfig(t *testing.T) (slides.Config, *fakeMailer) { t.Helper() mailer := &fakeMailer{} return slides.Config{ Origin: testOrigin, InstanceKey: testInstanceKey, Mailer: mailer, AllowedAddresses: []string{allowedAddress}, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), }, mailer } // newApp builds the whole app per test over a fresh temp database, // exactly as main.go does. func newApp(t *testing.T) *testApp { t.Helper() cfg, mailer := newConfig(t) return newAppWith(t, cfg, mailer) } // newAppWith is newApp with the config spelled out — for the tests // whose subject is a config decision rather than a screen. func newAppWith(t *testing.T, cfg slides.Config, mailer *fakeMailer) *testApp { t.Helper() d := openTestDB(t, cfg) mux, a, err := slides.New(d, cfg) if err != nil { t.Fatalf("slides.New: %v", err) } // Releases the compiled deck core's wazero runtime; every test app // built over slides.New leaks one without this (task 3 fix round 1). t.Cleanup(func() { a.Close() }) if mailer != nil { mailer.flush = a.FlushMail } return &testApp{Handler: localised(t, mux), mailer: mailer, db: d, mux: mux, app: a} } // openTestDB opens a fresh SQLite file for one test, closed on // cleanup. Separate from newAppWith so a test whose subject is // slides.New's own refusals can open a database without also expecting // New to succeed over it. func openTestDB(t *testing.T, cfg slides.Config) *db.DB { t.Helper() logger := cfg.Logger if logger == nil { logger = slog.New(slog.NewTextHandler(io.Discard, nil)) } d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger) if err != nil { t.Fatalf("db.Open: %v", err) } t.Cleanup(func() { d.Close() }) return d } // localised wraps the app's mux in the same locale middleware // rastrillo.Serve mounts around it in production (cmd/slides/main.go // wires Options.Locales/LocaleFS; Serve builds the middleware from // them). Without it rastrillo.T returns the key verbatim, so every // assertion about a rendered string would be an assertion about a key. func localised(t *testing.T, mux http.Handler) http.Handler { t.Helper() loc, err := rastrillo.NewLocales(slides.Locales, slides.DefaultLocale, rastrillo.BaseCatalog(), slides.LocaleFS) if err != nil { t.Fatalf("rastrillo.NewLocales: %v", err) } return loc.Middleware(mux) } func get(t *testing.T, h http.Handler, target string, cookies ...*http.Cookie) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodGet, target, nil) for _, c := range cookies { req.AddCookie(c) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // getHTML is a browser navigation. Error pages deliberately distinguish this // from a client fetch without Accept: text/html. func getHTML(t *testing.T, h http.Handler, target string, cookies ...*http.Cookie) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodGet, target, nil) req.Header.Set("Accept", "text/html") for _, c := range cookies { req.AddCookie(c) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } func post(t *testing.T, h http.Handler, target string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") // The same-origin evidence csrf.Protect requires — every current // browser sends it on a form POST; a test client must too. req.Header.Set("Origin", testOrigin) req.Header.Set("Sec-Fetch-Site", "same-origin") for _, c := range cookies { req.AddCookie(c) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // postLegacy is post without the Sec-Fetch-Site header, with origin as // the only same-origin evidence. // // It exists because post sends both, and csrf.Protect checks // Sec-Fetch-Site FIRST and never consults Origin at all when it is // present — so every ordinary test here leaves the app's configured // Origin entirely unexercised, and would pass with a wrong one. This is // the shape a non-browser client (or an old browser) sends, and the // only shape that puts the app's own resolved origin under test. func postLegacy(t *testing.T, h http.Handler, target, origin string, form url.Values) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", origin) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // confirmForm is the confirm door's one form, as a browser would read it // (signin.html's Confirm branch, auth.go's renderConfirm). var confirmForm = regexp.MustCompile(`