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
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
9import (
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
25var titleRe = regexp.MustCompile(`<title>([^<]*)</title>`)
26
27func 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.
38func 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.
73func 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.
116func 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.
137func 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".
170func 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.
208func 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.
219func 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).
233func 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.
252func 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>.
267func 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.
284func 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).
308func 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".
329func 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.
354func 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.
402func 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.
435func 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