package calls import ( "amadan.net/oficina/home/connect" "amadan.net/rastrillo/rastrillo/assertion" assertioncrypto "amadan.net/rastrillo/rastrillo/crypto" "amadan.net/rastrillo/rastrillo/db" "bytes" "context" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "html" "io" "log/slog" "net/http" "net/http/httptest" "net/url" "path/filepath" "regexp" "strings" "testing" "time" ) const homeTestOrigin = "https://meet.example.test" const homeTestIssuer = "https://id.example.test" type homeTestMail struct{ body string } func (m *homeTestMail) Send(_ context.Context, _, _, body string) error { m.body = body; return nil } type homeRouterFixture struct { app *app handler http.Handler key *assertioncrypto.Keypair mail *homeTestMail cookies map[string]*http.Cookie } func newHomeRouterFixture(t *testing.T) *homeRouterFixture { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) d, err := db.Open(filepath.Join(t.TempDir(), "calls.db"), logger) if err != nil { t.Fatal(err) } t.Cleanup(func() { d.Close() }) key, err := assertioncrypto.Generate() if err != nil { t.Fatal(err) } return newHomeRouterFixtureWith(t, d, logger, key, &connect.Credentials{Issuer: homeTestIssuer, ConsumerID: "calls-test", KeysBase64: homeKeysBase64(key), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))}) } // homeKeysBase64 is the public-key manifest Home publishes for key. func homeKeysBase64(key *assertioncrypto.Keypair) string { sum := sha256.Sum256(key.SignPub()) id := hex.EncodeToString(sum[:]) raw, _ := json.Marshal(map[string]string{id: base64.StdEncoding.EncodeToString(key.SignPub())}) return base64.StdEncoding.EncodeToString(raw) } // newHomeRouterFixtureWith builds the app with Home configured by creds, // whose assertions key signs. func newHomeRouterFixtureWith(t *testing.T, d *db.DB, logger *slog.Logger, key *assertioncrypto.Keypair, creds *connect.Credentials) *homeRouterFixture { t.Helper() mail := &homeTestMail{} mux, a, err := buildForTest(t, d, Config{Origin: homeTestOrigin, InstanceKey: "test-only-instance", DEK: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{3}, 32)), AllowedAddresses: []string{"alice@example.test"}, Mailer: mail, Logger: logger, Home: creds}) if err != nil { t.Fatal(err) } return &homeRouterFixture{a, mux, key, mail, map[string]*http.Cookie{}} } // mailBody is the last sign-in mail, once the queue has sent it // (links go out after the response: signin_mail.go). func (f *homeRouterFixture) mailBody() string { f.app.signinMail.wait() return f.mail.body } func homeKeyID(key *assertioncrypto.Keypair) string { sum := sha256.Sum256(key.SignPub()) return hex.EncodeToString(sum[:]) } func (f *homeRouterFixture) do(method, path, body string) *httptest.ResponseRecorder { return f.doWith(method, path, body, nil) } // doWith is do with extra request headers, set after do's own. func (f *homeRouterFixture) doWith(method, path, body string, extra http.Header) *httptest.ResponseRecorder { r := httptest.NewRequest(method, homeTestOrigin+path, strings.NewReader(body)) for _, c := range f.cookies { r.AddCookie(c) } if method == "POST" { r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.Header.Set("Origin", homeTestOrigin) if path == "/auth/assertion" { r.Header.Set("Origin", homeTestIssuer) } } for name, values := range extra { r.Header[name] = values } w := httptest.NewRecorder() f.handler.ServeHTTP(w, r) for _, c := range w.Result().Cookies() { if c.MaxAge < 0 { delete(f.cookies, c.Name) } else { f.cookies[c.Name] = c } } return w } // confirmForm is the confirm door's one form, as a browser reads it. var confirmForm = regexp.MustCompile(`]*method="post" action="([^"]+)">\s*`) // readConfirm checks w is the confirm door an emailed link opens on (a // 200 that mints nothing) and returns its form's action and the token it // carries, read off the page rather than the link, so a door that // dropped or mangled the token fails here. func readConfirm(t *testing.T, w *httptest.ResponseRecorder) (action, token string) { t.Helper() if w.Code != http.StatusOK || len(w.Result().Cookies()) != 0 { t.Fatalf("opening the link = %d with %d cookies, want 200 and the confirm door, minting nothing:\n%s", w.Code, len(w.Result().Cookies()), w.Body.String()) } m := confirmForm.FindStringSubmatch(w.Body.String()) if m == nil { t.Fatalf("no confirm form on the page:\n%s", w.Body.String()) } return html.UnescapeString(m[1]), html.UnescapeString(m[2]) } // followLink opens an emailed link in this browser, then presses the // confirm door's Sign in: the token POSTed back with a browser's // same-origin evidence, which is the request that spends the link. func (f *homeRouterFixture) followLink(t *testing.T, link string) *httptest.ResponseRecorder { t.Helper() u, err := url.Parse(link) if err != nil || link == "" { t.Fatalf("emailed link %q: %v", link, err) } action, token := readConfirm(t, f.do("GET", u.RequestURI(), "")) return f.doWith("POST", action, url.Values{"token": {token}}.Encode(), http.Header{"Sec-Fetch-Site": {"same-origin"}}) } func homeRedirect(t *testing.T, w *httptest.ResponseRecorder) string { t.Helper() if w.Code != 303 { t.Fatalf("redirect status %d: %s", w.Code, w.Body.String()) } return w.Header().Get("Location") } func (f *homeRouterFixture) local(t *testing.T) { t.Helper() homeRedirect(t, f.do("POST", "/signin", "address=alice%40example.test&force=1")) link := regexp.MustCompile(`https://[^\s<>\"]+/auth/verify\?[^\s<>\"]+`).FindString(f.mailBody()) if link == "" { t.Fatal("no captured local verification link") } landing := homeRedirect(t, f.followLink(t, link)) if landing != "/auth/local/complete" { t.Fatal("wrong local landing", landing) } landing = homeRedirect(t, f.do("GET", landing, "")) if landing != "/continue" { t.Fatal("lost local continue", landing) } homeRedirect(t, f.do("GET", landing, "")) } func (f *homeRouterFixture) exchange(t *testing.T, start, subject string) *httptest.ResponseRecorder { t.Helper() target := homeRedirect(t, f.do("GET", start, "")) u, err := url.Parse(target) if err != nil || u.Query().Get("consumer") != "calls-test" { t.Fatal("bad issuer redirect") } nonce := u.Query().Get("nonce") now := time.Now().Unix() jti, err := assertion.RandomID() if err != nil { t.Fatal(err) } token, err := assertion.Sign(f.key, assertion.Claims{Version: 1, Issuer: homeTestIssuer, Audience: homeTestOrigin, Subject: subject, KeyID: homeKeyID(f.key), ID: jti, Nonce: nonce, IssuedAt: now, ExpiresAt: now + 90, AuthTime: now}) if err != nil { t.Fatal(err) } callback := f.do("POST", "/auth/assertion", url.Values{"assertion": {token}}.Encode()) finish := homeRedirect(t, callback) return f.do("GET", finish, "") } func (f *homeRouterFixture) link(t *testing.T) { f.local(t) if w := f.do("GET", "/account/home", ""); w.Code != 200 { t.Fatal("account", w.Code) } start := homeRedirect(t, f.do("POST", "/account/home", "")) if next := homeRedirect(t, f.exchange(t, start, "opaque-home-alice")); next != "/account/home/confirm" { t.Fatal("confirmation", next) } homeRedirect(t, f.do("POST", "/account/home/confirm", "")) homeSignout(t, f.do("POST", "/signout", "")) start = homeRedirect(t, f.do("GET", "/signin", "")) homeRedirect(t, f.exchange(t, start, "opaque-home-alice")) } func TestHomeLinksExistingMemberAndKeepsMeetings(t *testing.T) { f := newHomeRouterFixture(t) f.local(t) created := f.do("POST", "/meetings", "title=Existing+private+meeting") if created.Code != 200 && created.Code != 303 { t.Fatal(created.Code, created.Body.String()) } var before string if err := f.app.db.Writer().QueryRow("SELECT created_by FROM meetings LIMIT 1").Scan(&before); err != nil { t.Fatal(err) } homeSignout(t, f.do("POST", "/signout", "")) f.link(t) w := f.do("GET", "/", "") if w.Code != 200 || !strings.Contains(w.Body.String(), "Existing private meeting") { t.Fatal("lost existing meeting", w.Code, w.Body.String()) } var after string f.app.db.Writer().QueryRow("SELECT created_by FROM meetings LIMIT 1").Scan(&after) if after != before { t.Fatal("changed attribution") } var count int f.app.db.Writer().QueryRow("SELECT count(*) FROM persons").Scan(&count) if count != 1 { t.Fatal("Home created person", count) } f.app.cfg.allow = allowlist{} // A removed member's page request ends the session and says why, on // the local form: a 404 there would offer "Sign in", which connect // passes straight back through the same live cookie (Docs #65). w = f.do("GET", "/", "") if w.Code != 303 || w.Header().Get("Location") != "/signin/local?err=removed" { t.Fatal("removed member accepted", w.Code, w.Header().Get("Location")) } if w = f.do("GET", "/settings", ""); w.Code != 303 || !strings.HasPrefix(w.Header().Get("Location"), "/signin") { t.Fatal("the removed member's session survived", w.Code, w.Header().Get("Location")) } } func TestUnknownHomeDoesNotCreatePerson(t *testing.T) { f := newHomeRouterFixture(t) start := homeRedirect(t, f.do("GET", "/signin", "")) w := f.exchange(t, start, "unknown-home-person") if w.Code != 403 { t.Fatal(w.Code) } var n int f.app.db.Writer().QueryRow("SELECT count(*) FROM persons").Scan(&n) if n != 0 { t.Fatal("Home allocated person", n) } } func TestHomeSessionFailsWhenDisabled(t *testing.T) { f := newHomeRouterFixture(t) f.link(t) f.app.home = nil if w := f.do("GET", "/", ""); w.Code != 404 { t.Fatal("disabled accepted", w.Code) } } func TestHomeEnabledPreservesInvitedGuestFlow(t *testing.T) { f := newHomeRouterFixture(t) f.local(t) f.do("POST", "/settings/defaults", "require_signin=on") made := f.do("POST", "/meetings", "title=Guest+meeting") token := regexp.MustCompile(`/j/([A-Za-z0-9_-]{43})`).FindStringSubmatch(made.Body.String()) sqid := regexp.MustCompile(`/m/([23456789a-z]{10})/join`).FindStringSubmatch(made.Body.String()) if token == nil || sqid == nil { t.Fatal("missing meeting links") } homeSignout(t, f.do("POST", "/signout", "")) path := homeRedirect(t, f.do("GET", "/j/"+token[1], "")) if path != "/signin" { t.Fatal(path) } if w := f.do("GET", path, ""); w.Code != 200 { t.Fatal("guest redirected to Home", w.Code) } homeRedirect(t, f.do("POST", "/signin", "address=guest%40example.test")) link := regexp.MustCompile(`https://[^\s<>\"]+/auth/verify\?[^\s<>\"]+`).FindString(f.mailBody()) landing := homeRedirect(t, f.followLink(t, link)) if landing != "/auth/local/complete" { t.Fatal(landing) } landing = homeRedirect(t, f.do("GET", landing, "")) if landing != "/continue" { t.Fatal("lost pending guest landing", landing) } landing = homeRedirect(t, f.do("GET", landing, "")) if landing != "/m/"+sqid[1]+"/join" { t.Fatal("lost grant", landing) } if w := f.do("GET", landing, ""); w.Code != 200 { t.Fatal("holding", w.Code) } if w := f.do("GET", "/", ""); w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/welcome" { t.Fatal("guest start page did not lead to welcome", w.Code) } for _, path := range []string{"/account/home"} { if w := f.do("GET", path, ""); w.Code != 404 { t.Fatal("guest gained member privilege", path, w.Code) } } } func TestHomePublicJoinResolvesExistingMember(t *testing.T) { f := newHomeRouterFixture(t) f.link(t) made := f.do("POST", "/meetings", "title=Home+meeting") token := regexp.MustCompile(`/j/([A-Za-z0-9_-]{43})`).FindStringSubmatch(made.Body.String()) if token == nil { t.Fatal("missing link") } if w := f.do("GET", "/j/"+token[1], ""); w.Code != 303 { t.Fatal("Home member follow", w.Code) } var n int f.app.db.Writer().QueryRow("SELECT count(*) FROM persons").Scan(&n) if n != 1 { t.Fatal("opaque Home subject became person", n) } f.app.cfg.allow = allowlist{} if w := f.do("GET", "/j/"+token[1], ""); w.Code != 404 { t.Fatal("removed member gained guest grant", w.Code) } f.app.db.Writer().QueryRow("SELECT count(*) FROM guest_grants").Scan(&n) if n != 0 { t.Fatal("removed Home member acquired grant", n) } } func TestHomeRemovedMemberSigninIsRefusedWithoutServiceError(t *testing.T) { f := newHomeRouterFixture(t) f.link(t) homeSignout(t, f.do("POST", "/signout", "")) f.app.cfg.allow = allowlist{} start := homeRedirect(t, f.do("GET", "/signin", "")) if w := f.exchange(t, start, "opaque-home-alice"); w.Code != 403 { t.Fatal("removed member should be unknown, not unavailable", w.Code) } } func TestHomeLookupDatabaseFailureKeepsSession(t *testing.T) { f := newHomeRouterFixture(t) f.link(t) var before int f.app.db.Writer().QueryRow("SELECT count(*) FROM sessions").Scan(&before) if _, err := f.app.db.Writer().Exec("ALTER TABLE persons RENAME TO temporarily_unavailable_persons"); err != nil { t.Fatal(err) } w := f.do("GET", "/", "") if w.Code == 200 { t.Fatal("database failure admitted") } var after int f.app.db.Writer().QueryRow("SELECT count(*) FROM sessions").Scan(&after) if before != after { t.Fatal("transient lookup failure revoked session") } if _, err := f.app.db.Writer().Exec("ALTER TABLE temporarily_unavailable_persons RENAME TO persons"); err != nil { t.Fatal(err) } if w = f.do("GET", "/", ""); w.Code != 200 { t.Fatal("recovered lookup did not preserve session", w.Code) } } func TestHomeSigninClearsOnlyDefinitivelyStaleSession(t *testing.T) { f := newHomeRouterFixture(t) f.local(t) cookieName := f.app.home.sessions.CookieName() saved := f.cookies[cookieName].Value if _, err := f.app.db.Writer().Exec("ALTER TABLE sessions RENAME TO unavailable_sessions"); err != nil { t.Fatal(err) } homeRedirect(t, f.do("GET", "/signin", "")) if f.cookies[cookieName] == nil || f.cookies[cookieName].Value != saved { t.Fatal("temporary storage failure cleared valid cookie") } if _, err := f.app.db.Writer().Exec("ALTER TABLE unavailable_sessions RENAME TO sessions"); err != nil { t.Fatal(err) } if _, err := f.app.db.Writer().Exec("DELETE FROM sessions"); err != nil { t.Fatal(err) } start := homeRedirect(t, f.do("GET", "/signin", "")) if f.cookies[cookieName] != nil { t.Fatal("stale cookie not cleared") } target := homeRedirect(t, f.do("GET", start, "")) if !strings.HasPrefix(target, homeTestIssuer+"/") { t.Fatal("stale cookie prevented Home sign-in", target) } } // With Home connected, Sign out lands on the Oficina door's signed-out // page, in the door's own words (not connect's, which name the hub), and // its one link, "Sign in", goes back to connect's sign-in action. func TestHomeSignedOutIsTheOficinaDoor(t *testing.T) { f := newHomeRouterFixture(t) w := httptest.NewRecorder() f.handler.ServeHTTP(w, httptest.NewRequest("GET", homeTestOrigin+"/signed-out", nil)) body := w.Body.String() if w.Code != http.StatusOK || w.Header().Get("Cache-Control") != "no-store" { t.Fatalf("GET /signed-out: %d %q", w.Code, w.Header().Get("Cache-Control")) } for _, want := range []string{"
Calls", "estilo-sky-layer", "Signed out · Calls", "

