room: page, removal and takeover confirm pages, participation-or-standing access, the scriptless exception
Adapts task-6-brief.md to the branch's current hub/session shapes: sessionKey now returns (string, bool), so roomAccess/remove/takeover each check it defensively and log-and-500 on a missing subject, the same pattern stream.go and signal.go already use. hub.Remove and hub.Takeover signatures match the brief; any error from either maps to 404, never 403 (ErrNotYours, ErrUnknownPeer, ErrNotAdmitted, ErrNotMember all mean 'not a peer you may act on'). sseReader.nextOf did not exist yet (task-11-brief.md lists it as already present from tasks 4/5, but it wasn't); added it to sse_test.go since room_test.go needs it. Two of the brief's test lines used client.get on /stream where the handler's EventSource-Accept gate (ruling 15, committed after the plan was written) refuses a plain GET before it reaches the conflict/resume logic being asserted; switched those two lines to client.getStream, which sends the Accept header, matching what those assertions actually need to exercise. room.html's data-* block gained a fifth attribute, data-peer=, that the brief's Produces line lists but its own template snippet omitted. confirmRemove's copy names the meeting, not the removed person: the hub has no exported way to look up a peer's name from another peer's session, and this task does not touch hub files to add one. Flagged for the reviewer; spec §3.5 asks for the person's name.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
11 files changed,
+426
−25
AGENTS.md+5 −2internal/meet/app.go+5 −0internal/meet/handlers_join.go+5 −22internal/meet/handlers_room.go+149 −0internal/meet/render.go+1 −1internal/meet/static/meet.css+95 −0internal/meet/static/room.js+9 −0internal/meet/templates/join.html+2 −0internal/meet/templates/room.html+22 −0internal/meettest/room_test.go+115 −0internal/meettest/sse_test.go+18 −0
CI passed — run details
diff --git a/AGENTS.md b/AGENTS.md| index 6d355c4..7467ca3 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -47,8 +47,11 @@ mechanically. |
| a rename is indistinguishable from a drop plus an add. |
| - Money is integer cents. A float never touches a value a person will |
| be held to. |
| -- Screens work with JavaScript disabled; destructive actions get their |
| - own confirm-page URL. |
| +- Screens work with JavaScript disabled, with one exception: browser |
| + media participation (the holding screen's camera preview and join |
| + button, and the call itself in the room) needs script; both pages |
| + render their title, standing, floor notice and navigation without it. |
| + Destructive actions get their own confirm-page URL. |
| - The gate is `make ci` (vet + gofmt + tests) — the same definition |
| CI runs. Run it before every push. `CGO_ENABLED=0` throughout: the |
| stack is cgo-free by design. |
diff --git a/internal/meet/app.go b/internal/meet/app.go| index 9c2fc82..75bc5ea 100644 |
| --- a/internal/meet/app.go |
| +++ b/internal/meet/app.go |
| @@ -307,6 +307,11 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err |
| r.Get("/continue", a.continueSignedIn) |
| r.Get("/welcome", a.welcome) |
| r.Get("/m/{sqid}/join", a.holding) |
| + r.Get("/m/{sqid}", a.room) |
| + r.Get("/m/{sqid}/remove/{peer}", a.confirmRemove) |
| + r.Post("/m/{sqid}/remove/{peer}", a.remove) |
| + r.Get("/m/{sqid}/takeover", a.confirmTakeover) |
| + r.Post("/m/{sqid}/takeover", a.takeover) |
| r.Group(func(r chi.Router) { |
| r.Use(a.requireMember) |
| r.Get("/", a.index) |
diff --git a/internal/meet/handlers_join.go b/internal/meet/handlers_join.go| index 6fdfda5..185514e 100644 |
| --- a/internal/meet/handlers_join.go |
| +++ b/internal/meet/handlers_join.go |
| @@ -52,29 +52,12 @@ func (a *app) followLink(w http.ResponseWriter, r *http.Request) { |
| http.Redirect(w, r, "/m/"+m.Sqid+"/join", http.StatusSeeOther) |
| } |
| -// holding is GET /m/{sqid}/join, authorized by standing (spec §2.5). In |
| -// this slice it renders the server-side half of the holding screen and |
| -// says the room is the next slice. |
| +// holding is GET /m/{sqid}/join, authorized by standing or a live |
| +// participation for this session (spec §2.5), the same row the room |
| +// page uses. |
| func (a *app) holding(w http.ResponseWriter, r *http.Request) { |
| - m, ok, err := a.store.meetingBySqid(r.Context(), chi.URLParam(r, "sqid")) |
| - if err != nil { |
| - a.logger.Error("meet: load meeting", "err", err) |
| - ErrorPage(w, r, http.StatusInternalServerError, "") |
| - return |
| - } |
| - if !ok || m.EndedAt != nil { |
| - ErrorPage(w, r, http.StatusNotFound, "") |
| - return |
| - } |
| - p, _ := currentPerson(r) |
| - st, err := a.standingFor(r.Context(), p, m) |
| - if err != nil { |
| - a.logger.Error("meet: standing", "err", err) |
| - ErrorPage(w, r, http.StatusInternalServerError, "") |
| - return |
| - } |
| - if st == StandingNone { |
| - ErrorPage(w, r, http.StatusNotFound, "") |
| + m, p, st, ok := a.roomAccess(w, r) |
| + if !ok { |
| return |
| } |
| render(w, "join", page(r, map[string]any{ |
diff --git a/internal/meet/handlers_room.go b/internal/meet/handlers_room.go| new file mode 100644 |
| index 0000000..e178624 |
| --- /dev/null |
| +++ b/internal/meet/handlers_room.go |
| @@ -0,0 +1,149 @@ |
| +package meet |
| + |
| +import ( |
| + "net/http" |
| + |
| + "github.com/go-chi/chi/v5" |
| + |
| + "amadan.net/oficina/meet/internal/meet/hub" |
| +) |
| + |
| +// roomAccess is §2.5's row for the two pages: standing, or a live |
| +// participation for this session in this room. |
| +func (a *app) roomAccess(w http.ResponseWriter, r *http.Request) (Meeting, Person, Standing, bool) { |
| + sqid := chi.URLParam(r, "sqid") |
| + m, ok, err := a.store.meetingBySqid(r.Context(), sqid) |
| + if err != nil { |
| + a.logger.Error("meet: load meeting", "err", err) |
| + ErrorPage(w, r, http.StatusInternalServerError, "") |
| + return Meeting{}, Person{}, StandingNone, false |
| + } |
| + if !ok || m.EndedAt != nil { |
| + ErrorPage(w, r, http.StatusNotFound, "") |
| + return Meeting{}, Person{}, StandingNone, false |
| + } |
| + p, _ := currentPerson(r) |
| + st, err := a.standingFor(r.Context(), p, m) |
| + if err != nil { |
| + a.logger.Error("meet: standing", "err", err) |
| + ErrorPage(w, r, http.StatusInternalServerError, "") |
| + return Meeting{}, Person{}, StandingNone, false |
| + } |
| + if st == StandingNone { |
| + session, ok := a.sessionKey(r) |
| + if !ok { |
| + a.logger.Error("meet: room access with no session subject", "path", r.URL.Path) |
| + ErrorPage(w, r, http.StatusInternalServerError, "") |
| + return Meeting{}, Person{}, StandingNone, false |
| + } |
| + if _, live := a.hub.LiveForSession(sqid, session); !live { |
| + ErrorPage(w, r, http.StatusNotFound, "") |
| + return Meeting{}, Person{}, StandingNone, false |
| + } |
| + st = StandingGuest |
| + } |
| + return m, p, st, true |
| +} |
| + |
| +// room is GET /m/{sqid}: standing or live participation (spec §2.5, |
| +// §3.4). Scriptless it renders the header, the standing line and a link |
| +// back to the holding screen; room.js reveals the call for a browser |
| +// that has script. |
| +func (a *app) room(w http.ResponseWriter, r *http.Request) { |
| + m, p, st, ok := a.roomAccess(w, r) |
| + if !ok { |
| + return |
| + } |
| + render(w, "room", page(r, map[string]any{ |
| + "Sqid": m.Sqid, "Title": a.store.title(m), "Starts": a.fmtStart(m.StartsAt), |
| + "Name": p.Name, "Standing": st.String(), "IsMember": st == StandingMember, |
| + "Floor": !a.cfg.RelayConfigured, "Boot": a.hub.Boot(), |
| + })) |
| +} |
| + |
| +// confirmRemove is GET /m/{sqid}/remove/{peer}: its own URL, per the |
| +// family's rule for a destructive action (spec §3.5). Standing member is |
| +// enough to see the page; hub.Remove is the actual authority, checked at |
| +// execution. |
| +func (a *app) confirmRemove(w http.ResponseWriter, r *http.Request) { |
| + m, _, st, ok := a.roomAccess(w, r) |
| + if !ok { |
| + return |
| + } |
| + if st != StandingMember { |
| + ErrorPage(w, r, http.StatusNotFound, "") |
| + return |
| + } |
| + target := chi.URLParam(r, "peer") |
| + render(w, "confirm", page(r, map[string]any{ |
| + "Title": "Remove this participant?", |
| + "Body": "They will be disconnected from \"" + a.store.title(m) + "\" and can only come back through the meeting link.", |
| + "Action": "/m/" + m.Sqid + "/remove/" + target, "Label": "Remove", |
| + "CancelHref": "/m/" + m.Sqid, "Sqid": m.Sqid, |
| + })) |
| +} |
| + |
| +// remove is POST /m/{sqid}/remove/{peer}: hub.Remove is the only |
| +// authority (spec §3.5), re-derived at execution. Its ErrNotYours, |
| +// ErrUnknownPeer, ErrNotAdmitted and ErrNotMember all mean the same |
| +// thing to the caller — this is not a peer they may remove — and all |
| +// answer 404, never 403 (spec §2.5). |
| +func (a *app) remove(w http.ResponseWriter, r *http.Request) { |
| + m, _, _, ok := a.roomAccess(w, r) |
| + if !ok { |
| + return |
| + } |
| + session, ok := a.sessionKey(r) |
| + if !ok { |
| + a.logger.Error("meet: remove with no session subject", "path", r.URL.Path) |
| + ErrorPage(w, r, http.StatusInternalServerError, "") |
| + return |
| + } |
| + actor, live := a.hub.LiveForSession(m.Sqid, session) |
| + if !live { |
| + ErrorPage(w, r, http.StatusNotFound, "") |
| + return |
| + } |
| + if err := a.hub.Remove(r.Context(), m.Sqid, session, actor, hub.PeerID(chi.URLParam(r, "peer"))); err != nil { |
| + ErrorPage(w, r, http.StatusNotFound, "") |
| + return |
| + } |
| + http.Redirect(w, r, "/m/"+m.Sqid, http.StatusSeeOther) |
| +} |
| + |
| +// confirmTakeover is GET /m/{sqid}/takeover: its own URL, so a GET never |
| +// takes over (spec §4.2). roomAccess's standing-or-participation row |
| +// covers the page; hub.Takeover is the authority for the POST. |
| +func (a *app) confirmTakeover(w http.ResponseWriter, r *http.Request) { |
| + m, _, _, ok := a.roomAccess(w, r) |
| + if !ok { |
| + return |
| + } |
| + render(w, "confirm", page(r, map[string]any{ |
| + "Title": "This meeting is open in another tab", |
| + "Body": "Use this tab instead? The other tab will leave the call.", |
| + "Action": "/m/" + m.Sqid + "/takeover", "Label": "Use this tab", |
| + "CancelHref": "/m/" + m.Sqid + "/join", "Sqid": m.Sqid, |
| + })) |
| +} |
| + |
| +// takeover is POST /m/{sqid}/takeover: a confirmed action on the |
| +// session's own live participation only (spec §4.2), never another |
| +// person's. |
| +func (a *app) takeover(w http.ResponseWriter, r *http.Request) { |
| + m, _, _, ok := a.roomAccess(w, r) |
| + if !ok { |
| + return |
| + } |
| + session, ok := a.sessionKey(r) |
| + if !ok { |
| + a.logger.Error("meet: takeover with no session subject", "path", r.URL.Path) |
| + ErrorPage(w, r, http.StatusInternalServerError, "") |
| + return |
| + } |
| + if _, err := a.hub.Takeover(m.Sqid, session); err != nil { |
| + ErrorPage(w, r, http.StatusNotFound, "") |
| + return |
| + } |
| + http.Redirect(w, r, "/m/"+m.Sqid, http.StatusSeeOther) |
| +} |
diff --git a/internal/meet/render.go b/internal/meet/render.go| index 81ea640..81bcea3 100644 |
| --- a/internal/meet/render.go |
| +++ b/internal/meet/render.go |
| @@ -17,7 +17,7 @@ var appFS embed.FS |
| var assets = rastrillo.NewAssets(appFS) |
| -var pageNames = []string{"index", "signin", "errors", "welcome", "link_once", "confirm", "join"} |
| +var pageNames = []string{"index", "signin", "errors", "welcome", "link_once", "confirm", "join", "room"} |
| var pages = map[string]*template.Template{} |
diff --git a/internal/meet/static/meet.css b/internal/meet/static/meet.css| new file mode 100644 |
| index 0000000..e0288bb |
| --- /dev/null |
| +++ b/internal/meet/static/meet.css |
| @@ -0,0 +1,95 @@ |
| +/* meet.css: the room and the holding screen's scripted parts. Small on |
| + purpose (spec §9's three hand-written scripts get their own styling |
| + as they grow); everything here reads from rst-* tokens so it follows |
| + the theme rather than fixing its own colours. */ |
| + |
| +.meet-grid { |
| + display: grid; |
| + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); |
| + gap: var(--rst-sp-3); |
| + margin-block: var(--rst-sp-4); |
| +} |
| + |
| +.meet-tile { |
| + position: relative; |
| + aspect-ratio: 4 / 3; |
| + background: var(--rst-surface-2); |
| + border: 1px solid var(--rst-line); |
| + border-radius: var(--rst-radius); |
| + overflow: hidden; |
| +} |
| + |
| +.meet-tile video { |
| + width: 100%; |
| + height: 100%; |
| + object-fit: cover; |
| +} |
| + |
| +.meet-tile-name { |
| + position: absolute; |
| + inset-block-end: var(--rst-sp-2); |
| + inset-inline-start: var(--rst-sp-2); |
| + padding: var(--rst-sp-1) var(--rst-sp-2); |
| + background: var(--rst-surface); |
| + border-radius: var(--rst-radius-sm); |
| + color: var(--rst-text); |
| + font-size: var(--rst-fs-sm); |
| +} |
| + |
| +.meet-controls { |
| + display: flex; |
| + flex-wrap: wrap; |
| + gap: var(--rst-sp-2); |
| + margin-block-end: var(--rst-sp-4); |
| +} |
| + |
| +.meet-queue { |
| + border: 1px solid var(--rst-line); |
| + border-radius: var(--rst-radius); |
| + padding: var(--rst-sp-3); |
| + margin-block-end: var(--rst-sp-4); |
| +} |
| + |
| +.meet-queue h2 { |
| + margin: 0 0 var(--rst-sp-2); |
| + font-size: var(--rst-fs-base); |
| + color: var(--rst-text-muted); |
| +} |
| + |
| +.meet-queue ul { |
| + list-style: none; |
| + margin: 0; |
| + padding: 0; |
| + display: flex; |
| + flex-direction: column; |
| + gap: var(--rst-sp-2); |
| +} |
| + |
| +.meet-queue li { |
| + display: flex; |
| + align-items: center; |
| + justify-content: space-between; |
| + gap: var(--rst-sp-2); |
| +} |
| + |
| +.meet-preview { |
| + width: 100%; |
| + max-width: 24rem; |
| + aspect-ratio: 4 / 3; |
| + background: var(--rst-surface-2); |
| + border: 1px solid var(--rst-line); |
| + border-radius: var(--rst-radius); |
| + object-fit: cover; |
| +} |
| + |
| +.meet-pickers { |
| + display: flex; |
| + flex-wrap: wrap; |
| + gap: var(--rst-sp-3); |
| + margin-block: var(--rst-sp-3); |
| +} |
| + |
| +.meet-status { |
| + color: var(--rst-text-muted); |
| + min-height: 1.2em; |
| +} |
diff --git a/internal/meet/static/room.js b/internal/meet/static/room.js| new file mode 100644 |
| index 0000000..03dd93e |
| --- /dev/null |
| +++ b/internal/meet/static/room.js |
| @@ -0,0 +1,9 @@ |
| +// room.js: placeholder. Task 8 replaces this with the real signalling, |
| +// resume and mesh logic; for now it only reveals the scripted room so a |
| +// browser with script sees more than the scriptless fallback. |
| +(() => { |
| + 'use strict'; |
| + const root = document.getElementById('room'); |
| + if (!root) return; |
| + root.hidden = false; |
| +})(); |
diff --git a/internal/meet/templates/join.html b/internal/meet/templates/join.html| index cd759b6..59fd75c 100644 |
| --- a/internal/meet/templates/join.html |
| +++ b/internal/meet/templates/join.html |
| @@ -6,5 +6,7 @@ |
| <p>You are {{.Name}}, joining as a {{.Standing}}.</p> |
| {{if .Floor}}<p rst-notice>This team's Meet has no relay yet. Calls usually connect only between people on the same network.</p>{{end}} |
| <p>Calls coming soon</p> |
| +<noscript><p>The call needs JavaScript to reach your camera and the other participants; everything else on this page works without it.</p></noscript> |
| +<div id="devices" data-sqid="{{.Sqid}}" hidden></div> |
| {{if .IsMember}}<p><a href="/meetings/{{.Sqid}}/end">End the meeting</a></p>{{end}} |
| {{end}} |
diff --git a/internal/meet/templates/room.html b/internal/meet/templates/room.html| new file mode 100644 |
| index 0000000..408821d |
| --- /dev/null |
| +++ b/internal/meet/templates/room.html |
| @@ -0,0 +1,22 @@ |
| +{{define "title"}}{{.Title}}{{end}} |
| +{{define "content"}} |
| +{{if .IsMember}}{{template "back-nav" dict "Href" "/" "Label" "Meetings"}}{{end}} |
| +{{template "page-header" dict "Title" .Title}} |
| +{{if .Starts}}<p>Starts {{.Starts}}.</p>{{end}} |
| +<p>You are {{.Name}}, in the call as a {{.Standing}}.</p> |
| +{{if .Floor}}<p rst-notice>This team's Meet has no relay yet. Calls usually connect only between people on the same network.</p>{{end}} |
| +<noscript><p>The call needs JavaScript to reach your camera and the other participants; everything else on this page works without it. <a href="/m/{{.Sqid}}/join">Back to the holding screen</a>.</p></noscript> |
| +<div id="room" data-sqid="{{.Sqid}}" data-boot="{{.Boot}}" data-self-name="{{.Name}}" data-is-member="{{if .IsMember}}1{{end}}" data-peer="" hidden> |
| + <div id="tiles" class="meet-grid"></div> |
| + <div id="controls" class="meet-controls"> |
| + <button type="button" id="mute" rst-btn>Mute</button> |
| + <button type="button" id="camera" rst-btn>Camera off</button> |
| + <button type="button" id="leave" rst-btn="danger">Leave</button> |
| + </div> |
| + <div id="queue" class="meet-queue" hidden><h2>Waiting to join</h2><ul id="knocks"></ul></div> |
| + <p id="status" class="meet-status" role="status"></p> |
| +</div> |
| +{{if .IsMember}}<p><a href="/meetings/{{.Sqid}}/end" target="_blank" rel="noopener">End the meeting</a></p>{{end}} |
| +<link rel="stylesheet" href="{{asset "static/meet.css"}}"> |
| +<script defer src="{{asset "static/room.js"}}"></script> |
| +{{end}} |
diff --git a/internal/meettest/room_test.go b/internal/meettest/room_test.go| new file mode 100644 |
| index 0000000..5a31962 |
| --- /dev/null |
| +++ b/internal/meettest/room_test.go |
| @@ -0,0 +1,115 @@ |
| +package meettest |
| + |
| +import ( |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestRoomPageIsScriptlessHonestAndStandingGated(t *testing.T) { |
| + x, sqid, _ := memberWithMeeting(t) |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + rec := c.get("/m/" + sqid) |
| + body := rec.Body.String() |
| + if rec.Code != http.StatusOK || !strings.Contains(body, "Board meeting") || !strings.Contains(body, `data-sqid="`+sqid+`"`) || !strings.Contains(body, "/m/"+sqid+"/join") { |
| + t.Fatalf("room page: %d\n%s", rec.Code, body) |
| + } |
| + if !strings.Contains(body, "<noscript>") || !strings.Contains(body, "needs JavaScript") { |
| + t.Fatal("room page lacks its scriptless paragraph") |
| + } |
| + stranger := newHarness(t).browser(t) |
| + stranger.signIn("nobody@example.com") |
| + if rec := stranger.get("/m/" + sqid); rec.Code != http.StatusNotFound { |
| + t.Fatalf("no standing: %d", rec.Code) |
| + } |
| +} |
| + |
| +func TestRemoveNeedsAConfirmPageAndAnAdmittedMemberActor(t *testing.T) { |
| + x, sqid, tok := memberWithMeeting(t) |
| + m := x.browser(t) |
| + m.signIn("paul@example.com") |
| + sm := m.stream("/m/" + sqid + "/stream") |
| + defer sm.close() |
| + pm, _ := sm.hello() |
| + m.signal(sqid, pm, `{"type":"join"}`) |
| + g := x.browser(t) |
| + g.get("/j/" + tok) |
| + g.signIn("guest@example.com") |
| + sg := g.stream("/m/" + sqid + "/stream") |
| + defer sg.close() |
| + pg, _ := sg.hello() |
| + g.signal(sqid, pg, `{"type":"join"}`) |
| + if rec := m.get("/m/" + sqid + "/remove/" + pg); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Remove") { |
| + t.Fatalf("confirm page: %d", rec.Code) |
| + } |
| + if rec := g.post("/m/"+sqid+"/remove/"+pm, nil); rec.Code != http.StatusNotFound { |
| + t.Fatalf("waiting guest removing the member: %d", rec.Code) |
| + } |
| + if rec := m.post("/m/"+sqid+"/remove/"+pg, nil); rec.Code != http.StatusSeeOther { |
| + t.Fatalf("member removing: %d", rec.Code) |
| + } |
| + ev, data, ok := sg.nextOf("lost", 2) |
| + if !ok || !strings.Contains(string(data), `"removed"`) { |
| + t.Fatalf("removed guest got %s %s", ev, data) |
| + } |
| +} |
| + |
| +func TestTakeoverIsAConfirmedPostAndAGetEndsNothing(t *testing.T) { |
| + x, sqid, _ := memberWithMeeting(t) |
| + m := x.browser(t) |
| + m.signIn("paul@example.com") |
| + s1 := m.stream("/m/" + sqid + "/stream") |
| + defer s1.close() |
| + p1, _ := s1.hello() |
| + m.signal(sqid, p1, `{"type":"join"}`) |
| + // A plain GET at the stream endpoint, EventSource's own Accept |
| + // header and all, must not let a query parameter perform a takeover: |
| + // the session already holds p1, so this is the ordinary |
| + // already-open conflict (stream.go, unmodified by this task), never |
| + // hub.Takeover. getStream, not get: this needs the Accept header |
| + // EventSource always sends (ruling 15) to reach that check at all. |
| + if rec := m.getStream("/m/" + sqid + "/stream?takeover=1"); rec.Code != http.StatusConflict { |
| + t.Fatalf("a GET with a query parameter must not take over: %d", rec.Code) |
| + } |
| + if rec := m.get("/m/" + sqid + "/takeover"); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "another tab") { |
| + t.Fatalf("takeover confirm: %d", rec.Code) |
| + } |
| + req := httptestPost("/m/"+sqid+"/takeover", url.Values{}) |
| + req.Header.Set("Origin", "https://evil.example") |
| + if rec := m.do(req); rec.Code != http.StatusForbidden { |
| + t.Fatalf("cross-origin takeover: %d", rec.Code) |
| + } |
| + if rec := m.post("/m/"+sqid+"/takeover", nil); rec.Code != http.StatusSeeOther { |
| + t.Fatalf("takeover: %d", rec.Code) |
| + } |
| + if ev, data, ok := s1.nextOf("lost", 2); !ok || !strings.Contains(string(data), `"replaced"`) { |
| + t.Fatalf("old stream got %s %s", ev, data) |
| + } |
| + // getStream, not get, for the same reason as above: a resume is |
| + // only ever attempted by EventSource, and the Accept-header gate |
| + // (ruling 15) applies ahead of the resume branch too. |
| + if rec := m.getStream("/m/" + sqid + "/stream?peer=" + p1); rec.Code != http.StatusGone { |
| + t.Fatalf("resume of the replaced peer: %d, want 410", rec.Code) |
| + } |
| + s2 := m.stream("/m/" + sqid + "/stream") |
| + defer s2.close() |
| + if p2, _ := s2.hello(); p2 == "" || p2 == p1 { |
| + t.Fatalf("fresh stream after takeover: %q", p2) |
| + } |
| +} |
| + |
| +func TestEndingTheMeetingEndsTheRoom(t *testing.T) { |
| + x, sqid, _ := memberWithMeeting(t) |
| + m := x.browser(t) |
| + m.signIn("paul@example.com") |
| + s := m.stream("/m/" + sqid + "/stream") |
| + defer s.close() |
| + p, _ := s.hello() |
| + m.signal(sqid, p, `{"type":"join"}`) |
| + m.post("/meetings/"+sqid+"/end", nil) |
| + if ev, data, ok := s.nextOf("lost", 2); !ok || !strings.Contains(string(data), `"ended"`) { |
| + t.Fatalf("got %s %s", ev, data) |
| + } |
| +} |
diff --git a/internal/meettest/sse_test.go b/internal/meettest/sse_test.go| index 629d3e6..3c7f0df 100644 |
| --- a/internal/meettest/sse_test.go |
| +++ b/internal/meettest/sse_test.go |
| @@ -151,6 +151,24 @@ func (s *sseReader) hello() (peer, boot string) { |
| return body.Peer, body.Boot |
| } |
| +// nextOf skips events until the named one arrives, or the timeout |
| +// passes — for a test that expects some event (a roster, a peer) ahead |
| +// of the one it actually wants to assert on. |
| +func (s *sseReader) nextOf(event string, timeoutSeconds int) (string, []byte, bool) { |
| + s.t.Helper() |
| + deadline := time.After(time.Duration(timeoutSeconds) * time.Second) |
| + for { |
| + select { |
| + case ev := <-s.events: |
| + if ev.name == event { |
| + return ev.name, ev.data, true |
| + } |
| + case <-deadline: |
| + return "", nil, false |
| + } |
| + } |
| +} |
| + |
| // waitHeartbeat waits for one SSE comment line, which is the only thing |
| // the server sends on an idle stream. |
| func (s *sseReader) waitHeartbeat(timeout time.Duration) bool { |