oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

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.
7package callstest
8
9import (
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.
34const 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.
39type 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.
61func (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.
91const serverSendBuffer = 8 << 10
92
93type smallSendBuffer struct{ net.Listener }
94
95func (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
117func (x *harness) sqlDB() *sql.DB { s, _ := x.db.G.DB(); return s }
118
119func (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).
129func (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.
134func (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.
139func (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).
153func (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
162type 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
173func (m *recordingMailer) AttachSigninQueue(wait func()) {
174 m.mu.Lock()
175 m.waits = append(m.waits, wait)
176 m.mu.Unlock()
177}
178
179func (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
188var magicLink = regexp.MustCompile(`https?://\S+/auth/verify\?token=\S+`)
189
190func (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
199func (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
209func (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
220func (m *recordingMailer) count() int {
221 m.flush()
222 m.mu.Lock()
223 defer m.mu.Unlock()
224 return len(m.to)
225}
226
227func 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.
243func 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).
251func 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.
288func newApp(t *testing.T) http.Handler { return newHarness(t).h }
289
290func 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
297func 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.
312type client struct {
313 t *testing.T
314 x *harness
315 cookies map[string]*http.Cookie
316}
317
318func (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.
326func 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
333func (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
349func (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.
358func (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.
366func (c *client) getNav(target string) *httptest.ResponseRecorder {
367 return c.do(c.boundedGet(target, "text/html,application/xhtml+xml"))
368}
369
370func (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
378func (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
385func 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
391type nopMailer struct{}
392
393func (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.
398func (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.
412var 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.
418func (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.
438func (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.
448func (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.
455func (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