oficina / meet Public

hub: fanout tests, prove allowlist, envelope strip, no replay

Fix round 1 from review:
1. The type-guard check now loops offer/roster/lost/join/"" through
   Fanout, each wanting ErrNotFanout, so a denylist of the targeted
   types (which would wrongly accept roster/lost/join/"") cannot pass.
2. The delivery test now builds the input with every field a forged or
   stale client could set (From, Name, To, Seq, Gen, Reason, Roster,
   Boot, Peer, Waiting) and asserts none of it survives into the
   delivered envelope, so a pass-through implementation that merely
   patches From/Name onto the input cannot pass.
3. New TestFanoutIsNeverReplayedOnResume: a chat sent to a peer whose
   stream is Detached must still not be ringed, and a subsequent Resume
   must report neither Replay nor Gap. The old ring check only ever ran
   against an attached recipient, so an implementation that rings a
   message specifically when the target is detached (arguably 'so it
   can be delivered later') passed the old suite; verified by mutation
   that this new test catches it while the old delivery test still
   passes unchanged.

Ring reads now go through a new ringLen(h, sqid, session, peer) helper
that takes h.mu itself, rather than through the *Participation pointer
h.Find returns, matching the hub's own lock discipline.

Each of the three fixes was checked by applying its named mutation to
a throwaway copy of the repo under /home/paulca/.cache/tmp/claude-1001 and confirming the affected
test fails while the others do not.
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 fa8b40dfb2dfcf50d6cb0c2a6ebe796e7dda7e23 parent 08c3926
1 file changed, +88 −11
  • internal/meet/hub/fanout_test.go +88 −11

CI passed — run details

diff --git a/internal/meet/hub/fanout_test.go b/internal/meet/hub/fanout_test.go
index 5c34de4..3dc397e 100644
--- a/internal/meet/hub/fanout_test.go
+++ b/internal/meet/hub/fanout_test.go
@@ -10,6 +10,19 @@ import (
"time"
)
+// ringLen reads a participation's ring length under the hub's own lock,
+// the way every other reader of ring must (hub.go's lock discipline):
+// never through a pointer a caller holds unsynchronized.
+func ringLen(h *Hub, sqid, session string, peer PeerID) int {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ p, ok := h.findLocked(sqid, session, peer)
+ if !ok {
+ return -1
+ }
+ return len(p.ring)
+}
+
func TestFanoutReachesEveryOtherAdmittedPeerAndNobodyElse(t *testing.T) {
now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
h := testHub(&now)
@@ -24,13 +37,38 @@ func TestFanoutReachesEveryOtherAdmittedPeerAndNobodyElse(t *testing.T) {
drain(b.Sub)
drain(g.Sub)
- err := h.Fanout(ctx, "r1", "sa", a.Peer, Message{Type: TypeChat, Payload: json.RawMessage(`{"text":"hi"}`)})
- if err != nil {
+ // Every field a forged or stale client could smuggle in: the
+ // envelope the hub rebuilds must carry none of it through.
+ in := Message{
+ Type: TypeChat,
+ From: "forged",
+ Name: "Admin",
+ To: b.Peer,
+ Seq: 99,
+ Gen: 42,
+ Payload: json.RawMessage(`{"text":"hi"}`),
+ Reason: ReasonEnded,
+ Roster: &Roster{},
+ Boot: "x",
+ Peer: "y",
+ Waiting: true,
+ }
+ if err := h.Fanout(ctx, "r1", "sa", a.Peer, in); err != nil {
t.Fatal(err)
}
got := drain(b.Sub)
- if len(got) != 1 || got[0].Type != TypeChat || got[0].From != a.Peer || got[0].Name != "Ann" || string(got[0].Payload) != `{"text":"hi"}` || got[0].To != "" || got[0].Seq != 0 || got[0].Gen != 0 {
- t.Fatalf("Bob got %+v", got)
+ if len(got) != 1 {
+ t.Fatalf("Bob got %d events, want 1: %+v", len(got), got)
+ }
+ gm := got[0]
+ if gm.Type != TypeChat || gm.From != a.Peer || gm.Name != "Ann" || string(gm.Payload) != `{"text":"hi"}` {
+ t.Fatalf("Bob got %+v, want chat from Ann with the same payload", gm)
+ }
+ if gm.To != "" || gm.Seq != 0 || gm.Gen != 0 {
+ t.Fatalf("Bob got %+v, want no To/Seq/Gen", gm)
+ }
+ if gm.Reason != "" || gm.Roster != nil || gm.Boot != "" || gm.Peer != "" || gm.Waiting {
+ t.Fatalf("Bob got %+v, want none of the input's other fields carried through", gm)
}
if n := len(drain(a.Sub)); n != 0 {
t.Fatalf("the sender must not receive its own chat, got %d", n)
@@ -38,15 +76,48 @@ func TestFanoutReachesEveryOtherAdmittedPeerAndNobodyElse(t *testing.T) {
if n := len(drain(g.Sub)); n != 0 {
t.Fatalf("a waiting guest must receive nothing, got %d", n)
}
- bp, ok := h.Find("r1", "sb", b.Peer)
- if !ok {
- t.Fatal("Bob's participation should still exist")
- }
- if n := len(bp.ring); n != 0 {
+ if n := ringLen(h, "r1", "sb", b.Peer); n != 0 {
t.Fatalf("chat is never ringed, ring has %d", n)
}
}
+// TestFanoutIsNeverReplayedOnResume is §6's "gone with the tab" through
+// the resume path, not just through a direct ring read: a chat sent
+// while the recipient is detached must still be neither ringed nor
+// replayed when that recipient resumes, and must not manufacture a gap
+// either (a gap means the ring evicted something above the resuming
+// peer's last-seen, and nothing was ever added to evict).
+func TestFanoutIsNeverReplayedOnResume(t *testing.T) {
+ now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
+ h := testHub(&now)
+ ctx := context.Background()
+ a, _ := h.Open("r1", "sa", 1, "Ann", KindMember)
+ h.Join(ctx, "r1", "sa", a.Peer)
+ b, _ := h.Open("r1", "sb", 2, "Bob", KindMember)
+ h.Join(ctx, "r1", "sb", b.Peer)
+ drain(a.Sub)
+ drain(b.Sub)
+
+ h.Detach("r1", b.Peer, b.Sub)
+ msg := Message{Type: TypeChat, Payload: json.RawMessage(`{"text":"hi"}`)}
+ if err := h.Fanout(ctx, "r1", "sa", a.Peer, msg); err != nil {
+ t.Fatal(err)
+ }
+ if n := ringLen(h, "r1", "sb", b.Peer); n != 0 {
+ t.Fatalf("chat sent to a detached peer must still not be ringed, ring has %d", n)
+ }
+ res, err := h.Resume(ctx, "r1", "sb", b.Peer, map[PeerID]uint64{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(res.Replay) != 0 {
+ t.Fatalf("chat must never be replayed on resume, got %d", len(res.Replay))
+ }
+ if len(res.Gap) != 0 {
+ t.Fatalf("nothing was ringed, so resume must report no gap either, got %d", len(res.Gap))
+ }
+}
+
func TestFanoutNeedsAnAdmittedSenderBoundToThisSession(t *testing.T) {
now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
h := testHub(&now)
@@ -68,8 +139,14 @@ func TestFanoutNeedsAnAdmittedSenderBoundToThisSession(t *testing.T) {
if err := h.Fanout(ctx, "r1", "sg", g.Peer, msg); !errors.Is(err, ErrNotAdmitted) {
t.Fatalf("waiting guest: %v", err)
}
- if err := h.Fanout(ctx, "r1", "sa", a.Peer, Message{Type: TypeOffer}); !errors.Is(err, ErrNotFanout) {
- t.Fatalf("a targeted type is not a fan-out: %v", err)
+ // The guard is an allowlist of {chat, share}, not a denylist of the
+ // targeted types: a server-raised type and the empty type must be
+ // refused too, or a denylist that merely excludes offer/answer/
+ // candidate would pass this check by accident.
+ for _, typ := range []string{TypeOffer, TypeRoster, TypeLost, TypeJoin, ""} {
+ if err := h.Fanout(ctx, "r1", "sa", a.Peer, Message{Type: typ}); !errors.Is(err, ErrNotFanout) {
+ t.Fatalf("type %q is not a fan-out: %v", typ, err)
+ }
}
}