carlos / movement Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package movementtest tests the app from the outside: real HTTP
2// requests against the real router, wired exactly as
3// cmd/movement/main.go wires it.
4//
5// The TDD loop: write a failing test here, make it pass in
6// internal/movement, repeat.
7package movementtest
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 "time"
21
22 "github.com/carlosframework/rastrillo/db"
23 "github.com/carlosframework/rastrillo/mail"
24
25 movement "amadan.net/carlos/movement/internal/movement"
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// testApp is the handler plus the database and the mailbox behind it, so a
33// test can assert on what a request actually wrote and actually sent rather
34// than inferring either from a page.
35type testApp struct {
36 http.Handler
37 DB *db.DB
38 Mail *recorder
39}
40
41// recorder stands in for the platform's SMTP sender. Confirmation tokens are
42// stored only as hashes, so reading the message is the only way to get one —
43// which is exactly the property the design intends.
44type recorder struct {
45 mu sync.Mutex
46 sent []recorded
47}
48
49type recorded struct{ To, Subject, Body string }
50
51func (r *recorder) Send(ctx context.Context, to, subject, body string) error {
52 r.mu.Lock()
53 defer r.mu.Unlock()
54 r.sent = append(r.sent, recorded{to, subject, body})
55 return nil
56}
57
58func (r *recorder) count() int {
59 r.mu.Lock()
60 defer r.mu.Unlock()
61 return len(r.sent)
62}
63
64func (r *recorder) countTo(addr string) int {
65 r.mu.Lock()
66 defer r.mu.Unlock()
67 n := 0
68 for _, m := range r.sent {
69 if m.To == addr {
70 n++
71 }
72 }
73 return n
74}
75
76// waitForMailTo blocks until at least atLeast messages have gone to addr, and
77// returns the latest one's body.
78//
79// The wait is needed because sends happen after the write transaction
80// commits, in a goroutine, so a message is not necessarily on the record by
81// the time the response has been written. The count matters as much as the
82// wait: returning as soon as any message to the address exists would hand
83// back a stale one, and a test reading yesterday's token would pass while
84// proving nothing.
85func (r *recorder) waitForMailTo(t *testing.T, addr string, atLeast int) string {
86 t.Helper()
87 deadline := time.Now().Add(2 * time.Second)
88 for time.Now().Before(deadline) {
89 r.mu.Lock()
90 var last string
91 n := 0
92 for _, m := range r.sent {
93 if m.To == addr {
94 n++
95 last = m.Body
96 }
97 }
98 r.mu.Unlock()
99 if n >= atLeast {
100 return last
101 }
102 time.Sleep(5 * time.Millisecond)
103 }
104 t.Fatalf("fewer than %d messages reached %s", atLeast, addr)
105 return ""
106}
107
108// newApp builds the whole app per test over a fresh temp database, exactly as
109// main.go does, with a recording mailer in place of the platform's SMTP.
110func newApp(t *testing.T) *testApp {
111 t.Helper()
112 box := &recorder{}
113 app := newAppWith(t, box)
114 app.Mail = box
115 return app
116}
117
118// newAppWith is newApp with a mailer of the test's choosing — for the cases
119// that need a sender which fails, or one that never returns at all.
120func newAppWith(t *testing.T, sender mail.Sender) *testApp {
121 t.Helper()
122 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
123 d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger)
124 if err != nil {
125 t.Fatalf("db.Open: %v", err)
126 }
127 t.Cleanup(func() { d.Close() })
128 movement.SetLogger(logger)
129
130 env := testEnv()
131 env.Mailer = sender
132
133 mux, err := movement.App(d, env)
134 if err != nil {
135 t.Fatalf("App: %v", err)
136 }
137 return &testApp{Handler: mux, DB: d}
138}
139
140func countSignups(t *testing.T, h *testApp, email string) int64 {
141 t.Helper()
142 var n int64
143 if err := h.DB.G.Model(&movement.Signup{}).Where("email = ?", email).Count(&n).Error; err != nil {
144 t.Fatalf("counting signups: %v", err)
145 }
146 return n
147}
148
149func loadSignup(t *testing.T, h *testApp, email string) movement.Signup {
150 t.Helper()
151 var row movement.Signup
152 if err := h.DB.G.Where("email = ?", email).First(&row).Error; err != nil {
153 t.Fatalf("loading %s: %v", email, err)
154 }
155 return row
156}
157
158func countNonces(t *testing.T, h *testApp) int64 {
159 t.Helper()
160 var n int64
161 if err := h.DB.G.Model(&movement.SpentNonce{}).Count(&n).Error; err != nil {
162 t.Fatalf("counting nonces: %v", err)
163 }
164 return n
165}
166
167// confirmSignup marks one address confirmed directly. These tests are about
168// what /signup does to an already-confirmed row, so they set that state up
169// rather than driving the confirmation flow, which has its own tests.
170func confirmSignup(t *testing.T, h *testApp, email string) {
171 t.Helper()
172 now := time.Now()
173 res := h.DB.G.Model(&movement.Signup{}).
174 Where("email = ?", email).
175 Updates(map[string]any{"confirmed": true, "confirmed_at": now, "token_hash": nil})
176 if res.Error != nil {
177 t.Fatalf("confirming %s: %v", email, res.Error)
178 }
179 if res.RowsAffected != 1 {
180 t.Fatalf("confirming %s: %d rows affected, want 1", email, res.RowsAffected)
181 }
182}
183
184// testEnv is the configuration a test runs against. It is not the canonical
185// host, which matters: placeholder copy refuses to boot there, and every
186// test in this package runs against a build that still carries placeholders.
187func testEnv() movement.Env {
188 return movement.Env{
189 Origin: testOrigin,
190 InstanceKey: strings.Repeat("k", 64),
191 }
192}
193
194func get(t *testing.T, h http.Handler, target string) *httptest.ResponseRecorder {
195 t.Helper()
196 rec := httptest.NewRecorder()
197 h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))
198 return rec
199}
200
201func post(t *testing.T, h http.Handler, target string, form url.Values) *httptest.ResponseRecorder {
202 t.Helper()
203 req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode()))
204 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
205 // The same-origin evidence csrf.Protect requires — every current
206 // browser sends it on a form POST; a test client must too.
207 req.Header.Set("Origin", testOrigin)
208 rec := httptest.NewRecorder()
209 h.ServeHTTP(rec, req)
210 return rec
211}
212