| new file mode 100644 |
| index 0000000..1dd532a |
| --- /dev/null |
| +++ b/internal/meet/hub/relay_test.go |
| @@ -0,0 +1,235 @@ |
| +package hub |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func twoAdmitted(t *testing.T) (*Hub, *time.Time, OpenResult, OpenResult) { |
| + t.Helper() |
| + 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) |
| + return h, &now, a, b |
| +} |
| + |
| +func TestRelayGoesOnlyToTheNamedAdmittedPeerWithServerBuiltEnvelope(t *testing.T) { |
| + h, _, a, b := twoAdmitted(t) |
| + ctx := context.Background() |
| + g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest) |
| + h.Join(ctx, "r1", "sg", g.Peer) |
| + drain(a.Sub) |
| + drain(b.Sub) |
| + err := h.Relay("r1", "sa", a.Peer, Message{Type: TypeOffer, To: b.Peer, Seq: 1, Gen: 1, From: "forged", Name: "Forged", Payload: json.RawMessage(`{"sdp":"x"}`)}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + bev := drain(b.Sub) |
| + if len(bev) != 1 || bev[0].From != a.Peer || bev[0].Name != "Ann" || string(bev[0].Payload) != `{"sdp":"x"}` { |
| + t.Fatalf("b got %+v", bev) |
| + } |
| + if ev := drain(a.Sub); len(ev) != 0 { |
| + t.Fatalf("sender echoed %v", types(ev)) |
| + } |
| + if ev := drain(g.Sub); len(ev) != 0 { |
| + t.Fatalf("waiting guest received a relay: %v", types(ev)) |
| + } |
| + if err := h.Relay("r1", "sa", a.Peer, Message{Type: TypeOffer, To: g.Peer, Seq: 2, Gen: 1}); err == nil { |
| + t.Fatal("relay to a waiting peer accepted") |
| + } |
| + if err := h.Relay("r1", "sg", g.Peer, Message{Type: TypeOffer, To: a.Peer, Seq: 1, Gen: 1}); err != ErrNotAdmitted { |
| + t.Fatalf("waiting guest relaying: %v", err) |
| + } |
| + if err := h.Relay("r1", "sb", a.Peer, Message{Type: TypeOffer, To: b.Peer, Seq: 3, Gen: 1}); err != ErrUnknownPeer { |
| + t.Fatalf("peer through another session: %v", err) |
| + } |
| + if err := h.Relay("r2", "sa", a.Peer, Message{Type: TypeOffer, To: b.Peer, Seq: 3, Gen: 1}); err != ErrUnknownPeer { |
| + t.Fatalf("peer through another room: %v", err) |
| + } |
| + if err := h.Relay("r1", "sa", a.Peer, Message{Type: TypeChat, To: b.Peer, Seq: 4, Gen: 1}); err == nil { |
| + t.Fatal("chat accepted as a targeted relay") |
| + } |
| +} |
| + |
| +func TestStaleGenIsRefusedAndResetConverges(t *testing.T) { |
| + h, _, a, b := twoAdmitted(t) |
| + if err := h.Relay("r1", "sa", a.Peer, Message{Type: TypeOffer, To: b.Peer, Seq: 1, Gen: 2}); err != ErrStaleGen { |
| + t.Fatalf("gen 2 before any reset: %v", err) |
| + } |
| + if err := h.Reset("r1", "sa", a.Peer, b.Peer); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := h.Reset("r1", "sb", b.Peer, a.Peer); err != nil { // simultaneous resets converge on the higher gen |
| + t.Fatal(err) |
| + } |
| + aev, bev := drain(a.Sub), drain(b.Sub) |
| + if len(aev) != 2 || aev[1].Type != TypePair || aev[1].Gen != 3 || aev[1].Peer != b.Peer { |
| + t.Fatalf("a got %+v", aev) |
| + } |
| + if len(bev) != 2 || bev[1].Gen != 3 || bev[1].Peer != a.Peer { |
| + t.Fatalf("b got %+v", bev) |
| + } |
| + if err := h.Relay("r1", "sa", a.Peer, Message{Type: TypeOffer, To: b.Peer, Seq: 1, Gen: 1}); err != ErrStaleGen { |
| + t.Fatal("old gen accepted after reset") |
| + } |
| + if err := h.Relay("r1", "sa", a.Peer, Message{Type: TypeOffer, To: b.Peer, Seq: 1, Gen: 3}); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestResumeReplaysAboveLastAndSendsRosterOnlyWhenAdmitted(t *testing.T) { |
| + h, now, a, b := twoAdmitted(t) |
| + ctx := context.Background() |
| + for i := uint64(1); i <= 5; i++ { |
| + h.Relay("r1", "sb", b.Peer, Message{Type: TypeCandidate, To: a.Peer, Seq: i, Gen: 1, Payload: json.RawMessage(`{}`)}) |
| + } |
| + drain(a.Sub) |
| + h.Detach("r1", a.Peer) |
| + *now = now.Add(5 * time.Second) |
| + res, err := h.Resume("r1", "sa", a.Peer, map[PeerID]uint64{b.Peer: 3}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !res.Admitted || res.Roster == nil || len(res.Replay) != 2 || res.Replay[0].Seq != 4 || res.Replay[1].Seq != 5 { |
| + t.Fatalf("resume %+v", res) |
| + } |
| + if len(res.Gap) != 0 { |
| + t.Fatalf("a ring that held everything reported a gap: %v", res.Gap) |
| + } |
| + if res.Sub == nil || res.Sub.Lost() { |
| + t.Fatal("resume did not attach a fresh subscriber") |
| + } |
| + // The old window is over; the tick must not reap a participation |
| + // that has resumed. |
| + *now = now.Add(ResumeWindow + time.Second) |
| + h.Tick(ctx) |
| + if _, ok := h.Find("r1", "sa", a.Peer); !ok { |
| + t.Fatal("the tick reaped a participation that had resumed") |
| + } |
| + // A waiting guest resumes and learns nothing. |
| + g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest) |
| + h.Join(ctx, "r1", "sg", g.Peer) |
| + h.Detach("r1", g.Peer) |
| + gres, err := h.Resume("r1", "sg", g.Peer, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if gres.Admitted || gres.Roster != nil || len(gres.Replay) != 0 { |
| + t.Fatalf("waiting guest resume leaked %+v", gres) |
| + } |
| +} |
| + |
| +func TestResumeGapWhenTheRingRolledOver(t *testing.T) { |
| + h, now, a, b := twoAdmitted(t) |
| + for i := uint64(1); i <= 40; i++ { |
| + h.Relay("r1", "sb", b.Peer, Message{Type: TypeCandidate, To: a.Peer, Seq: i, Gen: 1}) |
| + } |
| + drain(a.Sub) |
| + h.Detach("r1", a.Peer) |
| + *now = now.Add(time.Second) |
| + res, _ := h.Resume("r1", "sa", a.Peer, map[PeerID]uint64{b.Peer: 2}) |
| + if len(res.Gap) != 1 || res.Gap[0] != b.Peer || len(res.Replay) != 0 { |
| + t.Fatalf("expected a gap for b, got replay %d gap %v", len(res.Replay), res.Gap) |
| + } |
| +} |
| + |
| +// One ring is shared by every sender, so one peer's burst can evict |
| +// another's messages wholesale. The gap must still be reported, which |
| +// the ring's own oldest seq per sender cannot see. |
| +func TestResumeGapWhenAnotherSendersBurstEvictedTheWholeSender(t *testing.T) { |
| + h, now, a, b := twoAdmitted(t) |
| + ctx := context.Background() |
| + c, _ := h.Open("r1", "sc", 3, "Cat", KindMember) |
| + h.Join(ctx, "r1", "sc", c.Peer) |
| + drain(a.Sub) |
| + for i := uint64(1); i <= 3; i++ { |
| + h.Relay("r1", "sb", b.Peer, Message{Type: TypeCandidate, To: a.Peer, Seq: i, Gen: 1}) |
| + } |
| + for i := uint64(1); i <= RingSize; i++ { |
| + h.Relay("r1", "sc", c.Peer, Message{Type: TypeCandidate, To: a.Peer, Seq: i, Gen: 1}) |
| + } |
| + drain(a.Sub) |
| + h.Detach("r1", a.Peer) |
| + *now = now.Add(time.Second) |
| + res, err := h.Resume("r1", "sa", a.Peer, map[PeerID]uint64{b.Peer: 1, c.Peer: RingSize}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(res.Gap) != 1 || res.Gap[0] != b.Peer { |
| + t.Fatalf("b's messages were evicted wholesale, gap %v", res.Gap) |
| + } |
| +} |
| + |
| +// A second resume of an attached peer replaces the attachment: the old |
| +// stream is told replaced, and the participation itself carries on. |
| +func TestResumeReplacesAnExistingAttachment(t *testing.T) { |
| + h, _, a, b := twoAdmitted(t) |
| + res, err := h.Resume("r1", "sa", a.Peer, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + old := drain(a.Sub) |
| + if len(old) != 1 || old[0].Type != TypeLost || old[0].Reason != ReasonReplaced { |
| + t.Fatalf("old stream got %+v", old) |
| + } |
| + if !a.Sub.Lost() { |
| + t.Fatal("the old subscriber was not closed") |
| + } |
| + if ev := drain(b.Sub); len(ev) != 0 { |
| + t.Fatalf("the room was told the peer left: %v", types(ev)) |
| + } |
| + if !res.Admitted { |
| + t.Fatal("the replaced attachment lost its admission") |
| + } |
| + if err := h.Relay("r1", "sb", b.Peer, Message{Type: TypeOffer, To: a.Peer, Seq: 1, Gen: 1}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if ev := drain(res.Sub); len(ev) != 1 || ev[0].Type != TypeOffer { |
| + t.Fatalf("the new attachment got %v", types(ev)) |
| + } |
| + if _, err := h.Resume("r1", "sa", a.Peer, nil); err != nil { |
| + t.Fatalf("a replaced attachment left a tombstone: %v", err) |
| + } |
| +} |
| + |
| +func TestResumeAnswers(t *testing.T) { |
| + h, now, a, _ := twoAdmitted(t) |
| + if _, err := h.Resume("r1", "sb", a.Peer, nil); err == nil || err.(*ResumeError).Status != 404 || err.(*ResumeError).Boot != h.Boot() { |
| + t.Fatalf("resume through another session: %v", err) |
| + } |
| + if _, err := h.Resume("r9", "sa", a.Peer, nil); err == nil || err.(*ResumeError).Status != 404 { |
| + t.Fatalf("resume through another room: %v", err) |
| + } |
| + h.Takeover("r1", "sa") |
| + if _, err := h.Resume("r1", "sa", a.Peer, nil); err == nil || err.(*ResumeError).Status != 410 || err.(*ResumeError).Reason != ReasonReplaced { |
| + t.Fatalf("resume after takeover: %v", err) |
| + } |
| + *now = now.Add(TombstoneLife + time.Minute) |
| + h.Tick(context.Background()) |
| + if _, err := h.Resume("r1", "sa", a.Peer, nil); err == nil || err.(*ResumeError).Status != 404 { |
| + t.Fatalf("resume after tombstone expiry: %v", err) |
| + } |
| + if _, err := h.Open("r1", "sa", 1, "Ann", KindMember); err != nil { |
| + t.Fatalf("fresh stream after tombstone: %v", err) |
| + } |
| +} |
| + |
| +func TestResumeAfterWindowIsA404WithTheSameBoot(t *testing.T) { |
| + h, now, a, _ := twoAdmitted(t) |
| + h.Detach("r1", a.Peer) |
| + *now = now.Add(ResumeWindow + time.Second) |
| + h.Tick(context.Background()) |
| + _, err := h.Resume("r1", "sa", a.Peer, nil) |
| + re, ok := err.(*ResumeError) |
| + if !ok || re.Status != 404 || re.Boot != h.Boot() { |
| + t.Fatalf("lapsed resume: %v", err) |
| + } |
| +} |