| 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. |
| 7 | package movementtest |
| 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 | "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. |
| 30 | const 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. |
| 35 | type 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. |
| 44 | type recorder struct { |
| 45 | mu sync.Mutex |
| 46 | sent []recorded |
| 47 | } |
| 48 | |
| 49 | type recorded struct{ To, Subject, Body string } |
| 50 | |
| 51 | func (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 | |
| 58 | func (r *recorder) count() int { |
| 59 | r.mu.Lock() |
| 60 | defer r.mu.Unlock() |
| 61 | return len(r.sent) |
| 62 | } |
| 63 | |
| 64 | func (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. |
| 85 | func (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. |
| 110 | func 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. |
| 120 | func 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 | |
| 140 | func 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 | |
| 149 | func 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 | |
| 158 | func 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. |
| 170 | func 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. |
| 187 | func testEnv() movement.Env { |
| 188 | return movement.Env{ |
| 189 | Origin: testOrigin, |
| 190 | InstanceKey: strings.Repeat("k", 64), |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | func 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 | |
| 201 | func 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 | |