//go:build browser // The browser gate: a real call, in real Chromium, between two real // browser contexts with fake cameras. // // make browser // // Not part of the plain suite and not part of `make ci`: it needs node // and an npm install, and the gate the team runs on every push must not // depend on a toolchain the app itself does not use. // // Every other test in this repo lives inside one layer. The Go suite // asks Go what Go produced; room.js was landed with nothing but // `node --check` behind it. Everything in the media half — // getUserMedia, the offer/answer exchange, trickle ICE, a decoded frame, // the resume ladder, the takeover refusal — first runs here. // // Go owns the half a browser cannot reach: it starts the app on a real // port (the origin is load-bearing — csrf.Protect measures against it), // signs the two members in through the actual magic-link flow so it can // read the mailer, creates the meeting, and restarts the process on the // same port when the restart scenario asks for it. node owns the // browser. They talk over the node process's own stdin and stdout: node // writes a line beginning "CONTROL ", Go answers with one line. package callstest import ( "bufio" "bytes" "context" "encoding/base64" "encoding/json" "fmt" "html" "io" "log/slog" "net" "net/http" "net/http/cookiejar" "net/url" "os" "os/exec" "path/filepath" "regexp" "runtime" "strings" "sync" "testing" "time" "amadan.net/rastrillo/rastrillo" "amadan.net/rastrillo/rastrillo/db" "amadan.net/oficina/calls/internal/calls" ) // scenarioTimeout is per scenario, not for the run: a call that has not // connected in three minutes is not slow, it is broken. const scenarioTimeout = 3 * time.Minute // memberA and memberB are the two AllowedAddresses this gate signs in. // A guest signs itself in from the browser, through the emailed link, // with an address that is deliberately not on the list. // // The two local parts differ, and that is load-bearing rather than // cosmetic: a display name is the address up to the "@", so the rest of // this package's `paul@example.com`/`paul@rslw.com` pair would give both // contexts here the name "paul". Every assertion about whose name is on // an arriving chat line, or in "’s screen", would then hold just // as well with the wrong name on it. const ( memberA = "paul@example.com" memberB = "bea@rslw.com" ) // scenarios are the passes, in the order they run. Each gets its own // node process and its own freshly restarted app, so a participation one // scenario left behind cannot 409 the next one's fresh stream. // // All of them pass. A scenario that starts failing on the client rather // than on the gate keeps its place here and its assertions as written, // and is skipped from inside its own subtest with t.Skip carrying the // exact failure — never deleted, and never quietly loosened. var scenarios = []string{ "guestdirect", "layout", "glyphs", "stage", "window", "title", "chrome", "devices", "audiotest", "call", "mute", "meters", "spacemute", "switching", "share", "mutualshare", "laterolls", "renewal", "ice", "icerenewal", "icefail", "icestuck", "guest", "speaker", "chat", "removed", "allowlist", "departure", "staletarget", "probehang", "replay", "gap", "resetretry", "senddeadline", "sendhang", "rosterpair", "restart", "takeover", "crosssite", "stall", "signedout", "nowebrtc", "playfallback", "autoplay", "connecting", "quality", "scriptless", } // skipped are the scenarios whose assertions are right and whose client // is not. Each keeps its place in `scenarios` above and every assertion // as written; what is recorded here is the exact failure the gate // printed and the round that owns the fix. Delete an entry to put a // scenario back in the gate — that, and nothing else, is what "fixed" // means. // // FIXED in Task 4's fix round 2 — see task-4-report.md. The account // below is left as written because it is what the fix was made against, // and because the last paragraph of it is still true of `accept`. // // All three ICE scenarios shared one defect, and it was not in them: // // room.js applyRoster captures the roster, awaits the /ice fetch // (§5.2's ordering), and then reconciles the roster it captured // BEFORE the await. A `peer` event that arrives during the fetch — // ten milliseconds after the roster, in the run below — builds a // connection that the stale roster does not name, so reconcile // closes it with forget=true. That drops seqOut and lastIn for that // peer (closePC's own comment: the counters are kept only on a // rebuild). The connection is built again by the next message, its // outbound numbering restarts at 1, and the other browser — whose // lastIn for this peer is still 3 — drops the answer as already // seen. It stays at have-local-offer with nothing to retry, and the // call never connects. // // Watched directly: with /ice held 1200 ms by the gate's own route, one // browser logged `setConfiguration ok, restartIce ok, close ok` and then // a fresh connection, posting `answer seq=1` where the peer had already // recorded seq=3 from it. It is a race, not a certainty: the same // scenarios pass when the fetch beats the `peer` event, which is most of // the time on a loopback and none of the time on a busy machine — two // consecutive full `make browser` runs each lost one or two of the three // to it. Nothing about it is specific to the relay; a relay only makes // /ice slow enough to notice. // // The stale roster is what opens the hole, but the thing that kills the // call is one line older than this slice: `accept` tests the replay // guard BEFORE the generation, so the first message of a renumbered // connection — seq 1, where lastIn already says 3 — is discarded as a // replay whatever generation it carries. `staletarget` failed with the // identical signature (stable/connecting against have-local-offer, // frames {}, rosters 10) in a run of this same gate with no relay // configured anywhere, which is the flake Task 4's fix round recorded as // "watch" and could not explain. Same defect, different door. // // The fix is Task 4's own suggestion, from its report's last concern: // reconcile the roster this browser holds when the await returns, or // build connections at once and adopt the servers when the list lands — // and, under it, an `accept` that lets a new generation past the cursor // it is renumbering. // Empty, and the account above is kept because it is the clearest one // this branch has of a defect that cost two slices a flake each. Task // 4's fix round 2 closed three doors onto it, not the two the account // above describes: applyRoster reconciles the roster held when the // fetch returns; a close from reconcile no longer forgets §4.6's // counters for a peer the hub may still name; and — the one that was // actually killing these scenarios — a roster cannot close a connection // built after the hub sent it. That third one is missing from the // account because the account is missing the fact under it: the hub // does not re-send a roster when someone joins, it sends `peer` to the // people already there, so for the commonest case there is no newer // roster for the first door to prefer. The three ICE scenarios and // staletarget have run in the gate ever since. var skipped = map[string]string{ // FIXED in Task 4's fix round 2 — see task-4-report.md. mutualshare // has run in the gate ever since, and the account below is left as // written because it is what the fix was made against. What it could // not know is where the damage came from: not the m-line matching it // suspected, but the rollback itself. Chromium rebuilds every send // stream on a connection when it rolls back an offer that added an // m-line, and the camera's comes back having encoded nothing. The // fix is that the side which would roll back gives way before it // offers, so nothing collides — see POLITE_YIELD in room.js. // // Task 4 fix round 2 owns this. The scenario keeps its place above // and every assertion as written; what follows is exactly what the // gate printed, and the evidence under it. // // mutualshare drives §5.4 the one way `share` cannot: both peers // click Share screen together, three rounds of stop-and-share, and // then one stops while the other carries on. Every screen assertion // passes in every round — announced, captioned with the right name, // decoding real frames, negotiation settling and staying settled, // displaySurface saying both are really capturing a display. What // fails is the camera, from the first mutual share onwards: // // FAIL round 2: member A is still receiving the other's camera (5 -> 5) // FAIL round 2: member B is still receiving the other's camera (6 -> 6) // FAIL round 3: member A is still receiving the other's camera (5 -> 5) // FAIL round 3: member B is still receiving the other's camera (6 -> 6) // FAIL member B still has the camera through the one-sided stop (6 -> 6) // // Round 1 passes only because 5 > 3: the count moves twice and then // stops for the remaining seventy seconds, while the two screens go // on to six hundred frames each on the same connections. // // It is not the tile, the counter or the encoder. Per-connection // getStats, from the run that produced the lines above: // // A (has stopped sharing; audio mid 0, camera mid 1, B's screen mid 6): // transceivers ["0 sendrecv/sendrecv:audio", "1 sendrecv/sendrecv:video", "6 recvonly/recvonly:video"] // inbound [{ssrc 2155371570, framesReceived 513, framesDecoded 512, framesDropped 0, pliCount 0, freezeCount 0, muted false} // {ssrc 572440383, framesReceived 513, framesDecoded 513, framesDropped 1, pliCount 1, freezeCount 0, muted true}] // outbound [{ssrc 991534554, framesSent 0, framesEncoded 0, active true, limited "none"}] // // B (still sharing; audio 0, camera 1, its own screen 6, A's screen 7): // transceivers ["0 sendrecv/sendrecv:audio", "1 sendrecv/sendrecv:video", "6 sendonly/sendonly:video", "7 recvonly/recvonly:video"] // inbound [{ssrc 4101167316, framesReceived 508, framesDecoded 507, framesDropped 0, pliCount 0, freezeCount 0, muted false} // {ssrc 991534554, framesReceived 6, framesDecoded 6, framesDropped 0, pliCount 1, freezeCount 0, muted true}] // outbound [{ssrc 2155371570, framesSent 508, framesEncoded 508, active true, limited "none"} // {ssrc 572440383, framesSent 1146, framesEncoded 1146, active true, limited "none"}] // // Two facts, and they are the whole diagnosis. A's only outbound // video stream has sent zero frames and encoded zero frames, with // active true and a quality limitation of "none" — so this is not a // starved encoder in a headless browser, it is a stream that never // started. A's camera track is live and its m-line is mid 1 at // sendrecv/sendrecv throughout, which means the SSRC on that m-line // was replaced by a renegotiation and the new one never encoded. // And in the other direction B's camera is sending healthily (1146 // frames) and arriving at A (513 received) on a receiver whose track // reports muted true, so none of it reaches the tile. // // Both are outcomes of the simultaneous renegotiation: two peers // adding a share transceiver to one connection in the same moment, // one of them rolling its own offer back. Neither is visible when // only one side shares, which is why `share` passes. // // The fix belongs in room.js and nowhere in this gate. Delete this // entry to put the scenario back — that, and nothing else, is what // "fixed" means. } func TestCallInARealBrowser(t *testing.T) { rig := newBrowserRig(t) sqid, token := rig.createMeeting(t) rig.sqid, rig.token = sqid, token for _, name := range scenarios { t.Run(name, func(t *testing.T) { if why, ok := skipped[name]; ok { t.Skip(why) } // The relay contract is a per-scenario setting, applied by // the restart below and by every restart the scenario asks // for afterwards: the room page carries `data-relay` from // the app that rendered it, so it has to be in place before // the browser opens anything, and a scenario that is not // about the relay must still see §5.5's floor. rig.wantRelay(strings.HasPrefix(name, "ice")) // A fresh hub per scenario. The session cookies and the // meeting live in the database and survive; the hub's // participations do not, which is exactly what is wanted — // a tab that did not manage to send `leave` would otherwise // hold this session's one peer slot into the next scenario. rig.restart(t) rig.reportsFrom = rig.reports.size() rig.run(t, name, sqid, token) }) } } // browserRig is the app under a real listener, plus the two signed-in // members' cookies and the mail recorder a guest's own sign-in needs. type browserRig struct { dbPath string d *db.DB mail *recordingMailer addr string origin string mu sync.Mutex ln net.Listener srv *http.Server ctl *calls.TestHandle relay bool cookieName string cookieA string cookieB string // reports is every quality and problem report line the app has // written (spec §15.26), as the deployed app writes them: JSON. reports reportLog // The meeting and its link, which a report log must never contain. sqid string token string // reportsFrom is where the current scenario's report lines start: // the log is the rig's, and every scenario before this one wrote to // it too. reportsFrom int } // reportLog is a goroutine-safe sink for the app's report logger. type reportLog struct { mu sync.Mutex buf bytes.Buffer } func (l *reportLog) Write(p []byte) (int, error) { l.mu.Lock() defer l.mu.Unlock() return l.buf.Write(p) } // raw is everything written so far. func (l *reportLog) raw() string { l.mu.Lock() defer l.mu.Unlock() return l.buf.String() } // size is how much has been written, for linesFrom. func (l *reportLog) size() int { l.mu.Lock() defer l.mu.Unlock() return l.buf.Len() } // lines are the report lines with msg, decoded. func (l *reportLog) lines(msg string) []map[string]any { return l.linesFrom(0, msg) } // linesFrom are the report lines with msg written at or after offset. func (l *reportLog) linesFrom(offset int, msg string) []map[string]any { raw := l.raw() if offset > len(raw) { offset = len(raw) } var out []map[string]any for _, line := range strings.Split(raw[offset:], "\n") { var m map[string]any if json.Unmarshal([]byte(line), &m) == nil && m["msg"] == msg { out = append(out, m) } } return out } func newBrowserRig(t *testing.T) *browserRig { t.Helper() // The listener first: the origin is the CSRF yardstick and the base // of every emailed link, so the app cannot be built until the kernel // has named the port. ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen: %v", err) } rig := &browserRig{ dbPath: filepath.Join(t.TempDir(), "calls.db"), mail: &recordingMailer{}, addr: ln.Addr().String(), } rig.origin = "http://" + rig.addr logger := slog.New(slog.NewTextHandler(io.Discard, nil)) d, err := db.Open(rig.dbPath, logger) if err != nil { t.Fatalf("db.Open: %v", err) } t.Cleanup(func() { d.Close() }) rig.d = d rig.serve(t, ln) t.Cleanup(func() { rig.stop() }) rig.cookieName, rig.cookieA = rig.signIn(t, memberA) _, rig.cookieB = rig.signIn(t, memberB) return rig } // serve builds a fresh app over the same database and puts it behind ln. func (rig *browserRig) serve(t *testing.T, ln net.Listener) { t.Helper() mux, ctl, err := calls.NewForTest(rig.d, calls.Config{ Env: calls.EnvDevelopment, Origin: rig.origin, AllowedAddresses: []string{memberA, memberB}, Mailer: rig.mail, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), ReportLogger: slog.New(slog.NewJSONHandler(&rig.reports, nil)), }) if err != nil { t.Fatalf("calls.NewForTest: %v", err) } t.Cleanup(ctl.ShutdownForTest) // stops the sign-in mail worker too // Served through rastrillo's production stack, built from the same // calls.ServeOptions main hands to rastrillo.Serve: the security // headers and the CSP a deployed instance sends, so a style attribute // or script the policy refuses fails the gate (call.mjs reports every // violation) instead of passing against a bare mux that sends none. h, closeH, err := rastrillo.Handler(calls.ServeOptions(rastrillo.Options{Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}, mux, ctl.Runtime())) if err != nil { t.Fatalf("rastrillo.Handler: %v", err) } t.Cleanup(func() { _ = closeH() }) srv := &http.Server{Handler: h} go func() { _ = srv.Serve(ln) }() rig.mu.Lock() rig.ln, rig.srv, rig.ctl = ln, srv, ctl want := rig.relay rig.mu.Unlock() // A relay the app was not started with, handed to it the way a // platform would have (spec §5.1's contract, through the same parser // production reads $CARLOS_RELAY with). The value is the test vector // every Go test in this package uses; the host in it does not exist, // which is the point — ICE has to succeed on host candidates anyway. if want { if err := ctl.SetRelay(browserRelayContract()); err != nil { t.Fatalf("SetRelay: %v", err) } } } // wantRelay says whether the app this rig serves has a relay. It takes // effect on the next serve, which is the restart every scenario gets // before it starts and the one the restart control verb runs. func (rig *browserRig) wantRelay(on bool) { rig.mu.Lock() rig.relay = on rig.mu.Unlock() } // browserRelayContract is the platform vector: one region, a bare stun // URL and a turn URL at a host that resolves to nothing, the 32-byte key // and the four-part username suffix. Built here rather than shared with // ice_test.go's helper because that one takes a *testing.T and this is // called from serve, where a marshalling failure of a literal would be a // programming error and nothing else. func browserRelayContract() string { b, err := json.Marshal([]map[string]any{{ "region": "eu-west-1", "urls": []string{ "stun:relay.eu-west-1.oncarlos.com:3478", "turn:relay.eu-west-1.oncarlos.com:3478?transport=udp", }, "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1", }}) if err != nil { panic("browser relay contract: " + err.Error()) } return base64.StdEncoding.EncodeToString(b) } // stop is the SIGTERM and the socket close, in that order. Close does // not wait for handlers — it shuts the listener and every connection at // once — but cancelling the app's context first is what a real SIGTERM // does, and it is the only thing that lets a live stream handler return // through its own `case <-a.done` instead of having its socket pulled // out from under it mid-write. func (rig *browserRig) stop() { rig.mu.Lock() srv, ctl := rig.srv, rig.ctl rig.mu.Unlock() if ctl != nil { ctl.ShutdownForTest() } if srv != nil { _ = srv.Close() } } // restart is what the restart scenario asks for, and what every other // scenario gets for free before it starts: the process goes away and // comes back on the same port, with a new hub and a new boot id, over // the same database. func (rig *browserRig) restart(t *testing.T) { t.Helper() rig.stop() // SO_REUSEADDR is on by default for Go listeners, so the old // connections' TIME_WAIT does not block the bind — but a listener // closing is not instantaneous under load, so this is patient. deadline := time.Now().Add(10 * time.Second) for { ln, err := net.Listen("tcp", rig.addr) if err == nil { rig.serve(t, ln) return } if time.Now().After(deadline) { t.Fatalf("restart: could not re-listen on %s: %v", rig.addr, err) } time.Sleep(50 * time.Millisecond) } } // signIn drives the real sign-in against the live listener and returns // the session cookie's name and value. The name comes from the app, not // from a constant here: rastrillo/sessions picks it from the origin's // scheme (plain rastrillo_session on http, __Host- prefixed on https), // and a gate that hardcoded one would quietly stop signing anything in // the day this moves behind TLS. func (rig *browserRig) signIn(t *testing.T, address string) (name, value string) { t.Helper() jar, err := cookiejar.New(nil) if err != nil { t.Fatalf("cookiejar: %v", err) } client := &http.Client{ Jar: jar, // The magic link answers 303; following it is what mints the // session, so redirects are followed, but not endlessly. CheckRedirect: func(r *http.Request, via []*http.Request) error { if len(via) >= 5 { return http.ErrUseLastResponse } return nil }, } before := rig.mail.count() form := url.Values{"address": {address}} req, err := http.NewRequest(http.MethodPost, rig.origin+"/signin", strings.NewReader(form.Encode())) if err != nil { t.Fatalf("build signin request: %v", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") // The same-origin evidence csrf.Protect wants; a browser sends it on // every form POST. req.Header.Set("Origin", rig.origin) res, err := client.Do(req) if err != nil { t.Fatalf("POST /signin: %v", err) } res.Body.Close() if rig.mail.count() == before { t.Fatalf("no sign-in link was mailed to %s", address) } to, link := rig.mail.last() if to != address || link == "" { t.Fatalf("last mail went to %q, not %s", to, address) } // Opening the link shows the confirm door and spends nothing; its // one Sign in button POSTs the token back, and that signs in. res, err = client.Get(link) if err != nil { t.Fatalf("open the sign-in link: %v", err) } page, _ := io.ReadAll(res.Body) res.Body.Close() m := confirmForm.FindSubmatch(page) if res.StatusCode != http.StatusOK || m == nil { t.Fatalf("the sign-in link answered %d without the confirm door", res.StatusCode) } confirm := url.Values{"token": {html.UnescapeString(string(m[2]))}} req, err = http.NewRequest(http.MethodPost, rig.origin+html.UnescapeString(string(m[1])), strings.NewReader(confirm.Encode())) if err != nil { t.Fatalf("build confirm request: %v", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", rig.origin) res, err = client.Do(req) if err != nil { t.Fatalf("press Sign in on the confirm door: %v", err) } res.Body.Close() u, _ := url.Parse(rig.origin) for _, c := range jar.Cookies(u) { if strings.Contains(c.Name, "rastrillo_session") { return c.Name, c.Value } } t.Fatalf("following the sign-in link minted no session cookie (jar: %v)", jar.Cookies(u)) return "", "" } var sqidOnPage = regexp.MustCompile(`/m/([23456789a-z]{10})/join`) var tokenOnPage = regexp.MustCompile(`/j/([A-Za-z0-9_-]{43})`) // enableWaitingRoom turns one meeting's waiting room on, as member A. // // It exists because the waiting room is no longer the default: meeting // access defaults ship it off, so a guest following a link is admitted // straight away and no admission queue is ever drawn. A scenario whose // subject IS the queue has to ask for one, or it silently stops testing // what it is named after — which is how the native gate's guest case came // to wait thirty seconds for an Admit button that was never going to be // there. func (rig *browserRig) enableWaitingRoom(t *testing.T, sqid string) { t.Helper() req, err := http.NewRequest(http.MethodPost, rig.origin+"/meetings/"+sqid+"/settings", strings.NewReader(url.Values{"waiting_room": {"on"}}.Encode())) if err != nil { t.Fatalf("build settings request: %v", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", rig.origin) req.AddCookie(&http.Cookie{Name: rig.cookieName, Value: rig.cookieA}) // Not http.DefaultClient: it follows the 303 to the settings page and // hands back that page's 200, so a refused save and an accepted one // look identical. The redirect itself is the only honest evidence the // handler accepted this. client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} res, err := client.Do(req) if err != nil { t.Fatalf("POST meeting settings: %v", err) } defer res.Body.Close() if res.StatusCode != http.StatusSeeOther { body, _ := io.ReadAll(res.Body) t.Fatalf("POST meeting settings: %d\n%s", res.StatusCode, body) } } // createMeeting is member A starting a call and reading the link off the // page it is shown on exactly once. func (rig *browserRig) createMeeting(t *testing.T) (sqid, token string) { t.Helper() req, err := http.NewRequest(http.MethodPost, rig.origin+"/meetings", strings.NewReader(url.Values{"title": {"Board meeting"}}.Encode())) if err != nil { t.Fatalf("build create request: %v", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", rig.origin) req.AddCookie(&http.Cookie{Name: rig.cookieName, Value: rig.cookieA}) res, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("POST /meetings: %v", err) } defer res.Body.Close() body, _ := io.ReadAll(res.Body) if res.StatusCode != http.StatusOK { t.Fatalf("POST /meetings: %d\n%s", res.StatusCode, body) } s := sqidOnPage.FindSubmatch(body) k := tokenOnPage.FindSubmatch(body) if s == nil || k == nil { t.Fatalf("no meeting id or link on the page:\n%s", body) } return string(s[1]), string(k[1]) } // run drives one scenario. node's stdout is read a line at a time so a // CONTROL request is answered while the browser is still waiting on it; // everything else is logged as it arrives, which is what makes a hung // scenario readable rather than silent for three minutes. func (rig *browserRig) run(t *testing.T, scenario, sqid, token string) { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), scenarioTimeout) cmd := exec.CommandContext(ctx, "node", "test/browser/call.mjs", rig.origin, sqid, scenario) cmd.Dir = repoRootForBrowser(t) // The session cookies and the join-link token go in the environment, // never on argv: a command line is readable by every process on the // box, and either one of these is enough to be somebody in this // meeting. cmd.Env = append(os.Environ(), "CALLS_COOKIE_NAME="+rig.cookieName, "CALLS_COOKIE_A="+rig.cookieA, "CALLS_COOKIE_B="+rig.cookieB, "CALLS_JOIN_TOKEN="+token, ) stdout, err := cmd.StdoutPipe() if err != nil { t.Fatalf("stdout pipe: %v", err) } stdin, err := cmd.StdinPipe() if err != nil { t.Fatalf("stdin pipe: %v", err) } var errs bytes.Buffer cmd.Stderr = &errs if err := cmd.Start(); err != nil { cancel() t.Fatalf("start node: %v", err) } // Every path out of here reaps node, including the ones that do not // come back: rig.control calls t.Fatalf, which unwinds this // goroutine, and without this the browser would outlive the test. // Cancel first, so a hung node is killed rather than waited on. var waitErr error waited := false wait := func() error { if !waited { waited = true waitErr = cmd.Wait() } return waitErr } defer func() { cancel() _ = wait() }() scanner := bufio.NewScanner(stdout) scanner.Buffer(make([]byte, 0, 64<<10), 4<<20) for scanner.Scan() { line := scanner.Text() if !strings.HasPrefix(line, "CONTROL ") { t.Logf(" %s", line) continue } verb := strings.TrimSpace(strings.TrimPrefix(line, "CONTROL ")) t.Logf(" [control] %s", verb) fmt.Fprintln(stdin, rig.control(t, verb)) } _ = stdin.Close() err = wait() if s := strings.TrimSpace(errs.String()); s != "" { t.Logf("node stderr:\n%s", s) } if err != nil { t.Fatalf("the %s scenario failed: %v", scenario, err) } } // control answers one request from the browser side. Both verbs need // something only this process has: the mail recorder, and the app's own // lifecycle. func (rig *browserRig) control(t *testing.T, verb string) string { t.Helper() switch { case verb == "restart": rig.restart(t) return "ok" case verb == "drop-member-b": // The team changes, and the tick that notices is run by hand: // only this process holds the allowlist and the hub, and the // browser has no way to reach either (spec §2.4). rig.mu.Lock() ctl := rig.ctl rig.mu.Unlock() ctl.SetAllowlist(memberA) ctl.TickForTest() return "ok" case strings.HasPrefix(verb, "guest-link "): address := strings.TrimSpace(strings.TrimPrefix(verb, "guest-link ")) // The browser has just posted the sign-in form; the link is in // the recorder by the time the POST's response came back, but a // short wait costs nothing and turns a race into a wait. deadline := time.Now().Add(10 * time.Second) for { to, link := rig.mail.last() if to == address && link != "" { return link } if time.Now().After(deadline) { t.Fatalf("no sign-in link was mailed to %s (last went to %q)", address, to) } time.Sleep(50 * time.Millisecond) } case strings.HasPrefix(verb, "quality-check "): return rig.qualityCheck(strings.Fields(strings.TrimPrefix(verb, "quality-check "))) case strings.HasPrefix(verb, "problem-check "): fields := strings.Fields(strings.TrimPrefix(verb, "problem-check ")) if len(fields) < 1 { return "FAIL problem-check needs a reference" } return rig.problemCheck(fields[0], fields[1:]) case verb == "quality-final": for _, l := range rig.reports.linesFrom(rig.reportsFrom, "calls: quality") { if l["final"] == true { return "ok" } } return "FAIL no quality report with final:true" default: t.Fatalf("unknown control verb from the browser: %q", verb) return "" } } // qualityCheck answers the quality scenario (spec §15.26): the two // browsers' automatic reports reached the log as `calls: quality` lines // under one call tag and two different peer tags, each naming the other // by its tag, and the log as a whole names nobody. `peers` is the two // browsers' peer ids, which the log must not contain either. The answer // is "ok", or "FAIL" and why, for call.mjs to print as its check. func (rig *browserRig) qualityCheck(peers []string) string { lines := rig.reports.linesFrom(rig.reportsFrom, "calls: quality") if len(lines) < 2 { return fmt.Sprintf("FAIL %d calls: quality lines, want at least 2", len(lines)) } calls := map[string]bool{} tags := map[string]bool{} named := map[string]bool{} for _, l := range lines { call, _ := l["call"].(string) peer, _ := l["peer"].(string) if call == "" || peer == "" { return fmt.Sprintf("FAIL a quality line without its tags: %v", l) } if l["app"] != "web" { return fmt.Sprintf("FAIL a quality line from app %v", l["app"]) } calls[call] = true tags[peer] = true report, _ := l["report"].(map[string]any) ps, _ := report["peers"].([]any) for _, p := range ps { if m, ok := p.(map[string]any); ok { if tag, ok := m["peer"].(string); ok { named[tag] = true } } } } if len(calls) != 1 { return fmt.Sprintf("FAIL quality lines under %d call tags, want one: %v", len(calls), calls) } if len(tags) != 2 { return fmt.Sprintf("FAIL quality lines under %d peer tags, want two: %v", len(tags), tags) } for tag := range tags { if !named[tag] { return fmt.Sprintf("FAIL no report names peer tag %s as a remote peer (named: %v)", tag, named) } } if why := rig.leaks(peers); why != "" { return "FAIL " + why } return "ok" } // problemCheck answers "Report a problem": a `calls: problem report` // line under the reference the sheet showed, at least one diagnostics // line under it, and still nobody named anywhere in the log. func (rig *browserRig) problemCheck(ref string, peers []string) string { var head map[string]any for _, l := range rig.reports.linesFrom(rig.reportsFrom, "calls: problem report") { if l["ref"] == ref { head = l } } if head == nil { return "FAIL no calls: problem report line with ref " + ref } parts := 0 for _, l := range rig.reports.linesFrom(rig.reportsFrom, "calls: problem report diagnostics") { if l["ref"] == ref { parts++ } } if parts == 0 { return "FAIL no calls: problem report diagnostics line with ref " + ref } if why := rig.leaks(peers); why != "" { return "FAIL " + why } return fmt.Sprintf("ok (note %q, %d diagnostics part(s), live %v)", head["note"], parts, head["live"]) } // leaks is what the report log must never hold (spec §15.26): the two // members' addresses and display names, the meeting's sqid and link, // and any peer id of the call, in full or as the six characters the // diagnostics name people by. "" when it holds none of them. func (rig *browserRig) leaks(peers []string) string { raw := rig.reports.raw() forbidden := []string{memberA, memberB, rig.sqid, rig.token} for _, p := range peers { forbidden = append(forbidden, p) // The six-character prefix only when it has a letter in it: six // decimal digits are as likely to be a packet count as a person. if len(p) >= 6 && strings.ContainsAny(p[:6], "abcdef") { forbidden = append(forbidden, p[:6]) } } for _, f := range forbidden { if f != "" && strings.Contains(raw, f) { return fmt.Sprintf("the report log contains %q", f) } } // A display name is the address up to the "@" (see memberA); as a // word, so a random tag that happens to spell it is not a finding. for _, addr := range []string{memberA, memberB} { name := addr[:strings.Index(addr, "@")] if regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(name) + `\b`).MatchString(raw) { return fmt.Sprintf("the report log contains the name %q", name) } } return "" } // repoRootForBrowser finds the repository from this file's own path, // never from the working directory: go test runs in the package // directory and the node script is addressed from the root. func repoRootForBrowser(t *testing.T) string { t.Helper() _, file, _, ok := runtime.Caller(0) if !ok { t.Fatal("runtime.Caller failed") } return filepath.Dir(filepath.Dir(filepath.Dir(file))) }