oficina / meet Public

tests: gate the five guest-isolation predicates

W-1. §4.3's and ruling 9's isolation rule rests on five predicates in
admission.go, and the whole-branch review mutated each one away with the
suite staying green — including two §13 names as slice-2 gates, and one
the browser `guest` scenario was written to catch and did not.

The shape that makes them discriminating is a second guest left waiting.
With one guest in the room the queue is empty at the moment it is
admitted, and no admission happens while anyone waits, so nothing can
leak and nothing is proved.

- internal/meet/hub/isolation_test.go: five tests, one per predicate.
  An admitted guest is never told who is waiting; roster.peers lists
  admitted peers only; a waiting guest gets no peer event for anyone
  else's admission; a kind change never hands a waiting guest the
  roster; a knock reaches admitted members only.
- internal/meettest/isolation_test.go: the same over HTTP for the three
  §13 names it — the waiting queue, a waiting guest's silence, and the
  knock audience — reading the events off a real SSE stream.
- test/browser/call.mjs: the `guest` scenario gains a second guest that
  knocks first and stays knocking, so the admission happens with a name
  in the queue; the waiting guest's assertion now also requires it is
  still healthily waiting (a leaked peer event drives it into
  `replaced`, and a terminal page has an empty peer list too); and the
  waiter re-knocks after the admission so the knock audience is
  observable.

Mutation evidence, on a throwaway copy under $TMPDIR: all five
mutations the reviewer lists are now killed in the hub; three of them
in meettest; three of them in the browser `guest` scenario.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
Paul Campbell pushed by paul@keymail.dev 98fdbaf8fc0acc897f50fcf861ee261e94ce4186 parent cfc0dc0
3 files changed, +631 −8
  • internal/meet/hub/isolation_test.go +287 −0
  • internal/meettest/isolation_test.go +242 −0
  • test/browser/call.mjs +102 −8
