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/base64"
6 "encoding/json"
7 "fmt"
8 "io"
9 "log/slog"
10 "net/http"
11 "net/http/httptest"
12 "net/url"
13 "strings"
14 "testing"
15 "time"
16
17 "amadan.net/oficina/calls/internal/calls"
18 "amadan.net/oficina/calls/internal/calls/relay"
19)
20
21const vectorKey = "uMhwW4P/9O5dQUIs91t2CCHGhwJbeiM0xQMQ2G0CJLU="
22
23func relayContract(t *testing.T) string {
24 t.Helper()
25 b, _ := json.Marshal([]map[string]any{{
26 "region": "eu-west-1",
27 "urls": []string{"stun:relay.eu-west-1.oncarlos.com:3478", "turn:relay.eu-west-1.oncarlos.com:3478?transport=udp"},
28 "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1",
29 }})
30 return base64.StdEncoding.EncodeToString(b)
31}
32
33type iceAnswer struct {
34 ICEServers []relay.ICEServer `json:"iceServers"`
35 ExpiresAt int64 `json:"expiresAt"`
36 RenewAt int64 `json:"renewAt"`
37}
38
39// guestJoinedAndWaiting signs a fresh outsider in through tok, opens its
40// stream and posts join without anyone admitting it — the waiting shape
41// isolation_test.go's openIsolationRoom also builds inline, lifted here
42// for a single guest.
43func guestJoinedAndWaiting(t *testing.T, x *harness, sqid, tok string) (*client, string) {
44 enableWaitingRoom(t, x, sqid)
45 t.Helper()
46 g := x.browser(t)
47 g.get("/j/" + tok)
48 g.signIn("waiting-guest@example.com")
49 s := g.stream("/m/" + sqid + "/stream")
50 t.Cleanup(s.close)
51 peer, _ := s.hello()
52 if resp, b := g.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusOK {
53 t.Fatalf("guest join (the knock): %d %s", resp.StatusCode, b)
54 }
55 return g, peer
56}
57
58func TestIceHandsAnAdmittedParticipationCredentialsThatVerify(t *testing.T) {
59 x, sqid, _ := memberWithMeeting(t)
60 if err := x.ctl.SetRelay(relayContract(t)); err != nil {
61 t.Fatal(err)
62 }
63 c := x.browser(t)
64 c.signIn("paul@example.com")
65 s := c.stream("/m/" + sqid + "/stream")
66 defer s.close()
67 peer, _ := s.hello()
68 if resp, b := c.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusOK {
69 t.Fatalf("member join: %d %s", resp.StatusCode, b)
70 }
71 s.nextOf("roster", 2*time.Second)
72
73 rec := c.getStream("/m/" + sqid + "/ice?peer=" + peer) // any bounded GET helper; the Accept header is harmless
74 if rec.Code != http.StatusOK || rec.Header().Get("Cache-Control") != "no-store" {
75 t.Fatalf("ice: %d %q", rec.Code, rec.Header().Get("Cache-Control"))
76 }
77 var ans iceAnswer
78 if err := json.Unmarshal(rec.Body.Bytes(), &ans); err != nil {
79 t.Fatal(err)
80 }
81 if len(ans.ICEServers) != 2 || ans.ICEServers[0].Username != "" || ans.ICEServers[1].Username == "" {
82 t.Fatalf("servers %+v", ans.ICEServers)
83 }
84 now := x.now
85 if ans.ExpiresAt < now.Add(3*time.Hour-time.Second).Unix() || ans.ExpiresAt > now.Add(3*time.Hour+time.Second).Unix() {
86 t.Fatalf("expiry %d is not three hours ahead of %d", ans.ExpiresAt, now.Unix())
87 }
88 if ans.RenewAt != now.Add(150*time.Minute).Unix() {
89 t.Fatalf("renewAt %d", ans.RenewAt)
90 }
91 // The credential verifies against the vector key for the username the server chose.
92 key, _ := base64.StdEncoding.DecodeString(vectorKey)
93 turn := ans.ICEServers[1]
94 if relay.Credential(key, turn.Username) != turn.Credential {
95 t.Fatal("credential does not verify")
96 }
97 if want := fmt.Sprintf("%d:abc:meet:0123456789abcdef:k1", ans.ExpiresAt); turn.Username != want {
98 t.Fatalf("username %q, want %q", turn.Username, want)
99 }
100
101 // The wire shape itself, not just the typed struct's own tags:
102 // relay's own golden test pins Mint's output in isolation, but
103 // nothing before this pinned what actually crosses this endpoint —
104 // a bare entry carries only urls, a turn entry carries urls,
105 // username and credential and nothing else.
106 var raw struct {
107 ICEServers []map[string]json.RawMessage `json:"iceServers"`
108 }
109 if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil {
110 t.Fatal(err)
111 }
112 if len(raw.ICEServers) != 2 {
113 t.Fatalf("want two servers over the wire, got %d", len(raw.ICEServers))
114 }
115 for k := range raw.ICEServers[0] {
116 if k != "urls" {
117 t.Fatalf("bare entry over the wire carries an extra key %q", k)
118 }
119 }
120 for _, want := range []string{"urls", "username", "credential"} {
121 if _, ok := raw.ICEServers[1][want]; !ok {
122 t.Fatalf("turn entry over the wire is missing %q", want)
123 }
124 }
125 for k := range raw.ICEServers[1] {
126 if k != "urls" && k != "username" && k != "credential" {
127 t.Fatalf("turn entry over the wire carries an extra key %q", k)
128 }
129 }
130}
131
132// TestIceRenewAtIsFiveSixthsOfTheLife is a tripwire for the client, not
133// for this endpoint. room.js schedules its renewal from the *margin*
134// this answer leaves it — expiresAt minus renewAt, a subtraction of two
135// instants on one clock, and therefore the one reading immune to a
136// browser whose clock disagrees with ours — and it multiplies that
137// margin by five because the contract's shape says the margin is a
138// sixth of the life (§5.3 step 4). Move iceLife or iceRenewAfter without
139// the other and that arithmetic quietly starts scheduling renewals after
140// the credential it is renewing has already died. The client also reads
141// the clock directly and takes whichever answer is earlier, so this
142// would not be fatal — but it would silently cost the schedule its only
143// skew-proof half, and nothing else in the tree would notice.
144func TestIceRenewAtIsFiveSixthsOfTheLife(t *testing.T) {
145 x, sqid, _ := memberWithMeeting(t)
146 if err := x.ctl.SetRelay(relayContract(t)); err != nil {
147 t.Fatal(err)
148 }
149 c := x.browser(t)
150 c.signIn("paul@example.com")
151 s := c.stream("/m/" + sqid + "/stream")
152 defer s.close()
153 peer, _ := s.hello()
154 if resp, b := c.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusOK {
155 t.Fatalf("member join: %d %s", resp.StatusCode, b)
156 }
157 s.nextOf("roster", 2*time.Second)
158
159 rec := c.getStream("/m/" + sqid + "/ice?peer=" + peer)
160 var ans iceAnswer
161 if err := json.Unmarshal(rec.Body.Bytes(), &ans); err != nil {
162 t.Fatal(err)
163 }
164 now := x.now.Unix()
165 life, renew, margin := ans.ExpiresAt-now, ans.RenewAt-now, ans.ExpiresAt-ans.RenewAt
166 if renew*6 != life*5 {
167 t.Fatalf("renewAt is not five sixths of the life: life %ds, renew %ds", life, renew)
168 }
169 if margin*6 != life {
170 t.Fatalf("the margin the client multiplies by five is not a sixth of the life: margin %ds, life %ds", margin, life)
171 }
172}
173
174func TestIceIs404ForAnythingButAnAdmittedParticipationOfThisSession(t *testing.T) {
175 x, sqid, tok := memberWithMeeting(t)
176 if err := x.ctl.SetRelay(relayContract(t)); err != nil {
177 t.Fatal(err)
178 }
179 c := x.browser(t)
180 c.signIn("paul@example.com")
181 s := c.stream("/m/" + sqid + "/stream")
182 defer s.close()
183 peer, _ := s.hello()
184 unjoined := c.getStream("/m/" + sqid + "/ice?peer=" + peer)
185 if unjoined.Code != http.StatusNotFound {
186 t.Fatalf("unjoined: %d", unjoined.Code)
187 }
188
189 other := x.browser(t)
190 other.signIn("paul@rslw.com")
191 foreign := other.getStream("/m/" + sqid + "/ice?peer=" + peer)
192 if foreign.Code != http.StatusNotFound || !bytes.Equal(foreign.Body.Bytes(), unjoined.Body.Bytes()) {
193 t.Fatalf("another session must get the identical 404: %d %s vs %s", foreign.Code, foreign.Body.Bytes(), unjoined.Body.Bytes())
194 }
195
196 // A real /signal 404, from the same unbound session, must be the
197 // identical body too: both endpoints share one not-found shape
198 // (spec §2.5), not merely two handlers that happen to agree today.
199 signalResp, signalBody := other.signal(sqid, peer, `{"type":"leave"}`)
200 if signalResp.StatusCode != http.StatusNotFound || !bytes.Equal(signalBody, unjoined.Body.Bytes()) {
201 t.Fatalf("a real /signal 404 must be byte-identical: %d %s vs %s", signalResp.StatusCode, signalBody, unjoined.Body.Bytes())
202 }
203
204 bad := c.getStream("/m/" + sqid + "/ice?peer=zz")
205 if bad.Code != http.StatusNotFound || !bytes.Equal(bad.Body.Bytes(), unjoined.Body.Bytes()) {
206 t.Fatal("a malformed peer is the same 404")
207 }
208
209 // "2222222222" is validSqid's own shape (ten characters, every one
210 // in its alphabet) naming a room that does not exist; "0000000000"
211 // is not that shape at all — '0' is never in sqidAlphabet — so the
212 // two exercise different refusal arms (an unknown room vs a
213 // malformed one) and both must still be the one 404.
214 room := c.getStream("/m/2222222222/ice?peer=" + peer)
215 if room.Code != http.StatusNotFound || !bytes.Equal(room.Body.Bytes(), unjoined.Body.Bytes()) {
216 t.Fatal("an unknown room is the same 404")
217 }
218 malformedRoom := c.getStream("/m/0000000000/ice?peer=" + peer)
219 if malformedRoom.Code != http.StatusNotFound || !bytes.Equal(malformedRoom.Body.Bytes(), unjoined.Body.Bytes()) {
220 t.Fatal("a malformed sqid is the same 404")
221 }
222
223 // A waiting guest: sign a guest in through the link and join; 404.
224 g, gpeer := guestJoinedAndWaiting(t, x, sqid, tok)
225 waiting := g.getStream("/m/" + sqid + "/ice?peer=" + gpeer)
226 if waiting.Code != http.StatusNotFound || !bytes.Equal(waiting.Body.Bytes(), unjoined.Body.Bytes()) {
227 t.Fatal("a waiting guest is the same 404")
228 }
229}
230
231func TestIceWithNoRelayIsAnEmptyList(t *testing.T) {
232 x, sqid, _ := memberWithMeeting(t)
233 c := x.browser(t)
234 c.signIn("paul@example.com")
235 s := c.stream("/m/" + sqid + "/stream")
236 defer s.close()
237 peer, _ := s.hello()
238 if resp, b := c.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusOK {
239 t.Fatalf("member join: %d %s", resp.StatusCode, b)
240 }
241 s.nextOf("roster", 2*time.Second)
242 rec := c.getStream("/m/" + sqid + "/ice?peer=" + peer)
243 if rec.Code != http.StatusOK || rec.Body.String() != "{\"expiresAt\":0,\"iceServers\":[],\"renewAt\":0}\n" {
244 t.Fatalf("%d %q", rec.Code, rec.Body.String())
245 }
246}
247
248// TestIceTestTTLHeaderIsHonouredOnlyInDevelopment is stream_test.go's
249// TestTheTestTTLHeaderIsDevelopmentOnly over HTTP: EventSource cannot
250// set a header, but a plain GET can, so this one needs no browser gate
251// to reach the handler — only the development check does, and this
252// test is what proves it.
253func TestIceTestTTLHeaderIsHonouredOnlyInDevelopment(t *testing.T) {
254 withHeader := func(c *client, target, ttl string) *httptest.ResponseRecorder {
255 req := httptest.NewRequest(http.MethodGet, target, nil)
256 req.Header.Set("X-Calls-Test-Ice-TTL", ttl)
257 return c.do(req)
258 }
259
260 t.Run("development", func(t *testing.T) {
261 x, sqid, _ := memberWithMeeting(t)
262 if err := x.ctl.SetRelay(relayContract(t)); err != nil {
263 t.Fatal(err)
264 }
265 c := x.browser(t)
266 c.signIn("paul@example.com")
267 s := c.stream("/m/" + sqid + "/stream")
268 defer s.close()
269 peer, _ := s.hello()
270 c.signal(sqid, peer, `{"type":"join"}`)
271
272 // Twelve seconds, not two: expiresAt and renewAt are both
273 // truncated to whole seconds by Unix(), and life*5/6's fractional
274 // remainder can tie the two together at a short duration. Twelve
275 // gives a two-second gap (12s vs 10s) that survives the rounding.
276 rec := withHeader(c, "/m/"+sqid+"/ice?peer="+peer, "12s")
277 if rec.Code != http.StatusOK {
278 t.Fatalf("development with header: %d %s", rec.Code, rec.Body.String())
279 }
280 var ans iceAnswer
281 if err := json.Unmarshal(rec.Body.Bytes(), &ans); err != nil {
282 t.Fatal(err)
283 }
284 now := x.now
285 if ans.ExpiresAt < now.Unix() || ans.ExpiresAt > now.Add(15*time.Second).Unix() {
286 t.Fatalf("expiresAt %d is not ~12s ahead of %d", ans.ExpiresAt, now.Unix())
287 }
288 if ans.RenewAt >= ans.ExpiresAt {
289 t.Fatalf("renewAt %d is not before expiresAt %d", ans.RenewAt, ans.ExpiresAt)
290 }
291 })
292
293 t.Run("production", func(t *testing.T) {
294 x := newHarnessWithLogger(t, "production", slog.New(slog.NewTextHandler(io.Discard, nil)), "paul@example.com")
295 if err := x.ctl.SetRelay(relayContract(t)); err != nil {
296 t.Fatal(err)
297 }
298 c := x.browser(t)
299 c.signIn("paul@example.com")
300 sqid, _ := createMeeting(t, c, "Board meeting", "")
301 s := c.stream("/m/" + sqid + "/stream")
302 defer s.close()
303 peer, _ := s.hello()
304 c.signal(sqid, peer, `{"type":"join"}`)
305
306 rec := withHeader(c, "/m/"+sqid+"/ice?peer="+peer, "12s")
307 if rec.Code != http.StatusOK {
308 t.Fatalf("production with header: %d %s", rec.Code, rec.Body.String())
309 }
310 var ans iceAnswer
311 if err := json.Unmarshal(rec.Body.Bytes(), &ans); err != nil {
312 t.Fatal(err)
313 }
314 now := x.now
315 if ans.ExpiresAt < now.Add(3*time.Hour-time.Second).Unix() || ans.ExpiresAt > now.Add(3*time.Hour+time.Second).Unix() {
316 t.Fatalf("production must ignore the header: expiresAt %d, want ~3h ahead of %d", ans.ExpiresAt, now.Unix())
317 }
318 })
319}
320
321// TestIceLogsNothingOnSuccessAndNoCredentialOnRefusal is
322// signal_test.go's TestSignallingLogsCarryNoContent for this endpoint:
323// a successful call grows the log buffer by not one byte (the answer
324// itself carries the credential, so even a line mentioning the room
325// alone would be a leash on it), and once a real credential has been
326// minted, neither its authority refusal's line, nor any other line
327// this test produces, may ever contain that exact minted username or
328// credential — not merely the vector key or a hardcoded substring.
329func TestIceLogsNothingOnSuccessAndNoCredentialOnRefusal(t *testing.T) {
330 var buf bytes.Buffer
331 x := newHarnessWithLogger(t, calls.EnvDevelopment, slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})), "paul@example.com", "paul@rslw.com")
332 if err := x.ctl.SetRelay(relayContract(t)); err != nil {
333 t.Fatal(err)
334 }
335 c := x.browser(t)
336 c.signIn("paul@example.com")
337 sqid, _ := createMeeting(t, c, "Board meeting", "")
338 s := c.stream("/m/" + sqid + "/stream")
339 defer s.close()
340 peer, _ := s.hello()
341 if resp, b := c.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusOK {
342 t.Fatalf("member join: %d %s", resp.StatusCode, b)
343 }
344
345 before := buf.Len()
346 rec := c.getStream("/m/" + sqid + "/ice?peer=" + peer)
347 if rec.Code != http.StatusOK {
348 t.Fatalf("ice: %d %s", rec.Code, rec.Body.String())
349 }
350 if buf.Len() != before {
351 t.Fatalf("success grew the log by %d bytes: %q", buf.Len()-before, buf.String()[before:])
352 }
353 var ans iceAnswer
354 if err := json.Unmarshal(rec.Body.Bytes(), &ans); err != nil {
355 t.Fatal(err)
356 }
357 if len(ans.ICEServers) != 2 || ans.ICEServers[1].Username == "" || ans.ICEServers[1].Credential == "" {
358 t.Fatalf("no minted credential to check the log against: %+v", ans.ICEServers)
359 }
360 turn := ans.ICEServers[1]
361
362 // An authority refusal: a foreign session naming this session's own
363 // admitted peer.
364 other := x.browser(t)
365 other.signIn("paul@rslw.com")
366 if rec := other.getStream("/m/" + sqid + "/ice?peer=" + peer); rec.Code != http.StatusNotFound {
367 t.Fatalf("foreign session: %d", rec.Code)
368 }
369 // A shape refusal too, for the same reasons signal_test.go's own
370 // content test covers more than one refusal path.
371 if rec := c.getStream("/m/" + sqid + "/ice?peer=zz"); rec.Code != http.StatusNotFound {
372 t.Fatalf("malformed peer: %d", rec.Code)
373 }
374
375 logs := buf.String()
376 if !strings.Contains(logs, "calls: ice refused") {
377 t.Fatalf("the refusal was not logged:\n%s", logs)
378 }
379 for _, forbidden := range []string{vectorKey, ":abc:meet:", turn.Username, turn.Credential} {
380 if strings.Contains(logs, forbidden) {
381 t.Fatalf("log leaked a credential (%q):\n%s", forbidden, logs)
382 }
383 }
384}
385
386// TestIceRefusesAnUnboundSessionBeforeTheStoreOrTheLimiter is
387// signal.go's own LiveForSession gate (review Task 5 item 1), carried
388// over to this endpoint: a session with no live participation in this
389// room at all is refused before either the store or the limiter is
390// ever touched. The meetings table is dropped first, so a store read
391// that happened anyway would 500, not 404 — and the limiter's own map
392// staying empty is the second, independent witness that nothing was
393// spent for a room this session holds nothing in.
394func TestIceRefusesAnUnboundSessionBeforeTheStoreOrTheLimiter(t *testing.T) {
395 x, sqid, _ := memberWithMeeting(t)
396 c := x.browser(t)
397 c.signIn("paul@example.com")
398 // No stream opened at all: this session has no live participation
399 // anywhere, in this room least of all.
400 if err := x.db.G.Exec("DROP TABLE meetings").Error; err != nil {
401 t.Fatal(err)
402 }
403 rec := c.getStream("/m/" + sqid + "/ice?peer=" + strings.Repeat("a", 32))
404 if rec.Code != http.StatusNotFound {
405 t.Fatalf("unbound session: %d, want 404 (a store read would have hit the dropped table and 500ed)", rec.Code)
406 }
407 if n := x.ctl.SignalLimiterSize(); n != 0 {
408 t.Fatalf("the limiter minted a bucket for an unbound session: %d keys", n)
409 }
410}
411
412// TestIceSpendsTheSignalEndpointsOwnBucket is the shared-bucket claim
413// itself, which the burst test below cannot make: a limiter of /ice's
414// own would pass that one exactly as a shared one does. So this
415// exhausts the budget entirely through POST /signal and then asks /ice
416// for a list — one request, on a participation that is admitted and
417// would otherwise be answered 200 (Codex S3-7).
418func TestIceSpendsTheSignalEndpointsOwnBucket(t *testing.T) {
419 x, sqid, _ := memberWithMeeting(t)
420 if err := x.ctl.SetRelay(relayContract(t)); err != nil {
421 t.Fatal(err)
422 }
423 c := x.browser(t)
424 c.signIn("paul@example.com")
425 s := c.stream("/m/" + sqid + "/stream")
426 defer s.close()
427 peer, _ := s.hello()
428 if resp, b := c.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusOK {
429 t.Fatalf("member join: %d %s", resp.StatusCode, b)
430 }
431 // One list before the burst, so the 429 below cannot be a refusal
432 // this participation would have got anyway.
433 if rec := c.getStream("/m/" + sqid + "/ice?peer=" + peer); rec.Code != http.StatusOK {
434 t.Fatalf("ice before the burst: %d %s", rec.Code, rec.Body.String())
435 }
436
437 // Now spend the budget on the *other* endpoint. A candidate to a
438 // peer that is not there is refused on its merits, and every refusal
439 // still costs a token: the limiter runs in front of the handler.
440 spent := 0
441 for i := 0; i < 200; i++ {
442 resp, _ := c.signal(sqid, peer, `{"type":"candidate","to":"`+strings.Repeat("b", 32)+`","seq":1,"gen":1,"payload":{}}`)
443 spent++
444 if resp.StatusCode == http.StatusTooManyRequests {
445 break
446 }
447 }
448 if spent >= 200 {
449 t.Fatal("200 signal posts never hit the rate limit, so nothing was exhausted")
450 }
451
452 rec := c.getStream("/m/" + sqid + "/ice?peer=" + peer)
453 if rec.Code != http.StatusTooManyRequests {
454 t.Fatalf("ice after %d signal posts exhausted the bucket: %d %s — /ice has a limiter of its own",
455 spent, rec.Code, rec.Body.String())
456 }
457 if rec.Header().Get("Retry-After") == "" {
458 t.Fatal("429 without Retry-After")
459 }
460}
461
462// TestIceRateLimitsOneSessionsBurst proves /ice is behind the limiter at
463// all: a hundred requests in a row are refused before the hundredth.
464// The shared-bucket half is the test above.
465func TestIceRateLimitsOneSessionsBurst(t *testing.T) {
466 x, sqid, _ := memberWithMeeting(t)
467 if err := x.ctl.SetRelay(relayContract(t)); err != nil {
468 t.Fatal(err)
469 }
470 c := x.browser(t)
471 c.signIn("paul@example.com")
472 s := c.stream("/m/" + sqid + "/stream")
473 defer s.close()
474 peer, _ := s.hello()
475 if resp, b := c.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusOK {
476 t.Fatalf("member join: %d %s", resp.StatusCode, b)
477 }
478
479 var got429 bool
480 for i := 0; i < 100; i++ {
481 rec := c.getStream("/m/" + sqid + "/ice?peer=" + peer)
482 if rec.Code == http.StatusTooManyRequests {
483 if rec.Header().Get("Retry-After") == "" {
484 t.Fatal("429 without Retry-After")
485 }
486 got429 = true
487 break
488 }
489 if rec.Code != http.StatusOK {
490 t.Fatalf("unexpected status mid-burst: %d %s", rec.Code, rec.Body.String())
491 }
492 }
493 if !got429 {
494 t.Fatal("100 requests in a burst never hit the rate limit")
495 }
496}
497
498// TestIceHeaderClampsExtremeValues is item 3's clamp:
499// life = min(max(d, minIceLife), iceLife). A zero or negative header
500// floors to the two-second minimum; an unparseable one is ignored
501// outright (the untouched three-hour default); an oversized one is
502// capped at the three-hour default rather than minting a credential
503// that outlives it. renewAt is always strictly before expiresAt.
504//
505// The harness clock is truncated to a whole second first: expiresAt
506// and renewAt are both truncated to whole seconds by Unix(), and the
507// floor case's own two-thirds-of-a-second gap (2s vs on the order of
508// 1.667s) is not reliably on the same side of a rounding boundary
509// starting from a fractional second.
510func TestIceHeaderClampsExtremeValues(t *testing.T) {
511 x, sqid, _ := memberWithMeeting(t)
512 if err := x.ctl.SetRelay(relayContract(t)); err != nil {
513 t.Fatal(err)
514 }
515 x.now = x.now.Truncate(time.Second)
516 x.advance(0)
517
518 c := x.browser(t)
519 c.signIn("paul@example.com")
520 s := c.stream("/m/" + sqid + "/stream")
521 defer s.close()
522 peer, _ := s.hello()
523 if resp, b := c.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusOK {
524 t.Fatalf("member join: %d %s", resp.StatusCode, b)
525 }
526
527 ask := func(ttl string) iceAnswer {
528 t.Helper()
529 req := httptest.NewRequest(http.MethodGet, "/m/"+sqid+"/ice?peer="+peer, nil)
530 req.Header.Set("X-Calls-Test-Ice-TTL", ttl)
531 rec := c.do(req)
532 if rec.Code != http.StatusOK {
533 t.Fatalf("ttl=%q: %d %s", ttl, rec.Code, rec.Body.String())
534 }
535 var ans iceAnswer
536 if err := json.Unmarshal(rec.Body.Bytes(), &ans); err != nil {
537 t.Fatal(err)
538 }
539 return ans
540 }
541
542 now := x.now
543 cases := []struct {
544 name string
545 ttl string
546 wantExpiresAt, wantRenewAt int64
547 }{
548 {"zero clamps to the floor", "0s", now.Add(2 * time.Second).Unix(), now.Add(1 * time.Second).Unix()},
549 {"negative clamps to the floor", "-5s", now.Add(2 * time.Second).Unix(), now.Add(1 * time.Second).Unix()},
550 {"unparseable falls back to the default", "not a duration", now.Add(3 * time.Hour).Unix(), now.Add(150 * time.Minute).Unix()},
551 {"an outsized value clamps to the ceiling", "10h", now.Add(3 * time.Hour).Unix(), now.Add(150 * time.Minute).Unix()},
552 }
553 for _, tc := range cases {
554 t.Run(tc.name, func(t *testing.T) {
555 ans := ask(tc.ttl)
556 if ans.ExpiresAt != tc.wantExpiresAt {
557 t.Errorf("ttl=%q: expiresAt %d, want %d", tc.ttl, ans.ExpiresAt, tc.wantExpiresAt)
558 }
559 if ans.RenewAt != tc.wantRenewAt {
560 t.Errorf("ttl=%q: renewAt %d, want %d", tc.ttl, ans.RenewAt, tc.wantRenewAt)
561 }
562 if ans.RenewAt >= ans.ExpiresAt {
563 t.Errorf("ttl=%q: renewAt %d is not before expiresAt %d", tc.ttl, ans.RenewAt, ans.ExpiresAt)
564 }
565 })
566 }
567}
568
569// TestIceRefusesAnEndedMeetingEvenForAStillAdmittedGuest proves ice.go's
570// own ended_at read is load-bearing on its own: the meeting is ended
571// directly in the store here, never through the real end-meeting
572// handler (which also calls hub.End and erases the hub's own record of
573// the room) — so the hub still believes this guest is live and
574// admitted right up to the request this test makes. Only ice.go's own
575// store read stands between that stale hub state and a 200.
576func TestIceRefusesAnEndedMeetingEvenForAStillAdmittedGuest(t *testing.T) {
577 x, sqid, tok := memberWithMeeting(t)
578 enableWaitingRoom(t, x, sqid)
579 if err := x.ctl.SetRelay(relayContract(t)); err != nil {
580 t.Fatal(err)
581 }
582 m := x.browser(t)
583 m.signIn("paul@example.com")
584 sm := m.stream("/m/" + sqid + "/stream")
585 defer sm.close()
586 pm, _ := sm.hello()
587 if resp, b := m.signal(sqid, pm, `{"type":"join"}`); resp.StatusCode != http.StatusOK {
588 t.Fatalf("member join: %d %s", resp.StatusCode, b)
589 }
590
591 g := x.browser(t)
592 g.get("/j/" + tok)
593 g.signIn("guest@example.com")
594 sg := g.stream("/m/" + sqid + "/stream")
595 defer sg.close()
596 pg, _ := sg.hello()
597 if resp, b := g.signal(sqid, pg, `{"type":"join"}`); resp.StatusCode != http.StatusOK {
598 t.Fatalf("guest join: %d %s", resp.StatusCode, b)
599 }
600 if resp, b := m.signal(sqid, pm, `{"type":"admit","payload":{"peer":"`+pg+`"}}`); resp.StatusCode != http.StatusOK {
601 t.Fatalf("admit: %d %s", resp.StatusCode, b)
602 }
603 if ev, _, ok := sg.nextOf("roster", 2*time.Second); !ok {
604 t.Fatalf("the guest was never admitted (got %q)", ev)
605 }
606
607 // A baseline 404 from an unrelated, unbound session, established
608 // before the meeting ends: every ice.go refusal shares this one
609 // body, so it stands in for a.notFoundBody() without reaching into
610 // the app package.
611 baseline := x.browser(t)
612 baseline.signIn("paul@rslw.com")
613 base := baseline.getStream("/m/" + sqid + "/ice?peer=" + pg)
614 if base.Code != http.StatusNotFound {
615 t.Fatalf("baseline 404: %d", base.Code)
616 }
617
618 if err := x.db.G.Exec("UPDATE meetings SET ended_at = ? WHERE sqid = ?", x.now, sqid).Error; err != nil {
619 t.Fatal(err)
620 }
621
622 rec := g.getStream("/m/" + sqid + "/ice?peer=" + pg)
623 if rec.Code != http.StatusNotFound || !bytes.Equal(rec.Body.Bytes(), base.Body.Bytes()) {
624 t.Fatalf("ended meeting: %d %s, want the same 404 as %s", rec.Code, rec.Body.Bytes(), base.Body.Bytes())
625 }
626}
627
628// TestIceNeverLogsAMalformedPeerVerbatim is validPeerID's load-bearing
629// proof for this endpoint, signal.go's own precedent (review Important
630// 2): a peer that fails validation is never logged, whatever it
631// contains — a newline that could forge a second log line, or a length
632// that could bloat one.
633func TestIceNeverLogsAMalformedPeerVerbatim(t *testing.T) {
634 var buf bytes.Buffer
635 x := newHarnessWithLogger(t, calls.EnvDevelopment, slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})), "paul@example.com")
636 c := x.browser(t)
637 c.signIn("paul@example.com")
638 sqid, _ := createMeeting(t, c, "Board meeting", "")
639 s := c.stream("/m/" + sqid + "/stream")
640 defer s.close()
641 s.hello()
642
643 needle := "forged-log-line\n" + strings.Repeat("z", 4096)
644 rec := c.getStream("/m/" + sqid + "/ice?peer=" + url.QueryEscape(needle))
645 if rec.Code != http.StatusNotFound {
646 t.Fatalf("malformed peer: %d", rec.Code)
647 }
648 if strings.Contains(buf.String(), needle) || strings.Contains(buf.String(), "forged-log-line") {
649 t.Fatalf("the malformed peer reached the log:\n%s", buf.String())
650 }
651}
652
653// TestIceNeverLogsAMalformedRoomOrMintsABucketForOne is the room half of
654// the same guard, and it is written to fail if validSqid is taken out of
655// the handler. The old assertion could not: it checked only the status,
656// and an unchecked sqid is refused a step later anyway when
657// LiveForSession finds no participation there (Codex S3-7). Two things
658// only the shape guard can give: a log line that names the room as
659// `invalid` rather than repeating four kilobytes of someone else's
660// text, and a limiter that mints no bucket for a room id that cannot
661// exist — the key is session+room, so an unchecked one is an unbounded
662// space of keys anybody can fill.
663func TestIceNeverLogsAMalformedRoomOrMintsABucketForOne(t *testing.T) {
664 var buf bytes.Buffer
665 x := newHarnessWithLogger(t, calls.EnvDevelopment, slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})), "paul@example.com")
666 c := x.browser(t)
667 c.signIn("paul@example.com")
668
669 needle := "forged-room-line\n" + strings.Repeat("q", 4096)
670 rec := c.getStream("/m/" + url.PathEscape(needle) + "/ice?peer=" + strings.Repeat("a", 32))
671 if rec.Code != http.StatusNotFound {
672 t.Fatalf("malformed room: %d %s", rec.Code, rec.Body.String())
673 }
674 log := buf.String()
675 if strings.Contains(log, "forged-room-line") || strings.Contains(log, strings.Repeat("q", 64)) {
676 t.Fatalf("the malformed room reached the log:\n%s", log)
677 }
678 if strings.Contains(log, "ice refused") && !strings.Contains(log, "room=invalid") {
679 t.Fatalf("a refusal was logged without naming the room `invalid`:\n%s", log)
680 }
681 if n := x.ctl.SignalLimiterSize(); n != 0 {
682 t.Fatalf("the limiter minted a bucket for a room id that cannot exist: %d keys", n)
683 }
684}
685