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 "encoding/json"
5 "net/http"
6 "net/url"
7 "regexp"
8 "strconv"
9 "strings"
10 "testing"
11 "time"
12)
13
14var sqidInPage = regexp.MustCompile(`/m/([23456789a-z]{10})`)
15
16// Renamed from TestRoomPageIsScriptlessHonestAndStandingGated: the
17// standing gate is not tested here and never was — fix round 1's
18// Important 1 moved it to
19// TestRoomAndTakeoverAreGatedByStandingOrParticipation, which has a real
20// second meeting to check it against. What is left is what this test
21// actually asserts.
22func TestRoomPageRendersItsHeaderStandingAndScriptlessFallback(t *testing.T) {
23 x, sqid, _ := memberWithMeeting(t)
24 c := x.browser(t)
25 c.signIn("paul@example.com")
26 rec := c.get("/m/" + sqid)
27 body := rec.Body.String()
28 if rec.Code != http.StatusOK || !strings.Contains(body, "Board meeting") || !strings.Contains(body, `data-sqid="`+sqid+`"`) || !strings.Contains(body, "/m/"+sqid+"/join") {
29 t.Fatalf("room page: %d\n%s", rec.Code, body)
30 }
31 if !strings.Contains(body, "<noscript>") || !strings.Contains(body, "needs JavaScript") {
32 t.Fatal("room page lacks its scriptless paragraph")
33 }
34 // True before any participation exists: the room page renders
35 // server-side, ahead of anything room.js does (Important 14).
36 if !strings.Contains(body, "You’re in this call as paul.") {
37 t.Fatalf("room page's own-standing sentence: %s", body)
38 }
39 // calls.css and room.js move into layout.html's new head block
40 // (Important 15): both must appear in <head>, ahead of </head>,
41 // not at the tail of the body content.
42 headEnd := strings.Index(body, "</head>")
43 // asset() fingerprints the filename (static/calls.<hash>.css), so the
44 // literal "calls.css" never appears — match the stable prefix instead.
45 css := strings.Index(body, "/static/calls.")
46 if headEnd < 0 || css < 0 || css > headEnd {
47 t.Fatalf("calls.css is not inside <head>: head ends at %d, calls.css at %d\n%s", headEnd, css, body)
48 }
49 // A member must be able to reach Share without JavaScript. The form
50 // is rendered twice and never both at once: the control-row copy is
51 // inside #room, which room.js un-hides, and the <noscript> copy is
52 // the scriptless path. This asserts the first occurrence precedes
53 // #room, so deleting the <noscript> copy and leaving only the
54 // control-row one fails.
55 //
56 // Between c80a506 and a6c2ac8 only the control-row copy existed, so
57 // a member action sat behind JavaScript while the <noscript>
58 // paragraph went on promising "everything else on this page works
59 // without it". AGENTS.md makes that floor load-bearing with two
60 // named exceptions, and sharing is not one of them.
61 //
62 // Position, not presence, is what regressed: the form rendered in
63 // both layouts, so every Contains-style assertion stayed green.
64 // That is why this compares offsets.
65 share := strings.Index(body, `action="/m/`+sqid+`/share"`)
66 room := strings.Index(body, `<div id="room"`)
67 if share < 0 || room < 0 || share > room {
68 t.Fatalf("share form is not outside #room: share at %d, #room at %d\n%s", share, room, body)
69 }
70
71 // The no-standing case belongs to
72 // TestRoomAndTakeoverAreGatedByStandingOrParticipation: this
73 // harness's member allowlist refuses to even mail a magic link to
74 // an address with no standing and no pending join, so a "stranger"
75 // here can only be tested against a real second meeting a
76 // signed-in outsider holds no grant for — a fresh, memberless
77 // harness would let anyone sign in and a nonexistent meeting would
78 // 404 regardless of standing, which is exactly the vacuous version
79 // of this test fix round 1's Important 1 replaced.
80}
81
82// review C1-8's pattern (holding_test.go), applied to the room and
83// takeover pages: a real second meeting the guest holds no grant for is
84// the test that actually exercises standing being checked, not merely
85// a meeting's absence answering 404 on its own (fix round 1,
86// Important 1).
87func TestRoomAndTakeoverAreGatedByStandingOrParticipation(t *testing.T) {
88 x, first, tok := memberWithMeeting(t)
89 member := x.browser(t)
90 member.signIn("paul@example.com")
91 second, _ := createMeeting(t, member, "A meeting they were not invited to", "")
92
93 g := x.browser(t)
94 g.get("/j/" + tok)
95 g.signIn("guest@example.com")
96
97 if rec := g.get("/m/" + second); rec.Code != http.StatusNotFound {
98 t.Fatalf("room, a real meeting with no standing: %d", rec.Code)
99 }
100 if rec := g.get("/m/" + second + "/takeover"); rec.Code != http.StatusNotFound {
101 t.Fatalf("takeover confirm, a real meeting with no standing: %d", rec.Code)
102 }
103 if rec := g.post("/m/"+second+"/takeover", nil); rec.Code != http.StatusNotFound {
104 t.Fatalf("takeover POST, a real meeting with no standing: %d", rec.Code)
105 }
106
107 // The guest's own meeting, its grant now expired, and no
108 // participation was ever opened for it: still 404.
109 x.advance(2*time.Hour + time.Minute)
110 if rec := g.get("/m/" + first); rec.Code != http.StatusNotFound {
111 t.Fatalf("expired grant, no participation: %d", rec.Code)
112 }
113}
114
115// The positive fallback for the same rule: an admitted guest survives
116// its own grant expiring (ruling 14, hub.standingLostLocked), because
117// the room page's authority is standing OR a live participation, and
118// the participation does not end when only the guest's grant does.
119func TestRoomPageSurvivesGrantExpiryForAnAdmittedGuest(t *testing.T) {
120 x, sqid, tok := memberWithMeeting(t)
121 m := x.browser(t)
122 m.signIn("paul@example.com")
123 sm := m.stream("/m/" + sqid + "/stream")
124 defer sm.close()
125 pm, _ := sm.hello()
126 m.signal(sqid, pm, `{"type":"join"}`)
127
128 g := x.browser(t)
129 g.get("/j/" + tok)
130 g.signIn("guest@example.com")
131 sg := g.stream("/m/" + sqid + "/stream")
132 defer sg.close()
133 pg, _ := sg.hello()
134 g.signal(sqid, pg, `{"type":"join"}`)
135 m.signal(sqid, pm, `{"type":"admit","payload":{"peer":"`+pg+`"}}`)
136 // The admission itself, proved rather than assumed: a guest still
137 // only waiting would survive the expiry below on the waiting
138 // participation alone, and the test would say nothing about ruling
139 // 14. The roster is what admission sends and waiting never does.
140 if ev, _, ok := sg.nextOf("roster", 2*time.Second); !ok {
141 t.Fatalf("the guest was never admitted (got %q)", ev)
142 }
143
144 x.advance(2*time.Hour + time.Minute)
145 if rec := g.get("/m/" + sqid); rec.Code != http.StatusOK {
146 t.Fatalf("admitted guest past grant expiry: %d", rec.Code)
147 }
148}
149
150// The other side of ruling 14, and Task 6's issue 6: roomAccess falls
151// back to a live participation only where §2.4 makes the participation
152// the authority, which is an admitted **guest**. A member who has lost
153// the team is dropped by the next thing they do (§2.5), so their own
154// participation is not a way to keep the page — relabelling it as a
155// guest's would hand an ex-member the room for as long as the hub
156// happened to still hold it.
157func TestAnExMemberCannotKeepTheRoomThroughTheirOwnParticipation(t *testing.T) {
158 x, sqid, _ := memberWithMeeting(t)
159 a := x.browser(t)
160 a.signIn("paul@example.com")
161 sa := a.stream("/m/" + sqid + "/stream")
162 defer sa.close()
163 pa, _ := sa.hello()
164 a.signal(sqid, pa, `{"type":"join"}`)
165
166 b := newMemberBrowser(t, x, "paul@rslw.com")
167 sb := b.stream("/m/" + sqid + "/stream")
168 defer sb.close()
169 pb, _ := sb.hello()
170 if resp, _ := b.signal(sqid, pb, `{"type":"join"}`); resp.StatusCode != http.StatusOK {
171 t.Fatalf("b's join: %d", resp.StatusCode)
172 }
173 if ev, _, ok := sb.nextOf("roster", 2*time.Second); !ok {
174 t.Fatalf("b was never admitted (got %q)", ev)
175 }
176 for _, path := range []string{"/m/" + sqid, "/m/" + sqid + "/join"} {
177 if rec := b.get(path); rec.Code != http.StatusOK {
178 t.Fatalf("GET %s while on the team: %d", path, rec.Code)
179 }
180 }
181
182 // Off the team, and deliberately no tick: the participation is still
183 // there, still admitted, and still says member. Standing is the only
184 // thing that has changed.
185 x.ctl.SetAllowlist("paul@example.com")
186 for _, path := range []string{"/m/" + sqid, "/m/" + sqid + "/join"} {
187 if rec := b.get(path); rec.Code != http.StatusNotFound {
188 t.Fatalf("GET %s after leaving the team: %d, want 404", path, rec.Code)
189 }
190 }
191 // The member who is still on it is untouched.
192 if rec := a.get("/m/" + sqid); rec.Code != http.StatusOK {
193 t.Fatalf("the remaining member's room page: %d", rec.Code)
194 }
195}
196
197// Task 6's issue 3, finished: the removal confirm page needs current
198// member standing as well as the admitted member participation
199// hub.PeerNameFor checks. PeerNameFor reads the kind the last tick
200// wrote, so a member who has just left the team — and who still reaches
201// the page through a guest grant of their own — would otherwise be
202// offered a page whose POST hub.Remove refuses. Both refusals are the
203// same 404, never a 403 (§2.5).
204func TestTheRemovalConfirmPageNeedsFreshMemberStanding(t *testing.T) {
205 x, sqid, tok := memberWithMeeting(t)
206 a := x.browser(t)
207 a.signIn("paul@example.com")
208 sa := a.stream("/m/" + sqid + "/stream")
209 defer sa.close()
210 pa, _ := sa.hello()
211 a.signal(sqid, pa, `{"type":"join"}`)
212
213 b := newMemberBrowser(t, x, "paul@rslw.com")
214 sb := b.stream("/m/" + sqid + "/stream")
215 defer sb.close()
216 pb, _ := sb.hello()
217 b.signal(sqid, pb, `{"type":"join"}`)
218 if ev, _, ok := sb.nextOf("roster", 2*time.Second); !ok {
219 t.Fatalf("b was never admitted (got %q)", ev)
220 }
221 if rec := b.get("/m/" + sqid + "/remove/" + pa); rec.Code != http.StatusOK {
222 t.Fatalf("confirm page for a member on the team: %d", rec.Code)
223 }
224
225 // Off the team, and then in through the front door as an outsider:
226 // the link mints a guest grant, so roomAccess answers guest standing
227 // while the hub still has the participation down as a member. That
228 // gap is the one the standing check closes.
229 x.ctl.SetAllowlist("paul@example.com")
230 rec := b.get("/j/" + tok)
231 if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/m/"+sqid+"/join" {
232 t.Fatalf("the ex-member's walk through the join link: %d %s", rec.Code, rec.Header().Get("Location"))
233 }
234 if rec := b.get("/m/" + sqid + "/remove/" + pa); rec.Code != http.StatusNotFound {
235 t.Fatalf("confirm page for an ex-member holding a guest grant: %d, want 404", rec.Code)
236 }
237 // And the POST agrees, which is the whole point: a page that offers
238 // an action its own POST would refuse is a page that lies.
239 if rec := b.post("/m/"+sqid+"/remove/"+pa, nil); rec.Code != http.StatusNotFound {
240 t.Fatalf("POST remove for an ex-member holding a guest grant: %d, want 404", rec.Code)
241 }
242
243 // A plain guest, admitted and all, never gets the page either — not
244 // even for a peer that really is in the room.
245 g := x.browser(t)
246 g.get("/j/" + tok)
247 g.signIn("guest@example.com")
248 sg := g.stream("/m/" + sqid + "/stream")
249 defer sg.close()
250 pg, _ := sg.hello()
251 g.signal(sqid, pg, `{"type":"join"}`)
252 a.signal(sqid, pa, `{"type":"admit","payload":{"peer":"`+pg+`"}}`)
253 if ev, _, ok := sg.nextOf("roster", 2*time.Second); !ok {
254 t.Fatalf("the guest was never admitted (got %q)", ev)
255 }
256 if rec := g.get("/m/" + sqid + "/remove/" + pa); rec.Code != http.StatusNotFound {
257 t.Fatalf("confirm page for an admitted guest: %d, want 404", rec.Code)
258 }
259}
260
261func TestRemoveNeedsAConfirmPageAndAnAdmittedMemberActor(t *testing.T) {
262 x, sqid, tok := memberWithMeeting(t)
263 m := x.browser(t)
264 m.signIn("paul@example.com")
265 sm := m.stream("/m/" + sqid + "/stream")
266 defer sm.close()
267 pm, _ := sm.hello()
268 m.signal(sqid, pm, `{"type":"join"}`)
269 g := x.browser(t)
270 g.get("/j/" + tok)
271 g.signIn("guest@example.com")
272 sg := g.stream("/m/" + sqid + "/stream")
273 defer sg.close()
274 pg, _ := sg.hello()
275 g.signal(sqid, pg, `{"type":"join"}`)
276
277 // The confirm page names the person, derived from hub.PeerNameFor —
278 // exactly Remove's own authority, read rather than acted on
279 // (Important 2, Important 3) — not merely the meeting.
280 rec := m.get("/m/" + sqid + "/remove/" + pg)
281 if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Remove") || !strings.Contains(rec.Body.String(), "guest") {
282 t.Fatalf("confirm page: %d\n%s", rec.Code, rec.Body.String())
283 }
284 // Its back-nav names the meeting, not the generic "Meetings" list
285 // this page's Cancel does not lead to (Important 9).
286 if !strings.Contains(rec.Body.String(), "Board meeting") {
287 t.Fatalf("confirm page back-nav: %s", rec.Body.String())
288 }
289 // Cancel and the back link both lead to the holding screen, where a
290 // confirmed removal also lands (Important 11's other half). Never
291 // /m/{sqid}: this page opens in its own tab while the call holds the
292 // one behind it, and the room would open a second stream in this
293 // one — a 409 and a takeover offer, for someone who pressed Cancel.
294 if !strings.Contains(rec.Body.String(), `href="/m/`+sqid+`/join"`) {
295 t.Fatalf("confirm page must lead back to the holding screen:\n%s", rec.Body.String())
296 }
297 if strings.Contains(rec.Body.String(), `href="/m/`+sqid+`"`) {
298 t.Fatalf("confirm page still offers the room, which would open a second stream:\n%s", rec.Body.String())
299 }
300 // A peer that does not exist in the room at all must not render a
301 // working confirm page: PeerNameFor answers nothing to remove.
302 if rec := m.get("/m/" + sqid + "/remove/" + strings.Repeat("0", 32)); rec.Code != http.StatusNotFound {
303 t.Fatalf("confirm page for an absent peer: %d", rec.Code)
304 }
305 // A path segment that is not the hub's own peer shape (32 lowercase
306 // hex) is refused before it is ever looked up (Important 8), on
307 // both the confirm page and the POST.
308 if rec := m.get("/m/" + sqid + "/remove/not-a-peer-id"); rec.Code != http.StatusNotFound {
309 t.Fatalf("malformed peer segment, confirm: %d", rec.Code)
310 }
311 if rec := m.post("/m/"+sqid+"/remove/not-a-peer-id", nil); rec.Code != http.StatusNotFound {
312 t.Fatalf("malformed peer segment, POST: %d", rec.Code)
313 }
314 // A well-formed peer id that simply isn't in the room: hub.Remove's
315 // ErrUnknownPeer, mapped to 404 by the sentinel check (Important 7),
316 // not the 500 an unrecognised error would get.
317 if rec := m.post("/m/"+sqid+"/remove/"+strings.Repeat("0", 32), nil); rec.Code != http.StatusNotFound {
318 t.Fatalf("POST for an absent peer: %d", rec.Code)
319 }
320 if rec := g.post("/m/"+sqid+"/remove/"+pm, nil); rec.Code != http.StatusNotFound {
321 t.Fatalf("waiting guest removing the member: %d", rec.Code)
322 }
323 // A successful removal lands on the holding screen, which opens no
324 // stream of its own, not back on the room (Important 11).
325 rec = m.post("/m/"+sqid+"/remove/"+pg, nil)
326 if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/m/"+sqid+"/join" {
327 t.Fatalf("member removing: %d %s", rec.Code, rec.Header().Get("Location"))
328 }
329 ev, data, ok := sg.nextOf("lost", 2*time.Second)
330 if !ok || !strings.Contains(string(data), `"removed"`) {
331 t.Fatalf("removed guest got %s %s", ev, data)
332 }
333}
334
335func TestTakeoverIsAConfirmedPostAndAGetEndsNothing(t *testing.T) {
336 x, sqid, _ := memberWithMeeting(t)
337 m := x.browser(t)
338 m.signIn("paul@example.com")
339 s1 := m.stream("/m/" + sqid + "/stream")
340 defer s1.close()
341 p1, _ := s1.hello()
342 m.signal(sqid, p1, `{"type":"join"}`)
343 // A plain GET at the stream endpoint, EventSource's own Accept
344 // header and all, must not let a query parameter perform a takeover:
345 // the session already holds p1, so this is the ordinary
346 // already-open conflict (stream.go, unmodified by this task), never
347 // hub.Takeover. getStream, not get: this needs the Accept header
348 // EventSource always sends (ruling 15) to reach that check at all.
349 if rec := m.getStream("/m/" + sqid + "/stream?takeover=1"); rec.Code != http.StatusConflict {
350 t.Fatalf("a GET with a query parameter must not take over: %d", rec.Code)
351 }
352 rec := m.get("/m/" + sqid + "/takeover")
353 if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "another tab") {
354 t.Fatalf("takeover confirm: %d", rec.Code)
355 }
356 // Its back-nav names the meeting too (Important 9).
357 if !strings.Contains(rec.Body.String(), "Board meeting") {
358 t.Fatalf("takeover confirm back-nav: %s", rec.Body.String())
359 }
360 req := httptestPost("/m/"+sqid+"/takeover", url.Values{})
361 req.Header.Set("Origin", "https://evil.example")
362 if rec := m.do(req); rec.Code != http.StatusForbidden {
363 t.Fatalf("cross-origin takeover: %d", rec.Code)
364 }
365 if rec := m.post("/m/"+sqid+"/takeover", nil); rec.Code != http.StatusSeeOther {
366 t.Fatalf("takeover: %d", rec.Code)
367 }
368 if ev, data, ok := s1.nextOf("lost", 2*time.Second); !ok || !strings.Contains(string(data), `"replaced"`) {
369 t.Fatalf("old stream got %s %s", ev, data)
370 }
371 // getStream, not get, for the same reason as above: a resume is
372 // only ever attempted by EventSource, and the Accept-header gate
373 // (ruling 15) applies ahead of the resume branch too.
374 if rec := m.getStream("/m/" + sqid + "/stream?peer=" + p1); rec.Code != http.StatusGone {
375 t.Fatalf("resume of the replaced peer: %d, want 410", rec.Code)
376 }
377 s2 := m.stream("/m/" + sqid + "/stream")
378 defer s2.close()
379 if p2, _ := s2.hello(); p2 == "" || p2 == p1 {
380 t.Fatalf("fresh stream after takeover: %q", p2)
381 }
382}
383
384// A confirm page's premise — "this is open in another tab" — only
385// holds when this session actually has a live participation to take
386// over; asked with nothing to take over, it sends the caller straight
387// to the holding screen instead (Important 4).
388func TestTakeoverConfirmRedirectsWhenThereIsNothingToTakeOver(t *testing.T) {
389 x, sqid, _ := memberWithMeeting(t)
390 m := x.browser(t)
391 m.signIn("paul@example.com")
392 rec := m.get("/m/" + sqid + "/takeover")
393 if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/m/"+sqid+"/join" {
394 t.Fatalf("takeover confirm with nothing to take over: %d %s", rec.Code, rec.Header().Get("Location"))
395 }
396}
397
398// Takeover needs standing, not merely a participation (spec §2.5) —
399// unlike the room page's own authority, and unlike Remove's, both of
400// which an admitted guest's expired grant survives (ruling 14).
401// Important 5.
402func TestTakeoverRequiresStandingNotJustParticipation(t *testing.T) {
403 x, sqid, tok := memberWithMeeting(t)
404 m := x.browser(t)
405 m.signIn("paul@example.com")
406 sm := m.stream("/m/" + sqid + "/stream")
407 defer sm.close()
408 pm, _ := sm.hello()
409 m.signal(sqid, pm, `{"type":"join"}`)
410
411 g := x.browser(t)
412 g.get("/j/" + tok)
413 g.signIn("guest@example.com")
414 sg := g.stream("/m/" + sqid + "/stream")
415 defer sg.close()
416 pg, _ := sg.hello()
417 g.signal(sqid, pg, `{"type":"join"}`)
418 m.signal(sqid, pm, `{"type":"admit","payload":{"peer":"`+pg+`"}}`)
419 // Admission proved, not assumed: the point of this test is what an
420 // *admitted* guest may and may not do past the expiry.
421 if ev, _, ok := sg.nextOf("roster", 2*time.Second); !ok {
422 t.Fatalf("the guest was never admitted (got %q)", ev)
423 }
424
425 x.advance(2*time.Hour + time.Minute)
426 if rec := g.get("/m/" + sqid); rec.Code != http.StatusOK {
427 t.Fatalf("room page for the admitted guest: %d", rec.Code)
428 }
429 if rec := g.post("/m/"+sqid+"/takeover", nil); rec.Code != http.StatusNotFound {
430 t.Fatalf("takeover past grant expiry: %d", rec.Code)
431 }
432}
433
434func TestEndingTheMeetingEndsTheRoom(t *testing.T) {
435 x, sqid, _ := memberWithMeeting(t)
436 m := x.browser(t)
437 m.signIn("paul@example.com")
438 s := m.stream("/m/" + sqid + "/stream")
439 defer s.close()
440 p, _ := s.hello()
441 m.signal(sqid, p, `{"type":"join"}`)
442 m.post("/meetings/"+sqid+"/end", nil)
443 if ev, data, ok := s.nextOf("lost", 2*time.Second); !ok || !strings.Contains(string(data), `"ended"`) {
444 t.Fatalf("got %s %s", ev, data)
445 }
446 if rec := m.get("/m/" + sqid); rec.Code != http.StatusNotFound {
447 t.Fatalf("room page after end: %d", rec.Code)
448 }
449}
450
451// room.js is what makes the room a call, and it loads from layout's
452// head block for the same reason calls.css does (Important 15): the
453// page's CSP is default-src 'self', so nothing about the call may be
454// inline. The scriptless floor rests on #room being rendered hidden —
455// a browser with no script sees the noscript paragraph and the link
456// back to the holding screen, never an empty grid and three dead
457// buttons — so both facts are asserted together.
458func TestRoomPageLoadsRoomScriptFromTheHeadAndHidesTheRoom(t *testing.T) {
459 x, sqid, _ := memberWithMeeting(t)
460 c := x.browser(t)
461 c.signIn("paul@example.com")
462 body := c.get("/m/" + sqid).Body.String()
463
464 headEnd := strings.Index(body, "</head>")
465 // asset() fingerprints the filename (static/room.<hash>.js), so the
466 // literal "room.js" never appears — match the stable prefix.
467 script := strings.Index(body, "/static/room.")
468 if headEnd < 0 || script < 0 || script > headEnd {
469 t.Fatalf("room.js is not inside <head>: head ends at %d, room.js at %d\n%s", headEnd, script, body)
470 }
471 if !strings.Contains(body[:headEnd], "<script defer src=") {
472 t.Fatalf("room.js must stay deferred in the head block:\n%s", body[:headEnd])
473 }
474
475 open := strings.Index(body, "<div id=\"room\"")
476 if open < 0 {
477 t.Fatalf("no #room element:\n%s", body)
478 }
479 end := strings.Index(body[open:], ">")
480 if end < 0 || !strings.Contains(body[open:open+end], " hidden") {
481 t.Fatalf("#room must be rendered hidden for the scriptless floor: %q", body[open:open+end])
482 }
483
484 // The room's own toggles follow the holding screen's shape: a fixed
485 // accessible name in aria-label, the state in aria-pressed and in a
486 // word room.js rewrites, never in colour alone.
487 for _, want := range []string{
488 `id="mute" rst-btn aria-label="Microphone" aria-pressed="false"`,
489 `<span id="mute-state" class="calls-toggle-state" aria-hidden="true">On</span>`,
490 `id="camera" rst-btn aria-label="Camera" aria-pressed="false"`,
491 `<span id="camera-state" class="calls-toggle-state" aria-hidden="true">On</span>`,
492 } {
493 if !strings.Contains(body, want) {
494 t.Fatalf("room page's toggles: missing %q\n%s", want, body)
495 }
496 }
497}
498
499// The chat panel is estilo's first chat (§6, §10): one self-contained
500// partial and one script, so a second app can adopt both files
501// unchanged. Calls' job here is only to mount it — inside #room, after
502// the controls and before the admission queue — and to load its two
503// files the way every other room asset loads, from layout's head block
504// with defer, because the page's CSP is default-src 'self' and nothing
505// about the call may be inline.
506//
507// The panel renders hidden for the same reason #room does: a browser
508// with no script sees the noscript paragraph, never a dead form whose
509// Send button does nothing. room.js un-hides #room, and the panel with
510// it, on admission.
511func TestRoomPageMountsTheChatPanelHiddenWithItsScriptInTheHead(t *testing.T) {
512 x, sqid, _ := memberWithMeeting(t)
513 c := x.browser(t)
514 c.signIn("paul@example.com")
515 body := c.get("/m/" + sqid).Body.String()
516
517 headEnd := strings.Index(body, "</head>")
518 if headEnd < 0 {
519 t.Fatalf("no </head> in the room page:\n%s", body)
520 }
521 head := body[:headEnd]
522 // asset() fingerprints the filename (static/chat.<hash>.js), so the
523 // literal "chat.js" never appears — match the stable prefix.
524 if !strings.Contains(head, `<script defer src="/static/chat.`) {
525 t.Fatalf("chat.js must load deferred from the head block:\n%s", head)
526 }
527 if !strings.Contains(head, `<link rel="stylesheet" href="/static/chat.`) {
528 t.Fatalf("chat.css must load from the head block:\n%s", head)
529 }
530 // Two deferred scripts run in document order, and room.js mounts the
531 // panel through window.Chat — which chat.js has to have defined by
532 // then. Swapping these two lines breaks the room in a way no unit
533 // test would otherwise notice.
534 chatJS := strings.Index(head, `<script defer src="/static/chat.`)
535 roomJS := strings.Index(head, `<script defer src="/static/room.`)
536 if roomJS < 0 {
537 t.Fatalf("room.js is not in the head block any more:\n%s", head)
538 }
539 if chatJS > roomJS {
540 t.Fatalf("chat.js must be loaded before room.js: chat.js at %d, room.js at %d\n%s", chatJS, roomJS, head)
541 }
542
543 panel := strings.Index(body, `<section id="chat"`)
544 if panel < 0 {
545 t.Fatalf("no chat panel on the room page:\n%s", body)
546 }
547 end := strings.Index(body[panel:], ">")
548 if end < 0 || !strings.Contains(body[panel:panel+end], " hidden") {
549 t.Fatalf("the chat panel must render hidden for the scriptless floor: %q", body[panel:panel+end])
550 }
551 if !strings.Contains(body[panel:panel+end], " data-chat") {
552 t.Fatalf("the chat panel needs its data-chat mount point: %q", body[panel:panel+end])
553 }
554
555 // Since the call window (2026-09-28) the admission queue is one of
556 // the notices under the title, ahead of the stage, and chat is a
557 // sidebar after the bar; both are still inside #room, which is what
558 // makes them script-only.
559 room := strings.Index(body, `<div id="room"`)
560 controls := strings.Index(body, `id="controls"`)
561 queue := strings.Index(body, `id="queue"`)
562 if room < 0 || controls < 0 || queue < 0 {
563 t.Fatalf("room page shape changed: room %d controls %d queue %d", room, controls, queue)
564 }
565 if room > queue || room > controls || controls > panel {
566 t.Fatalf("the queue and the panel belong inside #room, the panel after the controls: room %d controls %d chat %d queue %d", room, controls, panel, queue)
567 }
568
569 // The form's own shape: one labelled control, the browser's own
570 // courtesy cap at §4.4's 2 KB, a polite live region for arrivals and
571 // a status line for the two sentences the panel can say.
572 //
573 // aria-describedby is asserted whole rather than by attribute name:
574 // it is the wiring that makes the status line part of the field for
575 // a screen reader instead of a paragraph that happens to be nearby,
576 // and it is the one attribute here that a person editing the markup
577 // would not miss the loss of by looking at the screen.
578 for _, want := range []string{
579 `<ol id="chat-log" class="chat-log" data-chat-log aria-live="polite" aria-relevant="additions"></ol>`,
580 `<label for="chat-text"`,
581 `id="chat-text"`,
582 `maxlength="2048"`,
583 `aria-describedby="chat-status"`,
584 `<button type="submit" id="chat-send"`,
585 `<p id="chat-status" class="chat-status" data-chat-status role="status"></p>`,
586 } {
587 if !strings.Contains(body, want) {
588 t.Fatalf("chat panel: missing %q\n%s", want, body[panel:])
589 }
590 }
591
592 // And no `required`, which is a decision rather than an omission —
593 // see the note in partials/chat.html. This form never reaches a
594 // server, so the attribute would buy only the browser's own
595 // validation bubble on an empty click of Send: a sentence nobody
596 // here wrote, that the copy list cannot catalogue, and that Enter
597 // could not raise anyway. Without it both ways of sending an empty
598 // box do the same nothing.
599 field := strings.Index(body[panel:], "<textarea")
600 if field < 0 {
601 t.Fatalf("no textarea in the chat panel:\n%s", body[panel:])
602 }
603 tag := body[panel+field:]
604 tag = tag[:strings.Index(tag, ">")]
605 if strings.Contains(tag, " required") {
606 t.Fatalf("the message box must not be `required`: an empty Send would raise a browser validation bubble that Enter cannot, and that this app's copy list does not own: %q", tag)
607 }
608}
609
610func TestNativeRoomInfoUsesRoomAuthority(t *testing.T) {
611 x, room, token := memberWithMeeting(t)
612 member := x.browser(t)
613 member.signIn("paul@example.com")
614 other, _ := createMeeting(t, member, "Private title", "")
615 guest := x.browser(t)
616 guest.get("/j/" + token)
617 guest.signIn("guest@example.com")
618 res := guest.get("/m/" + room + "/info")
619 var info struct {
620 Title string
621 Standing string
622 Floor bool
623 }
624 if res.Code != 200 || json.Unmarshal(res.Body.Bytes(), &info) != nil || info.Title != "Board meeting" || info.Standing != "guest" {
625 t.Fatalf("metadata: %d %+v", res.Code, info)
626 }
627 if res.Header().Get("Cache-Control") != "no-store" {
628 t.Fatal("metadata may be cached")
629 }
630 res = guest.get("/m/" + other + "/info")
631 if res.Code != 404 || strings.Contains(res.Body.String(), "Private title") {
632 t.Fatal("metadata leaked another meeting")
633 }
634}
635
636// The room's device panel (mid-call switching). The server renders it
637// closed and renders the button that opens it hidden: room.js reveals
638// the button only in a browser that can enumerate devices, so a
639// scriptless page — and a browser with no enumeration — shows neither a
640// panel nor a control that would do nothing. The panel reuses the
641// holding screen's ids on purpose, because the two fill routines are
642// mirrors of each other and test/browser/devices.test.mjs holds them to
643// it; asserting them here is what stops a rename on one page from
644// quietly unwiring the other.
645func TestRoomRendersItsDevicePanelClosed(t *testing.T) {
646 x, sqid, _ := memberWithMeeting(t)
647 c := x.browser(t)
648 c.signIn("paul@example.com")
649 body := c.get("/m/" + sqid).Body.String()
650 for _, want := range []string{
651 `<button type="button" id="devices-toggle" rst-btn aria-expanded="false" aria-controls="room-devices" hidden aria-label="Devices"`,
652 `<div id="room-devices" class="calls-pickers calls-popover calls-devices" role="group" aria-label="Devices" hidden>`,
653 `id="pick-camera"`,
654 `id="pick-mic"`,
655 `id="pick-speaker"`,
656 `id="device-note"`,
657 } {
658 if !strings.Contains(body, want) {
659 t.Fatalf("room page lacks %q for the device panel: %s", want, body)
660 }
661 }
662}
663
664// The device panel's own live region is not #status. renderStatus owns
665// #status and rewrites it on every roster, so a sentence about a camera
666// that would not open would be gone within the tick — which is exactly
667// the moment someone is reading it.
668func TestRoomDeviceNoteIsNotTheStatusLine(t *testing.T) {
669 x, sqid, _ := memberWithMeeting(t)
670 c := x.browser(t)
671 c.signIn("paul@example.com")
672 body := c.get("/m/" + sqid).Body.String()
673 note := strings.Index(body, `id="device-note"`)
674 status := strings.Index(body, `id="status"`)
675 if note < 0 || status < 0 {
676 t.Fatalf("room page is missing one of the two regions: note=%d status=%d", note, status)
677 }
678 if note == status {
679 t.Fatal("the device note and the status line are the same element")
680 }
681}
682
683// The unread-chat live region (the accessibility half of collapsing the
684// chat panel). Collapsing sets display:none on #chat, which takes its
685// aria-live log out of the accessibility tree, so a message arriving
686// while chat is closed announced nothing: the badge is aria-hidden, a
687// changed aria-label does not announce, and the log was gone. This
688// region is the one thing that survives the collapse, so what matters
689// in the markup is that the server renders it OUTSIDE #chat.
690func TestRoomAnnouncesUnreadChatOutsideTheCollapsingPanel(t *testing.T) {
691 x, sqid, _ := memberWithMeeting(t)
692 c := x.browser(t)
693 c.signIn("paul@example.com")
694 body := c.get("/m/" + sqid).Body.String()
695 live := strings.Index(body, `id="chat-unread-live"`)
696 if live < 0 {
697 t.Fatalf("room page has no unread live region: %s", body)
698 }
699 for _, want := range []string{`role="status"`, `aria-live="polite"`, `class="calls-sr-only"`} {
700 if !strings.Contains(body[live-200:live+200], want) {
701 t.Fatalf("the unread live region lacks %q: %s", want, body[live-200:live+200])
702 }
703 }
704 // Outside the panel, not merely present. A region inside #chat would
705 // be display:none'd with it and announce nothing, which is the whole
706 // defect.
707 panel := strings.Index(body, `<section id="chat"`)
708 if panel >= 0 && live > panel {
709 t.Fatal("the unread live region renders inside #chat, where collapsing hides it")
710 }
711}
712
713// The call window (2026-09-28): the room replaces the page shell with
714// one rounded window, so what the shell carried has to be somewhere a
715// scriptless browser can still reach. Since 2026-10-02 there is no
716// title pill and no ☰ menu: the header is the title, and — inside
717// <noscript>, so it is the scriptless page's and nobody else's — the
718// standing line, the floor notice and the navigation (Meetings,
719// Settings, Help, Sign out). With script the same navigation is in the
720// bar's More menu, inside #room, with End meeting for everyone last; and
721// with no script that is in the scriptless block beside the Share form.
722func TestRoomIsOneWindowWhoseNavigationWorksWithoutScript(t *testing.T) {
723 x, sqid, _ := memberWithMeeting(t)
724 c := x.browser(t)
725 c.signIn("paul@example.com")
726 body := c.get("/m/" + sqid).Body.String()
727
728 win := strings.Index(body, `<main id="main" class="calls-window"`)
729 room := strings.Index(body, `<div id="room"`)
730 if win < 0 || room < win {
731 t.Fatalf("the room is not inside the call window: window %d, #room %d\n%s", win, room, body)
732 }
733 if strings.Contains(body, "rst-shell-topbar") {
734 t.Fatal("the room still renders the page shell's header")
735 }
736 for _, gone := range []string{`id="call-nav"`, `id="call-state"`, "calls-nav-menu"} {
737 if strings.Contains(body, gone) {
738 t.Fatalf("the title pill's %s is still there", gone)
739 }
740 }
741 header := body[strings.Index(body, `<header id="call-top"`):strings.Index(body, `</header>`)]
742 if !strings.Contains(header, `<h1 id="call-title">Board meeting</h1>`) {
743 t.Fatalf("the header must carry the title:\n%s", header)
744 }
745 ns := strings.Index(header, "<noscript>")
746 if ns < 0 {
747 t.Fatalf("the header has no scriptless part:\n%s", header)
748 }
749 for _, want := range []string{"You’re in this call as paul.", "Calls outside your network may not connect yet.", `href="/">Meetings</a>`, `href="/settings">Settings</a>`, `href="/help/open-source"`, `<form method="post" action="/signout"><button type="submit">Sign out</button></form>`} {
750 if !strings.Contains(header[ns:], want) {
751 t.Fatalf("with no script the header lacks %q:\n%s", want, header[ns:])
752 }
753 if strings.Contains(header[:ns], want) {
754 t.Fatalf("with script the header is only the title, but it has %q", want)
755 }
756 }
757 sns := strings.Index(body, `<noscript><div class="calls-scriptless">`)
758 nsEnd := sns + strings.Index(body[max(sns, 0):], "</noscript>")
759 if sns < win || nsEnd < sns || nsEnd > room {
760 t.Fatalf("the scriptless block belongs in the window, before #room: %d..%d, room %d", sns, nsEnd, room)
761 }
762 if !strings.Contains(body[sns:nsEnd], `href="/meetings/`+sqid+`/end"`) {
763 t.Fatalf("with no script, End the meeting must still be reachable:\n%s", body[sns:nsEnd])
764 }
765 // With script it is all in the bar's More menu: the standing line
766 // first, then the account menu's items, End meeting for everyone
767 // last — still a link to its own confirm page, never a button that
768 // ends it.
769 more := strings.Index(body, `<div id="call-more"`)
770 if more < room {
771 t.Fatal("the bar's More menu must be inside #room")
772 }
773 menu := body[more : more+strings.Index(body[more:], "</div>")]
774 order := []string{`<p class="calls-menu-standing">You’re in this call as paul.`, `href="/">Meetings</a>`, `href="/settings">Settings</a>`, `href="/help/open-source"`, `action="/signout"`, `<a href="/meetings/` + sqid + `/end" target="_blank"`}
775 at := 0
776 for _, want := range order {
777 i := strings.Index(menu[at:], want)
778 if i < 0 {
779 t.Fatalf("the More menu lacks %q after position %d:\n%s", want, at, menu)
780 }
781 at += i
782 }
783 if !strings.Contains(body, `id="call-more-toggle"`) {
784 t.Fatal("the bar has no More button")
785 }
786}
787
788// A guest's room has no member actions anywhere: no Meetings list, no
789// Settings, no Share link and no way to end the meeting, in the More
790// menu, the bar or the scriptless block. More itself is there: it is
791// where a guest's Help and Sign out are.
792func TestGuestRoomWindowCarriesNoMemberActions(t *testing.T) {
793 x, sqid, token := memberWithMeeting(t)
794 guest := x.browser(t)
795 guest.get("/j/" + token)
796 guest.signIn("guest@example.com")
797 rec := guest.get("/m/" + sqid)
798 body := rec.Body.String()
799 if rec.Code != http.StatusOK || !strings.Contains(body, ", a guest.") {
800 t.Fatalf("guest room: %d\n%s", rec.Code, body)
801 }
802 for _, not := range []string{`href="/">Meetings</a>`, `href="/settings"`, `/end"`, `action="/m/` + sqid + `/share"`} {
803 if strings.Contains(body, not) {
804 t.Fatalf("a guest's room carries %q", not)
805 }
806 }
807 more := strings.Index(body, `<div id="call-more"`)
808 if more < 0 || !strings.Contains(body[more:], `action="/signout"`) || !strings.Contains(body[more:], `href="/help/open-source"`) {
809 t.Fatal("a guest keeps Help and Sign out in the More menu")
810 }
811}
812
813// The title (2026-10-02): a title someone chose is the window's title,
814// centred in the strip at the top (calls-top-named); a title Calls
815// wrote is the page's heading and its <title>, and nothing on screen.
816// The apps read the same fact from /info's "named".
817func TestRoomShowsAChosenTitleAndLeavesAnAutomaticOneOff(t *testing.T) {
818 x, named, _ := memberWithMeeting(t)
819 c := x.browser(t)
820 c.signIn("paul@example.com")
821 rec := c.post("/meetings/start", nil)
822 auto := sqidInPage.FindStringSubmatch(rec.Body.String())
823 if auto == nil {
824 t.Fatalf("no meeting on the started call's page:\n%s", rec.Body.String())
825 }
826 for _, tc := range []struct {
827 sqid string
828 named bool
829 }{{named, true}, {auto[1], false}} {
830 body := c.get("/m/" + tc.sqid).Body.String()
831 start := strings.Index(body, `<header id="call-top"`)
832 header := body[start : start+strings.Index(body[start:], ">")+1]
833 if got := strings.Contains(header, "calls-top-named"); got != tc.named {
834 t.Errorf("%s: header %q, named %v", tc.sqid, header, tc.named)
835 }
836 if !strings.Contains(body, `<h1 id="call-title">`) || !strings.Contains(body, "<title>") {
837 t.Errorf("%s: the title is still the heading and the <title>", tc.sqid)
838 }
839 info := c.get("/m/" + tc.sqid + "/info").Body.String()
840 if want := `"named":` + strconv.FormatBool(tc.named); !strings.Contains(info, want) {
841 t.Errorf("%s: info %s lacks %s", tc.sqid, info, want)
842 }
843 }
844 if body := c.get("/m/" + auto[1]).Body.String(); strings.Contains(body, " UTC</") {
845 t.Error("an automatic title names its zone")
846 }
847}
848