| 1 | // Package callstest tests the app from the outside: real HTTP |
| 2 | // requests against the real router, wired exactly as |
| 3 | // cmd/calls/main.go wires it. |
| 4 | // |
| 5 | // The TDD loop: write a failing test here, make it pass in |
| 6 | // internal/calls, repeat. |
| 7 | package callstest |
| 8 | |
| 9 | import ( |
| 10 | "context" |
| 11 | "database/sql" |
| 12 | "html" |
| 13 | "io" |
| 14 | "log/slog" |
| 15 | "net" |
| 16 | "net/http" |
| 17 | "net/http/httptest" |
| 18 | "net/url" |
| 19 | "path/filepath" |
| 20 | "regexp" |
| 21 | "strings" |
| 22 | "sync" |
| 23 | "syscall" |
| 24 | "testing" |
| 25 | "time" |
| 26 | |
| 27 | "amadan.net/rastrillo/rastrillo/db" |
| 28 | |
| 29 | "amadan.net/oficina/calls/internal/calls" |
| 30 | ) |
| 31 | |
| 32 | // testOrigin is the origin newApp wires and post presents as its |
| 33 | // same-origin evidence — the pair a browser form submission would be. |
| 34 | const testOrigin = "http://app.test" |
| 35 | |
| 36 | // harness is the whole app over a fresh temp database, plus the |
| 37 | // clock and mailer hooks tests need to drive time and read magic |
| 38 | // links without a real mail server. |
| 39 | type harness struct { |
| 40 | h http.Handler |
| 41 | db *db.DB |
| 42 | dbPath string |
| 43 | mail *recordingMailer |
| 44 | ctl *calls.TestHandle |
| 45 | now time.Time |
| 46 | |
| 47 | // t and srv are the streaming half: an SSE stream cannot be read |
| 48 | // from an httptest.ResponseRecorder, which only hands its bytes |
| 49 | // back when the handler has returned, so the tests that read one |
| 50 | // go through a real server over a real socket. It is started on |
| 51 | // first use and closed after every stream this test opened. |
| 52 | t *testing.T |
| 53 | srv *httptest.Server |
| 54 | } |
| 55 | |
| 56 | // server is the real HTTP server the stream tests talk to. Its origin |
| 57 | // is not testOrigin — it is 127.0.0.1 on whatever port the kernel gave |
| 58 | // it — which does not matter: csrf.Protect compares the Origin header |
| 59 | // the client sends against the configured origin, and the client sends |
| 60 | // testOrigin, exactly as a browser on the app's own origin would. |
| 61 | func (x *harness) server() *httptest.Server { |
| 62 | if x.srv == nil { |
| 63 | srv := httptest.NewUnstartedServer(x.h) |
| 64 | // Every accepted connection gets a small, fixed send buffer. It |
| 65 | // costs the other tests nothing — every event they read is a few |
| 66 | // hundred bytes — and it is what makes "the client stopped |
| 67 | // reading" mean something to the server: with the kernel's own |
| 68 | // defaults, an unread socket absorbs however much tcp_wmem's |
| 69 | // ceiling allows (about 1.8 MiB on this box, 8 MiB on another), |
| 70 | // so the write never blocks, the hub's sixty-four-event buffer |
| 71 | // never fills behind it, and a test of §4.4's slow subscriber |
| 72 | // would pass or fail on the host's sysctls rather than on the |
| 73 | // code. See TestASlowClientIsLostOnTheBuffer... |
| 74 | srv.Listener = smallSendBuffer{srv.Listener} |
| 75 | srv.Start() |
| 76 | x.srv = srv |
| 77 | // Registered before the shutdown, so it runs after it: Close |
| 78 | // waits for outstanding requests, and a live stream ends when |
| 79 | // the app's context is cancelled. A stream's own cleanup is |
| 80 | // registered later still, so it runs before both. |
| 81 | x.t.Cleanup(x.srv.Close) |
| 82 | x.t.Cleanup(x.shutdown) |
| 83 | } |
| 84 | return x.srv |
| 85 | } |
| 86 | |
| 87 | // serverSendBuffer is SO_SNDBUF on every accepted connection. Linux |
| 88 | // doubles what it is given and stops autotuning once it is set, so the |
| 89 | // server can hold roughly 16 KiB of unread response — a quarter of one |
| 90 | // maximum-sized signalling payload. |
| 91 | const serverSendBuffer = 8 << 10 |
| 92 | |
| 93 | type smallSendBuffer struct{ net.Listener } |
| 94 | |
| 95 | func (l smallSendBuffer) Accept() (net.Conn, error) { |
| 96 | c, err := l.Listener.Accept() |
| 97 | if err != nil { |
| 98 | return nil, err |
| 99 | } |
| 100 | tc, ok := c.(*net.TCPConn) |
| 101 | if !ok { |
| 102 | return c, nil |
| 103 | } |
| 104 | raw, err := tc.SyscallConn() |
| 105 | if err != nil { |
| 106 | return c, nil |
| 107 | } |
| 108 | // Best effort: a kernel that refuses the option leaves the |
| 109 | // connection working, and the one test that depends on the size |
| 110 | // says how many events got through when it fails. |
| 111 | _ = raw.Control(func(fd uintptr) { |
| 112 | _ = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_SNDBUF, serverSendBuffer) |
| 113 | }) |
| 114 | return c, nil |
| 115 | } |
| 116 | |
| 117 | func (x *harness) sqlDB() *sql.DB { s, _ := x.db.G.DB(); return s } |
| 118 | |
| 119 | func (x *harness) advance(d time.Duration) { |
| 120 | x.now = x.now.Add(d) |
| 121 | now := x.now |
| 122 | x.ctl.SetClock(func() time.Time { return now }) |
| 123 | } |
| 124 | |
| 125 | // sweep runs one bounded pass of what App runs hourly, at the harness's |
| 126 | // own current time — the same clock advance already set, given |
| 127 | // explicitly rather than read back off the app (Internal 5's one clock |
| 128 | // for the whole pass). |
| 129 | func (x *harness) sweep() { x.ctl.SweepForTest(x.now) } |
| 130 | |
| 131 | // shutdown is the SIGTERM the deployed process gets: the context the |
| 132 | // app was built on is cancelled, and live streams end themselves so a |
| 133 | // graceful shutdown is not held open by one. |
| 134 | func (x *harness) shutdown() { x.ctl.ShutdownForTest() } |
| 135 | |
| 136 | // authLinks counts rastrillo's unclicked magic links. It is the app's |
| 137 | // own table, not Calls', which is the point: nothing deletes a row there |
| 138 | // but presenting the token or sweeping. |
| 139 | func (x *harness) authLinks(t *testing.T) int { |
| 140 | t.Helper() |
| 141 | var n int |
| 142 | if err := x.db.G.Raw("SELECT count(*) FROM auth_links").Scan(&n).Error; err != nil { |
| 143 | t.Fatal(err) |
| 144 | } |
| 145 | return n |
| 146 | } |
| 147 | |
| 148 | // pendingJoins counts Calls' own pending_joins rows directly — used to |
| 149 | // confirm a scenario actually wrote the row it means to test the expiry |
| 150 | // of, rather than trusting a same-answer redirect alone: POST /signin's |
| 151 | // "?sent=1" also covers a request that never wrote anything (Codex |
| 152 | // round 3, C3-3's test-assessment table). |
| 153 | func (x *harness) pendingJoins(t *testing.T) int { |
| 154 | t.Helper() |
| 155 | var n int |
| 156 | if err := x.db.G.Raw("SELECT count(*) FROM pending_joins").Scan(&n).Error; err != nil { |
| 157 | t.Fatal(err) |
| 158 | } |
| 159 | return n |
| 160 | } |
| 161 | |
| 162 | type recordingMailer struct { |
| 163 | mu sync.Mutex |
| 164 | to []string |
| 165 | subjects []string |
| 166 | links []string |
| 167 | // waits are the sign-in mail queues in front of this recorder: |
| 168 | // links go out after the response (signin_mail.go), so every read |
| 169 | // waits for them first. |
| 170 | waits []func() |
| 171 | } |
| 172 | |
| 173 | func (m *recordingMailer) AttachSigninQueue(wait func()) { |
| 174 | m.mu.Lock() |
| 175 | m.waits = append(m.waits, wait) |
| 176 | m.mu.Unlock() |
| 177 | } |
| 178 | |
| 179 | func (m *recordingMailer) flush() { |
| 180 | m.mu.Lock() |
| 181 | waits := append([]func(){}, m.waits...) |
| 182 | m.mu.Unlock() |
| 183 | for _, w := range waits { |
| 184 | w() |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | var magicLink = regexp.MustCompile(`https?://\S+/auth/verify\?token=\S+`) |
| 189 | |
| 190 | func (m *recordingMailer) Send(ctx context.Context, to, subject, body string) error { |
| 191 | m.mu.Lock() |
| 192 | defer m.mu.Unlock() |
| 193 | m.to = append(m.to, to) |
| 194 | m.subjects = append(m.subjects, subject) |
| 195 | m.links = append(m.links, magicLink.FindString(body)) |
| 196 | return nil |
| 197 | } |
| 198 | |
| 199 | func (m *recordingMailer) last() (string, string) { |
| 200 | m.flush() |
| 201 | m.mu.Lock() |
| 202 | defer m.mu.Unlock() |
| 203 | if len(m.to) == 0 { |
| 204 | return "", "" |
| 205 | } |
| 206 | return m.to[len(m.to)-1], m.links[len(m.links)-1] |
| 207 | } |
| 208 | |
| 209 | func (m *recordingMailer) lastSubject(t *testing.T) string { |
| 210 | t.Helper() |
| 211 | m.flush() |
| 212 | m.mu.Lock() |
| 213 | defer m.mu.Unlock() |
| 214 | if len(m.subjects) == 0 { |
| 215 | t.Fatal("no mail sent") |
| 216 | } |
| 217 | return m.subjects[len(m.subjects)-1] |
| 218 | } |
| 219 | |
| 220 | func (m *recordingMailer) count() int { |
| 221 | m.flush() |
| 222 | m.mu.Lock() |
| 223 | defer m.mu.Unlock() |
| 224 | return len(m.to) |
| 225 | } |
| 226 | |
| 227 | func newHarness(t *testing.T, members ...string) *harness { |
| 228 | t.Helper() |
| 229 | return newHarnessWithLogger(t, calls.EnvDevelopment, slog.New(slog.NewTextHandler(io.Discard, nil)), members...) |
| 230 | } |
| 231 | |
| 232 | // newHarnessWithLogger is newHarness with the caller's own logger wired |
| 233 | // through both the app and its database, instead of the usual |
| 234 | // io.Discard one — used to capture and assert on log lines (spec §0.1, |
| 235 | // §4.4: no payload, address, title or token is ever a log argument) — |
| 236 | // and the environment as its own parameter, so a test can build the |
| 237 | // production harness that proves a development-only behaviour is inert |
| 238 | // outside development. Production needs the full set of startup |
| 239 | // secrets TestProductionRefusesToStartWithoutSecrets exercises — |
| 240 | // Origin, InstanceKey, DEK, Mailer and a non-empty allowlist — so this |
| 241 | // fills those in whenever env is not development, rather than merely |
| 242 | // swapping Env on the development set. |
| 243 | func newHarnessWithLogger(t *testing.T, env string, logger *slog.Logger, members ...string) *harness { |
| 244 | t.Helper() |
| 245 | return newHarnessWith(t, env, logger, nil, members...) |
| 246 | } |
| 247 | |
| 248 | // newHarnessWith is newHarnessWithLogger with a last word on the Config, |
| 249 | // for a test that needs a setting nothing else does (the report logger |
| 250 | // and the quality kill switch of spec §15.26). |
| 251 | func newHarnessWith(t *testing.T, env string, logger *slog.Logger, adjust func(*calls.Config), members ...string) *harness { |
| 252 | t.Helper() |
| 253 | dbPath := filepath.Join(t.TempDir(), "app.db") |
| 254 | d, err := db.Open(dbPath, logger) |
| 255 | if err != nil { |
| 256 | t.Fatal(err) |
| 257 | } |
| 258 | t.Cleanup(func() { d.Close() }) |
| 259 | rec := &recordingMailer{} |
| 260 | cfg := calls.Config{ |
| 261 | Env: env, Origin: testOrigin, AllowedAddresses: members, Mailer: rec, Logger: logger, |
| 262 | } |
| 263 | if env != calls.EnvDevelopment { |
| 264 | cfg.InstanceKey = "test-instance-key-not-a-real-secret" |
| 265 | cfg.DEK = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" |
| 266 | } |
| 267 | if adjust != nil { |
| 268 | adjust(&cfg) |
| 269 | } |
| 270 | mux, ctl, err := calls.NewForTest(d, cfg) |
| 271 | if err != nil { |
| 272 | t.Fatal(err) |
| 273 | } |
| 274 | // Ends the app's context, which stops its sign-in mail worker |
| 275 | // (signin_mail.go): no test leaves one running. |
| 276 | t.Cleanup(ctl.ShutdownForTest) |
| 277 | // The base is now, not a fixed date. Calls' clock is the only one a |
| 278 | // test can move; rastrillo stamps its auth_links rows from the real |
| 279 | // one. A fixed base put the two most of a day apart, so nothing that |
| 280 | // compares them could mean anything — see the auth_links sweep test. |
| 281 | x := &harness{h: mux, db: d, dbPath: dbPath, mail: rec, ctl: ctl, now: time.Now().UTC(), t: t} |
| 282 | x.advance(0) |
| 283 | return x |
| 284 | } |
| 285 | |
| 286 | // newApp builds the whole app per test over a fresh temp database, |
| 287 | // exactly as main.go does. |
| 288 | func newApp(t *testing.T) http.Handler { return newHarness(t).h } |
| 289 | |
| 290 | func get(t *testing.T, h http.Handler, target string) *httptest.ResponseRecorder { |
| 291 | t.Helper() |
| 292 | rec := httptest.NewRecorder() |
| 293 | h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil)) |
| 294 | return rec |
| 295 | } |
| 296 | |
| 297 | func post(t *testing.T, h http.Handler, target string, form url.Values) *httptest.ResponseRecorder { |
| 298 | t.Helper() |
| 299 | req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode())) |
| 300 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 301 | // The same-origin evidence csrf.Protect requires — every current |
| 302 | // browser sends it on a form POST; a test client must too. |
| 303 | req.Header.Set("Origin", testOrigin) |
| 304 | rec := httptest.NewRecorder() |
| 305 | h.ServeHTTP(rec, req) |
| 306 | return rec |
| 307 | } |
| 308 | |
| 309 | // client is a cookie-carrying browser stand-in: cookies set on one |
| 310 | // response are carried on the next request, and a MaxAge<0 clear |
| 311 | // (sign-out) drops them again. Used from Task 4 on. |
| 312 | type client struct { |
| 313 | t *testing.T |
| 314 | x *harness |
| 315 | cookies map[string]*http.Cookie |
| 316 | } |
| 317 | |
| 318 | func (x *harness) browser(t *testing.T) *client { |
| 319 | return &client{t: t, x: x, cookies: map[string]*http.Cookie{}} |
| 320 | } |
| 321 | |
| 322 | // newMemberBrowser is a fresh browser signed in as address, which must |
| 323 | // already be one of x's AllowedAddresses — a second member sharing the |
| 324 | // harness memberWithMeeting built, for tests that need two members in |
| 325 | // one room. |
| 326 | func newMemberBrowser(t *testing.T, x *harness, address string) *client { |
| 327 | t.Helper() |
| 328 | c := x.browser(t) |
| 329 | c.signIn(address) |
| 330 | return c |
| 331 | } |
| 332 | |
| 333 | func (c *client) do(req *http.Request) *httptest.ResponseRecorder { |
| 334 | for _, ck := range c.cookies { |
| 335 | req.AddCookie(ck) |
| 336 | } |
| 337 | rec := httptest.NewRecorder() |
| 338 | c.x.h.ServeHTTP(rec, req) |
| 339 | for _, ck := range rec.Result().Cookies() { |
| 340 | if ck.MaxAge < 0 { |
| 341 | delete(c.cookies, ck.Name) |
| 342 | continue |
| 343 | } |
| 344 | c.cookies[ck.Name] = ck |
| 345 | } |
| 346 | return rec |
| 347 | } |
| 348 | |
| 349 | func (c *client) get(target string) *httptest.ResponseRecorder { |
| 350 | return c.do(httptest.NewRequest(http.MethodGet, target, nil)) |
| 351 | } |
| 352 | |
| 353 | // getStream is get for the stream endpoint, for the answers that are |
| 354 | // not a stream: it carries EventSource's Accept header (ruling 15) and |
| 355 | // a two-second deadline, so a case that expects a refusal and gets a |
| 356 | // stream fails in two seconds instead of holding the recorder open for |
| 357 | // the whole TTL. A recorder cannot read a stream anyway. |
| 358 | func (c *client) getStream(target string) *httptest.ResponseRecorder { |
| 359 | return c.do(c.boundedGet(target, "text/event-stream")) |
| 360 | } |
| 361 | |
| 362 | // getNav is the other half of ruling 15: a top-level navigation to a |
| 363 | // stream URL, which asks for HTML and must be refused. Bounded like |
| 364 | // getStream, so a regression that lets it attach fails in two seconds |
| 365 | // instead of holding the recorder open for the whole TTL. |
| 366 | func (c *client) getNav(target string) *httptest.ResponseRecorder { |
| 367 | return c.do(c.boundedGet(target, "text/html,application/xhtml+xml")) |
| 368 | } |
| 369 | |
| 370 | func (c *client) boundedGet(target, accept string) *http.Request { |
| 371 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| 372 | c.t.Cleanup(cancel) |
| 373 | req := httptest.NewRequest(http.MethodGet, target, nil).WithContext(ctx) |
| 374 | req.Header.Set("Accept", accept) |
| 375 | return req |
| 376 | } |
| 377 | |
| 378 | func (c *client) post(target string, form url.Values) *httptest.ResponseRecorder { |
| 379 | req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode())) |
| 380 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 381 | req.Header.Set("Origin", testOrigin) |
| 382 | return c.do(req) |
| 383 | } |
| 384 | |
| 385 | func httptestPost(target string, form url.Values) *http.Request { |
| 386 | req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode())) |
| 387 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 388 | return req |
| 389 | } |
| 390 | |
| 391 | type nopMailer struct{} |
| 392 | |
| 393 | func (nopMailer) Send(ctx context.Context, to, subject, body string) error { return nil } |
| 394 | |
| 395 | // signIn asks for a magic link as address and follows it in this |
| 396 | // client, leaving the session cookie behind. It returns the Location of |
| 397 | // the post-sign-in redirect. |
| 398 | func (c *client) signIn(address string) string { |
| 399 | c.t.Helper() |
| 400 | rec := c.post("/signin", url.Values{"address": {address}}) |
| 401 | if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" { |
| 402 | c.t.Fatalf("POST /signin: %d %s", rec.Code, rec.Header().Get("Location")) |
| 403 | } |
| 404 | to, link := c.x.mail.last() |
| 405 | if to != address || link == "" { |
| 406 | c.t.Fatalf("no magic link mailed to %s (last to %q)", address, to) |
| 407 | } |
| 408 | return c.follow(link) |
| 409 | } |
| 410 | |
| 411 | // confirmForm is the confirm door's one form, as a browser reads it. |
| 412 | var confirmForm = regexp.MustCompile(`<form[^>]*method="post" action="([^"]+)">\s*<input type="hidden" name="token" value="([^"]+)">`) |
| 413 | |
| 414 | // open is opening an emailed link in this client: a GET that must spend |
| 415 | // nothing and answer with the confirm door. It returns the door's form |
| 416 | // action and the token it carries, read off the page rather than the |
| 417 | // link, so a door that dropped or mangled the token fails here. |
| 418 | func (c *client) open(link string) (action, token string) { |
| 419 | c.t.Helper() |
| 420 | u, err := url.Parse(link) |
| 421 | if err != nil || link == "" { |
| 422 | c.t.Fatalf("emailed link %q: %v", link, err) |
| 423 | } |
| 424 | rec := c.get(u.RequestURI()) |
| 425 | if rec.Code != http.StatusOK || len(rec.Result().Cookies()) != 0 { |
| 426 | c.t.Fatalf("GET %s = %d with %d cookies, want 200 and the confirm door, minting nothing:\n%s", u.Path, rec.Code, len(rec.Result().Cookies()), rec.Body.String()) |
| 427 | } |
| 428 | m := confirmForm.FindStringSubmatch(rec.Body.String()) |
| 429 | if m == nil { |
| 430 | c.t.Fatalf("GET %s: no confirm form on the page:\n%s", u.Path, rec.Body.String()) |
| 431 | } |
| 432 | return html.UnescapeString(m[1]), html.UnescapeString(m[2]) |
| 433 | } |
| 434 | |
| 435 | // press is pressing the confirm door's Sign in button: the token, |
| 436 | // POSTed back to the door's action with a browser's same-origin |
| 437 | // evidence. This is the request that spends the link. |
| 438 | func (c *client) press(action, token string) *httptest.ResponseRecorder { |
| 439 | req := httptest.NewRequest(http.MethodPost, action, strings.NewReader(url.Values{"token": {token}}.Encode())) |
| 440 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 441 | req.Header.Set("Origin", testOrigin) |
| 442 | req.Header.Set("Sec-Fetch-Site", "same-origin") |
| 443 | return c.do(req) |
| 444 | } |
| 445 | |
| 446 | // redeem is both: open the emailed link, then press Sign in. It returns |
| 447 | // the POST's answer. |
| 448 | func (c *client) redeem(link string) *httptest.ResponseRecorder { |
| 449 | c.t.Helper() |
| 450 | return c.press(c.open(link)) |
| 451 | } |
| 452 | |
| 453 | // follow opens an emailed link in this client, presses the confirm |
| 454 | // door's Sign in, and returns where it led. |
| 455 | func (c *client) follow(link string) string { |
| 456 | c.t.Helper() |
| 457 | rec := c.redeem(link) |
| 458 | if rec.Code != http.StatusSeeOther { |
| 459 | c.t.Fatalf("POST the link's token: %d\n%s", rec.Code, rec.Body.String()) |
| 460 | } |
| 461 | loc := rec.Header().Get("Location") |
| 462 | rec = c.get(loc) |
| 463 | if rec.Code == http.StatusSeeOther { |
| 464 | return rec.Header().Get("Location") |
| 465 | } |
| 466 | return loc |
| 467 | } |
| 468 | |