| 1 | package callstest |
| 2 | |
| 3 | // The shared chrome (estilo spec/shared-chrome, round 2), black-box: |
| 4 | // every page wears the 52px estilo bar with one account menu and one |
| 5 | // Sign out, is titled "<Page> · Calls", answers errors as pages for a |
| 6 | // browser and as text for a fetch, and sends a signed-out person back to |
| 7 | // the page they asked for once they have signed in. |
| 8 | |
| 9 | import ( |
| 10 | "net/http" |
| 11 | "net/http/httptest" |
| 12 | "net/url" |
| 13 | "os" |
| 14 | "path/filepath" |
| 15 | "regexp" |
| 16 | "strings" |
| 17 | "testing" |
| 18 | "time" |
| 19 | |
| 20 | "amadan.net/oficina/estilo.git/base" |
| 21 | "amadan.net/oficina/estilo.git/chrome" |
| 22 | estilomenu "amadan.net/oficina/estilo.git/menu" |
| 23 | ) |
| 24 | |
| 25 | var titleRe = regexp.MustCompile(`<title>([^<]*)</title>`) |
| 26 | |
| 27 | func pageTitle(t *testing.T, body string) string { |
| 28 | t.Helper() |
| 29 | m := titleRe.FindStringSubmatch(body) |
| 30 | if m == nil { |
| 31 | t.Fatalf("no <title>:\n%s", body) |
| 32 | } |
| 33 | return m[1] |
| 34 | } |
| 35 | |
| 36 | // Spec §10: every page's <title> is "<Page> · … · Calls", the page's h1 |
| 37 | // first, and the app root is "Calls" alone. |
| 38 | func TestEveryPageIsTitledPageDotCalls(t *testing.T) { |
| 39 | x, sqid, _ := memberWithMeeting(t) |
| 40 | c := x.browser(t) |
| 41 | c.signIn("paul@example.com") |
| 42 | for path, want := range map[string]string{ |
| 43 | "/": "Calls", |
| 44 | "/settings": "Settings · Calls", |
| 45 | "/m/" + sqid + "/join": "Board meeting · Calls", |
| 46 | "/m/" + sqid: "Board meeting · Calls", |
| 47 | "/meetings/" + sqid + "/settings": "Meeting settings · Board meeting · Calls", |
| 48 | "/meetings/" + sqid + "/end": "End meeting for everyone? · Board meeting · Calls", |
| 49 | "/help/open-source": "Open source · Calls", |
| 50 | "/no/such/page": "Page not found · Calls", |
| 51 | "/m/zzzzzzzzzz/join": "Meeting not available · Calls", |
| 52 | } { |
| 53 | if got := pageTitle(t, c.getNav(path).Body.String()); got != want { |
| 54 | t.Errorf("GET %s: title %q, want %q", path, got, want) |
| 55 | } |
| 56 | } |
| 57 | out := x.browser(t) |
| 58 | for path, want := range map[string]string{ |
| 59 | "/signin": "Sign in · Calls", |
| 60 | "/signin?sent=1": "Check your email · Calls", |
| 61 | "/signed-out": "Signed out · Calls", |
| 62 | "/no/such/page": "Page not found · Calls", |
| 63 | } { |
| 64 | if got := pageTitle(t, out.getNav(path).Body.String()); got != want { |
| 65 | t.Errorf("signed out GET %s: title %q, want %q", path, got, want) |
| 66 | } |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | // Spec §5 and §8: the estilo bar, not rastrillo's topbar; signed in, one |
| 71 | // account menu holding the only Sign out; signed out, a "Sign in" that |
| 72 | // comes back to this page; on the sign-in pages themselves, nothing. |
| 73 | func TestPagesWearTheEstiloBarWithOneAccountMenu(t *testing.T) { |
| 74 | x, sqid, _ := memberWithMeeting(t) |
| 75 | c := x.browser(t) |
| 76 | c.signIn("paul@example.com") |
| 77 | for _, path := range []string{"/", "/settings", "/m/" + sqid + "/join", "/meetings/" + sqid + "/settings", "/help/open-source", "/no/such/page"} { |
| 78 | body := c.getNav(path).Body.String() |
| 79 | if !strings.Contains(body, "<header estilo-bar>") || strings.Contains(body, "rst-shell-bar") { |
| 80 | t.Errorf("GET %s does not wear the estilo bar", path) |
| 81 | } |
| 82 | if n := strings.Count(body, ">Sign out<"); n != 1 { |
| 83 | t.Errorf("GET %s has %d Sign out, want exactly 1", path, n) |
| 84 | } |
| 85 | if !strings.Contains(body, `<summary estilo-bar-trigger aria-label="Account: paul@example.com">`) { |
| 86 | t.Errorf("GET %s has no account menu", path) |
| 87 | } |
| 88 | for _, want := range []string{`<span estilo-menu-identity-label>Signed in as</span>`, `<p estilo-menu-heading id="estilo-appearance">Appearance</p>`, `<a estilo-menu-item href="/help/open-source"><span estilo-menu-label>Open source</span></a>`} { |
| 89 | if !strings.Contains(body, want) { |
| 90 | t.Errorf("GET %s: the account menu lacks %q", path, want) |
| 91 | } |
| 92 | } |
| 93 | for _, gone := range []string{"Help: open source", "rst-shell-account", ">Help</summary>"} { |
| 94 | if strings.Contains(body, gone) { |
| 95 | t.Errorf("GET %s still carries %q", path, gone) |
| 96 | } |
| 97 | } |
| 98 | } |
| 99 | if body := c.getNav("/settings").Body.String(); !strings.Contains(body, `<a href="/settings" aria-current="page">Settings</a>`) { |
| 100 | t.Error("the bar's nav does not mark Settings as the current page") |
| 101 | } |
| 102 | out := x.browser(t) |
| 103 | body := out.getNav("/no/such/page").Body.String() |
| 104 | if !strings.Contains(body, `<a rst-btn href="/signin?return_to=%2Fno%2Fsuch%2Fpage" estilo-bar-signin>Sign in</a>`) || strings.Contains(body, "estilo-account") { |
| 105 | t.Errorf("a signed-out page's corner is not Sign in, back to it:\n%s", body) |
| 106 | } |
| 107 | for _, path := range []string{"/signin", "/signin?sent=1", "/signed-out"} { |
| 108 | body := out.getNav(path).Body.String() |
| 109 | if !strings.Contains(body, "<header estilo-bar>") || strings.Contains(body, "estilo-bar-signin") || strings.Contains(body, "estilo-account") { |
| 110 | t.Errorf("GET %s: the sign-in pages' corner must be empty", path) |
| 111 | } |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | // Spec §8.4: a guest gets the same "Sign in" corner, never an account. |
| 116 | func TestAGuestGetsTheSignInCorner(t *testing.T) { |
| 117 | x, sqid, token := memberWithMeeting(t) |
| 118 | g := x.browser(t) |
| 119 | invite := g.getNav("/j/" + token).Body.String() |
| 120 | if !strings.Contains(invite, `<a rst-btn href="/signin" estilo-bar-signin>Sign in</a>`) { |
| 121 | t.Errorf("the invitation page's corner must be a bare Sign in (its path is the capability):\n%s", invite) |
| 122 | } |
| 123 | if rec := g.post("/j/"+token+"/guest", url.Values{"name": {"Visitor"}}); rec.Code != http.StatusSeeOther { |
| 124 | t.Fatalf("guest join: %d", rec.Code) |
| 125 | } |
| 126 | body := g.getNav("/m/" + sqid + "/join").Body.String() |
| 127 | if !strings.Contains(body, "estilo-bar-signin") || strings.Contains(body, "estilo-account") || strings.Contains(body, ">Sign out<") { |
| 128 | t.Errorf("a guest's holding page corner is not Sign in:\n%s", body) |
| 129 | } |
| 130 | if !strings.Contains(body, "You’ll join as Visitor, a guest.") { |
| 131 | t.Errorf("the holding page does not say who the guest joins as:\n%s", body) |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | // Spec §3: an error is a page for a browser and plain text for anything |
| 136 | // else, so the room's own fetches keep the answers room.js reads. |
| 137 | func TestErrorPagesAreSplitByAccept(t *testing.T) { |
| 138 | x, _, _ := memberWithMeeting(t) |
| 139 | c := x.browser(t) |
| 140 | c.signIn("paul@example.com") |
| 141 | page := c.getNav("/no/such/page") |
| 142 | if page.Code != http.StatusNotFound || !strings.HasPrefix(page.Header().Get("Content-Type"), "text/html") || |
| 143 | !strings.Contains(page.Body.String(), "We can’t find that page") || !strings.Contains(page.Body.String(), `<a rst-btn="primary" href="/">Go to Calls</a>`) { |
| 144 | t.Errorf("unknown route for a browser: %d %q\n%s", page.Code, page.Header().Get("Content-Type"), page.Body.String()) |
| 145 | } |
| 146 | plain := c.get("/no/such/page") |
| 147 | if plain.Code != http.StatusNotFound || !strings.HasPrefix(plain.Header().Get("Content-Type"), "text/plain") { |
| 148 | t.Errorf("unknown route for a fetch: %d %q", plain.Code, plain.Header().Get("Content-Type")) |
| 149 | } |
| 150 | meeting := c.getNav("/m/zzzzzzzzzz/join") |
| 151 | if meeting.Code != http.StatusNotFound || !strings.Contains(meeting.Body.String(), "This meeting isn’t available") { |
| 152 | t.Errorf("missing meeting for a browser: %d\n%s", meeting.Code, meeting.Body.String()) |
| 153 | } |
| 154 | if fetch := c.get("/m/zzzzzzzzzz/info"); fetch.Code != http.StatusNotFound || !strings.HasPrefix(fetch.Header().Get("Content-Type"), "text/plain") { |
| 155 | t.Errorf("missing meeting for a fetch: %d %q", fetch.Code, fetch.Header().Get("Content-Type")) |
| 156 | } |
| 157 | // A POST-only path opened from history is no page, not a 405. |
| 158 | if rec := c.getNav("/signout"); rec.Code != http.StatusNotFound || !strings.Contains(rec.Body.String(), "We can’t find that page") { |
| 159 | t.Errorf("GET /signout: %d", rec.Code) |
| 160 | } |
| 161 | del := c.do(httptest.NewRequest(http.MethodDelete, "/signin", nil)) |
| 162 | if del.Code != http.StatusMethodNotAllowed || !strings.Contains(del.Header().Get("Allow"), "GET") { |
| 163 | t.Errorf("DELETE /signin: %d Allow %q", del.Code, del.Header().Get("Allow")) |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | // Spec §9.4: a signed-out page request carries the page to sign-in, and |
| 168 | // the emailed link lands back on it. The room's fetches keep today's |
| 169 | // bare redirect, which room.js reads as "signed out". |
| 170 | func TestSignInComesBackToTheDeepLink(t *testing.T) { |
| 171 | x, sqid, _ := memberWithMeeting(t) |
| 172 | c := x.browser(t) |
| 173 | rec := c.getNav("/meetings/" + sqid + "/settings") |
| 174 | want := "/signin?return_to=" + url.QueryEscape("/meetings/"+sqid+"/settings") |
| 175 | if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != want { |
| 176 | t.Fatalf("signed-out deep link: %d %q, want %q", rec.Code, rec.Header().Get("Location"), want) |
| 177 | } |
| 178 | if fetch := c.getStream("/m/" + sqid + "/stream"); fetch.Code != http.StatusSeeOther || fetch.Header().Get("Location") != "/signin" { |
| 179 | t.Errorf("the stream's signed-out answer changed: %d %q", fetch.Code, fetch.Header().Get("Location")) |
| 180 | } |
| 181 | door := c.getNav(want).Body.String() |
| 182 | if !strings.Contains(door, `name="return_to" value="/meetings/`+sqid+`/settings"`) { |
| 183 | t.Errorf("the door does not carry return_to:\n%s", door) |
| 184 | } |
| 185 | if rec := c.post("/signin", url.Values{"address": {"paul@example.com"}}); rec.Code != http.StatusSeeOther { |
| 186 | t.Fatalf("POST /signin: %d", rec.Code) |
| 187 | } |
| 188 | // "Use a different address" keeps the destination (Docs #65, 2). |
| 189 | sent := c.getNav("/signin?sent=1").Body.String() |
| 190 | if !strings.Contains(sent, `href="/signin?return_to=`+url.QueryEscape("/meetings/"+sqid+"/settings")+`">Use a different address</a>`) { |
| 191 | t.Errorf("Use a different address loses the destination:\n%s", sent) |
| 192 | } |
| 193 | _, link := x.mail.last() |
| 194 | if dest := c.follow(link); dest != "/meetings/"+sqid+"/settings" { |
| 195 | t.Fatalf("after the link: %q, want the deep link", dest) |
| 196 | } |
| 197 | // Signed in, /signin skips the form and goes where it was asked to. |
| 198 | if rec := c.getNav("/signin?return_to=%2Fsettings"); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/settings" { |
| 199 | t.Errorf("signed in, GET /signin: %d %q", rec.Code, rec.Header().Get("Location")) |
| 200 | } |
| 201 | // And a destination off this origin is never followed. |
| 202 | if rec := c.getNav("/signin?return_to=%2F%2Fevil.example"); rec.Header().Get("Location") != "/" { |
| 203 | t.Errorf("signed in, GET /signin?return_to=//evil.example: %q", rec.Header().Get("Location")) |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | // The remembered page is sealed: a hand-written cookie is ignored. |
| 208 | func TestAForgedReturnCookieIsIgnored(t *testing.T) { |
| 209 | x := newHarness(t, "paul@example.com") |
| 210 | c := x.browser(t) |
| 211 | c.cookies["calls_return"] = &http.Cookie{Name: "calls_return", Value: "%2F%2Fevil.example"} |
| 212 | if dest := c.signIn("paul@example.com"); dest != "/" { |
| 213 | t.Fatalf("a forged return cookie was followed: %q", dest) |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | // r3 audit C6: a malformed address is the field's own error, not |
| 218 | // "Check your email" — and nothing is sent. |
| 219 | func TestAMalformedAddressIsTheFieldsError(t *testing.T) { |
| 220 | x := newHarness(t, "paul@example.com") |
| 221 | c := x.browser(t) |
| 222 | rec := c.post("/signin", url.Values{"address": {"not-an-email@"}}) |
| 223 | if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?err=address" { |
| 224 | t.Fatalf("malformed address: %d %q", rec.Code, rec.Header().Get("Location")) |
| 225 | } |
| 226 | if x.mail.count() != 0 { |
| 227 | t.Fatal("a malformed address was mailed") |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | // Paul, 2026-10-01: the door's button says "Sign in"; the sent page |
| 232 | // says what happened in the suite's words (spec §9.2). |
| 233 | func TestTheDoorSpeaksTheSuitesWords(t *testing.T) { |
| 234 | x := newHarness(t, "paul@example.com") |
| 235 | c := x.browser(t) |
| 236 | door := c.getNav("/signin").Body.String() |
| 237 | for _, want := range []string{`type="submit">Sign in</button>`, "We’ll email you a link that signs you in. It works once and expires in 15 minutes.", `<a href="/help/open-source">Open source</a>`} { |
| 238 | if !strings.Contains(door, want) { |
| 239 | t.Errorf("the door lacks %q", want) |
| 240 | } |
| 241 | } |
| 242 | if strings.Contains(door, "Email me a link") { |
| 243 | t.Error("the door still says Email me a link") |
| 244 | } |
| 245 | sent := c.getNav("/signin?sent=1").Body.String() |
| 246 | if !strings.Contains(sent, "If that address can sign in here, we’ve sent it a link. It works once and expires in 15 minutes.") || strings.Contains(sent, "should be on its way") { |
| 247 | t.Errorf("the sent page's copy:\n%s", sent) |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | // Sign out lands on a page that says so (spec §9.2), not on a bare form. |
| 252 | func TestSignOutLandsOnTheSignedOutPage(t *testing.T) { |
| 253 | x := newHarness(t, "paul@example.com") |
| 254 | c := x.browser(t) |
| 255 | c.signIn("paul@example.com") |
| 256 | rec := c.post("/signout", nil) |
| 257 | if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signed-out" { |
| 258 | t.Fatalf("POST /signout: %d %q", rec.Code, rec.Header().Get("Location")) |
| 259 | } |
| 260 | page := c.getNav("/signed-out") |
| 261 | if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), "You’re signed out") || !strings.Contains(page.Body.String(), `<a rst-btn="primary" href="/signin">Sign in</a>`) { |
| 262 | t.Errorf("GET /signed-out: %d\n%s", page.Code, page.Body.String()) |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | // Spec §8.3: Appearance is a cookie the layout writes into <html>. |
| 267 | func TestAppearanceIsRememberedPerApp(t *testing.T) { |
| 268 | x := newHarness(t, "paul@example.com") |
| 269 | c := x.browser(t) |
| 270 | c.signIn("paul@example.com") |
| 271 | rec := c.post("/appearance", url.Values{"mode": {"dark"}, "return_to": {"/settings"}}) |
| 272 | if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/settings" { |
| 273 | t.Fatalf("POST /appearance: %d %q", rec.Code, rec.Header().Get("Location")) |
| 274 | } |
| 275 | body := c.getNav("/settings").Body.String() |
| 276 | if !strings.Contains(body, `<html lang="en" dir="ltr" data-theme="dark">`) || !strings.Contains(body, `value="dark" aria-pressed="true"`) { |
| 277 | t.Errorf("dark was not remembered:\n%s", body[:min(len(body), 600)]) |
| 278 | } |
| 279 | } |
| 280 | |
| 281 | // Real list rows (spec §2 of the audit): each meeting is an rst-row whose |
| 282 | // actions are one ⋯ menu in estilo's floating menu, with one label for |
| 283 | // ending a meeting. |
| 284 | func TestMeetingsAreListRowsWithOneMenu(t *testing.T) { |
| 285 | x, sqid, _ := memberWithMeeting(t) |
| 286 | c := x.browser(t) |
| 287 | c.signIn("paul@example.com") |
| 288 | body := c.getNav("/").Body.String() |
| 289 | for _, want := range []string{ |
| 290 | `<span rst-row-main><a href="/m/` + sqid + `/join">Board meeting</a>`, |
| 291 | `<details rst-row-menu name="estilo-menus">`, |
| 292 | `<summary aria-label="Actions for Board meeting">`, |
| 293 | `<a estilo-menu-item="danger" href="/meetings/` + sqid + `/end"><span estilo-menu-label>End meeting for everyone</span></a>`, |
| 294 | } { |
| 295 | if !strings.Contains(body, want) { |
| 296 | t.Errorf("the meeting list lacks %q", want) |
| 297 | } |
| 298 | } |
| 299 | if strings.Contains(body, "<article>") { |
| 300 | t.Error("the meeting list still renders bare articles") |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | // r3 audit C4 / spec S10: Start a call names the call in the starter's |
| 305 | // own time. Without script it is the instance's time, and since |
| 306 | // 2026-10-02 the title no longer names that zone ("… UTC"): the zone is |
| 307 | // kept with the meeting instead (Meeting.TitleZone). |
| 308 | func TestStartACallTitlesInTheStartersZone(t *testing.T) { |
| 309 | x := newHarness(t, "paul@example.com") |
| 310 | c := x.browser(t) |
| 311 | c.signIn("paul@example.com") |
| 312 | tokyo, _ := time.LoadLocation("Asia/Tokyo") |
| 313 | rec := c.post("/meetings/start", url.Values{"zone": {"Asia/Tokyo"}}) |
| 314 | if want := "Call, " + x.now.In(tokyo).Format("2 Jan 15:04"); !strings.Contains(rec.Body.String(), want) { |
| 315 | t.Errorf("with the browser's zone the title is not %q:\n%s", want, rec.Body.String()) |
| 316 | } |
| 317 | rec = c.post("/meetings/start", nil) |
| 318 | if want := "Call, " + x.now.UTC().Format("2 Jan 15:04") + "</"; !strings.Contains(rec.Body.String(), want) { |
| 319 | t.Errorf("without one the title is not %q", want) |
| 320 | } |
| 321 | if strings.Contains(rec.Body.String(), x.now.UTC().Format("2 Jan 15:04")+" UTC") { |
| 322 | t.Error("the title still names the instance zone") |
| 323 | } |
| 324 | } |
| 325 | |
| 326 | // Spec §4's grep gate: one notice vocabulary, rastrillo's callout in a |
| 327 | // tone. rst-notice is always success green, and carried the relay |
| 328 | // warning and "open in another tab". |
| 329 | func TestTemplatesUseCalloutTonesOnly(t *testing.T) { |
| 330 | bad := regexp.MustCompile(`rst-notice|rst-callout="|rst-tone="warn"`) |
| 331 | n := 0 |
| 332 | err := filepath.WalkDir(filepath.Join("..", "calls", "templates"), func(path string, d os.DirEntry, err error) error { |
| 333 | if err != nil || d.IsDir() || !strings.HasSuffix(path, ".html") { |
| 334 | return err |
| 335 | } |
| 336 | n++ |
| 337 | b, err := os.ReadFile(path) |
| 338 | if err != nil { |
| 339 | return err |
| 340 | } |
| 341 | if m := bad.Find(b); m != nil { |
| 342 | t.Errorf("%s uses %q", path, m) |
| 343 | } |
| 344 | return nil |
| 345 | }) |
| 346 | if err != nil || n < 10 { |
| 347 | t.Fatalf("walked %d templates: %v", n, err) |
| 348 | } |
| 349 | } |
| 350 | |
| 351 | // Spec §3.3's second gate: every remaining plain-text http.Error is |
| 352 | // listed here with why it is not a page. A new one fails until it is |
| 353 | // either a page (errorPage) or classified. |
| 354 | func TestEveryPlainTextErrorIsClassified(t *testing.T) { |
| 355 | classified := map[string]int{ |
| 356 | // The native app's JSON endpoints (/native/*): a Swift client |
| 357 | // reads the status, and a page is the wrong answer to it. The |
| 358 | // thirteenth is /native/request's 410 for an offer that has gone |
| 359 | // (2026-10-02), which callscore reads as ErrOfferExpired. |
| 360 | "native_auth.go": 13, |
| 361 | // POST /native/devices: JSON from the native app. |
| 362 | "knockpush.go": 7, |
| 363 | // methodNotAllowed's 405 (no person navigates with DELETE) and |
| 364 | // plainError, the answer to a request that did not ask for a page. |
| 365 | "errors.go": 2, |
| 366 | // render's last resort when a template itself fails. |
| 367 | "render.go": 1, |
| 368 | } |
| 369 | files, err := filepath.Glob(filepath.Join("..", "calls", "*.go")) |
| 370 | if err != nil || len(files) < 20 { |
| 371 | t.Fatalf("glob: %d files, %v", len(files), err) |
| 372 | } |
| 373 | got := map[string]int{} |
| 374 | for _, f := range files { |
| 375 | if strings.HasSuffix(f, "_test.go") { |
| 376 | continue |
| 377 | } |
| 378 | b, err := os.ReadFile(f) |
| 379 | if err != nil { |
| 380 | t.Fatal(err) |
| 381 | } |
| 382 | if n := strings.Count(string(b), "http.Error("); n > 0 { |
| 383 | got[filepath.Base(f)] = n |
| 384 | } |
| 385 | } |
| 386 | for f, n := range got { |
| 387 | if classified[f] != n { |
| 388 | t.Errorf("%s has %d http.Error calls, %d classified: make it a page, or classify it here with the reason", f, n, classified[f]) |
| 389 | } |
| 390 | } |
| 391 | for f, n := range classified { |
| 392 | if got[f] != n { |
| 393 | t.Errorf("%s: %d classified, %d found", f, n, got[f]) |
| 394 | } |
| 395 | } |
| 396 | } |
| 397 | |
| 398 | // The two-pin check (estilo AGENTS.md; spec §12.1 item 1): Calls pins |
| 399 | // rastrillo v0.26, estilo's own token tests run against v0.23, so every |
| 400 | // var(--rst-*) estilo's base, menu and chrome sheets use must be defined |
| 401 | // in the tokens.css and theme.css Calls actually serves. |
| 402 | func TestEstilosChromeUsesTokensCallsDefines(t *testing.T) { |
| 403 | var defined strings.Builder |
| 404 | for _, f := range []string{"tokens.css", "theme.css"} { |
| 405 | b, err := os.ReadFile(filepath.Join("..", "calls", "static", f)) |
| 406 | if err != nil { |
| 407 | t.Fatal(err) |
| 408 | } |
| 409 | defined.Write(b) |
| 410 | } |
| 411 | defs := map[string]bool{} |
| 412 | for _, m := range regexp.MustCompile(`(--rst-[a-z0-9-]+)\s*:`).FindAllStringSubmatch(defined.String(), -1) { |
| 413 | defs[m[1]] = true |
| 414 | } |
| 415 | use := regexp.MustCompile(`var\((--rst-[a-z0-9-]+)`) |
| 416 | for name, css := range map[string][]byte{"base.css": base.CSS(), "menu.css": estilomenu.CSS(), "chrome.css": chrome.CSS()} { |
| 417 | uses := use.FindAllSubmatch(css, -1) |
| 418 | if len(uses) == 0 && name != "base.css" { |
| 419 | t.Errorf("%s uses no tokens: this check proves nothing", name) |
| 420 | } |
| 421 | for _, m := range uses { |
| 422 | if !defs[string(m[1])] { |
| 423 | t.Errorf("estilo %s uses %s, which Calls' tokens.css and theme.css do not define", name, m[1]) |
| 424 | } |
| 425 | } |
| 426 | } |
| 427 | } |
| 428 | |
| 429 | // Spec §9.5, and Astra's P3 on meet #22: a page that shows Sign out sends |
| 430 | // Referrer-Policy strict-origin, never no-referrer — in Home mode the |
| 431 | // chained sign-out is admitted by id.* on the Referer's origin, and |
| 432 | // no-referrer strands it on "Finish signing out". Open source and Share |
| 433 | // meeting show the account menu, so they are strict-origin; the native |
| 434 | // hand-off pages keep no-referrer and so show no Sign out at all. |
| 435 | func TestPagesWithSignOutSendStrictOrigin(t *testing.T) { |
| 436 | x, sqid, _ := memberWithMeeting(t) |
| 437 | c := x.browser(t) |
| 438 | c.signIn("paul@example.com") |
| 439 | for name, rec := range map[string]*httptest.ResponseRecorder{ |
| 440 | "Open source": c.getNav("/help/open-source"), |
| 441 | "Share meeting": c.post("/m/"+sqid+"/share", nil), |
| 442 | } { |
| 443 | if !strings.Contains(rec.Body.String(), ">Sign out<") { |
| 444 | t.Errorf("%s: no Sign out, so this check proves nothing", name) |
| 445 | } |
| 446 | if got := rec.Header().Get("Referrer-Policy"); got != "strict-origin" { |
| 447 | t.Errorf("%s shows Sign out with Referrer-Policy %q, want strict-origin", name, got) |
| 448 | } |
| 449 | } |
| 450 | // Any page with Sign out and no-referrer is the bug, wherever it is. |
| 451 | for _, path := range []string{"/", "/settings", "/m/" + sqid + "/join", "/meetings/" + sqid + "/settings", "/no/such/page"} { |
| 452 | rec := c.getNav(path) |
| 453 | if strings.Contains(rec.Body.String(), ">Sign out<") && rec.Header().Get("Referrer-Policy") == "no-referrer" { |
| 454 | t.Errorf("GET %s shows Sign out under no-referrer", path) |
| 455 | } |
| 456 | } |
| 457 | } |
| 458 | |