package calls import ( "crypto/sha256" "encoding/base64" "encoding/json" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "amadan.net/oficina/calls/protocol" "amadan.net/oficina/home/connect" ) func protocolCode(id string) string { return protocol.AuthenticationCode(id) } func nativeJSON(f *homeRouterFixture, path string, input any) *httptest.ResponseRecorder { b, _ := json.Marshal(input) r := httptest.NewRequest("POST", homeTestOrigin+path, strings.NewReader(string(b))) r.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() f.handler.ServeHTTP(w, r) return w } func nativeBegin(t *testing.T, f *homeRouterFixture) (string, string) { t.Helper() v := nativeRandom() h := sha256.Sum256([]byte(v)) w := nativeJSON(f, "/native/request", map[string]string{"challenge": base64.RawURLEncoding.EncodeToString(h[:]), "target": "/m/abc"}) var out map[string]string if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &out) != nil || !nativeToken(out["id"]) { t.Fatal("request failed", w.Code) } return out["id"], v } func TestNativeHandoffPreservesHomeIdentityAndRequiresProof(t *testing.T) { f := newHomeRouterFixture(t) f.link(t) id, v := nativeBegin(t, f) exchange := func(verifier string) *httptest.ResponseRecorder { return nativeJSON(f, "/native/exchange", map[string]string{"id": id, "verifier": verifier}) } if w := exchange(v); w.Code != 202 { t.Fatal("not pending", w.Code) } if w := f.do("GET", "/native/authorize?id="+id, ""); w.Code != 200 || !strings.Contains(w.Body.String(), "Sign in to Calls app") { t.Fatal("missing consent", w.Code) } if w := exchange(v); w.Code != 202 { t.Fatal("GET authorized", w.Code) } // Ambient browser cookies alone cannot approve from a foreign origin. r := httptest.NewRequest("POST", homeTestOrigin+"/native/authorize?id="+id, nil) for _, c := range f.cookies { r.AddCookie(c) } r.Header.Set("Origin", "https://attacker.test") w := httptest.NewRecorder() f.handler.ServeHTTP(w, r) if w.Code != 403 { t.Fatal("CSRF accepted", w.Code) } if w := f.do("POST", "/native/authorize?id="+id, ""); w.Code != 303 { t.Fatal("approve", w.Code) } if w := exchange(nativeRandom()); w.Code != 404 { t.Fatal("wrong proof accepted", w.Code) } w = exchange(v) if w.Code != 200 { t.Fatal("exchange", w.Code) } var result map[string]string json.Unmarshal(w.Body.Bytes(), &result) req := httptest.NewRequest("GET", homeTestOrigin+"/", nil) req.AddCookie(&http.Cookie{Name: result["name"], Value: result["value"]}) session, ok := f.app.nativeAuth.sessions.From(req) if !ok || session.Method != connect.HomeMethod { t.Fatal("lost Home identity") } browser := httptest.NewRequest("GET", homeTestOrigin+"/", nil) for _, c := range f.cookies { browser.AddCookie(c) } original, _ := f.app.nativeAuth.sessions.From(browser) if original.Subject != session.Subject || !original.AuthTime.Equal(session.AuthTime) || result["value"] == f.cookies[result["name"]].Value { t.Fatal("handoff changed identity or reused browser credential") } if exchange(v).Code != 404 { t.Fatal("replay accepted") } } func TestNativeHandoffEmailContinuationAndRevocation(t *testing.T) { f := newHomeRouterFixture(t) id, v := nativeBegin(t, f) if dest := homeRedirect(t, f.do("GET", "/native/start?id="+id, "")); dest != "/m/abc/join" { t.Fatal(dest) } f.local(t) // follows the emailed link in the browser, keeping its pending cookie if dest := homeRedirect(t, f.do("GET", "/", "")); dest != "/native/authorize?id="+id { t.Fatal("email lost handoff", dest) } if _, ok := f.cookies[f.app.nativePendingCookie()]; ok { t.Fatal("pending cookie retained") } f.do("POST", "/native/authorize?id="+id, "") f.do("POST", "/signout", "") if w := nativeJSON(f, "/native/exchange", map[string]string{"id": id, "verifier": v}); w.Code != 401 { t.Fatal("revoked browser granted session", w.Code) } } func TestNativeHandoffExpiryAndTargetValidation(t *testing.T) { f := newHomeRouterFixture(t) id, v := nativeBegin(t, f) f.app.nativeAuth.pending[id].Expires = time.Now().Add(-time.Second) if w := nativeJSON(f, "/native/exchange", map[string]string{"id": id, "verifier": v}); w.Code != 404 { t.Fatal("expired accepted") } for _, target := range []string{"https://evil.test/m/abc", "//evil.test/m/abc", "/j/../signin", "/m/a?next=evil", "/m/%2f%2fevil", "/m/a#evil", "m/a"} { if nativeTarget(target) { t.Errorf("unsafe target %q", target) } } } func TestNativeHandoffResumesAfterHomeAssertion(t *testing.T) { f := newHomeRouterFixture(t) f.link(t) f.do("POST", "/signout", "") id, _ := nativeBegin(t, f) homeRedirect(t, f.do("GET", "/native/start?id="+id, "")) signin := homeRedirect(t, f.do("GET", "/m/abc/join", "")) start := homeRedirect(t, f.do("GET", signin, "")) destination := homeRedirect(t, f.exchange(t, start, "opaque-home-alice")) if destination != "/" && destination != "/m/abc/join" { t.Fatal("unexpected Home destination", destination) } if got := homeRedirect(t, f.do("GET", destination, "")); got != "/native/authorize?id="+id { t.Fatal("Home lost native continuation", got) } } func TestNativeHandoffPreservesAnonymousGuest(t *testing.T) { f := newHomeRouterFixture(t) m, token, err := f.app.store.createMeeting(t.Context(), "Guest call", nil, "person:1") if err != nil { t.Fatal(err) } if w := f.do("POST", "/j/"+token+"/guest", "name=Visitor"); w.Code != 303 { t.Fatal(w.Code, w.Body.String()) } id, v := nativeBegin(t, f) if w := f.do("GET", "/native/authorize?id="+id, ""); w.Code != 200 || !strings.Contains(w.Body.String(), "Continue as guest") { t.Fatal(w.Code) } if w := f.do("POST", "/native/authorize?id="+id, ""); w.Code != 303 { t.Fatal(w.Code) } w := nativeJSON(f, "/native/exchange", map[string]string{"id": id, "verifier": v}) if w.Code != 200 { t.Fatal(w.Code, w.Body.String()) } var result map[string]string if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { t.Fatal(err) } f.cookies = map[string]*http.Cookie{result["name"]: {Name: result["name"], Value: result["value"]}} if w := f.do("GET", "/m/"+m.Sqid+"/join", ""); w.Code != 200 { t.Fatal("native guest cannot join", w.Code) } if w := f.do("GET", "/settings", ""); w.Code != 404 { t.Fatal("native guest gained member authority", w.Code) } } func TestNativeGuestInvitationResumesAuthorizationWithoutSignin(t *testing.T) { f := newHomeRouterFixture(t) m, token, err := f.app.store.createMeeting(t.Context(), "Native guest call", nil, "person:1") if err != nil { t.Fatal(err) } verifier := nativeRandom() challenge := sha256.Sum256([]byte(verifier)) w := nativeJSON(f, "/native/request", map[string]string{"challenge": base64.RawURLEncoding.EncodeToString(challenge[:]), "target": "/j/" + token}) var request map[string]string if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &request) != nil { t.Fatal(w.Code) } if dest := homeRedirect(t, f.do("GET", "/native/start?id="+request["id"], "")); dest != "/j/"+token { t.Fatal(dest) } if w := f.do("GET", "/j/"+token, ""); w.Code != 200 { t.Fatal("guest invitation required sign-in", w.Code) } if dest := homeRedirect(t, f.do("POST", "/j/"+token+"/guest", "name=Visitor")); dest != "/m/"+m.Sqid+"/join" { t.Fatal(dest) } if dest := homeRedirect(t, f.do("GET", "/m/"+m.Sqid+"/join", "")); dest != "/native/authorize?id="+request["id"] { t.Fatal("guest lost native handoff", dest) } } // nativeMemberJSON is nativeJSON with the session and the origin: the // start endpoint runs on ambient authority, unlike /native/request and // /native/exchange, so both of the locks it sits behind have to be in // the request for the handler to be reached at all. func nativeMemberJSON(f *homeRouterFixture, path, body string) *httptest.ResponseRecorder { r := httptest.NewRequest("POST", homeTestOrigin+path, strings.NewReader(body)) r.Header.Set("Content-Type", "application/json") r.Header.Set("Origin", homeTestOrigin) for _, c := range f.cookies { r.AddCookie(c) } w := httptest.NewRecorder() f.handler.ServeHTTP(w, r) return w } func TestNativeStartMeetingAnswersASqidAMemberCanOpen(t *testing.T) { f := newHomeRouterFixture(t) f.local(t) w := nativeMemberJSON(f, "/native/meetings/start", "{}") if w.Code != 200 { t.Fatal("start refused", w.Code, w.Body.String()) } if got := w.Header().Get("Cache-Control"); got != "no-store" { t.Fatal("meeting identifier is cacheable", got) } var out map[string]string if json.Unmarshal(w.Body.Bytes(), &out) != nil || !validSqid(out["sqid"]) { t.Fatal("no usable sqid", w.Body.String()) } if out["title"] == "" { t.Fatal("no title") } // The sqid is only worth answering if it opens: the app's next act // is to join it, on the same session. if got := f.do("GET", "/m/"+out["sqid"]+"/join", ""); got.Code != 200 { t.Fatal("started meeting will not open", got.Code) } } func TestNativeStartMeetingRefusesWithoutBothLocks(t *testing.T) { f := newHomeRouterFixture(t) f.local(t) // A form content type is the cross-site shape: an HTML form can send // it, and nothing else about the request would stop it. form := httptest.NewRequest("POST", homeTestOrigin+"/native/meetings/start", strings.NewReader("")) form.Header.Set("Content-Type", "application/x-www-form-urlencoded") form.Header.Set("Origin", homeTestOrigin) for _, c := range f.cookies { form.AddCookie(c) } w := httptest.NewRecorder() f.handler.ServeHTTP(w, form) if w.Code != http.StatusUnsupportedMediaType { t.Fatal("form post accepted", w.Code) } // And no origin at all is csrf.Protect's refusal, before the handler. bare := httptest.NewRequest("POST", homeTestOrigin+"/native/meetings/start", strings.NewReader("{}")) bare.Header.Set("Content-Type", "application/json") for _, c := range f.cookies { bare.AddCookie(c) } w = httptest.NewRecorder() f.handler.ServeHTTP(w, bare) if w.Code == 200 { t.Fatal("origin-less post accepted") } var count int if err := f.app.db.Writer().QueryRow("SELECT count(*) FROM meetings").Scan(&count); err != nil { t.Fatal(err) } if count != 0 { t.Fatal("a refused request still created a meeting", count) } } func TestNativeStartMeetingRefusesASignedOutApp(t *testing.T) { f := newHomeRouterFixture(t) if w := nativeMemberJSON(f, "/native/meetings/start", "{}"); w.Code == 200 { t.Fatal("signed-out start accepted") } } func TestNativeTargetAcceptsTheMeetingListAndNothingElseUnpathed(t *testing.T) { // "/" is the app signing in to an instance rather than to a meeting: // the person who has no link because the meeting does not exist yet. if !nativeTarget("/") { t.Fatal("the meeting list is not a target") } for _, target := range []string{"", "//", "/?next=evil", "/#evil", "/welcome", "/settings", "/signin"} { if nativeTarget(target) { t.Errorf("unsafe or unintended target %q", target) } } } func TestNativeHandoffWithoutAMeetingLandsOnTheList(t *testing.T) { f := newHomeRouterFixture(t) f.local(t) v := nativeRandom() h := sha256.Sum256([]byte(v)) w := nativeJSON(f, "/native/request", map[string]string{"challenge": base64.RawURLEncoding.EncodeToString(h[:]), "target": "/"}) var out map[string]string if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &out) != nil { t.Fatal("request refused", w.Code) } id := out["id"] // No /join is appended to the list the way it is to a meeting. if got := homeRedirect(t, f.do("GET", "/native/start?id="+id, "")); got != "/" { t.Fatal("did not land on the list", got) } // And the resume middleware carries an already-signed-in person // straight to the consent screen from "/", which is the whole point // of the list being a target at all. if got := homeRedirect(t, f.do("GET", "/", "")); got != "/native/authorize?id="+id { t.Fatal("no continuation from the list", got) } if page := f.do("GET", "/native/authorize?id="+id, ""); page.Code != 200 { t.Fatal("no consent screen", page.Code) } homeRedirect(t, f.do("POST", "/native/authorize?id="+id, "")) ex := nativeJSON(f, "/native/exchange", map[string]string{"id": id, "verifier": v}) if ex.Code != 200 { t.Fatal("exchange refused", ex.Code, ex.Body.String()) } var session map[string]string if json.Unmarshal(ex.Body.Bytes(), &session) != nil || session["target"] != "/" { t.Fatal("target not carried back", ex.Body.String()) } } // The app keeps a member's native session across launches and reads // three things off this server by status alone (callscore.CheckSession, // OpenLink and SignOut): /settings for whether the session is still a // member's, /j/{token} for the meeting an invitation opens, and // /signout for revoking it. None of them is a native endpoint, so this // is where a change to any of those answers is caught before it signs // every app out, or never does. func TestNativeSessionReadsWhatTheAppReads(t *testing.T) { f := newHomeRouterFixture(t) f.local(t) m, token, err := f.app.store.createMeeting(t.Context(), "Kept session call", nil, "person:1") if err != nil { t.Fatal(err) } browser := httptest.NewRequest("GET", homeTestOrigin+"/", nil) for _, c := range f.cookies { browser.AddCookie(c) } session, ok := f.app.nativeAuth.sessions.From(browser) if !ok { t.Fatal("no browser session to hand over") } value, err := f.app.nativeAuth.sessions.Mint(session) if err != nil { t.Fatal(err) } // From here on the fixture is the app: its own session and nothing // the browser had. name := f.app.nativeAuth.sessions.CookieName() f.cookies = map[string]*http.Cookie{name: {Name: name, Value: value}} if w := f.do("GET", "/settings", ""); w.Code != 200 { t.Fatal("a member's native session is not read as one", w.Code) } if dest := homeRedirect(t, f.do("GET", "/j/"+token, "")); dest != "/m/"+m.Sqid+"/join" { t.Fatal("invitation did not lead to its meeting", dest) } homeSignout(t, f.do("POST", "/signout", "")) // f.do dropped the cleared cookie; the app still holds its copy. f.cookies = map[string]*http.Cookie{name: {Name: name, Value: value}} if dest := homeRedirect(t, f.do("GET", "/settings", "")); !strings.HasPrefix(dest, signinPath) { t.Fatal("a revoked session is not sent to sign in", dest) } if w := f.do("GET", "/j/"+token, ""); w.Code != 200 && !strings.HasPrefix(w.Header().Get("Location"), signinPath) { t.Fatal("a revoked session followed an invitation", w.Code, w.Header().Get("Location")) } } // The web's "Join in the app" (2026-10-02): a guest who gave only a name // in this browser is handed to the app as themselves, approved here, // with no sign-in — the case the /m/ link could never do, because the // app's own browser had no session for a guest and no way to make one. func TestJoinInTheAppHandsThisBrowsersGuestToTheApp(t *testing.T) { f := newHomeRouterFixture(t) m, token, err := f.app.store.createMeeting(t.Context(), "Guest call", nil, "person:1") if err != nil { t.Fatal(err) } if w := f.do("POST", "/j/"+token+"/guest", "name=Visitor"); w.Code != 303 { t.Fatal(w.Code, w.Body.String()) } holding := f.do("GET", "/m/"+m.Sqid+"/join", "").Body.String() if !strings.Contains(holding, `href="/m/`+m.Sqid+`/app"`) { t.Fatal("the holding screen's Join in the app is not the handoff") } page := homeRedirect(t, f.do("GET", "/m/"+m.Sqid+"/app", "")) offer := strings.TrimPrefix(page, "/native/offer?o=") if !nativeToken(offer) { t.Fatal("no offer", page) } w := f.do("GET", page, "") if w.Code != 200 || !strings.Contains(w.Body.String(), "oficina-calls://join?url="+url.QueryEscape(homeTestOrigin+"/m/"+m.Sqid)+"&offer="+offer) { t.Fatal("the waiting page does not open the app with the offer", w.Code, w.Body.String()) } if got := w.Header().Get("Cache-Control"); got != "no-store" { t.Fatal("cacheable", got) } // Somebody else's browser cannot watch the offer. other := httptest.NewRequest("GET", homeTestOrigin+page, nil) rec := httptest.NewRecorder() f.handler.ServeHTTP(rec, other) if rec.Code == 200 && strings.Contains(rec.Body.String(), offer) { t.Fatal("another browser sees the offer") } claim := func(target, offer string) (*httptest.ResponseRecorder, string) { v := nativeRandom() h := sha256.Sum256([]byte(v)) return nativeJSON(f, "/native/request", map[string]string{"challenge": base64.RawURLEncoding.EncodeToString(h[:]), "target": target, "offer": offer}), v } if w, _ := claim("/m/another", offer); w.Code != http.StatusGone { t.Fatal("an offer was claimed for another meeting", w.Code) } w, verifier := claim("/m/"+m.Sqid, offer) var request struct { ID string `json:"id"` Offer bool `json:"offer"` } if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &request) != nil || !request.Offer { t.Fatal("claim", w.Code, w.Body.String()) } if w, _ := claim("/m/"+m.Sqid, offer); w.Code != http.StatusGone { t.Fatal("an offer was claimed twice", w.Code) } if dest := homeRedirect(t, f.do("GET", page, "")); dest != "/native/authorize?id="+request.ID { t.Fatal("the waiting page did not become the confirmation", dest) } poll := httptest.NewRequest("GET", homeTestOrigin+page, nil) poll.Header.Set("Accept", "application/json") for _, c := range f.cookies { poll.AddCookie(c) } rec = httptest.NewRecorder() f.handler.ServeHTTP(rec, poll) if !strings.Contains(rec.Body.String(), `"next":"/native/authorize?id=`+request.ID+`"`) { t.Fatal("the poll did not name the confirmation", rec.Body.String()) } if w := f.do("GET", "/native/authorize?id="+request.ID, ""); w.Code != 200 || !strings.Contains(w.Body.String(), protocolCode(request.ID)) { t.Fatal("confirmation", w.Code) } if w := nativeJSON(f, "/native/exchange", map[string]string{"id": request.ID, "verifier": verifier}); w.Code != 202 { t.Fatal("released before approval", w.Code) } f.do("POST", "/native/authorize?id="+request.ID, "") w = nativeJSON(f, "/native/exchange", map[string]string{"id": request.ID, "verifier": verifier}) var result map[string]string if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &result) != nil || result["target"] != "/m/"+m.Sqid { t.Fatal("exchange", w.Code, w.Body.String()) } f.cookies = map[string]*http.Cookie{result["name"]: {Name: result["name"], Value: result["value"]}} if w := f.do("GET", "/m/"+m.Sqid+"/info", ""); w.Code != 200 || !strings.Contains(w.Body.String(), `"name":"Visitor"`) { t.Fatal("the app is not the same guest in the same meeting", w.Code, w.Body.String()) } if w := f.do("GET", "/settings", ""); w.Code != 404 { t.Fatal("the guest gained member authority", w.Code) } } // An offer expires with the five minutes every request has, and the page // says so rather than waiting for ever. func TestJoinInTheAppOfferExpires(t *testing.T) { f := newHomeRouterFixture(t) f.local(t) m, _, err := f.app.store.createMeeting(t.Context(), "Expiry", nil, "person:1") if err != nil { t.Fatal(err) } page := homeRedirect(t, f.do("GET", "/m/"+m.Sqid+"/app", "")) offer := strings.TrimPrefix(page, "/native/offer?o=") f.app.nativeAuth.offers[offer].Expires = time.Now().Add(-time.Second) if w := f.do("GET", page, ""); w.Code != 404 || !strings.Contains(w.Body.String(), "expired") { t.Fatal("expired offer", w.Code) } v := nativeRandom() h := sha256.Sum256([]byte(v)) if w := nativeJSON(f, "/native/request", map[string]string{"challenge": base64.RawURLEncoding.EncodeToString(h[:]), "target": "/m/" + m.Sqid, "offer": offer}); w.Code != http.StatusGone { t.Fatal("an expired offer was claimed", w.Code) } } // The apps' Start meeting names the device's zone, and the title is the // time there with no zone in the words (2026-10-02). func TestNativeStartMeetingTitlesInTheAppsZone(t *testing.T) { f := newHomeRouterFixture(t) f.local(t) w := nativeMemberJSON(f, "/native/meetings/start", `{"zone":"Asia/Tokyo"}`) var out map[string]string if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &out) != nil { t.Fatal(w.Code, w.Body.String()) } tokyo, _ := time.LoadLocation("Asia/Tokyo") if want := "Call, " + f.app.store.now().In(tokyo).Format("2 Jan 15:04"); out["title"] != want { t.Fatalf("title %q, want %q", out["title"], want) } m, ok, err := f.app.store.meetingBySqid(t.Context(), out["sqid"]) if err != nil || !ok || !m.AutoTitle || m.TitleZone != "Asia/Tokyo" { t.Fatalf("meeting %+v %v %v", m, ok, err) } }