carlos / contra Public

Clone
git clone https://amadan.net/carlos/contra

Plain git — no account needed to clone.

Download

Download this file

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.
7package contratest
8
9import (
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.
30const 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.
35var 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.
40type sentMail struct {
41 mu sync.Mutex
42 sent []struct{ To, Subject, Body string }
43}
44
45func (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.
54func (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.
78var (
79 mailboxMu sync.Mutex
80 mailboxes = map[http.Handler]*sentMail{}
81)
82
83func 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
94func 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.
100func 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.
108func 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".
116func 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.
124func 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
151func 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
158func 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