| 1 | package hub |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "testing" |
| 6 | "time" |
| 7 | ) |
| 8 | |
| 9 | // Guest isolation (spec §4.3, ruling 9, and two of §13's slice-2 gates). |
| 10 | // |
| 11 | // Five predicates in admission.go carry the rule that a guest learns |
| 12 | // nothing about anyone it is not already in the mesh with, and that a |
| 13 | // peer which has only knocked learns nothing at all. Whole-branch |
| 14 | // review W-1 mutated each of the five away and found the whole suite |
| 15 | // still green, so the tests below exist to kill exactly those five |
| 16 | // mutations. Each one names the mutation it is written against: a test |
| 17 | // whose subject is an audience filter is otherwise indistinguishable |
| 18 | // from a test of the happy path, and the happy path is what the |
| 19 | // pre-existing tests already covered. |
| 20 | // |
| 21 | // The shape that makes them discriminating is a room with someone still |
| 22 | // waiting in it. Every one of these leaks is invisible when the guest |
| 23 | // under test is the only guest in the room. |
| 24 | |
| 25 | // guestRoom builds that shape: one admitted member, one guest admitted |
| 26 | // after it, and one guest still waiting, with every subscriber drained |
| 27 | // so a test asserts only on what happens next. |
| 28 | func guestRoom(t *testing.T, h *Hub) (member, admittedGuest, waitingGuest OpenResult) { |
| 29 | t.Helper() |
| 30 | ctx := context.Background() |
| 31 | member, _ = h.Open("r1", "sm", 1, "Mia", KindMember) |
| 32 | if err := h.Join(ctx, "r1", "sm", member.Peer); err != nil { |
| 33 | t.Fatalf("member join: %v", err) |
| 34 | } |
| 35 | waitingGuest, _ = h.Open("r1", "sw", 101, "Wes", KindGuest) |
| 36 | if err := h.Join(ctx, "r1", "sw", waitingGuest.Peer); err != nil { |
| 37 | t.Fatalf("waiting guest join: %v", err) |
| 38 | } |
| 39 | admittedGuest, _ = h.Open("r1", "sg", 100, "Gus", KindGuest) |
| 40 | if err := h.Join(ctx, "r1", "sg", admittedGuest.Peer); err != nil { |
| 41 | t.Fatalf("guest join: %v", err) |
| 42 | } |
| 43 | if err := h.Admit(ctx, "r1", "sm", member.Peer, admittedGuest.Peer); err != nil { |
| 44 | t.Fatalf("admit: %v", err) |
| 45 | } |
| 46 | drain(member.Sub) |
| 47 | drain(admittedGuest.Sub) |
| 48 | drain(waitingGuest.Sub) |
| 49 | return member, admittedGuest, waitingGuest |
| 50 | } |
| 51 | |
| 52 | // countOf reports how many of the drained events carry one type. |
| 53 | func countOf(ms []Message, typ string) int { |
| 54 | n := 0 |
| 55 | for _, m := range ms { |
| 56 | if m.Type == typ { |
| 57 | n++ |
| 58 | } |
| 59 | } |
| 60 | return n |
| 61 | } |
| 62 | |
| 63 | // W-1 row 1 — mutation: rosterFor's `viewer.Kind == KindMember` → true. |
| 64 | // |
| 65 | // §13: "roster carrying the waiting queue to members and not to |
| 66 | // guests". The roster an admitted guest is sent must never carry the |
| 67 | // waiting queue, which is the display name of everyone knocking. The |
| 68 | // guest is admitted here while another guest is already waiting, so |
| 69 | // the Waiting field has something to leak — the pre-existing test |
| 70 | // admitted the only guest in the room, where an empty queue proved |
| 71 | // nothing about the filter. |
| 72 | func TestAnAdmittedGuestIsNeverToldWhoIsWaiting(t *testing.T) { |
| 73 | now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| 74 | h := testHub(&now) |
| 75 | ctx := context.Background() |
| 76 | m, _ := h.Open("r1", "sm", 1, "Mia", KindMember) |
| 77 | h.Join(ctx, "r1", "sm", m.Peer) |
| 78 | w, _ := h.Open("r1", "sw", 101, "Wes", KindGuest) |
| 79 | h.Join(ctx, "r1", "sw", w.Peer) |
| 80 | g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest) |
| 81 | h.Join(ctx, "r1", "sg", g.Peer) |
| 82 | drain(g.Sub) |
| 83 | |
| 84 | if err := h.Admit(ctx, "r1", "sm", m.Peer, g.Peer); err != nil { |
| 85 | t.Fatal(err) |
| 86 | } |
| 87 | ev := drain(g.Sub) |
| 88 | if len(ev) != 1 || ev[0].Type != TypeRoster { |
| 89 | t.Fatalf("the admitted guest got %v, want one roster", types(ev)) |
| 90 | } |
| 91 | if n := len(ev[0].Roster.Waiting); n != 0 { |
| 92 | t.Fatalf("the admitted guest was handed %d waiting peer(s): %+v", n, ev[0].Roster.Waiting) |
| 93 | } |
| 94 | |
| 95 | // The same moment from a member's side, so the test also says what |
| 96 | // the rule is for: the queue exists, and a member does see it. |
| 97 | m2, _ := h.Open("r1", "sm2", 2, "Moe", KindMember) |
| 98 | h.Join(ctx, "r1", "sm2", m2.Peer) |
| 99 | mev := drain(m2.Sub) |
| 100 | if len(mev) != 1 || mev[0].Type != TypeRoster { |
| 101 | t.Fatalf("the joining member got %v, want one roster", types(mev)) |
| 102 | } |
| 103 | if len(mev[0].Roster.Waiting) != 1 || mev[0].Roster.Waiting[0].Peer != w.Peer { |
| 104 | t.Fatalf("a member's roster must carry the queue, got %+v", mev[0].Roster.Waiting) |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | // W-1 row 2 — mutation: rosterFor's `!p.Admitted` → `!p.Joined`. |
| 109 | // |
| 110 | // roster.peers is the mesh: everyone the viewer should be negotiating a |
| 111 | // connection with. A peer that has only knocked is not in it, for |
| 112 | // anyone — a member sees it in Waiting and nowhere else, and a guest |
| 113 | // does not see it at all. |
| 114 | func TestTheRosterMeshListsAdmittedPeersOnly(t *testing.T) { |
| 115 | now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| 116 | h := testHub(&now) |
| 117 | ctx := context.Background() |
| 118 | m, g, w := guestRoom(t, h) |
| 119 | |
| 120 | // A member joining now is sent the authoritative snapshot. |
| 121 | m2, _ := h.Open("r1", "sm2", 2, "Moe", KindMember) |
| 122 | if err := h.Join(ctx, "r1", "sm2", m2.Peer); err != nil { |
| 123 | t.Fatal(err) |
| 124 | } |
| 125 | mev := drain(m2.Sub) |
| 126 | if len(mev) != 1 || mev[0].Type != TypeRoster { |
| 127 | t.Fatalf("joining member got %v", types(mev)) |
| 128 | } |
| 129 | assertMeshExcludesWaiting(t, "the joining member", mev[0].Roster, w.Peer, |
| 130 | m.Peer, g.Peer, m2.Peer) |
| 131 | |
| 132 | // And the same snapshot as an admitted guest sees it, which is the |
| 133 | // half with no Waiting field to carry the knock instead. |
| 134 | g2, _ := h.Open("r1", "sg2", 102, "Gil", KindGuest) |
| 135 | h.Join(ctx, "r1", "sg2", g2.Peer) |
| 136 | drain(g2.Sub) |
| 137 | if err := h.Admit(ctx, "r1", "sm", m.Peer, g2.Peer); err != nil { |
| 138 | t.Fatal(err) |
| 139 | } |
| 140 | gev := drain(g2.Sub) |
| 141 | if len(gev) != 1 || gev[0].Type != TypeRoster { |
| 142 | t.Fatalf("admitted guest got %v", types(gev)) |
| 143 | } |
| 144 | assertMeshExcludesWaiting(t, "the admitted guest", gev[0].Roster, w.Peer, |
| 145 | m.Peer, g.Peer, m2.Peer, g2.Peer) |
| 146 | } |
| 147 | |
| 148 | func assertMeshExcludesWaiting(t *testing.T, who string, ro *Roster, waiting PeerID, want ...PeerID) { |
| 149 | t.Helper() |
| 150 | got := map[PeerID]bool{} |
| 151 | for _, p := range ro.Peers { |
| 152 | got[p.Peer] = true |
| 153 | } |
| 154 | if got[waiting] { |
| 155 | t.Fatalf("%s: roster.peers listed a peer that has only knocked (%s): %+v", who, waiting, ro.Peers) |
| 156 | } |
| 157 | if len(got) != len(want) { |
| 158 | t.Fatalf("%s: roster.peers has %d entries, want %d: %+v", who, len(got), len(want), ro.Peers) |
| 159 | } |
| 160 | for _, id := range want { |
| 161 | if !got[id] { |
| 162 | t.Fatalf("%s: roster.peers is missing %s: %+v", who, id, ro.Peers) |
| 163 | } |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | // W-1 row 3 — mutation: admitLocked's `q.Admitted` dropped from the |
| 168 | // fan-out condition. |
| 169 | // |
| 170 | // §13: "a waiting guest receiving nothing but hello and its own |
| 171 | // outcome". Every admission after the knock — another guest let in, a |
| 172 | // member arriving — must leave the waiting subscriber silent, because |
| 173 | // a `peer` event is an instruction to open a connection to someone the |
| 174 | // guest has no business reaching. |
| 175 | func TestAWaitingGuestReceivesNoPeerEventForAnyoneElsesAdmission(t *testing.T) { |
| 176 | now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| 177 | h := testHub(&now) |
| 178 | ctx := context.Background() |
| 179 | m, _, w := guestRoom(t, h) |
| 180 | |
| 181 | // Another guest is let in while this one is still waiting. |
| 182 | g2, _ := h.Open("r1", "sg2", 102, "Gil", KindGuest) |
| 183 | h.Join(ctx, "r1", "sg2", g2.Peer) |
| 184 | if err := h.Admit(ctx, "r1", "sm", m.Peer, g2.Peer); err != nil { |
| 185 | t.Fatal(err) |
| 186 | } |
| 187 | if ev := drain(w.Sub); len(ev) != 0 { |
| 188 | t.Fatalf("the waiting guest was told about another admission: %v (%+v)", types(ev), ev) |
| 189 | } |
| 190 | |
| 191 | // And a member arrives, which is an admission too. |
| 192 | m2, _ := h.Open("r1", "sm2", 2, "Moe", KindMember) |
| 193 | if err := h.Join(ctx, "r1", "sm2", m2.Peer); err != nil { |
| 194 | t.Fatal(err) |
| 195 | } |
| 196 | if ev := drain(w.Sub); len(ev) != 0 { |
| 197 | t.Fatalf("the waiting guest was told a member joined: %v (%+v)", types(ev), ev) |
| 198 | } |
| 199 | |
| 200 | // The rule is an audience, not silence: the peer already in the mesh |
| 201 | // was told both times. |
| 202 | if n := countOf(drain(m.Sub), TypePeer); n != 2 { |
| 203 | t.Fatalf("the member in the mesh got %d peer events, want 2", n) |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | // W-1 row 4 — mutation: refreshRostersLocked's `q.Admitted` dropped. |
| 208 | // |
| 209 | // A kind change owes every *admitted* peer a fresh roster (§2.4). A |
| 210 | // waiting guest is owed nothing, and the roster it would be handed is |
| 211 | // the whole mesh — plus, when the changed peer is a member, the queue. |
| 212 | func TestAKindChangeNeverHandsAWaitingGuestTheRoster(t *testing.T) { |
| 213 | now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| 214 | kinds := map[int64]Kind{1: KindMember, 100: KindGuest, 101: KindGuest} |
| 215 | h := New(Config{ |
| 216 | Now: func() time.Time { return now }, |
| 217 | StandingFor: func(ctx context.Context, room string, personID int64) (Kind, bool, error) { |
| 218 | return kinds[personID], true, nil |
| 219 | }, |
| 220 | }) |
| 221 | ctx := context.Background() |
| 222 | m, g, w := guestRoom(t, h) |
| 223 | |
| 224 | // The admitted guest is added to the team mid-call: the tick |
| 225 | // re-derives its kind, and the change refreshes the rosters. |
| 226 | kinds[100] = KindMember |
| 227 | h.Tick(ctx) |
| 228 | |
| 229 | if ev := drain(w.Sub); len(ev) != 0 { |
| 230 | t.Fatalf("a kind change reached the waiting guest: %v (%+v)", types(ev), ev) |
| 231 | } |
| 232 | // The peers that were owed the refresh got it, so the test is not |
| 233 | // passing because nothing happened at all. |
| 234 | for _, c := range []struct { |
| 235 | who string |
| 236 | r OpenResult |
| 237 | }{{"the member", m}, {"the promoted guest", g}} { |
| 238 | ev := drain(c.r.Sub) |
| 239 | if len(ev) != 1 || ev[0].Type != TypeRoster { |
| 240 | t.Fatalf("%s got %v, want the refreshed roster", c.who, types(ev)) |
| 241 | } |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | // W-1 row 5 — mutation: knock's audience `admittedMembers` → |
| 246 | // `admittedPeers`. |
| 247 | // |
| 248 | // A knock carries the display name of the person asking to come in. |
| 249 | // Only a member can act on it, and only a member may learn it: an |
| 250 | // admitted guest is in the mesh, not on the door. knock-gone has the |
| 251 | // same audience for the same reason. |
| 252 | func TestAKnockReachesAdmittedMembersOnly(t *testing.T) { |
| 253 | now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| 254 | h := testHub(&now) |
| 255 | ctx := context.Background() |
| 256 | m, g, w := guestRoom(t, h) |
| 257 | |
| 258 | newcomer, _ := h.Open("r1", "sn", 103, "Nan", KindGuest) |
| 259 | if err := h.Join(ctx, "r1", "sn", newcomer.Peer); err != nil { |
| 260 | t.Fatal(err) |
| 261 | } |
| 262 | |
| 263 | mev := drain(m.Sub) |
| 264 | if len(mev) != 1 || mev[0].Type != TypeKnock || mev[0].From != newcomer.Peer || mev[0].Name != "Nan" { |
| 265 | t.Fatalf("the member did not get the knock: %v (%+v)", types(mev), mev) |
| 266 | } |
| 267 | if ev := drain(g.Sub); len(ev) != 0 { |
| 268 | t.Fatalf("an admitted guest learned who is knocking: %v (%+v)", types(ev), ev) |
| 269 | } |
| 270 | if ev := drain(w.Sub); len(ev) != 0 { |
| 271 | t.Fatalf("a waiting guest learned who is knocking: %v (%+v)", types(ev), ev) |
| 272 | } |
| 273 | |
| 274 | if err := h.Admit(ctx, "r1", "sm", m.Peer, newcomer.Peer); err != nil { |
| 275 | t.Fatal(err) |
| 276 | } |
| 277 | got := types(drain(m.Sub)) |
| 278 | if len(got) != 2 || got[0] != TypePeer || got[1] != TypeKnockGone { |
| 279 | t.Fatalf("the member got %v, want peer then knock-gone", got) |
| 280 | } |
| 281 | if ev := types(drain(g.Sub)); len(ev) != 1 || ev[0] != TypePeer { |
| 282 | t.Fatalf("the admitted guest got %v, want the peer event and no knock-gone", ev) |
| 283 | } |
| 284 | if ev := drain(w.Sub); len(ev) != 0 { |
| 285 | t.Fatalf("the waiting guest got %v", types(ev)) |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | // Review W-5: `Remove`'s refusal to take its own actor as a target. The |
| 290 | // guard survived being deleted, and what it stops is worth a test — a |
| 291 | // member removing itself would end its own participation through the |
| 292 | // removal path, which announces `gone` and writes no tombstone, so the |
| 293 | // tab would find itself out of the room with no way to resume and no |
| 294 | // reason given. Leaving is `leave`, and it is a different ending. |
| 295 | func TestAMemberCannotRemoveItself(t *testing.T) { |
| 296 | now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| 297 | h := testHub(&now) |
| 298 | ctx := context.Background() |
| 299 | m, g, _ := guestRoom(t, h) |
| 300 | |
| 301 | if err := h.Remove(ctx, "r1", "sm", m.Peer, m.Peer); err != ErrUnknownPeer { |
| 302 | t.Fatalf("a member removing itself: %v, want ErrUnknownPeer", err) |
| 303 | } |
| 304 | if p, ok := h.Find("r1", "sm", m.Peer); !ok || !p.Admitted { |
| 305 | t.Fatal("the member removed itself") |
| 306 | } |
| 307 | if ev := drain(m.Sub); len(ev) != 0 { |
| 308 | t.Fatalf("the room was told something: %v", types(ev)) |
| 309 | } |
| 310 | // The refusal is the same sentinel an unknown target gets, so it |
| 311 | // tells the caller nothing about itself either. |
| 312 | if err := h.Remove(ctx, "r1", "sm", m.Peer, PeerID("0123456789abcdef0123456789abcdef")); err != ErrUnknownPeer { |
| 313 | t.Fatalf("an unknown target: %v", err) |
| 314 | } |
| 315 | // And a real other peer is still removable, so the guard is not |
| 316 | // simply refusing everything. |
| 317 | if err := h.Remove(ctx, "r1", "sm", m.Peer, g.Peer); err != nil { |
| 318 | t.Fatalf("removing someone else: %v", err) |
| 319 | } |
| 320 | } |
| 321 | |