| 1 | package callstest |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "net/http" |
| 6 | "net/url" |
| 7 | "regexp" |
| 8 | "strconv" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | ) |
| 13 | |
| 14 | var 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. |
| 22 | func 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). |
| 87 | func 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. |
| 119 | func 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. |
| 157 | func 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). |
| 204 | func 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 | |
| 261 | func 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 | |
| 335 | func 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). |
| 388 | func 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. |
| 402 | func 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 | |
| 434 | func 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. |
| 458 | func 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. |
| 511 | func 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 | |
| 610 | func 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. |
| 645 | func 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. |
| 668 | func 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. |
| 690 | func 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. |
| 722 | func 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. |
| 792 | func 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". |
| 817 | func 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 | |