//go:build browser package callstest import ( "bufio" "bytes" "context" "encoding/json" "errors" "io" "net/http" "net/http/cookiejar" "net/url" "os/exec" "path/filepath" "strings" "testing" "time" "amadan.net/oficina/calls/callscore" ) // TestNativeBrowserAppLink is "App link doesn't work if you're signed in" // (a Calls user, 2026-10-02), case by case. The browser is real Chromium // (native-applink.mjs); the app is callscore, called exactly as the app // calls it, holding its own session or none. // // Before the fix the web's "Join in the app" was // oficina-calls://join?url=/m/, which names the meeting and // nothing about who is asking. The app's session is its own: a member's // app opened it with the member's session whoever was in the browser, and // an app with none ran a sign-in in the default browser, where a guest — // who has no account to sign in with — could go no further than the // sign-in page. Now the button makes an offer in this browser and the // app's request names it, so the person confirms where they already are. func TestNativeBrowserAppLink(t *testing.T) { rig := newBrowserRig(t) room, token := rig.createMeeting(t) cookie := func(v string) map[string]string { return map[string]string{"name": rig.cookieName, "value": v} } // The app holding a session: what OpenLink answers for the meeting. appOpens := func(t *testing.T, session, address string) (string, error) { jar, _ := cookiejar.New(nil) u, _ := url.Parse(rig.origin) jar.SetCookies(u, []*http.Cookie{{Name: rig.cookieName, Value: session}}) return callscore.OpenLink(t.Context(), rig.origin, address, &http.Client{Jar: jar}) } // Who the app is, in the meeting, with the session it ended up with. info := func(t *testing.T, session string) map[string]any { req, _ := http.NewRequest("GET", rig.origin+"/m/"+room+"/info", nil) req.AddCookie(&http.Cookie{Name: rig.cookieName, Value: session}) res, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer res.Body.Close() var out map[string]any body, _ := io.ReadAll(res.Body) if res.StatusCode != 200 || json.Unmarshal(body, &out) != nil { t.Fatalf("the app's session cannot open the meeting: %d %s", res.StatusCode, body) } return out } // The app taking up an offer, the browser confirming it. handOver := func(t *testing.T, b *applinkBrowser, appURL string) map[string]any { u, err := url.Parse(appURL) if err != nil || u.Scheme != "oficina-calls" || u.Host != "join" { t.Fatalf("not an app link: %q", appURL) } address, offer := u.Query().Get("url"), u.Query().Get("offer") if address != rig.origin+"/m/"+room || offer == "" { t.Fatalf("the app link does not name this meeting and an offer: %q", appURL) } ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) defer cancel() auth, err := callscore.BeginOfferAuthentication(ctx, address, offer) if err != nil { t.Fatal(err) } defer auth.Cancel() // A second app answering the same link is refused: the offer is // taken once. if _, err := callscore.BeginOfferAuthentication(ctx, address, offer); !errors.Is(err, callscore.ErrOfferExpired) { t.Fatalf("an offer was taken twice: %v", err) } done := b.do(t, map[string]any{"cmd": "approve", "code": auth.ConfirmationCode()}) if !strings.Contains(done["text"].(string), "Return to the Calls app") { t.Fatalf("no completion: %v", done) } result, err := auth.Wait() if err != nil { t.Fatal(err) } if result.Room != room || result.Origin != rig.origin || len(result.Cookies) != 1 { t.Fatalf("wrong destination: %+v", result) } return info(t, result.Cookies[0].Value) } t.Run("member on the web, app signed in", func(t *testing.T) { // The case that always worked: the app's own session opens it, // with no browser at all. b := newApplinkBrowser(t, rig.origin) b.do(t, map[string]any{"cmd": "context", "cookie": cookie(rig.cookieA)}) if got := b.do(t, map[string]any{"cmd": "goto", "path": "/m/" + room + "/join"}); got["appHref"] != "/m/"+room+"/app" { t.Fatalf("the holding screen's Join in the app: %v", got["appHref"]) } _, appSession := rig.signIn(t, memberA) got, err := appOpens(t, appSession, rig.origin+"/m/"+room) if err != nil || got != room { t.Fatalf("OpenLink %q %v", got, err) } if who := info(t, appSession); who["name"] != "paul" || who["standing"] != "member" { t.Fatalf("the app is %v", who) } }) t.Run("member on the web, app signed out", func(t *testing.T) { b := newApplinkBrowser(t, rig.origin) b.do(t, map[string]any{"cmd": "context", "cookie": cookie(rig.cookieA)}) b.do(t, map[string]any{"cmd": "goto", "path": "/m/" + room + "/join"}) app := b.do(t, map[string]any{"cmd": "click-app"}) if who := handOver(t, b, app["app"].(string)); who["name"] != "paul" || who["standing"] != "member" { t.Fatalf("the app is %v", who) } }) t.Run("member on the web, app signed out, no script", func(t *testing.T) { // The waiting page refreshes itself with no script and arrives at // the same confirmation. b := newApplinkBrowser(t, rig.origin) b.do(t, map[string]any{"cmd": "context", "cookie": cookie(rig.cookieA), "js": false}) b.do(t, map[string]any{"cmd": "goto", "path": "/m/" + room + "/join"}) app := b.do(t, map[string]any{"cmd": "click-app"}) if who := handOver(t, b, app["app"].(string)); who["name"] != "paul" { t.Fatalf("the app is %v", who) } }) t.Run("guest signed in on the web", func(t *testing.T) { // Bea, not on this team, signed in by email and admitted through // the invitation (a pending join, spec §3.2). The old /m/ link's // sign-in ran in the app's default browser; anywhere she was not // signed in it was a sign-in page that sends a non-member no mail. rig.ctl.SetAllowlist(memberA) defer rig.ctl.SetAllowlist(memberA, memberB) b := newApplinkBrowser(t, rig.origin) b.do(t, map[string]any{"cmd": "context", "cookie": cookie(rig.cookieB)}) landed := b.do(t, map[string]any{"cmd": "goto", "path": "/j/" + token}) if !strings.HasSuffix(landed["url"].(string), "/m/"+room+"/join") { t.Fatalf("the invitation did not admit her: %v", landed["url"]) } app := b.do(t, map[string]any{"cmd": "click-app"}) if who := handOver(t, b, app["app"].(string)); who["name"] != "bea" || who["standing"] != "guest" { t.Fatalf("the app is %v", who) } // And the app signed in as somebody else on this server is told // it cannot use its own session for this meeting, rather than // joining as them or failing at the join. _, other := rig.signIn(t, memberA) rig.ctl.SetAllowlist(memberB) // A is now nobody here if _, err := appOpens(t, other, rig.origin+"/m/"+room); !errors.Is(err, callscore.ErrNoAccess) && !errors.Is(err, callscore.ErrSignedOut) { t.Fatalf("a session without standing opened the meeting: %v", err) } }) t.Run("guest from the invitation page", func(t *testing.T) { // Nobody signed in: the invitation's own page offers the app with // the invitation itself, and the app's sign-in in a fresh browser // is the guest name form, then the confirmation. b := newApplinkBrowser(t, rig.origin) b.do(t, map[string]any{"cmd": "context", "cookie": nil}) page := b.do(t, map[string]any{"cmd": "goto", "path": "/j/" + token}) u, _ := url.Parse(page["appHref"].(string)) address := u.Query().Get("url") if u.Scheme != "oficina-calls" || address != rig.origin+"/j/"+token { t.Fatalf("the invitation page's app link: %v", page["appHref"]) } ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) defer cancel() auth, err := callscore.BeginAuthentication(ctx, address) if err != nil { t.Fatal(err) } defer auth.Cancel() fresh := newApplinkBrowser(t, rig.origin) fresh.do(t, map[string]any{"cmd": "context", "cookie": nil}) start := strings.TrimPrefix(auth.BrowserURL(), rig.origin) fresh.do(t, map[string]any{"cmd": "goto", "path": start}) fresh.do(t, map[string]any{"cmd": "guest", "name": "Dee"}) fresh.do(t, map[string]any{"cmd": "approve", "code": auth.ConfirmationCode()}) result, err := auth.Wait() if err != nil || result.Room != room { t.Fatalf("%+v %v", result, err) } if who := info(t, result.Cookies[0].Value); who["name"] != "Dee" || who["standing"] != "guest" { t.Fatalf("the app is %v", who) } }) t.Run("guest from the invitation page, continued in the browser", func(t *testing.T) { // Gave a name and went on in the browser, then chose Join in the // app on the holding screen: the case the /m/ link could not do // at all outside this one browser. b := newApplinkBrowser(t, rig.origin) b.do(t, map[string]any{"cmd": "context", "cookie": nil}) b.do(t, map[string]any{"cmd": "goto", "path": "/j/" + token}) b.do(t, map[string]any{"cmd": "guest", "name": "Eve"}) app := b.do(t, map[string]any{"cmd": "click-app"}) if who := handOver(t, b, app["app"].(string)); who["name"] != "Eve" || who["standing"] != "guest" { t.Fatalf("the app is %v", who) } }) } // applinkBrowser is one node process running native-applink.mjs. type applinkBrowser struct { in io.WriteCloser out *bufio.Scanner } func newApplinkBrowser(t *testing.T, origin string) *applinkBrowser { t.Helper() ctx, cancel := context.WithTimeout(t.Context(), 3*time.Minute) cmd := exec.CommandContext(ctx, "node", filepath.Join(repoRootForBrowser(t), "test/browser/native-applink.mjs")) in, err := cmd.StdinPipe() if err != nil { t.Fatal(err) } out, err := cmd.StdoutPipe() if err != nil { t.Fatal(err) } var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Start(); err != nil { t.Fatal(err) } b := &applinkBrowser{in: in, out: bufio.NewScanner(out)} b.out.Buffer(make([]byte, 64*1024), 1<<20) json.NewEncoder(in).Encode(map[string]string{"origin": origin}) t.Cleanup(func() { json.NewEncoder(in).Encode(map[string]string{"cmd": "quit"}) in.Close() done := make(chan struct{}) go func() { cmd.Wait(); close(done) }() select { case <-done: case <-time.After(10 * time.Second): } cancel() if t.Failed() && stderr.Len() > 0 { t.Logf("browser stderr: %s", stderr.String()) } }) return b } func (b *applinkBrowser) do(t *testing.T, c map[string]any) map[string]any { t.Helper() if err := json.NewEncoder(b.in).Encode(c); err != nil { t.Fatal(err) } if !b.out.Scan() { t.Fatalf("browser gave no answer to %v", c["cmd"]) } var got map[string]any if err := json.Unmarshal(b.out.Bytes(), &got); err != nil { t.Fatalf("browser answered %q", b.out.Text()) } if e, ok := got["error"]; ok { t.Fatalf("browser, %v: %v", c["cmd"], e) } return got }