// Package callstest tests the app from the outside: real HTTP // requests against the real router, wired exactly as // cmd/calls/main.go wires it. // // The TDD loop: write a failing test here, make it pass in // internal/calls, repeat. package callstest import ( "context" "database/sql" "html" "io" "log/slog" "net" "net/http" "net/http/httptest" "net/url" "path/filepath" "regexp" "strings" "sync" "syscall" "testing" "time" "amadan.net/rastrillo/rastrillo/db" "amadan.net/oficina/calls/internal/calls" ) // testOrigin is the origin newApp wires and post presents as its // same-origin evidence — the pair a browser form submission would be. const testOrigin = "http://app.test" // harness is the whole app over a fresh temp database, plus the // clock and mailer hooks tests need to drive time and read magic // links without a real mail server. type harness struct { h http.Handler db *db.DB dbPath string mail *recordingMailer ctl *calls.TestHandle now time.Time // t and srv are the streaming half: an SSE stream cannot be read // from an httptest.ResponseRecorder, which only hands its bytes // back when the handler has returned, so the tests that read one // go through a real server over a real socket. It is started on // first use and closed after every stream this test opened. t *testing.T srv *httptest.Server } // server is the real HTTP server the stream tests talk to. Its origin // is not testOrigin — it is 127.0.0.1 on whatever port the kernel gave // it — which does not matter: csrf.Protect compares the Origin header // the client sends against the configured origin, and the client sends // testOrigin, exactly as a browser on the app's own origin would. func (x *harness) server() *httptest.Server { if x.srv == nil { srv := httptest.NewUnstartedServer(x.h) // Every accepted connection gets a small, fixed send buffer. It // costs the other tests nothing — every event they read is a few // hundred bytes — and it is what makes "the client stopped // reading" mean something to the server: with the kernel's own // defaults, an unread socket absorbs however much tcp_wmem's // ceiling allows (about 1.8 MiB on this box, 8 MiB on another), // so the write never blocks, the hub's sixty-four-event buffer // never fills behind it, and a test of §4.4's slow subscriber // would pass or fail on the host's sysctls rather than on the // code. See TestASlowClientIsLostOnTheBuffer... srv.Listener = smallSendBuffer{srv.Listener} srv.Start() x.srv = srv // Registered before the shutdown, so it runs after it: Close // waits for outstanding requests, and a live stream ends when // the app's context is cancelled. A stream's own cleanup is // registered later still, so it runs before both. x.t.Cleanup(x.srv.Close) x.t.Cleanup(x.shutdown) } return x.srv } // serverSendBuffer is SO_SNDBUF on every accepted connection. Linux // doubles what it is given and stops autotuning once it is set, so the // server can hold roughly 16 KiB of unread response — a quarter of one // maximum-sized signalling payload. const serverSendBuffer = 8 << 10 type smallSendBuffer struct{ net.Listener } func (l smallSendBuffer) Accept() (net.Conn, error) { c, err := l.Listener.Accept() if err != nil { return nil, err } tc, ok := c.(*net.TCPConn) if !ok { return c, nil } raw, err := tc.SyscallConn() if err != nil { return c, nil } // Best effort: a kernel that refuses the option leaves the // connection working, and the one test that depends on the size // says how many events got through when it fails. _ = raw.Control(func(fd uintptr) { _ = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_SNDBUF, serverSendBuffer) }) return c, nil } func (x *harness) sqlDB() *sql.DB { s, _ := x.db.G.DB(); return s } func (x *harness) advance(d time.Duration) { x.now = x.now.Add(d) now := x.now x.ctl.SetClock(func() time.Time { return now }) } // sweep runs one bounded pass of what App runs hourly, at the harness's // own current time — the same clock advance already set, given // explicitly rather than read back off the app (Internal 5's one clock // for the whole pass). func (x *harness) sweep() { x.ctl.SweepForTest(x.now) } // shutdown is the SIGTERM the deployed process gets: the context the // app was built on is cancelled, and live streams end themselves so a // graceful shutdown is not held open by one. func (x *harness) shutdown() { x.ctl.ShutdownForTest() } // authLinks counts rastrillo's unclicked magic links. It is the app's // own table, not Calls', which is the point: nothing deletes a row there // but presenting the token or sweeping. func (x *harness) authLinks(t *testing.T) int { t.Helper() var n int if err := x.db.G.Raw("SELECT count(*) FROM auth_links").Scan(&n).Error; err != nil { t.Fatal(err) } return n } // pendingJoins counts Calls' own pending_joins rows directly — used to // confirm a scenario actually wrote the row it means to test the expiry // of, rather than trusting a same-answer redirect alone: POST /signin's // "?sent=1" also covers a request that never wrote anything (Codex // round 3, C3-3's test-assessment table). func (x *harness) pendingJoins(t *testing.T) int { t.Helper() var n int if err := x.db.G.Raw("SELECT count(*) FROM pending_joins").Scan(&n).Error; err != nil { t.Fatal(err) } return n } type recordingMailer struct { mu sync.Mutex to []string subjects []string links []string // waits are the sign-in mail queues in front of this recorder: // links go out after the response (signin_mail.go), so every read // waits for them first. waits []func() } func (m *recordingMailer) AttachSigninQueue(wait func()) { m.mu.Lock() m.waits = append(m.waits, wait) m.mu.Unlock() } func (m *recordingMailer) flush() { m.mu.Lock() waits := append([]func(){}, m.waits...) m.mu.Unlock() for _, w := range waits { w() } } var magicLink = regexp.MustCompile(`https?://\S+/auth/verify\?token=\S+`) func (m *recordingMailer) Send(ctx context.Context, to, subject, body string) error { m.mu.Lock() defer m.mu.Unlock() m.to = append(m.to, to) m.subjects = append(m.subjects, subject) m.links = append(m.links, magicLink.FindString(body)) return nil } func (m *recordingMailer) last() (string, string) { m.flush() m.mu.Lock() defer m.mu.Unlock() if len(m.to) == 0 { return "", "" } return m.to[len(m.to)-1], m.links[len(m.links)-1] } func (m *recordingMailer) lastSubject(t *testing.T) string { t.Helper() m.flush() m.mu.Lock() defer m.mu.Unlock() if len(m.subjects) == 0 { t.Fatal("no mail sent") } return m.subjects[len(m.subjects)-1] } func (m *recordingMailer) count() int { m.flush() m.mu.Lock() defer m.mu.Unlock() return len(m.to) } func newHarness(t *testing.T, members ...string) *harness { t.Helper() return newHarnessWithLogger(t, calls.EnvDevelopment, slog.New(slog.NewTextHandler(io.Discard, nil)), members...) } // newHarnessWithLogger is newHarness with the caller's own logger wired // through both the app and its database, instead of the usual // io.Discard one — used to capture and assert on log lines (spec §0.1, // §4.4: no payload, address, title or token is ever a log argument) — // and the environment as its own parameter, so a test can build the // production harness that proves a development-only behaviour is inert // outside development. Production needs the full set of startup // secrets TestProductionRefusesToStartWithoutSecrets exercises — // Origin, InstanceKey, DEK, Mailer and a non-empty allowlist — so this // fills those in whenever env is not development, rather than merely // swapping Env on the development set. func newHarnessWithLogger(t *testing.T, env string, logger *slog.Logger, members ...string) *harness { t.Helper() return newHarnessWith(t, env, logger, nil, members...) } // newHarnessWith is newHarnessWithLogger with a last word on the Config, // for a test that needs a setting nothing else does (the report logger // and the quality kill switch of spec §15.26). func newHarnessWith(t *testing.T, env string, logger *slog.Logger, adjust func(*calls.Config), members ...string) *harness { t.Helper() dbPath := filepath.Join(t.TempDir(), "app.db") d, err := db.Open(dbPath, logger) if err != nil { t.Fatal(err) } t.Cleanup(func() { d.Close() }) rec := &recordingMailer{} cfg := calls.Config{ Env: env, Origin: testOrigin, AllowedAddresses: members, Mailer: rec, Logger: logger, } if env != calls.EnvDevelopment { cfg.InstanceKey = "test-instance-key-not-a-real-secret" cfg.DEK = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" } if adjust != nil { adjust(&cfg) } mux, ctl, err := calls.NewForTest(d, cfg) if err != nil { t.Fatal(err) } // Ends the app's context, which stops its sign-in mail worker // (signin_mail.go): no test leaves one running. t.Cleanup(ctl.ShutdownForTest) // The base is now, not a fixed date. Calls' clock is the only one a // test can move; rastrillo stamps its auth_links rows from the real // one. A fixed base put the two most of a day apart, so nothing that // compares them could mean anything — see the auth_links sweep test. x := &harness{h: mux, db: d, dbPath: dbPath, mail: rec, ctl: ctl, now: time.Now().UTC(), t: t} x.advance(0) return x } // newApp builds the whole app per test over a fresh temp database, // exactly as main.go does. func newApp(t *testing.T) http.Handler { return newHarness(t).h } func get(t *testing.T, h http.Handler, target string) *httptest.ResponseRecorder { t.Helper() rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil)) return rec } func post(t *testing.T, h http.Handler, target string, form url.Values) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") // The same-origin evidence csrf.Protect requires — every current // browser sends it on a form POST; a test client must too. req.Header.Set("Origin", testOrigin) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } // client is a cookie-carrying browser stand-in: cookies set on one // response are carried on the next request, and a MaxAge<0 clear // (sign-out) drops them again. Used from Task 4 on. type client struct { t *testing.T x *harness cookies map[string]*http.Cookie } func (x *harness) browser(t *testing.T) *client { return &client{t: t, x: x, cookies: map[string]*http.Cookie{}} } // newMemberBrowser is a fresh browser signed in as address, which must // already be one of x's AllowedAddresses — a second member sharing the // harness memberWithMeeting built, for tests that need two members in // one room. func newMemberBrowser(t *testing.T, x *harness, address string) *client { t.Helper() c := x.browser(t) c.signIn(address) return c } func (c *client) do(req *http.Request) *httptest.ResponseRecorder { for _, ck := range c.cookies { req.AddCookie(ck) } rec := httptest.NewRecorder() c.x.h.ServeHTTP(rec, req) for _, ck := range rec.Result().Cookies() { if ck.MaxAge < 0 { delete(c.cookies, ck.Name) continue } c.cookies[ck.Name] = ck } return rec } func (c *client) get(target string) *httptest.ResponseRecorder { return c.do(httptest.NewRequest(http.MethodGet, target, nil)) } // getStream is get for the stream endpoint, for the answers that are // not a stream: it carries EventSource's Accept header (ruling 15) and // a two-second deadline, so a case that expects a refusal and gets a // stream fails in two seconds instead of holding the recorder open for // the whole TTL. A recorder cannot read a stream anyway. func (c *client) getStream(target string) *httptest.ResponseRecorder { return c.do(c.boundedGet(target, "text/event-stream")) } // getNav is the other half of ruling 15: a top-level navigation to a // stream URL, which asks for HTML and must be refused. Bounded like // getStream, so a regression that lets it attach fails in two seconds // instead of holding the recorder open for the whole TTL. func (c *client) getNav(target string) *httptest.ResponseRecorder { return c.do(c.boundedGet(target, "text/html,application/xhtml+xml")) } func (c *client) boundedGet(target, accept string) *http.Request { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) c.t.Cleanup(cancel) req := httptest.NewRequest(http.MethodGet, target, nil).WithContext(ctx) req.Header.Set("Accept", accept) return req } func (c *client) post(target string, form url.Values) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", testOrigin) return c.do(req) } func httptestPost(target string, form url.Values) *http.Request { req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") return req } type nopMailer struct{} func (nopMailer) Send(ctx context.Context, to, subject, body string) error { return nil } // signIn asks for a magic link as address and follows it in this // client, leaving the session cookie behind. It returns the Location of // the post-sign-in redirect. func (c *client) signIn(address string) string { c.t.Helper() rec := c.post("/signin", url.Values{"address": {address}}) if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" { c.t.Fatalf("POST /signin: %d %s", rec.Code, rec.Header().Get("Location")) } to, link := c.x.mail.last() if to != address || link == "" { c.t.Fatalf("no magic link mailed to %s (last to %q)", address, to) } return c.follow(link) } // confirmForm is the confirm door's one form, as a browser reads it. var confirmForm = regexp.MustCompile(`