oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1//go:build browser
2
3// The browser gate: a real call, in real Chromium, between two real
4// browser contexts with fake cameras.
5//
6// make browser
7//
8// Not part of the plain suite and not part of `make ci`: it needs node
9// and an npm install, and the gate the team runs on every push must not
10// depend on a toolchain the app itself does not use.
11//
12// Every other test in this repo lives inside one layer. The Go suite
13// asks Go what Go produced; room.js was landed with nothing but
14// `node --check` behind it. Everything in the media half —
15// getUserMedia, the offer/answer exchange, trickle ICE, a decoded frame,
16// the resume ladder, the takeover refusal — first runs here.
17//
18// Go owns the half a browser cannot reach: it starts the app on a real
19// port (the origin is load-bearing — csrf.Protect measures against it),
20// signs the two members in through the actual magic-link flow so it can
21// read the mailer, creates the meeting, and restarts the process on the
22// same port when the restart scenario asks for it. node owns the
23// browser. They talk over the node process's own stdin and stdout: node
24// writes a line beginning "CONTROL ", Go answers with one line.
25package callstest
26
27import (
28 "bufio"
29 "bytes"
30 "context"
31 "encoding/base64"
32 "encoding/json"
33 "fmt"
34 "html"
35 "io"
36 "log/slog"
37 "net"
38 "net/http"
39 "net/http/cookiejar"
40 "net/url"
41 "os"
42 "os/exec"
43 "path/filepath"
44 "regexp"
45 "runtime"
46 "strings"
47 "sync"
48 "testing"
49 "time"
50
51 "amadan.net/rastrillo/rastrillo"
52 "amadan.net/rastrillo/rastrillo/db"
53
54 "amadan.net/oficina/calls/internal/calls"
55)
56
57// scenarioTimeout is per scenario, not for the run: a call that has not
58// connected in three minutes is not slow, it is broken.
59const scenarioTimeout = 3 * time.Minute
60
61// memberA and memberB are the two AllowedAddresses this gate signs in.
62// A guest signs itself in from the browser, through the emailed link,
63// with an address that is deliberately not on the list.
64//
65// The two local parts differ, and that is load-bearing rather than
66// cosmetic: a display name is the address up to the "@", so the rest of
67// this package's `paul@example.com`/`paul@rslw.com` pair would give both
68// contexts here the name "paul". Every assertion about whose name is on
69// an arriving chat line, or in "<name>’s screen", would then hold just
70// as well with the wrong name on it.
71const (
72 memberA = "paul@example.com"
73 memberB = "bea@rslw.com"
74)
75
76// scenarios are the passes, in the order they run. Each gets its own
77// node process and its own freshly restarted app, so a participation one
78// scenario left behind cannot 409 the next one's fresh stream.
79//
80// All of them pass. A scenario that starts failing on the client rather
81// than on the gate keeps its place here and its assertions as written,
82// and is skipped from inside its own subtest with t.Skip carrying the
83// exact failure — never deleted, and never quietly loosened.
84var scenarios = []string{
85 "guestdirect",
86 "layout",
87 "glyphs",
88 "stage",
89 "window",
90 "title",
91 "chrome",
92 "devices",
93 "audiotest",
94 "call",
95 "mute",
96 "meters",
97 "spacemute",
98 "switching",
99 "share",
100 "mutualshare",
101 "laterolls",
102 "renewal",
103 "ice",
104 "icerenewal",
105 "icefail",
106 "icestuck",
107 "guest",
108 "speaker",
109 "chat",
110 "removed",
111 "allowlist",
112 "departure",
113 "staletarget",
114 "probehang",
115 "replay",
116 "gap",
117 "resetretry",
118 "senddeadline",
119 "sendhang",
120 "rosterpair",
121 "restart",
122 "takeover",
123 "crosssite",
124 "stall",
125 "signedout",
126 "nowebrtc",
127 "playfallback",
128 "autoplay",
129 "connecting",
130 "quality",
131 "scriptless",
132}
133
134// skipped are the scenarios whose assertions are right and whose client
135// is not. Each keeps its place in `scenarios` above and every assertion
136// as written; what is recorded here is the exact failure the gate
137// printed and the round that owns the fix. Delete an entry to put a
138// scenario back in the gate — that, and nothing else, is what "fixed"
139// means.
140//
141// FIXED in Task 4's fix round 2 — see task-4-report.md. The account
142// below is left as written because it is what the fix was made against,
143// and because the last paragraph of it is still true of `accept`.
144//
145// All three ICE scenarios shared one defect, and it was not in them:
146//
147// room.js applyRoster captures the roster, awaits the /ice fetch
148// (§5.2's ordering), and then reconciles the roster it captured
149// BEFORE the await. A `peer` event that arrives during the fetch —
150// ten milliseconds after the roster, in the run below — builds a
151// connection that the stale roster does not name, so reconcile
152// closes it with forget=true. That drops seqOut and lastIn for that
153// peer (closePC's own comment: the counters are kept only on a
154// rebuild). The connection is built again by the next message, its
155// outbound numbering restarts at 1, and the other browser — whose
156// lastIn for this peer is still 3 — drops the answer as already
157// seen. It stays at have-local-offer with nothing to retry, and the
158// call never connects.
159//
160// Watched directly: with /ice held 1200 ms by the gate's own route, one
161// browser logged `setConfiguration ok, restartIce ok, close ok` and then
162// a fresh connection, posting `answer seq=1` where the peer had already
163// recorded seq=3 from it. It is a race, not a certainty: the same
164// scenarios pass when the fetch beats the `peer` event, which is most of
165// the time on a loopback and none of the time on a busy machine — two
166// consecutive full `make browser` runs each lost one or two of the three
167// to it. Nothing about it is specific to the relay; a relay only makes
168// /ice slow enough to notice.
169//
170// The stale roster is what opens the hole, but the thing that kills the
171// call is one line older than this slice: `accept` tests the replay
172// guard BEFORE the generation, so the first message of a renumbered
173// connection — seq 1, where lastIn already says 3 — is discarded as a
174// replay whatever generation it carries. `staletarget` failed with the
175// identical signature (stable/connecting against have-local-offer,
176// frames {}, rosters 10) in a run of this same gate with no relay
177// configured anywhere, which is the flake Task 4's fix round recorded as
178// "watch" and could not explain. Same defect, different door.
179//
180// The fix is Task 4's own suggestion, from its report's last concern:
181// reconcile the roster this browser holds when the await returns, or
182// build connections at once and adopt the servers when the list lands —
183// and, under it, an `accept` that lets a new generation past the cursor
184// it is renumbering.
185// Empty, and the account above is kept because it is the clearest one
186// this branch has of a defect that cost two slices a flake each. Task
187// 4's fix round 2 closed three doors onto it, not the two the account
188// above describes: applyRoster reconciles the roster held when the
189// fetch returns; a close from reconcile no longer forgets §4.6's
190// counters for a peer the hub may still name; and — the one that was
191// actually killing these scenarios — a roster cannot close a connection
192// built after the hub sent it. That third one is missing from the
193// account because the account is missing the fact under it: the hub
194// does not re-send a roster when someone joins, it sends `peer` to the
195// people already there, so for the commonest case there is no newer
196// roster for the first door to prefer. The three ICE scenarios and
197// staletarget have run in the gate ever since.
198var skipped = map[string]string{
199 // FIXED in Task 4's fix round 2 — see task-4-report.md. mutualshare
200 // has run in the gate ever since, and the account below is left as
201 // written because it is what the fix was made against. What it could
202 // not know is where the damage came from: not the m-line matching it
203 // suspected, but the rollback itself. Chromium rebuilds every send
204 // stream on a connection when it rolls back an offer that added an
205 // m-line, and the camera's comes back having encoded nothing. The
206 // fix is that the side which would roll back gives way before it
207 // offers, so nothing collides — see POLITE_YIELD in room.js.
208 //
209 // Task 4 fix round 2 owns this. The scenario keeps its place above
210 // and every assertion as written; what follows is exactly what the
211 // gate printed, and the evidence under it.
212 //
213 // mutualshare drives §5.4 the one way `share` cannot: both peers
214 // click Share screen together, three rounds of stop-and-share, and
215 // then one stops while the other carries on. Every screen assertion
216 // passes in every round — announced, captioned with the right name,
217 // decoding real frames, negotiation settling and staying settled,
218 // displaySurface saying both are really capturing a display. What
219 // fails is the camera, from the first mutual share onwards:
220 //
221 // FAIL round 2: member A is still receiving the other's camera (5 -> 5)
222 // FAIL round 2: member B is still receiving the other's camera (6 -> 6)
223 // FAIL round 3: member A is still receiving the other's camera (5 -> 5)
224 // FAIL round 3: member B is still receiving the other's camera (6 -> 6)
225 // FAIL member B still has the camera through the one-sided stop (6 -> 6)
226 //
227 // Round 1 passes only because 5 > 3: the count moves twice and then
228 // stops for the remaining seventy seconds, while the two screens go
229 // on to six hundred frames each on the same connections.
230 //
231 // It is not the tile, the counter or the encoder. Per-connection
232 // getStats, from the run that produced the lines above:
233 //
234 // A (has stopped sharing; audio mid 0, camera mid 1, B's screen mid 6):
235 // transceivers ["0 sendrecv/sendrecv:audio", "1 sendrecv/sendrecv:video", "6 recvonly/recvonly:video"]
236 // inbound [{ssrc 2155371570, framesReceived 513, framesDecoded 512, framesDropped 0, pliCount 0, freezeCount 0, muted false}
237 // {ssrc 572440383, framesReceived 513, framesDecoded 513, framesDropped 1, pliCount 1, freezeCount 0, muted true}]
238 // outbound [{ssrc 991534554, framesSent 0, framesEncoded 0, active true, limited "none"}]
239 //
240 // B (still sharing; audio 0, camera 1, its own screen 6, A's screen 7):
241 // transceivers ["0 sendrecv/sendrecv:audio", "1 sendrecv/sendrecv:video", "6 sendonly/sendonly:video", "7 recvonly/recvonly:video"]
242 // inbound [{ssrc 4101167316, framesReceived 508, framesDecoded 507, framesDropped 0, pliCount 0, freezeCount 0, muted false}
243 // {ssrc 991534554, framesReceived 6, framesDecoded 6, framesDropped 0, pliCount 1, freezeCount 0, muted true}]
244 // outbound [{ssrc 2155371570, framesSent 508, framesEncoded 508, active true, limited "none"}
245 // {ssrc 572440383, framesSent 1146, framesEncoded 1146, active true, limited "none"}]
246 //
247 // Two facts, and they are the whole diagnosis. A's only outbound
248 // video stream has sent zero frames and encoded zero frames, with
249 // active true and a quality limitation of "none" — so this is not a
250 // starved encoder in a headless browser, it is a stream that never
251 // started. A's camera track is live and its m-line is mid 1 at
252 // sendrecv/sendrecv throughout, which means the SSRC on that m-line
253 // was replaced by a renegotiation and the new one never encoded.
254 // And in the other direction B's camera is sending healthily (1146
255 // frames) and arriving at A (513 received) on a receiver whose track
256 // reports muted true, so none of it reaches the tile.
257 //
258 // Both are outcomes of the simultaneous renegotiation: two peers
259 // adding a share transceiver to one connection in the same moment,
260 // one of them rolling its own offer back. Neither is visible when
261 // only one side shares, which is why `share` passes.
262 //
263 // The fix belongs in room.js and nowhere in this gate. Delete this
264 // entry to put the scenario back — that, and nothing else, is what
265 // "fixed" means.
266}
267
268func TestCallInARealBrowser(t *testing.T) {
269 rig := newBrowserRig(t)
270 sqid, token := rig.createMeeting(t)
271 rig.sqid, rig.token = sqid, token
272
273 for _, name := range scenarios {
274 t.Run(name, func(t *testing.T) {
275 if why, ok := skipped[name]; ok {
276 t.Skip(why)
277 }
278 // The relay contract is a per-scenario setting, applied by
279 // the restart below and by every restart the scenario asks
280 // for afterwards: the room page carries `data-relay` from
281 // the app that rendered it, so it has to be in place before
282 // the browser opens anything, and a scenario that is not
283 // about the relay must still see §5.5's floor.
284 rig.wantRelay(strings.HasPrefix(name, "ice"))
285
286 // A fresh hub per scenario. The session cookies and the
287 // meeting live in the database and survive; the hub's
288 // participations do not, which is exactly what is wanted —
289 // a tab that did not manage to send `leave` would otherwise
290 // hold this session's one peer slot into the next scenario.
291 rig.restart(t)
292 rig.reportsFrom = rig.reports.size()
293 rig.run(t, name, sqid, token)
294 })
295 }
296}
297
298// browserRig is the app under a real listener, plus the two signed-in
299// members' cookies and the mail recorder a guest's own sign-in needs.
300type browserRig struct {
301 dbPath string
302 d *db.DB
303 mail *recordingMailer
304
305 addr string
306 origin string
307
308 mu sync.Mutex
309 ln net.Listener
310 srv *http.Server
311 ctl *calls.TestHandle
312 relay bool
313
314 cookieName string
315 cookieA string
316 cookieB string
317
318 // reports is every quality and problem report line the app has
319 // written (spec §15.26), as the deployed app writes them: JSON.
320 reports reportLog
321
322 // The meeting and its link, which a report log must never contain.
323 sqid string
324 token string
325 // reportsFrom is where the current scenario's report lines start:
326 // the log is the rig's, and every scenario before this one wrote to
327 // it too.
328 reportsFrom int
329}
330
331// reportLog is a goroutine-safe sink for the app's report logger.
332type reportLog struct {
333 mu sync.Mutex
334 buf bytes.Buffer
335}
336
337func (l *reportLog) Write(p []byte) (int, error) {
338 l.mu.Lock()
339 defer l.mu.Unlock()
340 return l.buf.Write(p)
341}
342
343// raw is everything written so far.
344func (l *reportLog) raw() string {
345 l.mu.Lock()
346 defer l.mu.Unlock()
347 return l.buf.String()
348}
349
350// size is how much has been written, for linesFrom.
351func (l *reportLog) size() int {
352 l.mu.Lock()
353 defer l.mu.Unlock()
354 return l.buf.Len()
355}
356
357// lines are the report lines with msg, decoded.
358func (l *reportLog) lines(msg string) []map[string]any {
359 return l.linesFrom(0, msg)
360}
361
362// linesFrom are the report lines with msg written at or after offset.
363func (l *reportLog) linesFrom(offset int, msg string) []map[string]any {
364 raw := l.raw()
365 if offset > len(raw) {
366 offset = len(raw)
367 }
368 var out []map[string]any
369 for _, line := range strings.Split(raw[offset:], "\n") {
370 var m map[string]any
371 if json.Unmarshal([]byte(line), &m) == nil && m["msg"] == msg {
372 out = append(out, m)
373 }
374 }
375 return out
376}
377
378func newBrowserRig(t *testing.T) *browserRig {
379 t.Helper()
380
381 // The listener first: the origin is the CSRF yardstick and the base
382 // of every emailed link, so the app cannot be built until the kernel
383 // has named the port.
384 ln, err := net.Listen("tcp", "127.0.0.1:0")
385 if err != nil {
386 t.Fatalf("listen: %v", err)
387 }
388 rig := &browserRig{
389 dbPath: filepath.Join(t.TempDir(), "calls.db"),
390 mail: &recordingMailer{},
391 addr: ln.Addr().String(),
392 }
393 rig.origin = "http://" + rig.addr
394
395 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
396 d, err := db.Open(rig.dbPath, logger)
397 if err != nil {
398 t.Fatalf("db.Open: %v", err)
399 }
400 t.Cleanup(func() { d.Close() })
401 rig.d = d
402
403 rig.serve(t, ln)
404 t.Cleanup(func() { rig.stop() })
405
406 rig.cookieName, rig.cookieA = rig.signIn(t, memberA)
407 _, rig.cookieB = rig.signIn(t, memberB)
408 return rig
409}
410
411// serve builds a fresh app over the same database and puts it behind ln.
412func (rig *browserRig) serve(t *testing.T, ln net.Listener) {
413 t.Helper()
414 mux, ctl, err := calls.NewForTest(rig.d, calls.Config{
415 Env: calls.EnvDevelopment,
416 Origin: rig.origin,
417 AllowedAddresses: []string{memberA, memberB},
418 Mailer: rig.mail,
419 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
420 ReportLogger: slog.New(slog.NewJSONHandler(&rig.reports, nil)),
421 })
422 if err != nil {
423 t.Fatalf("calls.NewForTest: %v", err)
424 }
425 t.Cleanup(ctl.ShutdownForTest) // stops the sign-in mail worker too
426 // Served through rastrillo's production stack, built from the same
427 // calls.ServeOptions main hands to rastrillo.Serve: the security
428 // headers and the CSP a deployed instance sends, so a style attribute
429 // or script the policy refuses fails the gate (call.mjs reports every
430 // violation) instead of passing against a bare mux that sends none.
431 h, closeH, err := rastrillo.Handler(calls.ServeOptions(rastrillo.Options{Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}, mux, ctl.Runtime()))
432 if err != nil {
433 t.Fatalf("rastrillo.Handler: %v", err)
434 }
435 t.Cleanup(func() { _ = closeH() })
436 srv := &http.Server{Handler: h}
437 go func() { _ = srv.Serve(ln) }()
438
439 rig.mu.Lock()
440 rig.ln, rig.srv, rig.ctl = ln, srv, ctl
441 want := rig.relay
442 rig.mu.Unlock()
443
444 // A relay the app was not started with, handed to it the way a
445 // platform would have (spec §5.1's contract, through the same parser
446 // production reads $CARLOS_RELAY with). The value is the test vector
447 // every Go test in this package uses; the host in it does not exist,
448 // which is the point — ICE has to succeed on host candidates anyway.
449 if want {
450 if err := ctl.SetRelay(browserRelayContract()); err != nil {
451 t.Fatalf("SetRelay: %v", err)
452 }
453 }
454}
455
456// wantRelay says whether the app this rig serves has a relay. It takes
457// effect on the next serve, which is the restart every scenario gets
458// before it starts and the one the restart control verb runs.
459func (rig *browserRig) wantRelay(on bool) {
460 rig.mu.Lock()
461 rig.relay = on
462 rig.mu.Unlock()
463}
464
465// browserRelayContract is the platform vector: one region, a bare stun
466// URL and a turn URL at a host that resolves to nothing, the 32-byte key
467// and the four-part username suffix. Built here rather than shared with
468// ice_test.go's helper because that one takes a *testing.T and this is
469// called from serve, where a marshalling failure of a literal would be a
470// programming error and nothing else.
471func browserRelayContract() string {
472 b, err := json.Marshal([]map[string]any{{
473 "region": "eu-west-1",
474 "urls": []string{
475 "stun:relay.eu-west-1.oncarlos.com:3478",
476 "turn:relay.eu-west-1.oncarlos.com:3478?transport=udp",
477 },
478 "key": vectorKey,
479 "username_suffix": "abc:meet:0123456789abcdef:k1",
480 }})
481 if err != nil {
482 panic("browser relay contract: " + err.Error())
483 }
484 return base64.StdEncoding.EncodeToString(b)
485}
486
487// stop is the SIGTERM and the socket close, in that order. Close does
488// not wait for handlers — it shuts the listener and every connection at
489// once — but cancelling the app's context first is what a real SIGTERM
490// does, and it is the only thing that lets a live stream handler return
491// through its own `case <-a.done` instead of having its socket pulled
492// out from under it mid-write.
493func (rig *browserRig) stop() {
494 rig.mu.Lock()
495 srv, ctl := rig.srv, rig.ctl
496 rig.mu.Unlock()
497 if ctl != nil {
498 ctl.ShutdownForTest()
499 }
500 if srv != nil {
501 _ = srv.Close()
502 }
503}
504
505// restart is what the restart scenario asks for, and what every other
506// scenario gets for free before it starts: the process goes away and
507// comes back on the same port, with a new hub and a new boot id, over
508// the same database.
509func (rig *browserRig) restart(t *testing.T) {
510 t.Helper()
511 rig.stop()
512
513 // SO_REUSEADDR is on by default for Go listeners, so the old
514 // connections' TIME_WAIT does not block the bind — but a listener
515 // closing is not instantaneous under load, so this is patient.
516 deadline := time.Now().Add(10 * time.Second)
517 for {
518 ln, err := net.Listen("tcp", rig.addr)
519 if err == nil {
520 rig.serve(t, ln)
521 return
522 }
523 if time.Now().After(deadline) {
524 t.Fatalf("restart: could not re-listen on %s: %v", rig.addr, err)
525 }
526 time.Sleep(50 * time.Millisecond)
527 }
528}
529
530// signIn drives the real sign-in against the live listener and returns
531// the session cookie's name and value. The name comes from the app, not
532// from a constant here: rastrillo/sessions picks it from the origin's
533// scheme (plain rastrillo_session on http, __Host- prefixed on https),
534// and a gate that hardcoded one would quietly stop signing anything in
535// the day this moves behind TLS.
536func (rig *browserRig) signIn(t *testing.T, address string) (name, value string) {
537 t.Helper()
538 jar, err := cookiejar.New(nil)
539 if err != nil {
540 t.Fatalf("cookiejar: %v", err)
541 }
542 client := &http.Client{
543 Jar: jar,
544 // The magic link answers 303; following it is what mints the
545 // session, so redirects are followed, but not endlessly.
546 CheckRedirect: func(r *http.Request, via []*http.Request) error {
547 if len(via) >= 5 {
548 return http.ErrUseLastResponse
549 }
550 return nil
551 },
552 }
553
554 before := rig.mail.count()
555 form := url.Values{"address": {address}}
556 req, err := http.NewRequest(http.MethodPost, rig.origin+"/signin", strings.NewReader(form.Encode()))
557 if err != nil {
558 t.Fatalf("build signin request: %v", err)
559 }
560 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
561 // The same-origin evidence csrf.Protect wants; a browser sends it on
562 // every form POST.
563 req.Header.Set("Origin", rig.origin)
564 res, err := client.Do(req)
565 if err != nil {
566 t.Fatalf("POST /signin: %v", err)
567 }
568 res.Body.Close()
569
570 if rig.mail.count() == before {
571 t.Fatalf("no sign-in link was mailed to %s", address)
572 }
573 to, link := rig.mail.last()
574 if to != address || link == "" {
575 t.Fatalf("last mail went to %q, not %s", to, address)
576 }
577
578 // Opening the link shows the confirm door and spends nothing; its
579 // one Sign in button POSTs the token back, and that signs in.
580 res, err = client.Get(link)
581 if err != nil {
582 t.Fatalf("open the sign-in link: %v", err)
583 }
584 page, _ := io.ReadAll(res.Body)
585 res.Body.Close()
586 m := confirmForm.FindSubmatch(page)
587 if res.StatusCode != http.StatusOK || m == nil {
588 t.Fatalf("the sign-in link answered %d without the confirm door", res.StatusCode)
589 }
590 confirm := url.Values{"token": {html.UnescapeString(string(m[2]))}}
591 req, err = http.NewRequest(http.MethodPost, rig.origin+html.UnescapeString(string(m[1])), strings.NewReader(confirm.Encode()))
592 if err != nil {
593 t.Fatalf("build confirm request: %v", err)
594 }
595 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
596 req.Header.Set("Origin", rig.origin)
597 res, err = client.Do(req)
598 if err != nil {
599 t.Fatalf("press Sign in on the confirm door: %v", err)
600 }
601 res.Body.Close()
602
603 u, _ := url.Parse(rig.origin)
604 for _, c := range jar.Cookies(u) {
605 if strings.Contains(c.Name, "rastrillo_session") {
606 return c.Name, c.Value
607 }
608 }
609 t.Fatalf("following the sign-in link minted no session cookie (jar: %v)", jar.Cookies(u))
610 return "", ""
611}
612
613var sqidOnPage = regexp.MustCompile(`/m/([23456789a-z]{10})/join`)
614var tokenOnPage = regexp.MustCompile(`/j/([A-Za-z0-9_-]{43})`)
615
616// enableWaitingRoom turns one meeting's waiting room on, as member A.
617//
618// It exists because the waiting room is no longer the default: meeting
619// access defaults ship it off, so a guest following a link is admitted
620// straight away and no admission queue is ever drawn. A scenario whose
621// subject IS the queue has to ask for one, or it silently stops testing
622// what it is named after — which is how the native gate's guest case came
623// to wait thirty seconds for an Admit button that was never going to be
624// there.
625func (rig *browserRig) enableWaitingRoom(t *testing.T, sqid string) {
626 t.Helper()
627 req, err := http.NewRequest(http.MethodPost, rig.origin+"/meetings/"+sqid+"/settings",
628 strings.NewReader(url.Values{"waiting_room": {"on"}}.Encode()))
629 if err != nil {
630 t.Fatalf("build settings request: %v", err)
631 }
632 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
633 req.Header.Set("Origin", rig.origin)
634 req.AddCookie(&http.Cookie{Name: rig.cookieName, Value: rig.cookieA})
635 // Not http.DefaultClient: it follows the 303 to the settings page and
636 // hands back that page's 200, so a refused save and an accepted one
637 // look identical. The redirect itself is the only honest evidence the
638 // handler accepted this.
639 client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
640 return http.ErrUseLastResponse
641 }}
642 res, err := client.Do(req)
643 if err != nil {
644 t.Fatalf("POST meeting settings: %v", err)
645 }
646 defer res.Body.Close()
647 if res.StatusCode != http.StatusSeeOther {
648 body, _ := io.ReadAll(res.Body)
649 t.Fatalf("POST meeting settings: %d\n%s", res.StatusCode, body)
650 }
651}
652
653// createMeeting is member A starting a call and reading the link off the
654// page it is shown on exactly once.
655func (rig *browserRig) createMeeting(t *testing.T) (sqid, token string) {
656 t.Helper()
657 req, err := http.NewRequest(http.MethodPost, rig.origin+"/meetings",
658 strings.NewReader(url.Values{"title": {"Board meeting"}}.Encode()))
659 if err != nil {
660 t.Fatalf("build create request: %v", err)
661 }
662 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
663 req.Header.Set("Origin", rig.origin)
664 req.AddCookie(&http.Cookie{Name: rig.cookieName, Value: rig.cookieA})
665
666 res, err := http.DefaultClient.Do(req)
667 if err != nil {
668 t.Fatalf("POST /meetings: %v", err)
669 }
670 defer res.Body.Close()
671 body, _ := io.ReadAll(res.Body)
672 if res.StatusCode != http.StatusOK {
673 t.Fatalf("POST /meetings: %d\n%s", res.StatusCode, body)
674 }
675 s := sqidOnPage.FindSubmatch(body)
676 k := tokenOnPage.FindSubmatch(body)
677 if s == nil || k == nil {
678 t.Fatalf("no meeting id or link on the page:\n%s", body)
679 }
680 return string(s[1]), string(k[1])
681}
682
683// run drives one scenario. node's stdout is read a line at a time so a
684// CONTROL request is answered while the browser is still waiting on it;
685// everything else is logged as it arrives, which is what makes a hung
686// scenario readable rather than silent for three minutes.
687func (rig *browserRig) run(t *testing.T, scenario, sqid, token string) {
688 t.Helper()
689
690 ctx, cancel := context.WithTimeout(context.Background(), scenarioTimeout)
691
692 cmd := exec.CommandContext(ctx, "node", "test/browser/call.mjs",
693 rig.origin, sqid, scenario)
694 cmd.Dir = repoRootForBrowser(t)
695 // The session cookies and the join-link token go in the environment,
696 // never on argv: a command line is readable by every process on the
697 // box, and either one of these is enough to be somebody in this
698 // meeting.
699 cmd.Env = append(os.Environ(),
700 "CALLS_COOKIE_NAME="+rig.cookieName,
701 "CALLS_COOKIE_A="+rig.cookieA,
702 "CALLS_COOKIE_B="+rig.cookieB,
703 "CALLS_JOIN_TOKEN="+token,
704 )
705
706 stdout, err := cmd.StdoutPipe()
707 if err != nil {
708 t.Fatalf("stdout pipe: %v", err)
709 }
710 stdin, err := cmd.StdinPipe()
711 if err != nil {
712 t.Fatalf("stdin pipe: %v", err)
713 }
714 var errs bytes.Buffer
715 cmd.Stderr = &errs
716
717 if err := cmd.Start(); err != nil {
718 cancel()
719 t.Fatalf("start node: %v", err)
720 }
721
722 // Every path out of here reaps node, including the ones that do not
723 // come back: rig.control calls t.Fatalf, which unwinds this
724 // goroutine, and without this the browser would outlive the test.
725 // Cancel first, so a hung node is killed rather than waited on.
726 var waitErr error
727 waited := false
728 wait := func() error {
729 if !waited {
730 waited = true
731 waitErr = cmd.Wait()
732 }
733 return waitErr
734 }
735 defer func() {
736 cancel()
737 _ = wait()
738 }()
739
740 scanner := bufio.NewScanner(stdout)
741 scanner.Buffer(make([]byte, 0, 64<<10), 4<<20)
742 for scanner.Scan() {
743 line := scanner.Text()
744 if !strings.HasPrefix(line, "CONTROL ") {
745 t.Logf(" %s", line)
746 continue
747 }
748 verb := strings.TrimSpace(strings.TrimPrefix(line, "CONTROL "))
749 t.Logf(" [control] %s", verb)
750 fmt.Fprintln(stdin, rig.control(t, verb))
751 }
752 _ = stdin.Close()
753
754 err = wait()
755 if s := strings.TrimSpace(errs.String()); s != "" {
756 t.Logf("node stderr:\n%s", s)
757 }
758 if err != nil {
759 t.Fatalf("the %s scenario failed: %v", scenario, err)
760 }
761}
762
763// control answers one request from the browser side. Both verbs need
764// something only this process has: the mail recorder, and the app's own
765// lifecycle.
766func (rig *browserRig) control(t *testing.T, verb string) string {
767 t.Helper()
768 switch {
769 case verb == "restart":
770 rig.restart(t)
771 return "ok"
772 case verb == "drop-member-b":
773 // The team changes, and the tick that notices is run by hand:
774 // only this process holds the allowlist and the hub, and the
775 // browser has no way to reach either (spec §2.4).
776 rig.mu.Lock()
777 ctl := rig.ctl
778 rig.mu.Unlock()
779 ctl.SetAllowlist(memberA)
780 ctl.TickForTest()
781 return "ok"
782 case strings.HasPrefix(verb, "guest-link "):
783 address := strings.TrimSpace(strings.TrimPrefix(verb, "guest-link "))
784 // The browser has just posted the sign-in form; the link is in
785 // the recorder by the time the POST's response came back, but a
786 // short wait costs nothing and turns a race into a wait.
787 deadline := time.Now().Add(10 * time.Second)
788 for {
789 to, link := rig.mail.last()
790 if to == address && link != "" {
791 return link
792 }
793 if time.Now().After(deadline) {
794 t.Fatalf("no sign-in link was mailed to %s (last went to %q)", address, to)
795 }
796 time.Sleep(50 * time.Millisecond)
797 }
798 case strings.HasPrefix(verb, "quality-check "):
799 return rig.qualityCheck(strings.Fields(strings.TrimPrefix(verb, "quality-check ")))
800 case strings.HasPrefix(verb, "problem-check "):
801 fields := strings.Fields(strings.TrimPrefix(verb, "problem-check "))
802 if len(fields) < 1 {
803 return "FAIL problem-check needs a reference"
804 }
805 return rig.problemCheck(fields[0], fields[1:])
806 case verb == "quality-final":
807 for _, l := range rig.reports.linesFrom(rig.reportsFrom, "calls: quality") {
808 if l["final"] == true {
809 return "ok"
810 }
811 }
812 return "FAIL no quality report with final:true"
813 default:
814 t.Fatalf("unknown control verb from the browser: %q", verb)
815 return ""
816 }
817}
818
819// qualityCheck answers the quality scenario (spec §15.26): the two
820// browsers' automatic reports reached the log as `calls: quality` lines
821// under one call tag and two different peer tags, each naming the other
822// by its tag, and the log as a whole names nobody. `peers` is the two
823// browsers' peer ids, which the log must not contain either. The answer
824// is "ok", or "FAIL" and why, for call.mjs to print as its check.
825func (rig *browserRig) qualityCheck(peers []string) string {
826 lines := rig.reports.linesFrom(rig.reportsFrom, "calls: quality")
827 if len(lines) < 2 {
828 return fmt.Sprintf("FAIL %d calls: quality lines, want at least 2", len(lines))
829 }
830 calls := map[string]bool{}
831 tags := map[string]bool{}
832 named := map[string]bool{}
833 for _, l := range lines {
834 call, _ := l["call"].(string)
835 peer, _ := l["peer"].(string)
836 if call == "" || peer == "" {
837 return fmt.Sprintf("FAIL a quality line without its tags: %v", l)
838 }
839 if l["app"] != "web" {
840 return fmt.Sprintf("FAIL a quality line from app %v", l["app"])
841 }
842 calls[call] = true
843 tags[peer] = true
844 report, _ := l["report"].(map[string]any)
845 ps, _ := report["peers"].([]any)
846 for _, p := range ps {
847 if m, ok := p.(map[string]any); ok {
848 if tag, ok := m["peer"].(string); ok {
849 named[tag] = true
850 }
851 }
852 }
853 }
854 if len(calls) != 1 {
855 return fmt.Sprintf("FAIL quality lines under %d call tags, want one: %v", len(calls), calls)
856 }
857 if len(tags) != 2 {
858 return fmt.Sprintf("FAIL quality lines under %d peer tags, want two: %v", len(tags), tags)
859 }
860 for tag := range tags {
861 if !named[tag] {
862 return fmt.Sprintf("FAIL no report names peer tag %s as a remote peer (named: %v)", tag, named)
863 }
864 }
865 if why := rig.leaks(peers); why != "" {
866 return "FAIL " + why
867 }
868 return "ok"
869}
870
871// problemCheck answers "Report a problem": a `calls: problem report`
872// line under the reference the sheet showed, at least one diagnostics
873// line under it, and still nobody named anywhere in the log.
874func (rig *browserRig) problemCheck(ref string, peers []string) string {
875 var head map[string]any
876 for _, l := range rig.reports.linesFrom(rig.reportsFrom, "calls: problem report") {
877 if l["ref"] == ref {
878 head = l
879 }
880 }
881 if head == nil {
882 return "FAIL no calls: problem report line with ref " + ref
883 }
884 parts := 0
885 for _, l := range rig.reports.linesFrom(rig.reportsFrom, "calls: problem report diagnostics") {
886 if l["ref"] == ref {
887 parts++
888 }
889 }
890 if parts == 0 {
891 return "FAIL no calls: problem report diagnostics line with ref " + ref
892 }
893 if why := rig.leaks(peers); why != "" {
894 return "FAIL " + why
895 }
896 return fmt.Sprintf("ok (note %q, %d diagnostics part(s), live %v)", head["note"], parts, head["live"])
897}
898
899// leaks is what the report log must never hold (spec §15.26): the two
900// members' addresses and display names, the meeting's sqid and link,
901// and any peer id of the call, in full or as the six characters the
902// diagnostics name people by. "" when it holds none of them.
903func (rig *browserRig) leaks(peers []string) string {
904 raw := rig.reports.raw()
905 forbidden := []string{memberA, memberB, rig.sqid, rig.token}
906 for _, p := range peers {
907 forbidden = append(forbidden, p)
908 // The six-character prefix only when it has a letter in it: six
909 // decimal digits are as likely to be a packet count as a person.
910 if len(p) >= 6 && strings.ContainsAny(p[:6], "abcdef") {
911 forbidden = append(forbidden, p[:6])
912 }
913 }
914 for _, f := range forbidden {
915 if f != "" && strings.Contains(raw, f) {
916 return fmt.Sprintf("the report log contains %q", f)
917 }
918 }
919 // A display name is the address up to the "@" (see memberA); as a
920 // word, so a random tag that happens to spell it is not a finding.
921 for _, addr := range []string{memberA, memberB} {
922 name := addr[:strings.Index(addr, "@")]
923 if regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(name) + `\b`).MatchString(raw) {
924 return fmt.Sprintf("the report log contains the name %q", name)
925 }
926 }
927 return ""
928}
929
930// repoRootForBrowser finds the repository from this file's own path,
931// never from the working directory: go test runs in the package
932// directory and the node script is addressed from the root.
933func repoRootForBrowser(t *testing.T) string {
934 t.Helper()
935 _, file, _, ok := runtime.Caller(0)
936 if !ok {
937 t.Fatal("runtime.Caller failed")
938 }
939 return filepath.Dir(filepath.Dir(filepath.Dir(file)))
940}
941