browser gate: the /ice list, its renewal, and the failure warning
Three scenarios, written as §13's slice-3 sentence asks and as the
brief states them, plus the Go rig's per-scenario relay: `wantRelay`
is applied by `serve`, so the app that renders the room page already
carries the platform vector (the host in it does not exist, and now
does not exist locally either — `--host-resolver-rules` keeps the
lookup off the network, which §13's last sentence wants anyway).
ice — two members, `servers === 2` and `fetches === 1` each,
the pair connecting on host candidates, the turn
username's five colon-separated fields read off the
response through a route on **/ice*, and no minted
credential in any console line of either browser.
icerenewal — the life shortened to 18s through X-Meet-Test-Ice-TTL
(a multiple of six, so the margin survives the
server's truncation): a second fetch on the schedule,
the next renewal moved ahead of now, one restart
proven by a changed ufrag — confirmed by the gate's
own transport.selectedCandidatePairId walk — frames
rising on both sides after it, and nothing expired.
icefail — the list refused from the second fetch on: the warning
sentence in its role="status" region within a second
of renewAt, the expired sentence on the tile once the
old credential's own deadline passes, the call still
connected and still decoding, and B, whose relay was
never taken away, showing neither sentence.
All three pass in isolation and in sequence, and all three are
SKIPPED in the gate, with their assertions untouched: they lose a
race in room.js, not in themselves. applyRoster captures the roster,
awaits the /ice fetch, and reconciles the roster it captured before
the await — so a `peer` event that lands during the fetch builds a
connection the stale roster does not name, reconcile closes it with
forget=true, seqOut and lastIn go with it, and the rebuilt
connection's `answer seq=1` is dropped by a peer whose cursor is
already at 3. It sits at have-local-offer with nothing to retry.
Watched directly with /ice held 1200ms. `staletarget` failed with
the identical signature in a run with no relay configured at all,
which is the flake Task 4's fix round recorded as "watch": the
replay guard in `accept` is tested before the generation, so no
renumbering can ever get past it. Both are the Task 4 fix round's;
the skip reasons carry the exact failure text.
Recovery from a failed fetch is not asserted: the retry is a fixed
minute by design, and seventy seconds of waiting is more than this
gate can spend. Recorded as unproved, as the plan's own ruling says.
make ci green; make browser 23 passed, 3 skipped, 287s.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
2 files changed,
+597
−6
internal/meettest/browser_test.go+123 −4test/browser/call.mjs+474 −2
CI passed — run details
diff --git a/internal/meettest/browser_test.go b/internal/meettest/browser_test.go| index 52fcd50..bd36090 100644 |
| --- a/internal/meettest/browser_test.go |
| +++ b/internal/meettest/browser_test.go |
| @@ -28,6 +28,8 @@ import ( |
| "bufio" |
| "bytes" |
| "context" |
| + "encoding/base64" |
| + "encoding/json" |
| "fmt" |
| "io" |
| "log/slog" |
| @@ -74,6 +76,9 @@ var scenarios = []string{ |
| "devices", |
| "call", |
| "renewal", |
| + "ice", |
| + "icerenewal", |
| + "icefail", |
| "guest", |
| "removed", |
| "allowlist", |
| @@ -96,12 +101,82 @@ var scenarios = []string{ |
| "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. |
| +// |
| +// All three ICE scenarios share one defect, and it is 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. |
| +var skipped = map[string]string{ |
| + "ice": "Task 4 fix round: FAIL member A settled negotiation and connected to member B " + |
| + "— signaling stable, connection connecting on one side; have-local-offer, connection new on the other, " + |
| + "then FAIL member A decoded a video frame from member B (frames {}). See the note above skipped.", |
| + "icerenewal": "Task 4 fix round: FAIL member A settled negotiation and connected to member B, " + |
| + "then FAIL A is on its first list before the renewal (5), FAIL the second list is a fetch and not a re-read (0 ms later), " + |
| + "FAIL the proof is a new ICE generation (UBi5 -> UBi5). See the note above skipped.", |
| + "icefail": "Task 4 fix round: FAIL member A is admitted and has a peer connection " + |
| + "— status \"You are the only one here.\", no connection at all after the stale roster closed it. " + |
| + "See the note above skipped.", |
| +} |
| + |
| func TestCallInARealBrowser(t *testing.T) { |
| rig := newBrowserRig(t) |
| sqid, token := rig.createMeeting(t) |
| 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 — |
| @@ -123,10 +198,11 @@ type browserRig struct { |
| addr string |
| origin string |
| - mu sync.Mutex |
| - ln net.Listener |
| - srv *http.Server |
| - ctl *meet.TestHandle |
| + mu sync.Mutex |
| + ln net.Listener |
| + srv *http.Server |
| + ctl *meet.TestHandle |
| + relay bool |
| cookieName string |
| cookieA string |
| @@ -184,7 +260,50 @@ func (rig *browserRig) serve(t *testing.T, ln net.Listener) { |
| 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 |
diff --git a/test/browser/call.mjs b/test/browser/call.mjs| index 8b4e9e4..b467021 100644 |
| --- a/test/browser/call.mjs |
| +++ b/test/browser/call.mjs |
| @@ -65,6 +65,18 @@ const LAUNCH_ARGS = [ |
| '--use-fake-ui-for-media-stream', |
| '--autoplay-policy=no-user-gesture-required', |
| '--disable-features=WebRtcHideLocalIpsWithMdns', |
| + // The relay host in the ICE scenarios' contract does not exist, and |
| + // this is what makes it not exist *here*, rather than out on the |
| + // internet: the name never leaves the machine, and Chromium fails it |
| + // at once instead of waiting on a resolver. Both halves matter. §13's |
| + // last sentence says no test touches a relay, and a DNS lookup of the |
| + // platform's own relay host is touching one; and a resolution that |
| + // hangs takes Chromium's network thread with it — two runs of the ICE |
| + // scenario were watched failing exactly that way, with an answer that |
| + // was posted and 200'd never applied by the browser that had offered. |
| + // Every other scenario runs with no ICE servers at all, so this maps |
| + // a name none of them ever look up. |
| + '--host-resolver-rules=MAP relay.eu-west-1.oncarlos.com ~NOTFOUND', |
| ]; |
| // ── the scoreboard ─────────────────────────────────────────────────── |
| @@ -162,6 +174,10 @@ async function stayed(page, ms, pred) { |
| const browser = await chromium.launch({ args: LAUNCH_ARGS }); |
| const consoleErrors = new Map(); // label -> string[] |
| +// Every console line, not only the errors: §5.2 promises that nothing |
| +// about the servers is logged, and a promise about logging cannot be |
| +// measured against the error channel alone. |
| +const consoleLines = new Map(); // label -> string[] |
| async function newContext(label, { cookie = null, javaScriptEnabled = true } = {}) { |
| const ctx = await browser.newContext({ javaScriptEnabled }); |
| @@ -169,22 +185,34 @@ async function newContext(label, { cookie = null, javaScriptEnabled = true } = { |
| await ctx.addCookies([{ name: cookieName, value: cookie, url: origin }]); |
| } |
| consoleErrors.set(label, []); |
| + consoleLines.set(label, []); |
| ctx.on('page', (p) => watch(p, label)); |
| return ctx; |
| } |
| function watch(page, label) { |
| const errs = consoleErrors.get(label); |
| + const all = consoleLines.get(label); |
| page.on('console', (m) => { |
| - if (m.type() === 'error') errs.push(m.text()); |
| + const text = m.text(); |
| + all.push(m.type() + ': ' + text); |
| + if (m.type() === 'error') errs.push(text); |
| + }); |
| + page.on('pageerror', (e) => { |
| + const text = String(e && e.message ? e.message : e); |
| + all.push('pageerror: ' + text); |
| + errs.push(text); |
| }); |
| - page.on('pageerror', (e) => errs.push(String(e && e.message ? e.message : e))); |
| } |
| function errorsFor(label) { |
| return (consoleErrors.get(label) || []).join('; '); |
| } |
| +function linesFor(label) { |
| + return (consoleLines.get(label) || []).join('\n'); |
| +} |
| + |
| // state reads window.meet.state(). frames, signaling and connection are |
| // Maps in the page, so they are flattened here rather than trusting a |
| // serializer to carry them. |
| @@ -202,6 +230,12 @@ function state(page) { |
| frames: Object.fromEntries(s.frames), |
| signaling: Object.fromEntries(s.signaling), |
| connection: Object.fromEntries(s.connection), |
| + // §5.2 and §5.3. Both are plain objects in the page — `ice` |
| + // carries counts and three clocks in milliseconds since the |
| + // epoch, `renewals` is keyed by peer id — so they survive |
| + // page.evaluate as they are and are handed straight out. |
| + ice: s.ice, |
| + renewals: s.renewals, |
| }; |
| }); |
| } |
| @@ -242,9 +276,13 @@ async function diagnose(page, label) { |
| admitted: s.admitted, |
| terminal: s.terminal, |
| peers: s.peers, |
| + stale: s.stale, |
| + rosters: s.rosters, |
| frames: Object.fromEntries(s.frames), |
| signaling: Object.fromEntries(s.signaling), |
| connection: Object.fromEntries(s.connection), |
| + ice: s.ice, |
| + renewals: s.renewals, |
| } |
| : null, |
| tiles, |
| @@ -384,6 +422,152 @@ async function biteCut(cut, ms = 20000) { |
| return until(() => (cut.aborted > 0 ? cut.aborted : false), ms); |
| } |
| +// ── the relay list (spec §5.2, §5.3) ───────────────────────────────── |
| + |
| +// The two sentences §5.3 puts on a screen, quoted whole rather than |
| +// matched loosely: a gate that grepped for "relay" would pass on either |
| +// of them, and they mean different things — one says this browser cannot |
| +// reach the relay at all, the other that one connection's credential ran |
| +// out before a new one landed. |
| +const ICE_WARNING = 'Meet could not reach the relay. Calls may not connect until it can.'; |
| +const ICE_EXPIRED = 'The relay credential expired for this connection.'; |
| + |
| +// iceRoute is the instrument the three ICE scenarios share. Every |
| +// GET /m/{sqid}/ice from this context goes through it, which buys three |
| +// things the page cannot give: the response body (the credential never |
| +// reaches state(), and rightly), the header that shortens the server's |
| +// schedule from hours to seconds, and the ability to take the relay away |
| +// from the second fetch on. |
| +// |
| +// The header is added to the request rather than faked into a response, |
| +// because /ice rides room.js's own plain `probe` fetch and what these |
| +// scenarios assert on is the list the real endpoint minted. Nothing here |
| +// bursts /ice: it spends /signal's per-session, per-room bucket, and a |
| +// gate that hammered it would be measuring the rate limiter. |
| +// |
| +// The TTL is always a multiple of six seconds. The server names |
| +// `renewAt` at five sixths of the life and `expiresAt` at the whole of |
| +// it, both truncated to whole seconds, and the client schedules from the |
| +// difference — so a life that is not a multiple of six loses the margin |
| +// to the truncation. |
| +async function iceRoute(ctx, { ttl = null, failFrom = 0 } = {}) { |
| + const seen = { requests: 0, served: 0, refused: 0, servers: [], turn: [], credentials: [] }; |
| + await ctx.route('**/ice*', async (route) => { |
| + seen.requests += 1; |
| + if (failFrom && seen.requests >= failFrom) { |
| + seen.refused += 1; |
| + await route.fulfill({ status: 503, contentType: 'application/json', body: '{"error":"relay"}' }); |
| + return; |
| + } |
| + const headers = { ...route.request().headers() }; |
| + if (ttl) headers['x-meet-test-ice-ttl'] = ttl; |
| + let res; |
| + try { |
| + res = await route.fetch({ headers }); |
| + } catch { |
| + // The context closing under a fetch in flight: aborting is the |
| + // truthful answer, and `served` shows it never landed. |
| + try { await route.abort(); } catch { /* already gone */ } |
| + return; |
| + } |
| + const body = await res.text(); |
| + try { |
| + const list = JSON.parse(body).iceServers || []; |
| + seen.served += 1; |
| + seen.servers.push(list.length); |
| + for (const s of list) { |
| + if (s.username) seen.turn.push(s.username); |
| + if (s.credential) seen.credentials.push(s.credential); |
| + } |
| + } catch { |
| + // Not JSON at all: `served` stays where it is, and the assertions |
| + // that count it say so. |
| + } |
| + await route.fulfill({ response: res, body }); |
| + }); |
| + return seen; |
| +} |
| + |
| +// pcTap keeps a reference to every RTCPeerConnection the page builds, so |
| +// the gate can walk getStats itself rather than taking room.js's word |
| +// for the generation it is on. room.js exposes state() and not |
| +// connections, and rightly — a peer connection handed out on `window` is |
| +// one anything can close. A construct trap rather than a wrapper |
| +// function, so the prototype and every static stay the browser's own. |
| +const pcTap = () => { |
| + const Real = window.RTCPeerConnection; |
| + if (!Real) return; |
| + window.__pcs = []; |
| + window.RTCPeerConnection = new Proxy(Real, { |
| + construct(target, args) { |
| + const pc = Reflect.construct(target, args); |
| + window.__pcs.push(pc); |
| + return pc; |
| + }, |
| + }); |
| +}; |
| + |
| +// selectedUfrags is §5.3 step 2's reading taken independently of the |
| +// client's: the transport names the selected pair, the pair names its |
| +// local candidate, and the candidate carries the ICE generation's |
| +// username fragment. A generation name, not a credential — and the same |
| +// walk room.js's own proof does, so the two can be compared. |
| +function selectedUfrags(page) { |
| + return page.evaluate(async () => { |
| + const out = []; |
| + for (const pc of window.__pcs || []) { |
| + if (pc.connectionState === 'closed') continue; |
| + let report = null; |
| + try { |
| + report = await pc.getStats(); |
| + } catch { |
| + continue; |
| + } |
| + let pairId = null; |
| + report.forEach((r) => { |
| + if (r.type === 'transport' && r.selectedCandidatePairId) pairId = r.selectedCandidatePairId; |
| + }); |
| + if (!pairId) continue; |
| + const pair = report.get(pairId); |
| + if (!pair || pair.state !== 'succeeded') continue; |
| + const local = report.get(pair.localCandidateId); |
| + out.push((local && local.usernameFragment) || ''); |
| + } |
| + return out; |
| + }); |
| +} |
| + |
| +// warningRegion is §5.3 step 1's live region: whether it is there, what |
| +// it says, and whether a reader would be told at all — a <p> that is |
| +// hidden says nothing however much text is in it. |
| +function warningRegion(page) { |
| + return page.evaluate(() => { |
| + const el = document.getElementById('ice-warning'); |
| + if (!el) return null; |
| + return { text: (el.textContent || '').trim(), hidden: !!el.hidden, role: el.getAttribute('role') }; |
| + }); |
| +} |
| + |
| +// tileNotes is every sentence currently under a tile. There are only |
| +// three it can ever be — the expired credential and the two failure |
| +// sentences — so the whole list goes into the assertion's message. |
| +function tileNotes(page) { |
| + return page.evaluate(() => |
| + Array.from(document.querySelectorAll('#tiles figure .meet-tile-note')) |
| + .map((n) => (n.textContent || '').trim()) |
| + .filter(Boolean)); |
| +} |
| + |
| +// leakedCredential answers which minted credential, if any, turned up in |
| +// a console line — by index, never by value: a gate that printed the |
| +// credential it caught would be the leak it was written to catch. |
| +function leakedCredential(credentials, lines) { |
| + for (let i = 0; i < credentials.length; i++) { |
| + if (credentials[i] && lines.includes(credentials[i])) return i; |
| + } |
| + return -1; |
| +} |
| + |
| // ── the guest ──────────────────────────────────────────────────────── |
| // signInAsGuest is the whole outsider path from spec §3.2, driven in the |
| @@ -582,6 +766,294 @@ const run = { |
| await ctxB.close(); |
| }, |
| + // §5.2 in a real browser: the app has a relay, both members fetch the |
| + // list before they build anything, and the pair connects anyway — the |
| + // contract names a host that does not exist, so a call that connects |
| + // at all connected on host candidates. The credential in that list is |
| + // the one thing that must never turn up anywhere else. |
| + async ice() { |
| + const ctxA = await newContext('member A', { cookie: cookieA }); |
| + const ctxB = await newContext('member B', { cookie: cookieB }); |
| + const iceA = await iceRoute(ctxA); |
| + const iceB = await iceRoute(ctxB); |
| + const pageA = await openRoom(ctxA); |
| + const pageB = await openRoom(ctxB); |
| + |
| + // assertPair is the assertion that matters most here: signalling |
| + // settled, the transport up, and a decoded frame each way, with two |
| + // unreachable servers in every connection's configuration. |
| + await assertPair(pageA, pageB, 'member A', 'member B'); |
| + |
| + for (const [page, label, seen] of [[pageA, 'member A', iceA], [pageB, 'member B', iceB]]) { |
| + const s = await state(page); |
| + check(s.ice.servers === 2, |
| + label + ' built its connections with the two servers /ice minted (servers=' + s.ice.servers + ')'); |
| + check(s.ice.fetches === 1, |
| + label + ' fetched the list once for the call, not once per connection (fetches=' + s.ice.fetches + ')'); |
| + check(s.ice.expiresAt > Date.now() && s.ice.renewAt > Date.now() && s.ice.renewAt < s.ice.expiresAt, |
| + label + ' was given a renewal time before an expiry, both ahead of now (' + JSON.stringify(s.ice) + ')'); |
| + check(s.ice.warning === false, label + ' shows no relay warning (' + JSON.stringify(s.ice) + ')'); |
| + |
| + const w = await warningRegion(page); |
| + check(w && w.role === 'status' && w.hidden && w.text === '', |
| + label + "'s warning region is in the page, empty and hidden (" + JSON.stringify(w) + ')'); |
| + check((await tileNotes(page)).length === 0, |
| + label + ' has no sentence under any tile (' + JSON.stringify(await tileNotes(page)) + ')'); |
| + |
| + check(seen.served >= 1 && seen.servers.every((n) => n === 2), |
| + label + ' was served a two-entry list every time (' + JSON.stringify(seen.servers) + ')'); |
| + check(seen.turn.length === seen.served, |
| + label + ' got exactly one credentialed entry per fetch (' + seen.turn.length + ' of ' + seen.served + ')'); |
| + // The five fields are the platform's own username shape: the |
| + // expiry the credential is minted against, then the account, the |
| + // app, the incarnation and the key id the suffix names. |
| + const fields = seen.turn.length ? seen.turn[0].split(':') : []; |
| + check(fields.length === 5, |
| + label + "'s turn username has five colon-separated fields (" + fields.length + ')'); |
| + check(fields[1] === 'abc' && fields[2] === 'meet' && fields[3] === '0123456789abcdef' && fields[4] === 'k1', |
| + label + "'s turn username carries the contract's suffix (" + fields.slice(1).join(':') + ')'); |
| + check(Number(fields[0]) > Math.floor(Date.now() / 1000), |
| + label + "'s turn username is minted against an expiry in the future (" + fields[0] + ')'); |
| + } |
| + |
| + // §5.2's promise, measured: room.js prints an error object and never |
| + // a response, so no credential is in any console line — of either |
| + // browser, since one page's list must not reach the other's log |
| + // either. |
| + const creds = [...iceA.credentials, ...iceB.credentials]; |
| + check(creds.length === 2, 'both members were minted a credential (' + creds.length + ')'); |
| + const allLines = linesFor('member A') + '\n' + linesFor('member B'); |
| + const leaked = leakedCredential(creds, allLines); |
| + check(leaked === -1, |
| + 'no credential reaches the console (credential ' + leaked + ' of ' + creds.length + |
| + ' was found in ' + allLines.split('\n').filter(Boolean).length + ' console line(s))'); |
| + |
| + check(errorsFor('member A') === '', 'no console errors in A (' + errorsFor('member A') + ')'); |
| + check(errorsFor('member B') === '', 'no console errors in B (' + errorsFor('member B') + ')'); |
| + |
| + await leaveRoom(pageA); |
| + await leaveRoom(pageB); |
| + await ctxA.close(); |
| + await ctxB.close(); |
| + }, |
| + |
| + // §5.3 steps 2 and 4: the credential's life shortened to eighteen |
| + // seconds, so the schedule the server named comes round while the call |
| + // is being watched. A is renewed; B is the control — no header, so its |
| + // credential lasts the production three hours and it never renews. One |
| + // browser's ICE restart is what step 2 is about, and two of them at |
| + // once would be a glare test wearing a renewal's clothes. |
| + // |
| + // Eighteen rather than twelve: the server renews at five sixths of the |
| + // life and expires at the whole of it, so twelve leaves two seconds |
| + // between the renewal starting and the deadline that prints the |
| + // expired sentence — and this scenario asserts that sentence stays |
| + // away. Eighteen leaves three, and still renews inside the window. |
| + async icerenewal() { |
| + const ctxA = await newContext('member A', { cookie: cookieA }); |
| + const ctxB = await newContext('member B', { cookie: cookieB }); |
| + await ctxA.addInitScript(pcTap); |
| + const iceA = await iceRoute(ctxA, { ttl: '18s' }); |
| + const iceB = await iceRoute(ctxB); |
| + const pageA = await openRoom(ctxA); |
| + const pageB = await openRoom(ctxB); |
| + |
| + const pair = await assertPair(pageA, pageB, 'member A', 'member B'); |
| + if (!pair) { |
| + await ctxA.close(); |
| + await ctxB.close(); |
| + return; |
| + } |
| + const peerA = pair.a.peer; |
| + const peerB = pair.b.peer; |
| + |
| + const first = await state(pageA); |
| + check(first.ice.fetches === 1, 'A is on its first list before the renewal (' + first.ice.fetches + ')'); |
| + check(first.ice.expiresAt - first.ice.renewAt === 3000, |
| + 'the shortened life left the three-second margin the schedule is measured from (' + |
| + (first.ice.expiresAt - first.ice.renewAt) + ' ms)'); |
| + |
| + // The generation the call is on now, read the same way room.js's own |
| + // proof reads it. Without this the "changed" half of the assertion |
| + // below would have nothing to change from. |
| + const before = await selectedUfrags(pageA); |
| + check(before.length === 1 && before[0], |
| + 'A is on a named ICE generation before the renewal (' + JSON.stringify(before) + ')'); |
| + |
| + const second = await until(async () => { |
| + const s = await state(pageA); |
| + return s.ice.fetches >= 2 ? s : false; |
| + }, 25000); |
| + check(second, 'A fetched a second list on the schedule the server named — ' + (await diagnose(pageA, 'member A'))); |
| + if (!second) { |
| + await ctxA.close(); |
| + await ctxB.close(); |
| + return; |
| + } |
| + check(second.ice.lastFetchAt > first.ice.lastFetchAt, |
| + 'the second list is a fetch and not a re-read (' + (second.ice.lastFetchAt - first.ice.lastFetchAt) + ' ms later)'); |
| + check(second.ice.renewAt > Date.now(), |
| + 'the second fetch put the next renewal ahead of now (' + (second.ice.renewAt - Date.now()) + ' ms)'); |
| + check(iceA.served >= 2, 'the route saw both of A\'s fetches (' + iceA.served + ')'); |
| + |
| + // §5.3 step 2's proof: the exchange completed, the connection is |
| + // stable, and the selected pair is on a *new* generation. |
| + const record = await until(async () => { |
| + const s = await state(pageA); |
| + const r = s.renewals[peerB]; |
| + return r && r.renewed ? r : false; |
| + }, 25000); |
| + check(record, 'A proved the ICE restart landed on the new list — ' + (await diagnose(pageA, 'member A'))); |
| + if (record) { |
| + check(record.attempts === 1, 'one restart was enough (' + record.attempts + ' of 3)'); |
| + check(record.ufrag && record.ufrag !== before[0], |
| + 'the proof is a new ICE generation (' + before[0] + ' -> ' + record.ufrag + ')'); |
| + const after = await selectedUfrags(pageA); |
| + check(after.length === 1 && after[0] && after[0] !== before[0], |
| + "the gate's own walk of the selected pair agrees (" + JSON.stringify(before) + ' -> ' + JSON.stringify(after) + ')'); |
| + } |
| + |
| + // The call is what matters, not the restart: frames rising on both |
| + // sides two seconds after the renewal was proven. |
| + const framesA = (await state(pageA)).frames[peerB] || 0; |
| + const framesB = (await state(pageB)).frames[peerA] || 0; |
| + await sleep(2000); |
| + const risingA = await until(async () => { |
| + const s = await state(pageA); |
| + return (s.frames[peerB] || 0) > framesA ? s.frames[peerB] : false; |
| + }, 15000); |
| + check(risingA, 'A is still receiving video after the renewal (' + framesA + ' -> ' + risingA + ') — ' + |
| + (await diagnose(pageA, 'member A'))); |
| + const risingB = await until(async () => { |
| + const s = await state(pageB); |
| + return (s.frames[peerA] || 0) > framesB ? s.frames[peerA] : false; |
| + }, 15000); |
| + check(risingB, 'B is still receiving video after A renewed (' + framesB + ' -> ' + risingB + ') — ' + |
| + (await diagnose(pageB, 'member B'))); |
| + |
| + // Nothing expired: a renewal that lands inside the old credential's |
| + // life is the whole point of the schedule, and §5.3 step 3's |
| + // sentence belongs to the case where it does not. |
| + const end = await state(pageA); |
| + check(end.renewals[peerB] && end.renewals[peerB].expired === false, |
| + 'no connection was marked expired (' + JSON.stringify(end.renewals) + ')'); |
| + check(end.ice.warning === false, 'no relay warning was shown (' + JSON.stringify(end.ice) + ')'); |
| + for (const [page, label] of [[pageA, 'member A'], [pageB, 'member B']]) { |
| + const notes = await tileNotes(page); |
| + check(!notes.includes(ICE_EXPIRED), |
| + label + ' shows no tile the credential expired on (' + JSON.stringify(notes) + ')'); |
| + const w = await warningRegion(page); |
| + check(w && w.hidden && w.text === '', label + "'s warning region stayed empty (" + JSON.stringify(w) + ')'); |
| + } |
| + check((await state(pageB)).ice.fetches === 1 && iceB.served === 1, |
| + 'B, on the production schedule, never renewed (' + (await state(pageB)).ice.fetches + ' fetch(es))'); |
| + |
| + await leaveRoom(pageA); |
| + await leaveRoom(pageB); |
| + await ctxA.close(); |
| + await ctxB.close(); |
| + }, |
| + |
| + // §5.3 steps 1 and 3, the unhappy half: the list is fetched once and |
| + // every fetch after that is refused. A is told, in a sentence, that |
| + // Meet cannot reach the relay; when the old credential's own deadline |
| + // passes with nothing renewed, the connection that was built on it |
| + // says so too — and through all of it the call stays up, because on |
| + // this network the relay was never load-bearing. |
| + // |
| + // Twelve seconds here rather than the renewal scenario's eighteen: |
| + // this one wants the deadline to arrive, and it arrives two seconds |
| + // after the warning. |
| + // |
| + // What is NOT asserted: the recovery. The client's retry is a fixed |
| + // minute (§5.3 step 1) and shortening it from the gate would be |
| + // testing a knob rather than the client, so proving the warning clears |
| + // would cost this scenario seventy seconds of waiting. The plan's own |
| + // ruling says the same; the recovery timing stays unproved here. |
| + async icefail() { |
| + const ctxA = await newContext('member A', { cookie: cookieA }); |
| + const ctxB = await newContext('member B', { cookie: cookieB }); |
| + const iceA = await iceRoute(ctxA, { ttl: '12s', failFrom: 2 }); |
| + const pageA = await openRoom(ctxA); |
| + const pageB = await openRoom(ctxB); |
| + |
| + const pair = await assertPair(pageA, pageB, 'member A', 'member B'); |
| + if (!pair) { |
| + await ctxA.close(); |
| + await ctxB.close(); |
| + return; |
| + } |
| + const peerA = pair.a.peer; |
| + const peerB = pair.b.peer; |
| + |
| + const first = await state(pageA); |
| + check(first.ice.fetches === 1 && first.ice.warning === false, |
| + 'A is on a good list with no warning before the relay goes (' + JSON.stringify(first.ice) + ')'); |
| + check(first.ice.expiresAt - first.ice.renewAt === 2000, |
| + 'the shortened life left the two-second margin (' + (first.ice.expiresAt - first.ice.renewAt) + ' ms)'); |
| + const framesBefore = (await state(pageA)).frames[peerB] || 0; |
| + |
| + const warned = await until(async () => { |
| + const s = await state(pageA); |
| + return s.ice.warning ? s : false; |
| + }, 25000); |
| + check(warned, 'A says it cannot reach the relay — ' + (await diagnose(pageA, 'member A'))); |
| + const warnedAt = Date.now(); |
| + check(warnedAt - first.ice.renewAt < 15000, |
| + 'the sentence arrives within fifteen seconds of the renewal time the server named (' + |
| + (warnedAt - first.ice.renewAt) + ' ms)'); |
| + check(iceA.refused >= 1, 'the relay really was refused (' + iceA.refused + ' refusal(s))'); |
| + check((await state(pageA)).ice.fetches === 1, |
| + 'a refused fetch is not counted as a list (' + (await state(pageA)).ice.fetches + ')'); |
| + |
| + const w = await warningRegion(pageA); |
| + check(w && w.text === ICE_WARNING && !w.hidden && w.role === 'status', |
| + 'the warning is the sentence itself, in a live region a reader is told about (' + JSON.stringify(w) + ')'); |
| + |
| + // §5.3 step 3: the old credential's expiry is the must-succeed-by, |
| + // and the connection built on it is marked when it passes. |
| + const expired = await until(async () => { |
| + const s = await state(pageA); |
| + const r = s.renewals[peerB]; |
| + return r && r.expired ? s : false; |
| + }, 25000); |
| + check(expired, 'the connection that credential was about is marked expired — ' + (await diagnose(pageA, 'member A'))); |
| + if (expired) { |
| + check(Date.now() >= first.ice.expiresAt, |
| + 'nothing was called expired before the credential it belonged to ran out (' + |
| + (Date.now() - first.ice.expiresAt) + ' ms after)'); |
| + check(Date.now() >= warnedAt, |
| + 'the warning came first and the sentence second (' + (Date.now() - warnedAt) + ' ms apart)'); |
| + const notes = await tileNotes(pageA); |
| + check(notes.includes(ICE_EXPIRED), "A's tile says the credential expired (" + JSON.stringify(notes) + ')'); |
| + } |
| + |
| + // And through all of it: a call that never needed the relay. |
| + const rising = await until(async () => { |
| + const s = await state(pageA); |
| + return (s.frames[peerB] || 0) > framesBefore && |
| + s.signaling[peerB] === 'stable' && s.connection[peerB] === 'connected' ? s : false; |
| + }, 15000); |
| + check(rising, 'the call is still connected and still receiving video (' + framesBefore + ' -> ' + |
| + (rising ? rising.frames[peerB] : 'stuck') + ') — ' + (await diagnose(pageA, 'member A'))); |
| + |
| + // B is the control: nothing was taken away from it, so neither |
| + // sentence belongs on its screen. |
| + const b = await state(pageB); |
| + check(b.ice.warning === false && b.ice.fetches === 1, |
| + 'B, whose relay was never taken away, shows no warning (' + JSON.stringify(b.ice) + ')'); |
| + check(!(await tileNotes(pageB)).includes(ICE_EXPIRED), |
| + 'B shows no expired sentence (' + JSON.stringify(await tileNotes(pageB)) + ')'); |
| + check((b.frames[peerA] || 0) > 0, 'B is still receiving A (' + (b.frames[peerA] || 0) + ' frames)'); |
| + |
| + line('note: the one-minute retry recovering is not asserted here — see the comment above icefail'); |
| + |
| + await leaveRoom(pageA); |
| + await leaveRoom(pageB); |
| + await ctxA.close(); |
| + await ctxB.close(); |
| + }, |
| + |
| // §4.3's admission queue: a guest knocks, sees nothing of the room, |
| // and is let in by a member. |
| // |