oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1package hub
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "io"
8 "log/slog"
9 "testing"
10 "time"
11)
12
13// ringLen reads a participation's ring length under the hub's own lock,
14// the way every other reader of ring must (hub.go's lock discipline):
15// never through a pointer a caller holds unsynchronized.
16func ringLen(h *Hub, sqid, session string, peer PeerID) int {
17 h.mu.Lock()
18 defer h.mu.Unlock()
19 p, ok := h.findLocked(sqid, session, peer)
20 if !ok {
21 return -1
22 }
23 return len(p.ring)
24}
25
26func TestFanoutReachesEveryOtherAdmittedPeerAndNobodyElse(t *testing.T) {
27 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
28 h := testHub(&now)
29 ctx := context.Background()
30 a, _ := h.Open("r1", "sa", 1, "Ann", KindMember)
31 h.Join(ctx, "r1", "sa", a.Peer)
32 b, _ := h.Open("r1", "sb", 2, "Bob", KindMember)
33 h.Join(ctx, "r1", "sb", b.Peer)
34 g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest)
35 h.Join(ctx, "r1", "sg", g.Peer) // waiting
36 drain(a.Sub)
37 drain(b.Sub)
38 drain(g.Sub)
39
40 // Every field a forged or stale client could smuggle in: the
41 // envelope the hub rebuilds must carry none of it through.
42 in := Message{
43 Type: TypeChat,
44 From: "forged",
45 Name: "Admin",
46 To: b.Peer,
47 Seq: 99,
48 Gen: 42,
49 Payload: json.RawMessage(`{"text":"hi"}`),
50 Reason: ReasonEnded,
51 Roster: &Roster{},
52 Boot: "x",
53 Peer: "y",
54 Waiting: true,
55 }
56 if err := h.Fanout(ctx, "r1", "sa", a.Peer, in); err != nil {
57 t.Fatal(err)
58 }
59 got := drain(b.Sub)
60 if len(got) != 1 {
61 t.Fatalf("Bob got %d events, want 1: %+v", len(got), got)
62 }
63 gm := got[0]
64 if gm.Type != TypeChat || gm.From != a.Peer || gm.Name != "Ann" || string(gm.Payload) != `{"text":"hi"}` {
65 t.Fatalf("Bob got %+v, want chat from Ann with the same payload", gm)
66 }
67 if gm.To != "" || gm.Seq != 0 || gm.Gen != 0 {
68 t.Fatalf("Bob got %+v, want no To/Seq/Gen", gm)
69 }
70 if gm.Reason != "" || gm.Roster != nil || gm.Boot != "" || gm.Peer != "" || gm.Waiting {
71 t.Fatalf("Bob got %+v, want none of the input's other fields carried through", gm)
72 }
73 if n := len(drain(a.Sub)); n != 0 {
74 t.Fatalf("the sender must not receive its own chat, got %d", n)
75 }
76 if n := len(drain(g.Sub)); n != 0 {
77 t.Fatalf("a waiting guest must receive nothing, got %d", n)
78 }
79 if n := ringLen(h, "r1", "sb", b.Peer); n != 0 {
80 t.Fatalf("chat is never ringed, ring has %d", n)
81 }
82}
83
84// TestFanoutIsNeverReplayedOnResume is §6's "gone with the tab" through
85// the resume path, not just through a direct ring read: a chat sent
86// while the recipient is detached must still be neither ringed nor
87// replayed when that recipient resumes, and must not manufacture a gap
88// either (a gap means the ring evicted something above the resuming
89// peer's last-seen, and nothing was ever added to evict).
90func TestFanoutIsNeverReplayedOnResume(t *testing.T) {
91 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
92 h := testHub(&now)
93 ctx := context.Background()
94 a, _ := h.Open("r1", "sa", 1, "Ann", KindMember)
95 h.Join(ctx, "r1", "sa", a.Peer)
96 b, _ := h.Open("r1", "sb", 2, "Bob", KindMember)
97 h.Join(ctx, "r1", "sb", b.Peer)
98 drain(a.Sub)
99 drain(b.Sub)
100
101 h.Detach("r1", b.Peer, b.Sub)
102 msg := Message{Type: TypeChat, Payload: json.RawMessage(`{"text":"hi"}`)}
103 if err := h.Fanout(ctx, "r1", "sa", a.Peer, msg); err != nil {
104 t.Fatal(err)
105 }
106 if n := ringLen(h, "r1", "sb", b.Peer); n != 0 {
107 t.Fatalf("chat sent to a detached peer must still not be ringed, ring has %d", n)
108 }
109 res, err := h.Resume(ctx, "r1", "sb", b.Peer, map[PeerID]uint64{})
110 if err != nil {
111 t.Fatal(err)
112 }
113 if len(res.Replay) != 0 {
114 t.Fatalf("chat must never be replayed on resume, got %d", len(res.Replay))
115 }
116 if len(res.Gap) != 0 {
117 t.Fatalf("nothing was ringed, so resume must report no gap either, got %d", len(res.Gap))
118 }
119}
120
121func TestFanoutNeedsAnAdmittedSenderBoundToThisSession(t *testing.T) {
122 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
123 h := testHub(&now)
124 ctx := context.Background()
125 a, _ := h.Open("r1", "sa", 1, "Ann", KindMember)
126 msg := Message{Type: TypeChat, Payload: json.RawMessage(`{"text":"hi"}`)}
127 if err := h.Fanout(ctx, "r1", "sa", a.Peer, msg); !errors.Is(err, ErrNotAdmitted) {
128 t.Fatalf("unjoined: %v", err)
129 }
130 h.Join(ctx, "r1", "sa", a.Peer)
131 if err := h.Fanout(ctx, "r1", "sb", a.Peer, msg); !errors.Is(err, ErrUnknownPeer) {
132 t.Fatalf("another session: %v", err)
133 }
134 if err := h.Fanout(ctx, "r2", "sa", a.Peer, msg); !errors.Is(err, ErrUnknownPeer) {
135 t.Fatalf("another room: %v", err)
136 }
137 g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest)
138 h.Join(ctx, "r1", "sg", g.Peer)
139 if err := h.Fanout(ctx, "r1", "sg", g.Peer, msg); !errors.Is(err, ErrNotAdmitted) {
140 t.Fatalf("waiting guest: %v", err)
141 }
142 // The guard is an allowlist of {chat, share}, not a denylist of the
143 // targeted types: a server-raised type and the empty type must be
144 // refused too, or a denylist that merely excludes offer/answer/
145 // candidate would pass this check by accident.
146 for _, typ := range []string{TypeOffer, TypeRoster, TypeLost, TypeJoin, ""} {
147 if err := h.Fanout(ctx, "r1", "sa", a.Peer, Message{Type: typ}); !errors.Is(err, ErrNotFanout) {
148 t.Fatalf("type %q is not a fan-out: %v", typ, err)
149 }
150 }
151}
152
153// TestFanoutDropsAMemberWhoseStandingIsGoneAndKeepsAnAdmittedGuest mirrors
154// authorize_test.go's TestAuthorizeDropsAMemberWhoseStandingIsGoneAndKeepsAnAdmittedGuest:
155// the same mutable-standing fixture, moved without a tick running, so the
156// only thing that could refuse Ann is Fanout's own re-derivation (§15.12).
157// Carol, a second admitted member, is the witness that Bob's later chat
158// actually reaches somebody once Ann is gone; Bob himself, an admitted
159// guest whose grant has since "expired", never even asks standing
160// (ruling 14) and fans out anyway.
161func TestFanoutDropsAMemberWhoseStandingIsGoneAndKeepsAnAdmittedGuest(t *testing.T) {
162 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
163 standing := map[int64]bool{1: true, 3: true, 100: true}
164 guestStandingCalls := 0
165 h := New(Config{
166 Now: func() time.Time { return now },
167 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
168 StandingFor: func(ctx context.Context, room string, id int64) (Kind, bool, error) {
169 if id == 100 {
170 guestStandingCalls++
171 return KindGuest, standing[id], nil
172 }
173 return KindMember, standing[id], nil
174 },
175 })
176 ctx := context.Background()
177 a, _ := h.Open("r1", "sa", 1, "Ann", KindMember)
178 if err := h.Join(ctx, "r1", "sa", a.Peer); err != nil {
179 t.Fatal(err)
180 }
181 c, _ := h.Open("r1", "sc", 3, "Carol", KindMember)
182 if err := h.Join(ctx, "r1", "sc", c.Peer); err != nil {
183 t.Fatal(err)
184 }
185 b, _ := h.Open("r1", "sb", 100, "Bob", KindGuest)
186 if err := h.Join(ctx, "r1", "sb", b.Peer); err != nil {
187 t.Fatal(err)
188 }
189 if err := h.Admit(ctx, "r1", "sa", a.Peer, b.Peer); err != nil {
190 t.Fatal(err)
191 }
192 drain(a.Sub)
193 drain(b.Sub)
194 drain(c.Sub)
195
196 standing[1] = false // Ann taken off the team, no tick has run
197 msg := Message{Type: TypeChat, Payload: json.RawMessage(`{"text":"hi"}`)}
198 if err := h.Fanout(ctx, "r1", "sa", a.Peer, msg); !errors.Is(err, ErrNotYours) {
199 t.Fatalf("member whose standing is gone: want ErrNotYours, got %v", err)
200 }
201 if _, found := h.Find("r1", "sa", a.Peer); found {
202 t.Fatal("Ann's participation should have ended")
203 }
204 bev := drain(b.Sub)
205 if len(bev) != 1 || bev[0].Type != TypeGone || bev[0].From != a.Peer {
206 t.Fatalf("Bob's stream got %v, want gone naming Ann and no chat", types(bev))
207 }
208 drain(c.Sub) // Carol also saw the departure; not this test's point
209
210 standing[100] = false // Bob's grant has "expired"
211 before := guestStandingCalls
212 if err := h.Fanout(ctx, "r1", "sb", b.Peer, msg); err != nil {
213 t.Fatalf("admitted guest with a lapsed grant: %v", err)
214 }
215 if guestStandingCalls != before {
216 t.Fatalf("Fanout asked StandingFor for a guest: %d calls during this Fanout, want 0", guestStandingCalls-before)
217 }
218 cev := drain(c.Sub)
219 if len(cev) != 1 || cev[0].Type != TypeChat || cev[0].From != b.Peer {
220 t.Fatalf("Carol should have received Bob's chat, got %v", types(cev))
221 }
222 if n := len(drain(b.Sub)); n != 0 {
223 t.Fatalf("Bob must not receive his own chat, got %d", n)
224 }
225}
226