| 1 | // Package contratest tests the app from the outside: real HTTP |
| 2 | // requests against the real router, wired exactly as |
| 3 | // cmd/contra/main.go wires it. |
| 4 | // |
| 5 | // The TDD loop: write a failing test here, make it pass in |
| 6 | // internal/contra, repeat. |
| 7 | package contratest |
| 8 | |
| 9 | import ( |
| 10 | "context" |
| 11 | "io" |
| 12 | "log/slog" |
| 13 | "net/http" |
| 14 | "net/http/httptest" |
| 15 | "net/url" |
| 16 | "path/filepath" |
| 17 | "strings" |
| 18 | "sync" |
| 19 | "testing" |
| 20 | |
| 21 | "gorm.io/gorm" |
| 22 | |
| 23 | "github.com/carlosframework/rastrillo/db" |
| 24 | |
| 25 | contra "github.com/carlosframework/contra/internal/contra" |
| 26 | ) |
| 27 | |
| 28 | // testOrigin is the origin newApp wires and post presents as its |
| 29 | // same-origin evidence — the pair a browser form submission would be. |
| 30 | const testOrigin = "http://app.test" |
| 31 | |
| 32 | // testPepper is generated, fixed, and obviously not real (AGENTS.md decision |
| 33 | // 8: fixtures are generated, never real). It blinds the email index in tests |
| 34 | // exactly as a deployed pepper would, and decrypts nothing either way. |
| 35 | var testPepper = []byte("contra test pepper -- generated, not a secret, 0f3a") |
| 36 | |
| 37 | // sentMail records what the app would have posted. A test never opens a |
| 38 | // socket, and the link a step-one signup emails is the thing step two needs — |
| 39 | // so capturing it here is what makes the whole ceremony testable end to end. |
| 40 | type sentMail struct { |
| 41 | mu sync.Mutex |
| 42 | sent []struct{ To, Subject, Body string } |
| 43 | } |
| 44 | |
| 45 | func (m *sentMail) Send(_ context.Context, to, subject, body string) error { |
| 46 | m.mu.Lock() |
| 47 | defer m.mu.Unlock() |
| 48 | m.sent = append(m.sent, struct{ To, Subject, Body string }{to, subject, body}) |
| 49 | return nil |
| 50 | } |
| 51 | |
| 52 | // linkFor returns the signup link sent to an address, and says so plainly if |
| 53 | // there is none: "no mail" and "mail without a link" are different bugs. |
| 54 | func (m *sentMail) linkFor(t *testing.T, to string) string { |
| 55 | t.Helper() |
| 56 | m.mu.Lock() |
| 57 | defer m.mu.Unlock() |
| 58 | // Case-insensitively: addresses are normalised to lower case before they |
| 59 | // are mailed, so a test that typed mixed case is still looking for its |
| 60 | // own message. |
| 61 | for i := len(m.sent) - 1; i >= 0; i-- { |
| 62 | if !strings.EqualFold(m.sent[i].To, to) { |
| 63 | continue |
| 64 | } |
| 65 | if at := strings.Index(m.sent[i].Body, "/signup/verify?t="); at >= 0 { |
| 66 | rest := m.sent[i].Body[at:] |
| 67 | return strings.TrimSpace(strings.SplitN(rest, "\n", 2)[0]) |
| 68 | } |
| 69 | t.Fatalf("the message to %s carries no signup link:\n%s", to, m.sent[i].Body) |
| 70 | } |
| 71 | t.Fatalf("no message was sent to %s", to) |
| 72 | return "" |
| 73 | } |
| 74 | |
| 75 | // mailboxes lets a helper find the outbox for an app it was only handed the |
| 76 | // handler for. Every app a test builds gets one, so doSignup can follow the |
| 77 | // link without every call site having to thread it through. |
| 78 | var ( |
| 79 | mailboxMu sync.Mutex |
| 80 | mailboxes = map[http.Handler]*sentMail{} |
| 81 | ) |
| 82 | |
| 83 | func mailboxFor(t *testing.T, h http.Handler) *sentMail { |
| 84 | t.Helper() |
| 85 | mailboxMu.Lock() |
| 86 | defer mailboxMu.Unlock() |
| 87 | box, ok := mailboxes[h] |
| 88 | if !ok { |
| 89 | t.Fatal("this app was not built by the test harness, so it has no outbox") |
| 90 | } |
| 91 | return box |
| 92 | } |
| 93 | |
| 94 | func testConfig() contra.Config { |
| 95 | return contra.Config{Origin: testOrigin, Pepper: testPepper} |
| 96 | } |
| 97 | |
| 98 | // newApp builds the whole app per test over a fresh temp database, exactly as |
| 99 | // main.go does. |
| 100 | func newApp(t *testing.T) http.Handler { |
| 101 | t.Helper() |
| 102 | h, _ := newAppWithDB(t) |
| 103 | return h |
| 104 | } |
| 105 | |
| 106 | // newAppWithDB is newApp plus the handle, for the tests that need to look at |
| 107 | // the row that was actually stored rather than at what a handler said. |
| 108 | func newAppWithDB(t *testing.T) (http.Handler, *gorm.DB) { |
| 109 | t.Helper() |
| 110 | h, db, _ := newAppWithMail(t) |
| 111 | return h, db |
| 112 | } |
| 113 | |
| 114 | // newAppWithMail is the whole app plus the outbox, for the tests that need to |
| 115 | // follow a link the app "sent". |
| 116 | func newAppWithMail(t *testing.T) (http.Handler, *gorm.DB, *sentMail) { |
| 117 | t.Helper() |
| 118 | h, d, box := newAppAt(t, filepath.Join(t.TempDir(), "app.db")) |
| 119 | return h, d.G, box |
| 120 | } |
| 121 | |
| 122 | // newAppAt is newAppWithMail over a named file, handing back the database |
| 123 | // itself — for the tests that go and read its bytes afterwards. |
| 124 | func newAppAt(t *testing.T, dbPath string) (http.Handler, *db.DB, *sentMail) { |
| 125 | t.Helper() |
| 126 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 127 | d, err := db.Open(dbPath, logger) |
| 128 | if err != nil { |
| 129 | t.Fatalf("db.Open: %v", err) |
| 130 | } |
| 131 | t.Cleanup(func() { d.Close() }) |
| 132 | |
| 133 | box := &sentMail{} |
| 134 | cfg := testConfig() |
| 135 | cfg.Mailer = box |
| 136 | mux, err := contra.App(d, cfg, logger) |
| 137 | if err != nil { |
| 138 | t.Fatalf("App: %v", err) |
| 139 | } |
| 140 | mailboxMu.Lock() |
| 141 | mailboxes[mux] = box |
| 142 | mailboxMu.Unlock() |
| 143 | t.Cleanup(func() { |
| 144 | mailboxMu.Lock() |
| 145 | delete(mailboxes, mux) |
| 146 | mailboxMu.Unlock() |
| 147 | }) |
| 148 | return mux, d, box |
| 149 | } |
| 150 | |
| 151 | func get(t *testing.T, h http.Handler, target string) *httptest.ResponseRecorder { |
| 152 | t.Helper() |
| 153 | rec := httptest.NewRecorder() |
| 154 | h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil)) |
| 155 | return rec |
| 156 | } |
| 157 | |
| 158 | func post(t *testing.T, h http.Handler, target string, form url.Values) *httptest.ResponseRecorder { |
| 159 | t.Helper() |
| 160 | req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode())) |
| 161 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 162 | // The same-origin evidence csrf.Protect requires — every current |
| 163 | // browser sends it on a form POST; a test client must too. |
| 164 | req.Header.Set("Origin", testOrigin) |
| 165 | rec := httptest.NewRecorder() |
| 166 | h.ServeHTTP(rec, req) |
| 167 | return rec |
| 168 | } |
| 169 | |