diff --git a/internal/meet/hub/isolation_test.go b/internal/meet/hub/isolation_test.go
new file mode 100644
index 0000000..c941e51
--- /dev/null
+++ b/internal/meet/hub/isolation_test.go
@@ -0,0 +1,287 @@
+package hub
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+// Guest isolation (spec §4.3, ruling 9, and two of §13's slice-2 gates).
+//
+// Five predicates in admission.go carry the rule that a guest learns
+// nothing about anyone it is not already in the mesh with, and that a
+// peer which has only knocked learns nothing at all. Whole-branch
+// review W-1 mutated each of the five away and found the whole suite
+// still green, so the tests below exist to kill exactly those five
+// mutations. Each one names the mutation it is written against: a test
+// whose subject is an audience filter is otherwise indistinguishable
+// from a test of the happy path, and the happy path is what the
+// pre-existing tests already covered.
+//
+// The shape that makes them discriminating is a room with someone still
+// waiting in it. Every one of these leaks is invisible when the guest
+// under test is the only guest in the room.
+
+// guestRoom builds that shape: one admitted member, one guest admitted
+// after it, and one guest still waiting, with every subscriber drained
+// so a test asserts only on what happens next.
+func guestRoom(t *testing.T, h *Hub) (member, admittedGuest, waitingGuest OpenResult) {
+ t.Helper()
+ ctx := context.Background()
+ member, _ = h.Open("r1", "sm", 1, "Mia", KindMember)
+ if err := h.Join(ctx, "r1", "sm", member.Peer); err != nil {
+ t.Fatalf("member join: %v", err)
+ }
+ waitingGuest, _ = h.Open("r1", "sw", 101, "Wes", KindGuest)
+ if err := h.Join(ctx, "r1", "sw", waitingGuest.Peer); err != nil {
+ t.Fatalf("waiting guest join: %v", err)
+ }
+ admittedGuest, _ = h.Open("r1", "sg", 100, "Gus", KindGuest)
+ if err := h.Join(ctx, "r1", "sg", admittedGuest.Peer); err != nil {
+ t.Fatalf("guest join: %v", err)
+ }
+ if err := h.Admit(ctx, "r1", "sm", member.Peer, admittedGuest.Peer); err != nil {
+ t.Fatalf("admit: %v", err)
+ }
+ drain(member.Sub)
+ drain(admittedGuest.Sub)
+ drain(waitingGuest.Sub)
+ return member, admittedGuest, waitingGuest
+}
+
+// countOf reports how many of the drained events carry one type.
+func countOf(ms []Message, typ string) int {
+ n := 0
+ for _, m := range ms {
+ if m.Type == typ {
+ n++
+ }
+ }
+ return n
+}
+
+// W-1 row 1 — mutation: rosterFor's `viewer.Kind == KindMember` → true.
+//
+// §13: "roster carrying the waiting queue to members and not to
+// guests". The roster an admitted guest is sent must never carry the
+// waiting queue, which is the display name of everyone knocking. The
+// guest is admitted here while another guest is already waiting, so
+// the Waiting field has something to leak — the pre-existing test
+// admitted the only guest in the room, where an empty queue proved
+// nothing about the filter.
+func TestAnAdmittedGuestIsNeverToldWhoIsWaiting(t *testing.T) {
+ now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
+ h := testHub(&now)
+ ctx := context.Background()
+ m, _ := h.Open("r1", "sm", 1, "Mia", KindMember)
+ h.Join(ctx, "r1", "sm", m.Peer)
+ w, _ := h.Open("r1", "sw", 101, "Wes", KindGuest)
+ h.Join(ctx, "r1", "sw", w.Peer)
+ g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest)
+ h.Join(ctx, "r1", "sg", g.Peer)
+ drain(g.Sub)
+
+ if err := h.Admit(ctx, "r1", "sm", m.Peer, g.Peer); err != nil {
+ t.Fatal(err)
+ }
+ ev := drain(g.Sub)
+ if len(ev) != 1 || ev[0].Type != TypeRoster {
+ t.Fatalf("the admitted guest got %v, want one roster", types(ev))
+ }
+ if n := len(ev[0].Roster.Waiting); n != 0 {
+ t.Fatalf("the admitted guest was handed %d waiting peer(s): %+v", n, ev[0].Roster.Waiting)
+ }
+
+ // The same moment from a member's side, so the test also says what
+ // the rule is for: the queue exists, and a member does see it.
+ m2, _ := h.Open("r1", "sm2", 2, "Moe", KindMember)
+ h.Join(ctx, "r1", "sm2", m2.Peer)
+ mev := drain(m2.Sub)
+ if len(mev) != 1 || mev[0].Type != TypeRoster {
+ t.Fatalf("the joining member got %v, want one roster", types(mev))
+ }
+ if len(mev[0].Roster.Waiting) != 1 || mev[0].Roster.Waiting[0].Peer != w.Peer {
+ t.Fatalf("a member's roster must carry the queue, got %+v", mev[0].Roster.Waiting)
+ }
+}
+
+// W-1 row 2 — mutation: rosterFor's `!p.Admitted` → `!p.Joined`.
+//
+// roster.peers is the mesh: everyone the viewer should be negotiating a
+// connection with. A peer that has only knocked is not in it, for
+// anyone — a member sees it in Waiting and nowhere else, and a guest
+// does not see it at all.
+func TestTheRosterMeshListsAdmittedPeersOnly(t *testing.T) {
+ now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
+ h := testHub(&now)
+ ctx := context.Background()
+ m, g, w := guestRoom(t, h)
+
+ // A member joining now is sent the authoritative snapshot.
+ m2, _ := h.Open("r1", "sm2", 2, "Moe", KindMember)
+ if err := h.Join(ctx, "r1", "sm2", m2.Peer); err != nil {
+ t.Fatal(err)
+ }
+ mev := drain(m2.Sub)
+ if len(mev) != 1 || mev[0].Type != TypeRoster {
+ t.Fatalf("joining member got %v", types(mev))
+ }
+ assertMeshExcludesWaiting(t, "the joining member", mev[0].Roster, w.Peer,
+ m.Peer, g.Peer, m2.Peer)
+
+ // And the same snapshot as an admitted guest sees it, which is the
+ // half with no Waiting field to carry the knock instead.
+ g2, _ := h.Open("r1", "sg2", 102, "Gil", KindGuest)
+ h.Join(ctx, "r1", "sg2", g2.Peer)
+ drain(g2.Sub)
+ if err := h.Admit(ctx, "r1", "sm", m.Peer, g2.Peer); err != nil {
+ t.Fatal(err)
+ }
+ gev := drain(g2.Sub)
+ if len(gev) != 1 || gev[0].Type != TypeRoster {
+ t.Fatalf("admitted guest got %v", types(gev))
+ }
+ assertMeshExcludesWaiting(t, "the admitted guest", gev[0].Roster, w.Peer,
+ m.Peer, g.Peer, m2.Peer, g2.Peer)
+}
+
+func assertMeshExcludesWaiting(t *testing.T, who string, ro *Roster, waiting PeerID, want ...PeerID) {
+ t.Helper()
+ got := map[PeerID]bool{}
+ for _, p := range ro.Peers {
+ got[p.Peer] = true
+ }
+ if got[waiting] {
+ t.Fatalf("%s: roster.peers listed a peer that has only knocked (%s): %+v", who, waiting, ro.Peers)
+ }
+ if len(got) != len(want) {
+ t.Fatalf("%s: roster.peers has %d entries, want %d: %+v", who, len(got), len(want), ro.Peers)
+ }
+ for _, id := range want {
+ if !got[id] {
+ t.Fatalf("%s: roster.peers is missing %s: %+v", who, id, ro.Peers)
+ }
+ }
+}
+
+// W-1 row 3 — mutation: admitLocked's `q.Admitted` dropped from the
+// fan-out condition.
+//
+// §13: "a waiting guest receiving nothing but hello and its own
+// outcome". Every admission after the knock — another guest let in, a
+// member arriving — must leave the waiting subscriber silent, because
+// a `peer` event is an instruction to open a connection to someone the
+// guest has no business reaching.
+func TestAWaitingGuestReceivesNoPeerEventForAnyoneElsesAdmission(t *testing.T) {
+ now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
+ h := testHub(&now)
+ ctx := context.Background()
+ m, _, w := guestRoom(t, h)
+
+ // Another guest is let in while this one is still waiting.
+ g2, _ := h.Open("r1", "sg2", 102, "Gil", KindGuest)
+ h.Join(ctx, "r1", "sg2", g2.Peer)
+ if err := h.Admit(ctx, "r1", "sm", m.Peer, g2.Peer); err != nil {
+ t.Fatal(err)
+ }
+ if ev := drain(w.Sub); len(ev) != 0 {
+ t.Fatalf("the waiting guest was told about another admission: %v (%+v)", types(ev), ev)
+ }
+
+ // And a member arrives, which is an admission too.
+ m2, _ := h.Open("r1", "sm2", 2, "Moe", KindMember)
+ if err := h.Join(ctx, "r1", "sm2", m2.Peer); err != nil {
+ t.Fatal(err)
+ }
+ if ev := drain(w.Sub); len(ev) != 0 {
+ t.Fatalf("the waiting guest was told a member joined: %v (%+v)", types(ev), ev)
+ }
+
+ // The rule is an audience, not silence: the peer already in the mesh
+ // was told both times.
+ if n := countOf(drain(m.Sub), TypePeer); n != 2 {
+ t.Fatalf("the member in the mesh got %d peer events, want 2", n)
+ }
+}
+
+// W-1 row 4 — mutation: refreshRostersLocked's `q.Admitted` dropped.
+//
+// A kind change owes every *admitted* peer a fresh roster (§2.4). A
+// waiting guest is owed nothing, and the roster it would be handed is
+// the whole mesh — plus, when the changed peer is a member, the queue.
+func TestAKindChangeNeverHandsAWaitingGuestTheRoster(t *testing.T) {
+ now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
+ kinds := map[int64]Kind{1: KindMember, 100: KindGuest, 101: KindGuest}
+ h := New(Config{
+ Now: func() time.Time { return now },
+ StandingFor: func(ctx context.Context, room string, personID int64) (Kind, bool, error) {
+ return kinds[personID], true, nil
+ },
+ })
+ ctx := context.Background()
+ m, g, w := guestRoom(t, h)
+
+ // The admitted guest is added to the team mid-call: the tick
+ // re-derives its kind, and the change refreshes the rosters.
+ kinds[100] = KindMember
+ h.Tick(ctx)
+
+ if ev := drain(w.Sub); len(ev) != 0 {
+ t.Fatalf("a kind change reached the waiting guest: %v (%+v)", types(ev), ev)
+ }
+ // The peers that were owed the refresh got it, so the test is not
+ // passing because nothing happened at all.
+ for _, c := range []struct {
+ who string
+ r OpenResult
+ }{{"the member", m}, {"the promoted guest", g}} {
+ ev := drain(c.r.Sub)
+ if len(ev) != 1 || ev[0].Type != TypeRoster {
+ t.Fatalf("%s got %v, want the refreshed roster", c.who, types(ev))
+ }
+ }
+}
+
+// W-1 row 5 — mutation: knock's audience `admittedMembers` →
+// `admittedPeers`.
+//
+// A knock carries the display name of the person asking to come in.
+// Only a member can act on it, and only a member may learn it: an
+// admitted guest is in the mesh, not on the door. knock-gone has the
+// same audience for the same reason.
+func TestAKnockReachesAdmittedMembersOnly(t *testing.T) {
+ now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
+ h := testHub(&now)
+ ctx := context.Background()
+ m, g, w := guestRoom(t, h)
+
+ newcomer, _ := h.Open("r1", "sn", 103, "Nan", KindGuest)
+ if err := h.Join(ctx, "r1", "sn", newcomer.Peer); err != nil {
+ t.Fatal(err)
+ }
+
+ mev := drain(m.Sub)
+ if len(mev) != 1 || mev[0].Type != TypeKnock || mev[0].From != newcomer.Peer || mev[0].Name != "Nan" {
+ t.Fatalf("the member did not get the knock: %v (%+v)", types(mev), mev)
+ }
+ if ev := drain(g.Sub); len(ev) != 0 {
+ t.Fatalf("an admitted guest learned who is knocking: %v (%+v)", types(ev), ev)
+ }
+ if ev := drain(w.Sub); len(ev) != 0 {
+ t.Fatalf("a waiting guest learned who is knocking: %v (%+v)", types(ev), ev)
+ }
+
+ if err := h.Admit(ctx, "r1", "sm", m.Peer, newcomer.Peer); err != nil {
+ t.Fatal(err)
+ }
+ got := types(drain(m.Sub))
+ if len(got) != 2 || got[0] != TypePeer || got[1] != TypeKnockGone {
+ t.Fatalf("the member got %v, want peer then knock-gone", got)
+ }
+ if ev := types(drain(g.Sub)); len(ev) != 1 || ev[0] != TypePeer {
+ t.Fatalf("the admitted guest got %v, want the peer event and no knock-gone", ev)
+ }
+ if ev := drain(w.Sub); len(ev) != 0 {
+ t.Fatalf("the waiting guest got %v", types(ev))
+ }
+}
diff --git a/internal/meettest/isolation_test.go b/internal/meettest/isolation_test.go
new file mode 100644
index 0000000..c0f8726
--- /dev/null
+++ b/internal/meettest/isolation_test.go
@@ -0,0 +1,242 @@
+package meettest
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+)
+
+// Guest isolation over HTTP — §13's slice-2 gates, end to end.
+//
+// The hub half lives in internal/meet/hub/isolation_test.go. These are
+// the two §13 names it in so many words:
+//
+// "roster carrying the waiting queue to members and not to guests"
+// "a waiting guest receiving nothing but hello and its own outcome"
+//
+// plus the knock audience, which is the same rule and the one slice 4's
+// chat fan-out will extend. Whole-branch review W-1 found all three
+// ungated: the filters could be deleted and every test stayed green.
+//
+// What makes these discriminating is a second guest left waiting. With
+// only one guest in the room the waiting queue is empty and no
+// admission happens while anyone is waiting, so nothing can leak and
+// nothing is proved.
+
+// guestRoom signs a member in, opens a stream for it, and leaves two
+// guests knocking on the same link: `waiting` stays waiting for the
+// whole test, and `newcomer` is whoever the test is about to admit.
+// Returns the harness, the room, the member's browser and its peer,
+// and the two guests' readers and peers.
+type isolationRoom struct {
+ x *harness
+ sqid string
+ token string
+ member *client
+ memberSSE *sseReader
+ memberID string
+ waiting *sseReader
+ waitingID string
+ guest *sseReader
+ guestID string
+}
+
+func openIsolationRoom(t *testing.T) *isolationRoom {
+ t.Helper()
+ x, sqid, tok := memberWithMeeting(t)
+
+ m := x.browser(t)
+ m.signIn("paul@example.com")
+ sm := m.stream("/m/" + sqid + "/stream")
+ t.Cleanup(sm.close)
+ pm, _ := sm.hello()
+ m.signal(sqid, pm, `{"type":"join"}`)
+ if ev, _, ok := sm.nextOf("roster", 2*time.Second); !ok {
+ t.Fatalf("the member never joined the mesh (got %q)", ev)
+ }
+
+ // The guest that stays waiting for the whole test. It knocks first,
+ // so it is in the queue before anything below happens.
+ w := x.browser(t)
+ w.get("/j/" + tok)
+ w.signIn("waiter@example.com")
+ sw := w.stream("/m/" + sqid + "/stream")
+ t.Cleanup(sw.close)
+ pw, _ := sw.hello()
+ w.signal(sqid, pw, `{"type":"join"}`)
+ if ev, _, ok := sm.nextOf("knock", 2*time.Second); !ok {
+ t.Fatalf("the first guest never reached the queue (got %q)", ev)
+ }
+
+ g := x.browser(t)
+ g.get("/j/" + tok)
+ g.signIn("guest@example.com")
+ sg := g.stream("/m/" + sqid + "/stream")
+ t.Cleanup(sg.close)
+ pg, _ := sg.hello()
+ g.signal(sqid, pg, `{"type":"join"}`)
+ if ev, _, ok := sm.nextOf("knock", 2*time.Second); !ok {
+ t.Fatalf("the second guest never reached the queue (got %q)", ev)
+ }
+
+ return &isolationRoom{x: x, sqid: sqid, token: tok, member: m, memberSSE: sm, memberID: pm,
+ waiting: sw, waitingID: pw, guest: sg, guestID: pg}
+}
+
+// silence asserts a reader carries no event at all for a short window.
+// The heartbeat is an SSE comment and never reaches this channel, so an
+// idle stream really is silent here.
+func silence(t *testing.T, s *sseReader, who string) {
+ t.Helper()
+ if ev, data, ok := s.next(400 * time.Millisecond); ok {
+ t.Fatalf("%s received %q: %s", who, ev, data)
+ }
+}
+
+// rosterBody is the roster event as it goes over the wire: the roster
+// rides inside the message envelope, so a test that unmarshals the
+// event data straight into a peers/waiting struct gets two empty slices
+// and proves nothing.
+type rosterBody struct {
+ Peers []struct{ Peer, Name, Kind string } `json:"peers"`
+ Waiting []struct{ Peer, Name, Kind string } `json:"waiting"`
+}
+
+func parseRoster(t *testing.T, data []byte) rosterBody {
+ t.Helper()
+ var msg struct {
+ Type string `json:"type"`
+ Roster rosterBody `json:"roster"`
+ }
+ if err := json.Unmarshal(data, &msg); err != nil {
+ t.Fatalf("roster %s: %v", data, err)
+ }
+ if msg.Type != "roster" {
+ t.Fatalf("event is %q, want roster: %s", msg.Type, data)
+ }
+ if len(msg.Roster.Peers) == 0 {
+ t.Fatalf("roster carries no peers at all, which no admitted peer ever sees: %s", data)
+ }
+ return msg.Roster
+}
+
+// §13: "roster carrying the waiting queue to members and not to
+// guests" (W-1 row 1, and rows 2 and 4 as they appear on the wire).
+// The guest is admitted while another guest is still knocking, so the
+// waiting queue has a name in it to leak.
+func TestTheWaitingQueueReachesMembersAndNeverAGuest(t *testing.T) {
+ r := openIsolationRoom(t)
+ r.member.signal(r.sqid, r.memberID, `{"type":"admit","payload":{"peer":"`+r.guestID+`"}}`)
+
+ ev, data, ok := r.guest.nextOf("roster", 2*time.Second)
+ if !ok {
+ t.Fatalf("the guest was never admitted (got %q)", ev)
+ }
+ roster := parseRoster(t, data)
+ if len(roster.Waiting) != 0 {
+ t.Fatalf("an admitted guest was handed the waiting queue: %s", data)
+ }
+ // The queue is not merely absent from the parsed struct: the name of
+ // the person knocking is nowhere in the bytes.
+ if strings.Contains(string(data), r.waitingID) || strings.Contains(string(data), "waiter") {
+ t.Fatalf("the waiting guest appears in an admitted guest's roster: %s", data)
+ }
+ // And roster.peers is the mesh, not everyone joined: the guest still
+ // knocking is not in it either (W-1 row 2 over the wire).
+ for _, p := range roster.Peers {
+ if p.Peer == r.waitingID {
+ t.Fatalf("roster.peers listed a peer that has only knocked: %s", data)
+ }
+ }
+ if len(roster.Peers) != 2 {
+ t.Fatalf("roster.peers has %d entries, want the member and the guest: %s", len(roster.Peers), data)
+ }
+
+ // The member half of the same sentence, so the test says what the
+ // rule is for and cannot pass by the queue being empty: a second
+ // member joining is sent the queue the guest was not.
+ m2 := newMemberBrowser(t, r.x, "paul@rslw.com")
+ s2 := m2.stream("/m/" + r.sqid + "/stream")
+ defer s2.close()
+ p2, _ := s2.hello()
+ m2.signal(r.sqid, p2, `{"type":"join"}`)
+ ev, data, ok = s2.nextOf("roster", 2*time.Second)
+ if !ok {
+ t.Fatalf("the second member never joined (got %q)", ev)
+ }
+ roster = parseRoster(t, data)
+ if len(roster.Waiting) != 1 || roster.Waiting[0].Peer != r.waitingID {
+ t.Fatalf("a member's roster must carry the queue, got %s", data)
+ }
+}
+
+// §13: "a waiting guest receiving nothing but hello and its own
+// outcome" (W-1 row 3 over the wire). Its own knock is delivered to the
+// members; everything that happens next in the room — another guest
+// admitted, a member arriving — is addressed to the mesh, and the
+// waiting subscriber is not in it.
+func TestAWaitingGuestIsSentNothingButHelloAndItsOwnOutcome(t *testing.T) {
+ r := openIsolationRoom(t)
+ silence(t, r.waiting, "a guest that has only knocked")
+
+ // Another guest is let in.
+ r.member.signal(r.sqid, r.memberID, `{"type":"admit","payload":{"peer":"`+r.guestID+`"}}`)
+ if ev, _, ok := r.guest.nextOf("roster", 2*time.Second); !ok {
+ t.Fatalf("the other guest was never admitted (got %q)", ev)
+ }
+ silence(t, r.waiting, "a waiting guest, after someone else was admitted")
+
+ // A second member arrives, which is an admission too.
+ m2 := newMemberBrowser(t, r.x, "paul@rslw.com")
+ s2 := m2.stream("/m/" + r.sqid + "/stream")
+ defer s2.close()
+ p2, _ := s2.hello()
+ m2.signal(r.sqid, p2, `{"type":"join"}`)
+ if ev, _, ok := s2.nextOf("roster", 2*time.Second); !ok {
+ t.Fatalf("the second member never joined (got %q)", ev)
+ }
+ // The mesh was told, so the silence above is an audience and not a
+ // server that stopped sending.
+ if ev, _, ok := r.guest.nextOf("peer", 2*time.Second); !ok {
+ t.Fatalf("the admitted guest was not told the member arrived (got %q)", ev)
+ }
+ silence(t, r.waiting, "a waiting guest, after a member joined")
+
+ // Its own outcome still reaches it, which is the other half of the
+ // §13 sentence.
+ r.member.signal(r.sqid, r.memberID, `{"type":"admit","payload":{"peer":"`+r.waitingID+`"}}`)
+ if ev, _, ok := r.waiting.nextOf("roster", 2*time.Second); !ok {
+ t.Fatalf("the waiting guest was never told its own outcome (got %q)", ev)
+ }
+}
+
+// W-1 row 5 over the wire: a knock carries the display name of the
+// person asking to come in, so it goes to admitted members and to
+// nobody else. Slice 4's chat fan-out is the same audience question.
+func TestAKnockReachesAdmittedMembersAndNoGuest(t *testing.T) {
+ r := openIsolationRoom(t)
+ r.member.signal(r.sqid, r.memberID, `{"type":"admit","payload":{"peer":"`+r.guestID+`"}}`)
+ if ev, _, ok := r.guest.nextOf("roster", 2*time.Second); !ok {
+ t.Fatalf("the guest was never admitted (got %q)", ev)
+ }
+
+ // A third outsider knocks while an admitted guest is in the mesh.
+ third := r.x.browser(t)
+ third.get("/j/" + r.token)
+ third.signIn("newcomer@example.com")
+ s3 := third.stream("/m/" + r.sqid + "/stream")
+ defer s3.close()
+ p3, _ := s3.hello()
+ third.signal(r.sqid, p3, `{"type":"join"}`)
+
+ ev, data, ok := r.memberSSE.nextOf("knock", 2*time.Second)
+ if !ok {
+ t.Fatalf("the member did not get the knock (got %q)", ev)
+ }
+ if !strings.Contains(string(data), p3) {
+ t.Fatalf("the knock names someone else: %s", data)
+ }
+ silence(t, r.guest, "an admitted guest, while someone knocks")
+ silence(t, r.waiting, "a waiting guest, while someone else knocks")
+}
diff --git a/test/browser/call.mjs b/test/browser/call.mjs
index 7ffcb15..6543d30 100644
--- a/test/browser/call.mjs
+++ b/test/browser/call.mjs
@@ -47,6 +47,13 @@ if (!origin || !sqid || !scenario || !cookieName || !cookieA || !cookieB) {
}
const GUEST = 'guest@example.com';
+// A second outsider, whose only job is to be knocking while somebody
+// else is let in: the guest-isolation filters (§4.3, ruling 9) leak
+// nothing when the guest under test is the only guest in the room, so
+// whole-branch review W-1 found this scenario passing with the filter
+// deleted. Same link, so it costs one of the five sign-ins that link
+// allows in fifteen minutes.
+const WAITER = 'waiter@example.com';
// The Go driver gives up at three minutes. This gives up first, so a
// stuck scenario prints why instead of being killed silently. unref'd:
@@ -371,13 +378,13 @@ async function biteCut(cut, ms = 20000) {
// signInAsGuest is the whole outsider path from spec §3.2, driven in the
// browser: follow the emailed link signed out, ask for a sign-in link,
// and click the one that was actually mailed.
-async function signInAsGuest(ctx) {
+async function signInAsGuest(ctx, address = GUEST) {
const page = await ctx.newPage();
await page.goto(origin + '/j/' + token, { waitUntil: 'domcontentloaded' });
check(page.url().startsWith(origin + '/signin'),
'the link sends a signed-out outsider to sign in (at ' + where(page) + ')');
- await page.fill('input[name="address"]', GUEST);
+ await page.fill('input[name="address"]', address);
await Promise.all([
page.waitForURL('**/signin?sent=1', { timeout: 15000 }),
page.click('form[rst-form] button[type="submit"]'),
@@ -385,13 +392,38 @@ async function signInAsGuest(ctx) {
check(page.url() === origin + '/signin?sent=1',
'asking for a sign-in link gives the same answer every time (at ' + where(page) + ')');
- const link = await control('guest-link ' + GUEST);
+ const link = await control('guest-link ' + address);
await page.goto(link, { waitUntil: 'domcontentloaded' });
check(page.url() === origin + '/m/' + sqid + '/join',
'the emailed link lands the guest on this meeting (at ' + where(page) + ')');
await page.close();
}
+// admitKnock clicks the Admit button on the queue entry with this
+// display name. The queue holds more than one entry in the guest
+// scenario, and clicking the first one would admit whoever happened to
+// knock first.
+async function admitKnock(page, name) {
+ const lis = page.locator('#knocks li');
+ const n = await lis.count();
+ for (let i = 0; i < n; i++) {
+ const who = ((await lis.nth(i).locator('span').first().textContent()) || '').trim();
+ if (who === name) {
+ await lis.nth(i).locator('button').click();
+ return true;
+ }
+ }
+ return false;
+}
+
+// queueNames is the admission queue as this page has it, which is
+// exactly what the server decided this page may know: room.js fills it
+// from knock, knock-gone and roster.waiting and from nothing else.
+async function queueNames(page) {
+ return page.evaluate(() =>
+ Array.from(document.querySelectorAll('#knocks li span')).map((s) => s.textContent.trim()));
+}
+
// ── the scenarios ────────────────────────────────────────────────────
const run = {
@@ -510,14 +542,30 @@ const run = {
// §4.3's admission queue: a guest knocks, sees nothing of the room,
// and is let in by a member.
+ //
+ // Two guests knock, and only one is admitted. That is what makes the
+ // isolation half of this scenario load-bearing (W-1): with a single
+ // guest, the queue is empty at the moment it is admitted and no
+ // admission ever happens while anyone is waiting, so every one of
+ // §4.3's audience filters can be deleted and the scenario still
+ // passes. It was, and it did.
async guest() {
const ctxA = await newContext('member A', { cookie: cookieA });
+ const ctxW = await newContext('waiting guest');
const ctxG = await newContext('guest');
const pageA = await openRoom(ctxA);
check(await until(async () => (await state(pageA)).admitted, 20000),
'the member is in the room — ' + (await diagnose(pageA, 'member A')));
- await signInAsGuest(ctxG);
+ // The guest that stays in the queue for the whole scenario.
+ await signInAsGuest(ctxW, WAITER);
+ const pageW = await openRoom(ctxW);
+ check(await until(async () => ((await state(pageW)).peer ? true : false), 20000),
+ 'the first guest knocked — ' + (await diagnose(pageW, 'waiting guest')));
+ check(await until(async () => (await pageA.locator('#knocks li').count()) === 1, 20000),
+ "the member's queue shows the first knock — " + (await diagnose(pageA, 'member A')));
+
+ await signInAsGuest(ctxG, GUEST);
const pageG = await openRoom(ctxG);
const knocked = await until(async () => ((await state(pageG)).peer ? true : false), 20000);
@@ -529,16 +577,62 @@ const run = {
check(waitingState.admitted === false, 'the guest is not admitted before anyone admits it');
check(waitingState.peers.length === 0,
'the guest has no peer connections before admission (' + JSON.stringify(waitingState.peers) + ')');
-
- const queued = await until(async () => (await pageA.locator('#knocks li').count()) === 1, 20000);
- check(queued, "the member's queue shows the knock — " + (await diagnose(pageA, 'member A')));
- if (queued) await pageA.click('#knocks li button');
+ check((await queueNames(pageG)).length === 0,
+ 'a guest that has only knocked is told nothing about the queue (' + JSON.stringify(await queueNames(pageG)) + ')');
+
+ const queued = await until(async () => (await pageA.locator('#knocks li').count()) === 2, 20000);
+ check(queued, "the member's queue shows both knocks — " + (await diagnose(pageA, 'member A')));
+ if (queued) {
+ check(await admitKnock(pageA, 'guest'),
+ 'the queue names the guest to admit (' + JSON.stringify(await queueNames(pageA)) + ')');
+ }
await assertPair(pageA, pageG, 'member A', 'guest');
+ // W-1, as a browser can see it. The guest was admitted while
+ // somebody else was knocking, so roster.waiting had a name in it to
+ // leak, and an admission happened while the other guest waited.
+ check((await queueNames(pageG)).length === 0,
+ 'the admitted guest was never handed the waiting queue (' + JSON.stringify(await queueNames(pageG)) + ')');
+ check(await pageG.locator('#queue').isHidden(),
+ "the admitted guest's queue stays hidden");
+ // `terminal === null` and a peer id are load-bearing, not decoration:
+ // a `peer` event sent to a waiting guest makes it open a connection
+ // and start signalling, which is refused, which sends it round the
+ // fresh-stream ladder into `replaced` — and a terminal page has an
+ // empty peer list too. Without these two the assertion would pass on
+ // the very leak it is written to catch.
+ const stillWaiting = await stayed(pageW, 3000,
+ (s) => s.terminal === null && !!s.peer && s.admitted === false && s.peers.length === 0);
+ check(stillWaiting === null,
+ 'the guest still waiting is still just waiting, told nothing of the admission — ' +
+ JSON.stringify(stillWaiting));
+ check((await queueNames(pageW)).length === 0,
+ 'the guest still waiting knows nothing of the queue it is in (' + JSON.stringify(await queueNames(pageW)) + ')');
+
+ // The member does see the queue, so the three checks above are an
+ // audience and not an empty room.
+ check(JSON.stringify(await queueNames(pageA)) === JSON.stringify(['waiter']),
+ "the member's queue still holds the guest that was not admitted (" + JSON.stringify(await queueNames(pageA)) + ')');
+
+ // And a knock that arrives *while* the guest is admitted: the same
+ // audience rule, and the one slice 4's chat fan-out extends. The
+ // waiter leaves and comes back so the knock happens now rather than
+ // before the admission.
+ await leaveRoom(pageW);
+ const pageW2 = await openRoom(ctxW);
+ check(await until(async () => ((await state(pageW2)).peer ? true : false), 20000),
+ 'the guest knocked again — ' + (await diagnose(pageW2, 'waiting guest')));
+ check(await until(async () => (await pageA.locator('#knocks li').count()) === 1, 20000),
+ "the member is told about the second knock — " + (await diagnose(pageA, 'member A')));
+ check((await queueNames(pageG)).length === 0,
+ 'the admitted guest is not told who is knocking (' + JSON.stringify(await queueNames(pageG)) + ')');
+
await leaveRoom(pageG);
+ await leaveRoom(pageW2);
await leaveRoom(pageA);
await ctxA.close();
+ await ctxW.close();
await ctxG.close();
},