You’re signed out

", "You’ve signed out of Calls on this device.", `Sign in`} { if !strings.Contains(body, want) { t.Errorf("signed-out page is missing %q:\n%s", want, body) } } // "Sign in with Home" is the retired label: connect.English now says // "Sign in" itself (Home #79), so asserting its absence by key would // contradict the "Sign in" button required above. for _, gone := range []string{connect.English["signedout_body"], "Sign in with Home", "Home"} { if strings.Contains(body, gone) { t.Errorf("signed-out page still carries %q:\n%s", gone, body) } } } // The revoked-user loop Astra found in Docs (#65, 1) and Sheets (#39, 2): // connect's Signin skips the exchange for any live session cookie and // sends it back to the page, which refuses it again. A removed member's // GET /signin must end that session and say why, and the next /signin // must start a real sign-in. func TestARemovedHomeMemberIsNotBouncedBetweenSigninAndThePage(t *testing.T) { f := newHomeRouterFixture(t) f.link(t) f.app.cfg.allow = allowlist{} w := f.do("GET", "/signin?return_to=%2Fsettings", "") if w.Code != 303 || !strings.Contains(w.Header().Get("Location"), "err=removed") { t.Fatalf("a removed member's /signin: %d %q, want the removed notice", w.Code, w.Header().Get("Location")) } if next := homeRedirect(t, f.do("GET", "/signin", "")); !strings.HasPrefix(next, "/auth/start") { t.Fatalf("after the notice, /signin did not start a sign-in: %q", next) } } // The account link is "Connect your Oficina account" (spec §13 Q3: the // hub is never named), in the account menu, offered to a local session // only: "Connect Home account" on the list page is retired. func TestTheAccountMenuOffersToConnectYourOficinaAccount(t *testing.T) { f := newHomeRouterFixture(t) f.local(t) body := f.do("GET", "/", "").Body.String() if !strings.Contains(body, `Connect your Oficina account`) || strings.Contains(body, "Connect Home account") { t.Fatalf("the list page's account link:\n%s", body) } f.link(t) if body := f.do("GET", "/", "").Body.String(); strings.Contains(body, "/account/home") { t.Fatal("a Home session is offered to connect the account it already is") } }