// Package contratest tests the app from the outside: real HTTP // requests against the real router, wired exactly as // cmd/contra/main.go wires it. // // The TDD loop: write a failing test here, make it pass in // internal/contra, repeat. package contratest import ( "context" "io" "log/slog" "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "sync" "testing" "gorm.io/gorm" "github.com/carlosframework/rastrillo/db" contra "github.com/carlosframework/contra/internal/contra" ) // 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" // testPepper is generated, fixed, and obviously not real (AGENTS.md decision // 8: fixtures are generated, never real). It blinds the email index in tests // exactly as a deployed pepper would, and decrypts nothing either way. var testPepper = []byte("contra test pepper -- generated, not a secret, 0f3a") // sentMail records what the app would have posted. A test never opens a // socket, and the link a step-one signup emails is the thing step two needs — // so capturing it here is what makes the whole ceremony testable end to end. type sentMail struct { mu sync.Mutex sent []struct{ To, Subject, Body string } } func (m *sentMail) Send(_ context.Context, to, subject, body string) error { m.mu.Lock() defer m.mu.Unlock() m.sent = append(m.sent, struct{ To, Subject, Body string }{to, subject, body}) return nil } // linkFor returns the signup link sent to an address, and says so plainly if // there is none: "no mail" and "mail without a link" are different bugs. func (m *sentMail) linkFor(t *testing.T, to string) string { t.Helper() m.mu.Lock() defer m.mu.Unlock() // Case-insensitively: addresses are normalised to lower case before they // are mailed, so a test that typed mixed case is still looking for its // own message. for i := len(m.sent) - 1; i >= 0; i-- { if !strings.EqualFold(m.sent[i].To, to) { continue } if at := strings.Index(m.sent[i].Body, "/signup/verify?t="); at >= 0 { rest := m.sent[i].Body[at:] return strings.TrimSpace(strings.SplitN(rest, "\n", 2)[0]) } t.Fatalf("the message to %s carries no signup link:\n%s", to, m.sent[i].Body) } t.Fatalf("no message was sent to %s", to) return "" } // mailboxes lets a helper find the outbox for an app it was only handed the // handler for. Every app a test builds gets one, so doSignup can follow the // link without every call site having to thread it through. var ( mailboxMu sync.Mutex mailboxes = map[http.Handler]*sentMail{} ) func mailboxFor(t *testing.T, h http.Handler) *sentMail { t.Helper() mailboxMu.Lock() defer mailboxMu.Unlock() box, ok := mailboxes[h] if !ok { t.Fatal("this app was not built by the test harness, so it has no outbox") } return box } func testConfig() contra.Config { return contra.Config{Origin: testOrigin, Pepper: testPepper} } // newApp builds the whole app per test over a fresh temp database, exactly as // main.go does. func newApp(t *testing.T) http.Handler { t.Helper() h, _ := newAppWithDB(t) return h } // newAppWithDB is newApp plus the handle, for the tests that need to look at // the row that was actually stored rather than at what a handler said. func newAppWithDB(t *testing.T) (http.Handler, *gorm.DB) { t.Helper() h, db, _ := newAppWithMail(t) return h, db } // newAppWithMail is the whole app plus the outbox, for the tests that need to // follow a link the app "sent". func newAppWithMail(t *testing.T) (http.Handler, *gorm.DB, *sentMail) { t.Helper() h, d, box := newAppAt(t, filepath.Join(t.TempDir(), "app.db")) return h, d.G, box } // newAppAt is newAppWithMail over a named file, handing back the database // itself — for the tests that go and read its bytes afterwards. func newAppAt(t *testing.T, dbPath string) (http.Handler, *db.DB, *sentMail) { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) d, err := db.Open(dbPath, logger) if err != nil { t.Fatalf("db.Open: %v", err) } t.Cleanup(func() { d.Close() }) box := &sentMail{} cfg := testConfig() cfg.Mailer = box mux, err := contra.App(d, cfg, logger) if err != nil { t.Fatalf("App: %v", err) } mailboxMu.Lock() mailboxes[mux] = box mailboxMu.Unlock() t.Cleanup(func() { mailboxMu.Lock() delete(mailboxes, mux) mailboxMu.Unlock() }) return mux, d, box } func get(t *testing.T, h http.Handler, target string) *httptest.ResponseRecorder { t.Helper() rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil)) return rec } func post(t *testing.T, h http.Handler, target 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") // 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) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec }