ice: GET /m/{sqid}/ice mints the RTCIceServer list for an admitted participation
hub.Authorize is the §2.5 last-row check: a participation bound to
this session, in this room, admitted. A member re-derives standing
first (§15.12) exactly as Relay does; a guest never consults it
(ruling 14). GET /m/{sqid}/ice, routed beside /stream and /signal,
answers with relay.Mint's per-call TURN credentials, 404 with the
identical body /signal uses for every refusal, and honours
X-Meet-Test-Ice-TTL only in development.
Deviations from the task brief's sketch:
- memberStandingGone returns (bool, error), not a bare error; Authorize
mirrors Relay's own gone/err handling rather than the brief's
single-return sketch.
- The hub test's waiting-guest case opens Gus with personID 100, not 2:
testHub's own StandingFor treats personID < 100 as a member, so 2
would have been admitted outright and proved nothing.
- meettest's client.signal takes (sqid, peer, body), not a body map; the
brief's iceContract-adjacent snippets used the map-shaped call form
that does not exist. Likewise x.h (the mux) has no SetRelay — that
lives on x.ctl (*meet.TestHandle) — so every SetRelay call in the new
tests goes through x.ctl.
- Added newHarnessWithEnv and guestJoinedAndWaiting as local helpers in
ice_test.go (isolation_test.go's steps were still inline; nothing
needed lifting from there, so nothing else was touched).
- The development-header test uses a 12s/10s TTL rather than 2s: at 2s,
life*5/6's fractional remainder can round to the same whole second as
life itself once truncated by Unix(), intermittently tying
expiresAt and renewAt.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
6 files changed,
+557
−7
internal/meet/app.go+1 −0internal/meet/hub/authorize.go+29 −0internal/meet/hub/authorize_test.go+96 −0internal/meet/ice.go+104 −0internal/meettest/ice_test.go+308 −0internal/meettest/stream_test.go+19 −7
CI passed — run details
diff --git a/internal/meet/app.go b/internal/meet/app.go| index ae01879..8b761e2 100644 |
| --- a/internal/meet/app.go |
| +++ b/internal/meet/app.go |
| @@ -421,6 +421,7 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err |
| }) |
| r.Get("/m/{sqid}/stream", a.stream) |
| r.Post("/m/{sqid}/signal", a.signal) |
| + r.Get("/m/{sqid}/ice", a.ice) |
| }) |
| mux := http.NewServeMux() |
diff --git a/internal/meet/hub/authorize.go b/internal/meet/hub/authorize.go| new file mode 100644 |
| index 0000000..fafec08 |
| --- /dev/null |
| +++ b/internal/meet/hub/authorize.go |
| @@ -0,0 +1,29 @@ |
| +package hub |
| + |
| +import "context" |
| + |
| +// Authorize is the §2.5 last-row check on its own: a participation |
| +// bound to this session, in this room, admitted. A member-kind |
| +// participation re-derives standing first (§15.12), exactly as Relay |
| +// does, and one whose standing is gone is ended here with lost{standing} |
| +// and answered ErrNotYours; a guest never consults standing (ruling 14). |
| +// GET /m/{sqid}/ice is its caller; it changes nothing on success. |
| +func (h *Hub) Authorize(ctx context.Context, sqid, session string, peer PeerID) error { |
| + gone, err := h.memberStandingGone(ctx, sqid, session, peer, true) |
| + if err != nil { |
| + return err |
| + } |
| + if gone { |
| + return ErrNotYours |
| + } |
| + h.mu.Lock() |
| + defer h.mu.Unlock() |
| + p, ok := h.findLocked(sqid, session, peer) |
| + if !ok { |
| + return ErrUnknownPeer |
| + } |
| + if !p.Admitted { |
| + return ErrNotAdmitted |
| + } |
| + return nil |
| +} |
diff --git a/internal/meet/hub/authorize_test.go b/internal/meet/hub/authorize_test.go| new file mode 100644 |
| index 0000000..d3bfd86 |
| --- /dev/null |
| +++ b/internal/meet/hub/authorize_test.go |
| @@ -0,0 +1,96 @@ |
| +package hub |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "io" |
| + "log/slog" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestAuthorizeIsTheAdmittedParticipationRule(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) |
| + if err := h.Authorize(ctx, "r1", "sa", a.Peer); !errors.Is(err, ErrNotAdmitted) { |
| + t.Fatalf("unjoined: want ErrNotAdmitted, got %v", err) |
| + } |
| + if err := h.Join(ctx, "r1", "sa", a.Peer); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := h.Authorize(ctx, "r1", "sa", a.Peer); err != nil { |
| + t.Fatalf("admitted member: %v", err) |
| + } |
| + if err := h.Authorize(ctx, "r1", "sb", a.Peer); !errors.Is(err, ErrUnknownPeer) { |
| + t.Fatalf("another session: want ErrUnknownPeer, got %v", err) |
| + } |
| + if err := h.Authorize(ctx, "r2", "sa", a.Peer); !errors.Is(err, ErrUnknownPeer) { |
| + t.Fatalf("another room: want ErrUnknownPeer, got %v", err) |
| + } |
| + // personID 100 is testHub's own guest threshold (personID < 100 is a |
| + // member, as TestMemberJoinsAndGuestWaits in admission_test.go |
| + // already relies on) — a personID of 2 here would derive as a |
| + // member and be admitted outright, proving nothing about a waiting |
| + // guest. |
| + g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest) |
| + h.Join(ctx, "r1", "sg", g.Peer) |
| + if err := h.Authorize(ctx, "r1", "sg", g.Peer); !errors.Is(err, ErrNotAdmitted) { |
| + t.Fatalf("waiting guest: want ErrNotAdmitted, got %v", err) |
| + } |
| +} |
| + |
| +// TestAuthorizeDropsAMemberWhoseStandingIsGoneAndKeepsAnAdmittedGuest is |
| +// §15.12 through Authorize, mirroring admission_test.go's |
| +// TestJoinDropsAndAnnouncesAMemberWhoLostStanding: standing is a |
| +// mutable map keyed on personID, moved without a tick running, so the |
| +// only thing that could have refused the member is Authorize's own |
| +// re-derivation. Bob is admitted as a guest (personID 100, testHub's |
| +// own guest threshold) before his grant "expires" — ruling 14 says |
| +// Authorize never even asks for a guest. |
| +func TestAuthorizeDropsAMemberWhoseStandingIsGoneAndKeepsAnAdmittedGuest(t *testing.T) { |
| + now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| + standing := map[int64]bool{1: true, 100: true} |
| + h := New(Config{ |
| + Now: func() time.Time { return now }, |
| + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), |
| + StandingFor: func(ctx context.Context, room string, id int64) (Kind, bool, error) { |
| + if id == 100 { |
| + return KindGuest, standing[id], nil |
| + } |
| + return KindMember, standing[id], nil |
| + }, |
| + }) |
| + ctx := context.Background() |
| + a, _ := h.Open("r1", "sa", 1, "Ann", KindMember) |
| + if err := h.Join(ctx, "r1", "sa", a.Peer); err != nil { |
| + t.Fatal(err) |
| + } |
| + b, _ := h.Open("r1", "sb", 100, "Bob", KindGuest) |
| + if err := h.Join(ctx, "r1", "sb", b.Peer); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := h.Admit(ctx, "r1", "sa", a.Peer, b.Peer); err != nil { |
| + t.Fatal(err) |
| + } |
| + drain(a.Sub) |
| + drain(b.Sub) |
| + |
| + standing[1] = false // Ann taken off the team, no tick has run |
| + if err := h.Authorize(ctx, "r1", "sa", a.Peer); !errors.Is(err, ErrNotYours) { |
| + t.Fatalf("member whose standing is gone: want ErrNotYours, got %v", err) |
| + } |
| + if _, found := h.Find("r1", "sa", a.Peer); found { |
| + t.Fatal("Ann's participation should have ended") |
| + } |
| + bev := drain(b.Sub) |
| + if len(bev) == 0 || bev[len(bev)-1].Type != TypeGone || bev[len(bev)-1].From != a.Peer { |
| + t.Fatalf("Bob's stream got %v, want gone naming Ann last", types(bev)) |
| + } |
| + |
| + standing[100] = false // Bob's grant has "expired"; Authorize never asks for a guest |
| + if err := h.Authorize(ctx, "r1", "sb", b.Peer); err != nil { |
| + t.Fatalf("admitted guest with a lapsed grant: %v", err) |
| + } |
| +} |
diff --git a/internal/meet/ice.go b/internal/meet/ice.go| new file mode 100644 |
| index 0000000..4d62547 |
| --- /dev/null |
| +++ b/internal/meet/ice.go |
| @@ -0,0 +1,104 @@ |
| +package meet |
| + |
| +import ( |
| + "errors" |
| + "net/http" |
| + "time" |
| + |
| + "github.com/go-chi/chi/v5" |
| + |
| + "amadan.net/oficina/meet/internal/meet/hub" |
| + "amadan.net/oficina/meet/internal/meet/relay" |
| +) |
| + |
| +const ( |
| + // iceLife and iceRenewAfter are §5.2's numbers: a call's credentials |
| + // are minted for three hours, inside the platform contract's |
| + // four-hour maximum, and the browser is told to renew at 150 |
| + // minutes (§5.3 step 4) — well ahead of expiry, so a slow renewal |
| + // never runs out mid-call. |
| + iceLife = 3 * time.Hour |
| + iceRenewAfter = 150 * time.Minute |
| + // testIceHeader shortens one answer's life, and only in development |
| + // (like stream.go's testTTLHeader): a plain GET can set it, unlike |
| + // EventSource, so this one needs no browser gate to reach — the |
| + // development gate is what keeps it off production. |
| + testIceHeader = "X-Meet-Test-Ice-TTL" |
| + minIceLife = 2 * time.Second |
| +) |
| + |
| +// ice answers GET /m/{sqid}/ice?peer= with the RTCIceServer list for one |
| +// call (§5.2). Authority is the §2.5 last row — an admitted |
| +// participation bound to this session — through hub.Authorize, which |
| +// also applies the member re-derivation of §15.12. Every refusal is the |
| +// same 404 as /signal's, so nothing about another session's |
| +// participation is observable here. Nothing is logged on success: the |
| +// answer carries a credential. |
| +func (a *app) ice(w http.ResponseWriter, r *http.Request) { |
| + sqid := chi.URLParam(r, "sqid") |
| + session, ok := a.sessionKey(r) |
| + if !ok { |
| + a.logger.Error("meet: ice with no session subject", "path", r.URL.Path) |
| + streamJSON(w, http.StatusInternalServerError, nil) |
| + return |
| + } |
| + if !validSqid(sqid) { |
| + a.logger.Info("meet: ice refused", "room", "invalid", "peer", "", "outcome", "shape") |
| + streamJSON(w, http.StatusNotFound, a.notFoundBody()) |
| + return |
| + } |
| + peer := r.URL.Query().Get("peer") |
| + if !validPeerID(hub.PeerID(peer)) { |
| + // peer is never logged here, exactly like signal.go's refusal: |
| + // it has not been validated as bounded, printable text (review |
| + // Important 2's rule, carried over). |
| + a.logger.Info("meet: ice refused", "room", sqid, "peer", "", "outcome", "shape") |
| + streamJSON(w, http.StatusNotFound, a.notFoundBody()) |
| + return |
| + } |
| + // The meeting itself must be live: a fresh process holds no hub |
| + // state for a room nobody has opened, so ended_at is the only |
| + // authority it has — stream.go's own fresh-open precedent. |
| + m, found, err := a.store.meetingBySqid(r.Context(), sqid) |
| + if err != nil { |
| + a.logger.Error("meet: ice: load meeting", "err", err) |
| + streamJSON(w, http.StatusInternalServerError, nil) |
| + return |
| + } |
| + if !found || m.EndedAt != nil { |
| + streamJSON(w, http.StatusNotFound, a.notFoundBody()) |
| + return |
| + } |
| + switch err := a.hub.Authorize(r.Context(), sqid, session, hub.PeerID(peer)); { |
| + case err == nil: |
| + case errors.Is(err, hub.ErrUnknownPeer), errors.Is(err, hub.ErrNotAdmitted), errors.Is(err, hub.ErrNotYours): |
| + a.logger.Info("meet: ice refused", "room", sqid, "peer", peer, "outcome", "authority") |
| + streamJSON(w, http.StatusNotFound, a.notFoundBody()) |
| + return |
| + default: |
| + a.logger.Error("meet: ice: authorize", "room", sqid, "peer", peer, "outcome", "internal", "err", err) |
| + streamJSON(w, http.StatusInternalServerError, nil) |
| + return |
| + } |
| + |
| + now := a.store.now() |
| + life, renew := iceLife, iceRenewAfter |
| + if a.cfg.development() { |
| + if d, err := time.ParseDuration(r.Header.Get(testIceHeader)); err == nil && d > 0 { |
| + life = max(d, minIceLife) |
| + renew = life * 5 / 6 |
| + } |
| + } |
| + |
| + regions := a.relayRegions() |
| + if len(regions) == 0 { |
| + streamJSON(w, http.StatusOK, map[string]any{"iceServers": []relay.ICEServer{}, "expiresAt": 0, "renewAt": 0}) |
| + return |
| + } |
| + expiry := now.Add(life) |
| + streamJSON(w, http.StatusOK, map[string]any{ |
| + "iceServers": relay.Mint(regions, expiry), |
| + "expiresAt": expiry.Unix(), |
| + "renewAt": now.Add(renew).Unix(), |
| + }) |
| +} |
diff --git a/internal/meettest/ice_test.go b/internal/meettest/ice_test.go| new file mode 100644 |
| index 0000000..586f4cb |
| --- /dev/null |
| +++ b/internal/meettest/ice_test.go |
| @@ -0,0 +1,308 @@ |
| +package meettest |
| + |
| +import ( |
| + "bytes" |
| + "encoding/base64" |
| + "encoding/json" |
| + "fmt" |
| + "io" |
| + "log/slog" |
| + "net/http" |
| + "net/http/httptest" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/rastrillo/rastrillo/db" |
| + |
| + meet "amadan.net/oficina/meet/internal/meet" |
| + "amadan.net/oficina/meet/internal/meet/relay" |
| +) |
| + |
| +const vectorKey = "uMhwW4P/9O5dQUIs91t2CCHGhwJbeiM0xQMQ2G0CJLU=" |
| + |
| +func relayContract(t *testing.T) string { |
| + t.Helper() |
| + b, _ := json.Marshal([]map[string]any{{ |
| + "region": "eu-west-1", |
| + "urls": []string{"stun:relay.eu-west-1.oncarlos.com:3478", "turn:relay.eu-west-1.oncarlos.com:3478?transport=udp"}, |
| + "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1", |
| + }}) |
| + return base64.StdEncoding.EncodeToString(b) |
| +} |
| + |
| +type iceAnswer struct { |
| + ICEServers []relay.ICEServer `json:"iceServers"` |
| + ExpiresAt int64 `json:"expiresAt"` |
| + RenewAt int64 `json:"renewAt"` |
| +} |
| + |
| +// guestJoinedAndWaiting signs a fresh outsider in through tok, opens its |
| +// stream and posts join without anyone admitting it — the waiting shape |
| +// isolation_test.go's openIsolationRoom also builds inline, lifted here |
| +// for a single guest. |
| +func guestJoinedAndWaiting(t *testing.T, x *harness, sqid, tok string) (*client, string) { |
| + t.Helper() |
| + g := x.browser(t) |
| + g.get("/j/" + tok) |
| + g.signIn("waiting-guest@example.com") |
| + s := g.stream("/m/" + sqid + "/stream") |
| + t.Cleanup(s.close) |
| + peer, _ := s.hello() |
| + if resp, b := g.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("guest join (the knock): %d %s", resp.StatusCode, b) |
| + } |
| + return g, peer |
| +} |
| + |
| +// newHarnessWithEnv is newHarnessWithLogger with the environment as a |
| +// parameter, so a test can build the production harness that proves |
| +// the development-only ICE TTL header is inert outside development. |
| +// Production needs the full set of startup secrets |
| +// TestProductionRefusesToStartWithoutSecrets exercises — Origin, |
| +// InstanceKey, DEK, Mailer and a non-empty allowlist — so this fills |
| +// those in rather than merely swapping Env. |
| +func newHarnessWithEnv(t *testing.T, env string, members ...string) *harness { |
| + t.Helper() |
| + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| + dbPath := filepath.Join(t.TempDir(), "app.db") |
| + d, err := db.Open(dbPath, logger) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { d.Close() }) |
| + rec := &recordingMailer{} |
| + cfg := meet.Config{ |
| + Env: env, Origin: testOrigin, AllowedAddresses: members, Mailer: rec, Logger: logger, |
| + } |
| + if env != meet.EnvDevelopment { |
| + cfg.InstanceKey = "test-instance-key-not-a-real-secret" |
| + cfg.DEK = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" |
| + } |
| + mux, ctl, err := meet.NewForTest(d, cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + x := &harness{h: mux, db: d, dbPath: dbPath, mail: rec, ctl: ctl, now: time.Now().UTC(), t: t} |
| + x.advance(0) |
| + return x |
| +} |
| + |
| +func TestIceHandsAnAdmittedParticipationCredentialsThatVerify(t *testing.T) { |
| + x, sqid, _ := memberWithMeeting(t) |
| + if err := x.ctl.SetRelay(relayContract(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + s := c.stream("/m/" + sqid + "/stream") |
| + defer s.close() |
| + peer, _ := s.hello() |
| + if resp, b := c.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("member join: %d %s", resp.StatusCode, b) |
| + } |
| + s.nextOf("roster", 2*time.Second) |
| + |
| + rec := c.getStream("/m/" + sqid + "/ice?peer=" + peer) // any bounded GET helper; the Accept header is harmless |
| + if rec.Code != http.StatusOK || rec.Header().Get("Cache-Control") != "no-store" { |
| + t.Fatalf("ice: %d %q", rec.Code, rec.Header().Get("Cache-Control")) |
| + } |
| + var ans iceAnswer |
| + if err := json.Unmarshal(rec.Body.Bytes(), &ans); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(ans.ICEServers) != 2 || ans.ICEServers[0].Username != "" || ans.ICEServers[1].Username == "" { |
| + t.Fatalf("servers %+v", ans.ICEServers) |
| + } |
| + now := x.now |
| + if ans.ExpiresAt < now.Add(3*time.Hour-time.Second).Unix() || ans.ExpiresAt > now.Add(3*time.Hour+time.Second).Unix() { |
| + t.Fatalf("expiry %d is not three hours ahead of %d", ans.ExpiresAt, now.Unix()) |
| + } |
| + if ans.RenewAt != now.Add(150*time.Minute).Unix() { |
| + t.Fatalf("renewAt %d", ans.RenewAt) |
| + } |
| + // The credential verifies against the vector key for the username the server chose. |
| + key, _ := base64.StdEncoding.DecodeString(vectorKey) |
| + turn := ans.ICEServers[1] |
| + if relay.Credential(key, turn.Username) != turn.Credential { |
| + t.Fatal("credential does not verify") |
| + } |
| + if want := fmt.Sprintf("%d:abc:meet:0123456789abcdef:k1", ans.ExpiresAt); turn.Username != want { |
| + t.Fatalf("username %q, want %q", turn.Username, want) |
| + } |
| +} |
| + |
| +func TestIceIs404ForAnythingButAnAdmittedParticipationOfThisSession(t *testing.T) { |
| + x, sqid, tok := memberWithMeeting(t) |
| + if err := x.ctl.SetRelay(relayContract(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + s := c.stream("/m/" + sqid + "/stream") |
| + defer s.close() |
| + peer, _ := s.hello() |
| + unjoined := c.getStream("/m/" + sqid + "/ice?peer=" + peer) |
| + if unjoined.Code != http.StatusNotFound { |
| + t.Fatalf("unjoined: %d", unjoined.Code) |
| + } |
| + |
| + other := x.browser(t) |
| + other.signIn("paul@rslw.com") |
| + foreign := other.getStream("/m/" + sqid + "/ice?peer=" + peer) |
| + if foreign.Code != http.StatusNotFound || !bytes.Equal(foreign.Body.Bytes(), unjoined.Body.Bytes()) { |
| + t.Fatalf("another session must get the identical 404: %d %s vs %s", foreign.Code, foreign.Body.Bytes(), unjoined.Body.Bytes()) |
| + } |
| + |
| + bad := c.getStream("/m/" + sqid + "/ice?peer=zz") |
| + if bad.Code != http.StatusNotFound || !bytes.Equal(bad.Body.Bytes(), unjoined.Body.Bytes()) { |
| + t.Fatal("a malformed peer is the same 404") |
| + } |
| + |
| + room := c.getStream("/m/2222222222/ice?peer=" + peer) |
| + if room.Code != http.StatusNotFound || !bytes.Equal(room.Body.Bytes(), unjoined.Body.Bytes()) { |
| + t.Fatal("an unknown room is the same 404") |
| + } |
| + |
| + // A waiting guest: sign a guest in through the link and join; 404. |
| + g, gpeer := guestJoinedAndWaiting(t, x, sqid, tok) |
| + waiting := g.getStream("/m/" + sqid + "/ice?peer=" + gpeer) |
| + if waiting.Code != http.StatusNotFound || !bytes.Equal(waiting.Body.Bytes(), unjoined.Body.Bytes()) { |
| + t.Fatal("a waiting guest is the same 404") |
| + } |
| +} |
| + |
| +func TestIceWithNoRelayIsAnEmptyList(t *testing.T) { |
| + x, sqid, _ := memberWithMeeting(t) |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + s := c.stream("/m/" + sqid + "/stream") |
| + defer s.close() |
| + peer, _ := s.hello() |
| + if resp, b := c.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("member join: %d %s", resp.StatusCode, b) |
| + } |
| + s.nextOf("roster", 2*time.Second) |
| + rec := c.getStream("/m/" + sqid + "/ice?peer=" + peer) |
| + if rec.Code != http.StatusOK || rec.Body.String() != "{\"expiresAt\":0,\"iceServers\":[],\"renewAt\":0}\n" { |
| + t.Fatalf("%d %q", rec.Code, rec.Body.String()) |
| + } |
| +} |
| + |
| +// TestIceTestTTLHeaderIsHonouredOnlyInDevelopment is stream_test.go's |
| +// TestTheTestTTLHeaderIsDevelopmentOnly over HTTP: EventSource cannot |
| +// set a header, but a plain GET can, so this one needs no browser gate |
| +// to reach the handler — only the development check does, and this |
| +// test is what proves it. |
| +func TestIceTestTTLHeaderIsHonouredOnlyInDevelopment(t *testing.T) { |
| + withHeader := func(c *client, target, ttl string) *httptest.ResponseRecorder { |
| + req := httptest.NewRequest(http.MethodGet, target, nil) |
| + req.Header.Set("X-Meet-Test-Ice-TTL", ttl) |
| + return c.do(req) |
| + } |
| + |
| + t.Run("development", func(t *testing.T) { |
| + x, sqid, _ := memberWithMeeting(t) |
| + if err := x.ctl.SetRelay(relayContract(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + s := c.stream("/m/" + sqid + "/stream") |
| + defer s.close() |
| + peer, _ := s.hello() |
| + c.signal(sqid, peer, `{"type":"join"}`) |
| + |
| + // Twelve seconds, not two: expiresAt and renewAt are both |
| + // truncated to whole seconds by Unix(), and life*5/6's fractional |
| + // remainder can tie the two together at a short duration. Twelve |
| + // gives a two-second gap (12s vs 10s) that survives the rounding. |
| + rec := withHeader(c, "/m/"+sqid+"/ice?peer="+peer, "12s") |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("development with header: %d %s", rec.Code, rec.Body.String()) |
| + } |
| + var ans iceAnswer |
| + if err := json.Unmarshal(rec.Body.Bytes(), &ans); err != nil { |
| + t.Fatal(err) |
| + } |
| + now := x.now |
| + if ans.ExpiresAt < now.Unix() || ans.ExpiresAt > now.Add(15*time.Second).Unix() { |
| + t.Fatalf("expiresAt %d is not ~12s ahead of %d", ans.ExpiresAt, now.Unix()) |
| + } |
| + if ans.RenewAt >= ans.ExpiresAt { |
| + t.Fatalf("renewAt %d is not before expiresAt %d", ans.RenewAt, ans.ExpiresAt) |
| + } |
| + }) |
| + |
| + t.Run("production", func(t *testing.T) { |
| + x := newHarnessWithEnv(t, "production", "paul@example.com") |
| + if err := x.ctl.SetRelay(relayContract(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + sqid, _ := createMeeting(t, c, "Board meeting", "") |
| + s := c.stream("/m/" + sqid + "/stream") |
| + defer s.close() |
| + peer, _ := s.hello() |
| + c.signal(sqid, peer, `{"type":"join"}`) |
| + |
| + rec := withHeader(c, "/m/"+sqid+"/ice?peer="+peer, "12s") |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("production with header: %d %s", rec.Code, rec.Body.String()) |
| + } |
| + var ans iceAnswer |
| + if err := json.Unmarshal(rec.Body.Bytes(), &ans); err != nil { |
| + t.Fatal(err) |
| + } |
| + now := x.now |
| + if ans.ExpiresAt < now.Add(3*time.Hour-time.Second).Unix() || ans.ExpiresAt > now.Add(3*time.Hour+time.Second).Unix() { |
| + t.Fatalf("production must ignore the header: expiresAt %d, want ~3h ahead of %d", ans.ExpiresAt, now.Unix()) |
| + } |
| + }) |
| +} |
| + |
| +// TestIceLogsNothingOnSuccessAndNoCredentialOnRefusal is |
| +// signal_test.go's TestSignallingLogsCarryNoContent for this endpoint: |
| +// nothing at all is logged for a successful call (the answer carries a |
| +// credential), and a refusal's log line never carries the relay key, |
| +// the minted username or its credential. |
| +func TestIceLogsNothingOnSuccessAndNoCredentialOnRefusal(t *testing.T) { |
| + var buf bytes.Buffer |
| + x := newHarnessWithLogger(t, slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})), "paul@example.com") |
| + if err := x.ctl.SetRelay(relayContract(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + sqid, _ := createMeeting(t, c, "Board meeting", "") |
| + s := c.stream("/m/" + sqid + "/stream") |
| + defer s.close() |
| + peer, _ := s.hello() |
| + if resp, b := c.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("member join: %d %s", resp.StatusCode, b) |
| + } |
| + |
| + before := buf.Len() |
| + if rec := c.getStream("/m/" + sqid + "/ice?peer=" + peer); rec.Code != http.StatusOK { |
| + t.Fatalf("ice: %d %s", rec.Code, rec.Body.String()) |
| + } |
| + if success := buf.String()[before:]; strings.Contains(success, "ice") { |
| + t.Fatalf("success logged something: %q", success) |
| + } |
| + |
| + if rec := c.getStream("/m/" + sqid + "/ice?peer=zz"); rec.Code != http.StatusNotFound { |
| + t.Fatalf("malformed peer: %d", rec.Code) |
| + } |
| + |
| + logs := buf.String() |
| + if !strings.Contains(logs, "meet: ice refused") { |
| + t.Fatalf("the refusal was not logged:\n%s", logs) |
| + } |
| + for _, forbidden := range []string{vectorKey, ":abc:meet:"} { |
| + if strings.Contains(logs, forbidden) { |
| + t.Fatalf("log leaked a credential:\n%s", logs) |
| + } |
| + } |
| +} |
diff --git a/internal/meettest/stream_test.go b/internal/meettest/stream_test.go| index d9123b5..8eb7eed 100644 |
| --- a/internal/meettest/stream_test.go |
| +++ b/internal/meettest/stream_test.go |
| @@ -549,13 +549,25 @@ func TestAnAdmittedGuestCarriesOnPastGrantExpiryButGetsNoFreshEntry(t *testing.T |
| t.Fatalf("the resumed guest is not admitted any more (got %q)", ev) |
| } |
| - // GET /m/{sqid}/ice is slice 3's, and is not mounted yet, so it |
| - // answers chi's own 404 for everybody. When slice 3 mounts it, this |
| - // line becomes the assertion that the expired guest is served like |
| - // every other admitted peer — the endpoint is named in §13's "on |
| - // every endpoint" and this is where it lands. |
| - if rec := g.get("/m/" + sqid + "/ice"); rec.Code != http.StatusNotFound { |
| - t.Fatalf("GET /ice: %d — slice 3 has mounted it; give this test its real arm", rec.Code) |
| + // GET /m/{sqid}/ice is authorized by the guest's admitted |
| + // participation (hub.Authorize), never by standing — ruling 14 |
| + // again, on the one endpoint §13 names "on every endpoint": a |
| + // lapsed grant does not cost this expired guest their ICE servers. |
| + x.ctl.SetRelay(relayContract(t)) |
| + rec := g.get("/m/" + sqid + "/ice?peer=" + pg) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("GET /ice past grant expiry: %d, want 200", rec.Code) |
| + } |
| + var ice struct { |
| + ICEServers []struct { |
| + URLs []string `json:"urls"` |
| + } `json:"iceServers"` |
| + } |
| + if err := json.Unmarshal(rec.Body.Bytes(), &ice); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(ice.ICEServers) == 0 { |
| + t.Fatal("GET /ice past grant expiry returned no servers") |
| } |
| // Fresh entry is not theirs: a stream with no peer is authorized by |