oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1package callstest
2
3import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "log/slog"
8 "net/http"
9 "net/url"
10 "strings"
11 "testing"
12 "time"
13
14 "amadan.net/oficina/calls/internal/calls"
15)
16
17// Spec §4.2: every POST /signal must carry a peer the hub finds in this
18// room, bound to this session; anything else is refused with 404 —
19// never 403, and the same 404 whether the peer never existed or exists
20// through another session (§2.5's "a meeting that is not yours does not
21// exist").
22func TestSignalNeedsAPeerBoundToThisSessionAndRoom(t *testing.T) {
23 x, sqid, tok := memberWithMeeting(t)
24 c := x.browser(t)
25 c.signIn("paul@example.com")
26 s := c.stream("/m/" + sqid + "/stream")
27 defer s.close()
28 peer, boot := s.hello()
29
30 resp, unknownBody := c.signal(sqid, "0123456789abcdef0123456789abcdef", `{"type":"join"}`)
31 if resp.StatusCode != http.StatusNotFound {
32 t.Fatalf("foreign peer: %d", resp.StatusCode)
33 }
34 // Minor 6: the 404 body carries boot, identical for ErrUnknownPeer
35 // and ErrNotYours, the same shape stream.go's own 404s carry.
36 var unknownAnswer struct {
37 Error string
38 Boot string
39 }
40 if err := json.Unmarshal(unknownBody, &unknownAnswer); err != nil {
41 t.Fatalf("404 body %s: %v", unknownBody, err)
42 }
43 if unknownAnswer.Error != "not-found" || unknownAnswer.Boot != boot {
44 t.Fatalf("404 body %+v, want error=not-found boot=%q", unknownAnswer, boot)
45 }
46
47 other := x.browser(t)
48 other.get("/j/" + tok)
49 other.signIn("guest@example.com")
50 resp, otherSessionBody := other.signal(sqid, peer, `{"type":"join"}`)
51 if resp.StatusCode != http.StatusNotFound {
52 t.Fatalf("peer through another session: %d", resp.StatusCode)
53 }
54 if !bytes.Equal(unknownBody, otherSessionBody) {
55 t.Fatalf("404 bodies differ: unknown %q, other session %q", unknownBody, otherSessionBody)
56 }
57
58 resp, _ = c.signal(sqid, peer, `{"type":"join"}`)
59 if resp.StatusCode != http.StatusOK {
60 t.Fatalf("own join: %d", resp.StatusCode)
61 }
62}
63
64// A peer that is perfectly real, and bound to this very session, but in
65// a different room, is the same 404 — and from Task 5's fix round 2 it
66// is refused earlier than it used to be: the LiveForSession gate answers
67// it before the limiter, the body read or any hub dispatch, because this
68// session holds no participation in the room named in the URL. Either
69// way the rule is §4.2's "cross-room delivery is impossible by
70// construction": every lookup starts from the room in the URL, so a peer
71// id from elsewhere names nothing here.
72func TestSignalRefusesAPeerFromAnotherMeeting(t *testing.T) {
73 x, sqid, _ := memberWithMeeting(t)
74 c := x.browser(t)
75 c.signIn("paul@example.com")
76 s := c.stream("/m/" + sqid + "/stream")
77 defer s.close()
78 peer, _ := s.hello()
79
80 elsewhere, _ := createMeeting(t, c, "Different room", "")
81 if resp, _ := c.signal(elsewhere, peer, `{"type":"join"}`); resp.StatusCode != http.StatusNotFound {
82 t.Fatalf("peer from another meeting: %d, want 404", resp.StatusCode)
83 }
84}
85
86// Task 5 review Important 1: the limiter is keyed on session+room, never
87// on anything the request body claims, so a second session flooding the
88// endpoint spends only its own bucket.
89func TestSignalRateLimitIsPerSessionNotPerClaimedPeer(t *testing.T) {
90 x, sqid, _ := memberWithMeeting(t)
91 victim := x.browser(t)
92 victim.signIn("paul@example.com")
93 vs := victim.stream("/m/" + sqid + "/stream")
94 defer vs.close()
95 vpeer, _ := vs.hello()
96 if resp, _ := victim.signal(sqid, vpeer, `{"type":"join"}`); resp.StatusCode != http.StatusOK {
97 t.Fatalf("victim join: %d", resp.StatusCode)
98 }
99
100 // A second session in the same room, flooding while *claiming the
101 // victim's own peer id* — peer ids are published in rosters, so
102 // this is exactly what a limiter keyed on the body's claimed peer
103 // cannot tell from the victim's own traffic (review Important 1).
104 attacker := newMemberBrowser(t, x, "paul@rslw.com")
105 as := attacker.stream("/m/" + sqid + "/stream")
106 defer as.close()
107 as.hello() // the attacker's own peer, unused below on purpose
108
109 for i := 0; i < 80; i++ {
110 attacker.signal(sqid, vpeer, `{"type":"leave"}`)
111 }
112
113 // The victim's own session, still using its own real peer, must be
114 // unaffected by traffic that only ever came from another session.
115 if resp, _ := victim.signal(sqid, vpeer, `{"type":"leave"}`); resp.StatusCode == http.StatusTooManyRequests {
116 t.Fatal("another session impersonating this peer id in its own requests drained its rate-limit bucket")
117 }
118}
119
120// Task 5 review Important 1: rotating the claimed peer id within one
121// session must not evade the limit — every claimed id, real or not,
122// spends the same session+room bucket. (The room half of the key is
123// gated by LiveForSession before the limiter is ever reached — fix
124// round 2 item 1 — so this test is only about the peer half: the
125// session here already holds a live participation in sqid throughout.)
126func TestSignalRateLimitSharesOneBucketAcrossClaimedPeerIDs(t *testing.T) {
127 x, sqid, _ := memberWithMeeting(t)
128 c := x.browser(t)
129 c.signIn("paul@example.com")
130 s := c.stream("/m/" + sqid + "/stream")
131 defer s.close()
132 peer, _ := s.hello()
133 c.signal(sqid, peer, `{"type":"join"}`)
134
135 var got429 bool
136 for i := 0; i < 100; i++ {
137 // A distinct, format-valid, but otherwise made-up peer id on
138 // every call: none of them is bound to anything, so if the
139 // limiter were still keyed on the claimed peer, rotating like
140 // this would never be throttled.
141 fake := fmt.Sprintf("%032x", i)
142 resp, _ := c.signal(sqid, fake, `{"type":"join"}`)
143 if resp.StatusCode == http.StatusTooManyRequests {
144 got429 = true
145 break
146 }
147 }
148 if !got429 {
149 t.Fatal("rotating the claimed peer id within one session evaded the rate limit")
150 }
151}
152
153// Task 5 review Important 2: an oversized peer field is refused, and
154// never becomes a log argument — the log's "peer" is only ever a value
155// already known to be exactly 32 lowercase hex.
156func TestSignalRefusesAnOversizedPeerAndNeverLogsIt(t *testing.T) {
157 var buf bytes.Buffer
158 x := newHarnessWithLogger(t, calls.EnvDevelopment, slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})), "paul@example.com")
159 c := x.browser(t)
160 c.signIn("paul@example.com")
161 sqid, _ := createMeeting(t, c, "x", "")
162 s := c.stream("/m/" + sqid + "/stream")
163 defer s.close()
164 s.hello()
165
166 huge := strings.Repeat("a", 60*1024)
167 resp, _ := c.signal(sqid, huge, `{"type":"join"}`)
168 if resp.StatusCode != http.StatusBadRequest {
169 t.Fatalf("60 KB peer: %d, want 400", resp.StatusCode)
170 }
171 if strings.Contains(buf.String(), huge) {
172 t.Fatal("the oversized peer string reached the log")
173 }
174}
175
176// fakeButValidSqid builds a room id in the exact shape ids.go's
177// validSqid accepts (ten characters of sqidAlphabet) without touching
178// crypto/rand — deterministic and cheap for a test that needs many
179// distinct, well-formed ids no meeting will ever have.
180func fakeButValidSqid(i int) string {
181 const digits = "23456789" // a safe subset of sqidAlphabet
182 b := make([]byte, 10)
183 for j := 9; j >= 0; j-- {
184 b[j] = digits[i%len(digits)]
185 i /= len(digits)
186 }
187 return string(b)
188}
189
190// Task 5 review fix round 2 item 1: rotating the room id in the URL
191// must not mint a rate-limit bucket per request. A session with no live
192// participation in a room is refused before the limiter is ever
193// touched, so fifty rotated (but well-formed) room ids leave the
194// limiter's map exactly as it was after the one real signal.
195func TestSignalSqidRotationNeverMintsARateLimiterBucket(t *testing.T) {
196 x, sqid, _ := memberWithMeeting(t)
197 c := x.browser(t)
198 c.signIn("paul@example.com")
199 s := c.stream("/m/" + sqid + "/stream")
200 defer s.close()
201 peer, _ := s.hello()
202 if resp, _ := c.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusOK {
203 t.Fatalf("join: %d", resp.StatusCode)
204 }
205 if n := x.ctl.SignalLimiterSize(); n != 1 {
206 t.Fatalf("limiter buckets after one signal: %d, want 1", n)
207 }
208
209 for i := 0; i < 50; i++ {
210 fake := fakeButValidSqid(i)
211 if resp, _ := c.signal(fake, peer, `{"type":"join"}`); resp.StatusCode != http.StatusNotFound {
212 t.Fatalf("rotated room %q: %d, want 404", fake, resp.StatusCode)
213 }
214 }
215 if n := x.ctl.SignalLimiterSize(); n != 1 {
216 t.Fatalf("limiter buckets after 50 rotated rooms: %d, want 1 — rotation must not mint a bucket per request", n)
217 }
218}
219
220// Task 5 review fix round 2 item 1: an oversized room id is refused
221// before it can reach the limiter's key, mint a bucket, or a log line.
222func TestSignalRefusesAnOversizedRoomIDAndNeverLogsIt(t *testing.T) {
223 var buf bytes.Buffer
224 x := newHarnessWithLogger(t, calls.EnvDevelopment, slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})), "paul@example.com")
225 c := x.browser(t)
226 c.signIn("paul@example.com")
227
228 huge := strings.Repeat("a", 1024)
229 resp, badRoomBody := c.signalRaw(huge, `{"peer":"0123456789abcdef0123456789abcdef","type":"join"}`)
230 if resp.StatusCode != http.StatusNotFound {
231 t.Fatalf("1 KB room id: %d, want 404", resp.StatusCode)
232 }
233 if n := x.ctl.SignalLimiterSize(); n != 0 {
234 t.Fatalf("limiter buckets after an oversized room id: %d, want 0", n)
235 }
236 if strings.Contains(buf.String(), huge) {
237 t.Fatal("the oversized room id reached the log")
238 }
239
240 // Byte for byte the not-found body every other refusal here carries.
241 // A malformed room id is refused earlier and for a different reason
242 // than a peer that is not yours, and neither of those is anybody's
243 // business from outside: one answer, or the difference between them
244 // is a way to ask which meetings exist (§2.5).
245 sqid, _ := createMeeting(t, c, "A room this session has never opened a stream in", "")
246 resp, unboundBody := c.signal(sqid, "0123456789abcdef0123456789abcdef", `{"type":"join"}`)
247 if resp.StatusCode != http.StatusNotFound {
248 t.Fatalf("a room with no live participation: %d, want 404", resp.StatusCode)
249 }
250 if !bytes.Equal(badRoomBody, unboundBody) {
251 t.Fatalf("404 bodies differ: bad room %q, no participation %q", badRoomBody, unboundBody)
252 }
253}
254
255// Task 5's fix round 2 ruling, and §4.5's window: the LiveForSession
256// gate in front of the limiter is about a session that holds nothing in
257// this room, not about a browser whose stream happens to be down. A
258// detached participation inside its resume window is still this
259// session's peer, so its signalling is still accepted — which is what
260// lets a browser post `leave`, or answer an offer, in the gap between
261// two streams. Once the window lapses and the tick clears it, the same
262// request is the same 404 a stranger gets.
263func TestSignalFromADetachedSessionInsideItsWindowIsAccepted(t *testing.T) {
264 x, sqid, _ := memberWithMeeting(t)
265 c := x.browser(t)
266 c.signIn("paul@example.com")
267 s := c.stream("/m/" + sqid + "/stream")
268 peer, _ := s.hello()
269 if resp, _ := c.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusOK {
270 t.Fatalf("join: %d", resp.StatusCode)
271 }
272 if ev, _, ok := s.nextOf("roster", 2*time.Second); !ok {
273 t.Fatalf("never admitted (got %q)", ev)
274 }
275
276 s.close() // the stream is gone; the participation has thirty seconds
277 if resp, b := c.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusOK {
278 t.Fatalf("signal from a detached session inside its window: %d %s", resp.StatusCode, b)
279 }
280 if n := x.ctl.SignalLimiterSize(); n != 1 {
281 t.Fatalf("limiter buckets: %d, want 1 — the detached session reached the limiter", n)
282 }
283
284 x.advance(31 * time.Second)
285 x.ctl.TickForTest()
286 if resp, _ := c.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusNotFound {
287 t.Fatalf("signal after the window lapsed: %d, want 404", resp.StatusCode)
288 }
289}
290
291// Task 5's fix round 2 item 3: the 429 line names the peer the hub says
292// this session holds, never the one the request claimed. A flood that
293// rotates its claimed peer id must leave one log line's worth of truth
294// behind — otherwise the rate-limit log is written by the attacker.
295func TestTheRateLimitLogNamesTheSessionsRealPeer(t *testing.T) {
296 var buf bytes.Buffer
297 x := newHarnessWithLogger(t, calls.EnvDevelopment, slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})), "paul@example.com")
298 c := x.browser(t)
299 c.signIn("paul@example.com")
300 sqid, _ := createMeeting(t, c, "x", "")
301 s := c.stream("/m/" + sqid + "/stream")
302 defer s.close()
303 peer, _ := s.hello()
304 c.signal(sqid, peer, `{"type":"join"}`)
305
306 var claimed string
307 var got429 bool
308 for i := 0; i < 100; i++ {
309 claimed = fmt.Sprintf("%032x", i)
310 resp, _ := c.signal(sqid, claimed, `{"type":"join"}`)
311 if resp.StatusCode == http.StatusTooManyRequests {
312 got429 = true
313 break
314 }
315 }
316 if !got429 {
317 t.Fatal("the burst never hit the rate limit")
318 }
319
320 var rateLine string
321 for _, line := range strings.Split(buf.String(), "\n") {
322 if strings.Contains(line, "outcome=rate") {
323 rateLine = line
324 break
325 }
326 }
327 if rateLine == "" {
328 t.Fatalf("no rate-limit line in the log:\n%s", buf.String())
329 }
330 if !strings.Contains(rateLine, "peer="+peer) {
331 t.Fatalf("the 429 line does not name this session's own peer: %s", rateLine)
332 }
333 if strings.Contains(rateLine, "peer="+claimed) {
334 t.Fatalf("the 429 line names the peer the request claimed: %s", rateLine)
335 }
336 // And nothing else: type is unknown, because the body was never
337 // read (the limiter runs first), and the room is the one in the URL.
338 if !strings.Contains(rateLine, "type=unknown") || !strings.Contains(rateLine, "room="+sqid) {
339 t.Fatalf("the 429 line: %s", rateLine)
340 }
341}
342
343// Task 5 review minor 8: the body shapes signal.go must classify
344// correctly, including the ones client.signal cannot express (a
345// malformed document, a body with no peer at all).
346func TestSignalBodyShapeTable(t *testing.T) {
347 x, sqid, _ := memberWithMeeting(t)
348 c := x.browser(t)
349 c.signIn("paul@example.com")
350 s := c.stream("/m/" + sqid + "/stream")
351 defer s.close()
352 peer, _ := s.hello()
353 if resp, _ := c.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusOK {
354 t.Fatalf("setup join: %d", resp.StatusCode)
355 }
356 absent := fmt.Sprintf("%032x", 0) // well-formed, bound to nobody
357
358 table := []struct {
359 name string
360 body string
361 want int
362 }{
363 {"chat is slice 4", `{"peer":"` + string(peer) + `","type":"chat"}`, http.StatusBadRequest},
364 {"share is slice 4", `{"peer":"` + string(peer) + `","type":"share"}`, http.StatusBadRequest},
365 {"bogus type", `{"peer":"` + string(peer) + `","type":"bogus"}`, http.StatusBadRequest},
366 {"malformed JSON", `{`, http.StatusBadRequest},
367 {"missing peer", `{"type":"join"}`, http.StatusBadRequest},
368 {"malformed admit payload.peer", `{"peer":"` + string(peer) + `","type":"admit","payload":{"peer":"not-hex"}}`, http.StatusBadRequest},
369 {"well-formed absent admit target", `{"peer":"` + string(peer) + `","type":"admit","payload":{"peer":"` + absent + `"}}`, http.StatusConflict},
370 }
371 for _, tc := range table {
372 t.Run(tc.name, func(t *testing.T) {
373 resp, _ := c.signalRaw(sqid, tc.body)
374 if resp.StatusCode != tc.want {
375 t.Fatalf("%s: %d, want %d", tc.name, resp.StatusCode, tc.want)
376 }
377 })
378 }
379}
380
381func TestSignalLimits(t *testing.T) {
382 x, sqid, _ := memberWithMeeting(t)
383 c := x.browser(t)
384 c.signIn("paul@example.com")
385 s := c.stream("/m/" + sqid + "/stream")
386 defer s.close()
387 peer, _ := s.hello()
388 c.signal(sqid, peer, `{"type":"join"}`)
389
390 big := `{"type":"candidate","to":"` + peer + `","seq":1,"gen":1,"payload":{"x":"` + strings.Repeat("a", 70*1024) + `"}}`
391 if resp, _ := c.signal(sqid, peer, big); resp.StatusCode != http.StatusRequestEntityTooLarge {
392 t.Fatalf("64 KB: %d", resp.StatusCode)
393 }
394
395 // join, not leave: a successful leave ends the participation, and
396 // from fix round 2 on a session with no live participation in this
397 // room is refused at the LiveForSession gate before it ever reaches
398 // the limiter (item 1) — a burst that ended its own room membership
399 // on the first call would never reach the limiter again to prove
400 // anything. join on an already-admitted peer is idempotent (spec
401 // §4.3), so the participation, and the gate, stay open throughout.
402 var got429 bool
403 for i := 0; i < 100; i++ {
404 resp, _ := c.signal(sqid, peer, `{"type":"join"}`)
405 if resp.StatusCode == http.StatusTooManyRequests {
406 if resp.Header.Get("Retry-After") == "" {
407 t.Fatal("429 without Retry-After")
408 }
409 got429 = true
410 break
411 }
412 }
413 if !got429 {
414 t.Fatal("100 messages in a burst never hit the rate limit")
415 }
416}
417
418// Renamed from TestSignalStaleGenAnswers409WithTheCurrentGen: the
419// ledger records the decision that no gen goes inline in the response
420// (see task-5-report.md) — the roster already carries it (spec §4.6).
421func TestSignalStaleGenIsRefusedWith409(t *testing.T) {
422 x, sqid, _ := memberWithMeeting(t)
423 a := x.browser(t)
424 a.signIn("paul@example.com")
425 sa := a.stream("/m/" + sqid + "/stream")
426 defer sa.close()
427 pa, _ := sa.hello()
428 a.signal(sqid, pa, `{"type":"join"}`)
429
430 b := newMemberBrowser(t, x, "paul@rslw.com")
431 sb := b.stream("/m/" + sqid + "/stream")
432 defer sb.close()
433 pb, _ := sb.hello()
434 b.signal(sqid, pb, `{"type":"join"}`)
435
436 resp, _ := a.signal(sqid, pa, `{"type":"offer","to":"`+pb+`","seq":1,"gen":7,"payload":{}}`)
437 if resp.StatusCode != http.StatusConflict {
438 t.Fatalf("stale gen: %d", resp.StatusCode)
439 }
440}
441
442// Spec §4.3: a guest's join marks it waiting and tells admitted members
443// with knock; the guest itself learns nothing at all from a fresh join
444// (ruling 9, and hub's own TestMemberJoinsAndGuestWaits, which asserts
445// zero events on a waiting guest's sub) — it is the *resume* of a
446// waiting or unjoined participation that answers state{waiting:true}
447// (stream.go, Task 4), not the join itself. This is the guest half of
448// standingFor's hub.KindGuest mapping proven end-to-end over HTTP: a
449// guest waits, a member in the same room sees knock.
450func TestSignalGuestJoinWaitsAndKnocksTheRoom(t *testing.T) {
451 x, sqid, tok := memberWithMeeting(t)
452 enableWaitingRoom(t, x, sqid)
453 member := x.browser(t)
454 member.signIn("paul@example.com")
455 ms := member.stream("/m/" + sqid + "/stream")
456 defer ms.close()
457 mpeer, _ := ms.hello()
458 if resp, _ := member.signal(sqid, mpeer, `{"type":"join"}`); resp.StatusCode != http.StatusOK {
459 t.Fatalf("member join: %d", resp.StatusCode)
460 }
461 if ev, _, ok := ms.next(2 * time.Second); !ok || ev != "roster" {
462 t.Fatalf("member after its own join: %s ok=%v, want roster", ev, ok)
463 }
464
465 guest := x.browser(t)
466 guest.get("/j/" + tok)
467 guest.signIn("guest@example.com")
468 gs := guest.stream("/m/" + sqid + "/stream")
469 defer gs.close()
470 gpeer, _ := gs.hello()
471
472 if resp, _ := guest.signal(sqid, gpeer, `{"type":"join"}`); resp.StatusCode != http.StatusOK {
473 t.Fatalf("guest join: %d", resp.StatusCode)
474 }
475 if ev, _, ok := ms.next(2 * time.Second); !ok || ev != "knock" {
476 t.Fatalf("member after the guest's join: %s ok=%v, want knock", ev, ok)
477 }
478 if ev, _, ok := gs.next(200 * time.Millisecond); ok {
479 t.Fatalf("waiting guest received %q, want nothing", ev)
480 }
481
482 // The guest really is only waiting, not admitted: resuming its
483 // stream answers state{waiting:true}, not a roster.
484 gs.close()
485 gs2 := guest.stream("/m/" + sqid + "/stream?peer=" + gpeer)
486 defer gs2.close()
487 if p, _ := gs2.hello(); p != gpeer {
488 t.Fatalf("resume peer %q, want %q", p, gpeer)
489 }
490 ev, data, ok := gs2.next(2 * time.Second)
491 if !ok || ev != "state" {
492 t.Fatalf("waiting guest's resume: %s ok=%v, want state", ev, ok)
493 }
494 var state struct{ Waiting bool }
495 json.Unmarshal(data, &state)
496 if !state.Waiting {
497 t.Fatalf("state %s, want waiting", data)
498 }
499}
500
501// Spec §4.5 and §4.6: two admitted peers, one drops its stream and
502// resumes with ?peer= and last, and gets hello, roster, then the
503// replay — in that order, and only the messages above last.
504func TestSignalAdmittedResumeReplaysAfterRoster(t *testing.T) {
505 x, sqid, _ := memberWithMeeting(t)
506 a := x.browser(t)
507 a.signIn("paul@example.com")
508 sa := a.stream("/m/" + sqid + "/stream")
509 defer sa.close()
510 pa, _ := sa.hello()
511 if resp, _ := a.signal(sqid, pa, `{"type":"join"}`); resp.StatusCode != http.StatusOK {
512 t.Fatalf("a's join: %d", resp.StatusCode)
513 }
514 if ev, _, ok := sa.next(2 * time.Second); !ok || ev != "roster" {
515 t.Fatalf("a after its own join: %s ok=%v, want roster", ev, ok)
516 }
517
518 b := newMemberBrowser(t, x, "paul@rslw.com")
519 sb := b.stream("/m/" + sqid + "/stream")
520 pb, _ := sb.hello()
521 if resp, _ := b.signal(sqid, pb, `{"type":"join"}`); resp.StatusCode != http.StatusOK {
522 t.Fatalf("b's join: %d", resp.StatusCode)
523 }
524 if ev, _, ok := sb.next(2 * time.Second); !ok || ev != "roster" {
525 t.Fatalf("b after its own join: %s ok=%v, want roster", ev, ok)
526 }
527 if ev, _, ok := sa.next(2 * time.Second); !ok || ev != "peer" {
528 t.Fatalf("a on b's admission: %s ok=%v, want peer", ev, ok)
529 }
530
531 // One offer delivered live, so it is out of the replay's range.
532 if resp, _ := a.signal(sqid, pa, `{"type":"offer","to":"`+pb+`","seq":1,"gen":1,"payload":{}}`); resp.StatusCode != http.StatusOK {
533 t.Fatalf("a's first offer: %d", resp.StatusCode)
534 }
535 if ev, _, ok := sb.next(2 * time.Second); !ok || ev != "offer" {
536 t.Fatalf("b receiving the first offer live: %s ok=%v", ev, ok)
537 }
538
539 sb.close()
540
541 // A second offer, sent while b is detached, lands only in the ring.
542 if resp, _ := a.signal(sqid, pa, `{"type":"offer","to":"`+pb+`","seq":2,"gen":1,"payload":{}}`); resp.StatusCode != http.StatusOK {
543 t.Fatalf("a's second offer: %d", resp.StatusCode)
544 }
545
546 last, err := json.Marshal(map[string]uint64{pa: 1})
547 if err != nil {
548 t.Fatal(err)
549 }
550 sb2 := b.stream("/m/" + sqid + "/stream?peer=" + pb + "&last=" + url.QueryEscape(string(last)))
551 defer sb2.close()
552 if p, _ := sb2.hello(); p != pb {
553 t.Fatalf("resume hello peer %q, want %q", p, pb)
554 }
555 if ev, _, ok := sb2.next(2 * time.Second); !ok || ev != "roster" {
556 t.Fatalf("first after resume hello: %s ok=%v, want roster", ev, ok)
557 }
558 ev, data, ok := sb2.next(2 * time.Second)
559 if !ok || ev != "offer" {
560 t.Fatalf("second after resume: %s ok=%v, want the replayed offer", ev, ok)
561 }
562 var replay struct{ Seq uint64 }
563 json.Unmarshal(data, &replay)
564 if replay.Seq != 2 {
565 t.Fatalf("replayed offer seq %d, want 2 (the one sent while b was detached)", replay.Seq)
566 }
567}
568
569// The §0.1 and §4.4 promise: no payload, address, name, title, token or
570// session key in any log line, on accepted and refused messages and on
571// an internal-error path. Task 5 review item 4: the earlier version of
572// this test passed vacuously — it never exercised a guest's knock, an
573// admit, or the 500 arm, and its address ("paul@rslw.com") happened not
574// to leave a distinctive trace ("paul" and "example.com" both appear
575// harmlessly elsewhere). zaphod-xyzzy@example.com is chosen so its
576// derived display name (store.ensurePerson's local-part rule,
577// "zaphod-xyzzy") is its own needle.
578func TestSignallingLogsCarryNoContent(t *testing.T) {
579 var buf bytes.Buffer
580 x := newHarnessWithLogger(t, calls.EnvDevelopment, slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})), "paul@example.com")
581 c := x.browser(t)
582 c.signIn("paul@example.com")
583 sqid, tok := createMeeting(t, c, "Secret title", "")
584 enableWaitingRoom(t, x, sqid)
585 s := c.stream("/m/" + sqid + "/stream")
586 defer s.close()
587 peer, _ := s.hello()
588 if resp, _ := c.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusOK {
589 t.Fatalf("member join: %d", resp.StatusCode)
590 }
591
592 // A real guest, via the join link, not another member: its join is
593 // the knock, and it stays out of the log entirely until admitted.
594 guest := x.browser(t)
595 guest.get("/j/" + tok)
596 guest.signIn("zaphod-xyzzy@example.com")
597 gs := guest.stream("/m/" + sqid + "/stream")
598 defer gs.close()
599 gpeer, _ := gs.hello()
600 if resp, _ := guest.signal(sqid, gpeer, `{"type":"join"}`); resp.StatusCode != http.StatusOK {
601 t.Fatalf("guest join (the knock): %d", resp.StatusCode)
602 }
603
604 // The admit round: previously untested by this file at all.
605 if resp, _ := c.signal(sqid, peer, `{"type":"admit","payload":{"peer":"`+gpeer+`"}}`); resp.StatusCode != http.StatusOK {
606 t.Fatalf("admit: %d", resp.StatusCode)
607 }
608
609 c.signal(sqid, peer, `{"type":"offer","to":"`+gpeer+`","seq":1,"gen":1,"payload":{"sdp":"v=0 SDPSECRET a=candidate 203.0.113.9"}}`)
610 c.signal(sqid, peer, `{"type":"candidate","to":"`+gpeer+`","seq":2,"gen":1,"payload":{"candidate":"candidate:1 1 udp 2 203.0.113.9 5000 typ host"}}`)
611 c.signal(sqid, peer, `{"type":"offer","to":"`+gpeer+`","seq":3,"gen":9,"payload":{"sdp":"STALESECRET"}}`) // outcome=stale-gen
612 c.signal(sqid, peer, `{"type":"bogus"}`) // outcome=type
613
614 // An accepted chat appends nothing to the log at all — no line, let
615 // alone one carrying its text — and an oversize one is refused with
616 // the usual outcome-only line, never the text that overflowed it.
617 beforeChat := buf.Len()
618 if resp, _ := c.signal(sqid, peer, `{"type":"chat","payload":{"text":"xyzzy-plugh-chat"}}`); resp.StatusCode != http.StatusOK {
619 t.Fatalf("accepted chat: %d", resp.StatusCode)
620 }
621 if buf.Len() != beforeChat {
622 t.Fatalf("an accepted chat appended %d bytes to the log: %q", buf.Len()-beforeChat, buf.String()[beforeChat:])
623 }
624 // "xyzzy-oversize-" is its own needle: the earlier assertion only
625 // proved the accepted chat's text never grew the log, not that a
626 // *refused* chat's text is equally absent (review fix round 1,
627 // item 2).
628 oversizeChat := `{"type":"chat","payload":{"text":"xyzzy-oversize-` + strings.Repeat("a", 2049) + `"}}`
629 if resp, _ := c.signal(sqid, peer, oversizeChat); resp.StatusCode != http.StatusRequestEntityTooLarge {
630 t.Fatalf("oversize chat: %d", resp.StatusCode)
631 } // outcome=size
632
633 x.advance(time.Minute)
634 x.ctl.TickForTest()
635
636 // Force the internal-error arm without breaking sign-in itself:
637 // closing the whole database also breaks rastrillo's own
638 // per-request session lookup, which refuses the request before it
639 // ever reaches the signal handler. Dropping only the meetings table
640 // leaves sessions and persons answering normally, so the request
641 // reaches hub.Join, whose standing re-derivation reads meetings and
642 // surfaces a genuine driver error — a sentinel this switch does not
643 // already name — exercising the one log line no other case in this
644 // test reaches (review minor 9).
645 //
646 // In its own trailing t.Run, named for what it does and that it
647 // must be last: the table it drops is gone for the rest of the
648 // process, so nothing after this subtest may touch it (review fix
649 // round 2 item 7 — a structural marker, not just a comment, for a
650 // constraint a future edit could otherwise violate silently).
651 t.Run("forced internal error (must run last: drops the meetings table)", func(t *testing.T) {
652 if err := x.db.G.Exec("DROP TABLE meetings").Error; err != nil {
653 t.Fatal(err)
654 }
655 resp, _ := c.signal(sqid, peer, `{"type":"join"}`)
656 if resp.StatusCode != http.StatusInternalServerError {
657 t.Fatalf("forced internal error: %d, want 500", resp.StatusCode)
658 }
659 })
660
661 logs := buf.String()
662 for _, want := range []string{"outcome=stale-gen", "outcome=type", "outcome=size", "outcome=internal"} {
663 if !strings.Contains(logs, want) {
664 t.Errorf("log missing %q:\n%s", want, logs)
665 }
666 }
667
668 forbidden := []string{
669 "Secret title", "SDPSECRET", "203.0.113.9", "STALESECRET", tok,
670 "paul@example.com", "zaphod-xyzzy@example.com", "zaphod-xyzzy",
671 "xyzzy-plugh-chat", "xyzzy-oversize-",
672 }
673 for _, ck := range c.cookies {
674 forbidden = append(forbidden, ck.Value)
675 }
676 for _, ck := range guest.cookies {
677 forbidden = append(forbidden, ck.Value)
678 }
679 for _, needle := range forbidden {
680 if needle != "" && strings.Contains(logs, needle) {
681 t.Errorf("log carries %q", needle)
682 }
683 }
684}
685