| 1 | //go:build browser |
| 2 | |
| 3 | package callstest |
| 4 | |
| 5 | import ( |
| 6 | "bufio" |
| 7 | "bytes" |
| 8 | "context" |
| 9 | "encoding/json" |
| 10 | "errors" |
| 11 | "io" |
| 12 | "net/http" |
| 13 | "net/http/cookiejar" |
| 14 | "net/url" |
| 15 | "os/exec" |
| 16 | "path/filepath" |
| 17 | "strings" |
| 18 | "testing" |
| 19 | "time" |
| 20 | |
| 21 | "amadan.net/oficina/calls/callscore" |
| 22 | ) |
| 23 | |
| 24 | // TestNativeBrowserAppLink is "App link doesn't work if you're signed in" |
| 25 | // (a Calls user, 2026-10-02), case by case. The browser is real Chromium |
| 26 | // (native-applink.mjs); the app is callscore, called exactly as the app |
| 27 | // calls it, holding its own session or none. |
| 28 | // |
| 29 | // Before the fix the web's "Join in the app" was |
| 30 | // oficina-calls://join?url=<origin>/m/<sqid>, which names the meeting and |
| 31 | // nothing about who is asking. The app's session is its own: a member's |
| 32 | // app opened it with the member's session whoever was in the browser, and |
| 33 | // an app with none ran a sign-in in the default browser, where a guest — |
| 34 | // who has no account to sign in with — could go no further than the |
| 35 | // sign-in page. Now the button makes an offer in this browser and the |
| 36 | // app's request names it, so the person confirms where they already are. |
| 37 | func TestNativeBrowserAppLink(t *testing.T) { |
| 38 | rig := newBrowserRig(t) |
| 39 | room, token := rig.createMeeting(t) |
| 40 | cookie := func(v string) map[string]string { return map[string]string{"name": rig.cookieName, "value": v} } |
| 41 | |
| 42 | // The app holding a session: what OpenLink answers for the meeting. |
| 43 | appOpens := func(t *testing.T, session, address string) (string, error) { |
| 44 | jar, _ := cookiejar.New(nil) |
| 45 | u, _ := url.Parse(rig.origin) |
| 46 | jar.SetCookies(u, []*http.Cookie{{Name: rig.cookieName, Value: session}}) |
| 47 | return callscore.OpenLink(t.Context(), rig.origin, address, &http.Client{Jar: jar}) |
| 48 | } |
| 49 | // Who the app is, in the meeting, with the session it ended up with. |
| 50 | info := func(t *testing.T, session string) map[string]any { |
| 51 | req, _ := http.NewRequest("GET", rig.origin+"/m/"+room+"/info", nil) |
| 52 | req.AddCookie(&http.Cookie{Name: rig.cookieName, Value: session}) |
| 53 | res, err := http.DefaultClient.Do(req) |
| 54 | if err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | defer res.Body.Close() |
| 58 | var out map[string]any |
| 59 | body, _ := io.ReadAll(res.Body) |
| 60 | if res.StatusCode != 200 || json.Unmarshal(body, &out) != nil { |
| 61 | t.Fatalf("the app's session cannot open the meeting: %d %s", res.StatusCode, body) |
| 62 | } |
| 63 | return out |
| 64 | } |
| 65 | // The app taking up an offer, the browser confirming it. |
| 66 | handOver := func(t *testing.T, b *applinkBrowser, appURL string) map[string]any { |
| 67 | u, err := url.Parse(appURL) |
| 68 | if err != nil || u.Scheme != "oficina-calls" || u.Host != "join" { |
| 69 | t.Fatalf("not an app link: %q", appURL) |
| 70 | } |
| 71 | address, offer := u.Query().Get("url"), u.Query().Get("offer") |
| 72 | if address != rig.origin+"/m/"+room || offer == "" { |
| 73 | t.Fatalf("the app link does not name this meeting and an offer: %q", appURL) |
| 74 | } |
| 75 | ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) |
| 76 | defer cancel() |
| 77 | auth, err := callscore.BeginOfferAuthentication(ctx, address, offer) |
| 78 | if err != nil { |
| 79 | t.Fatal(err) |
| 80 | } |
| 81 | defer auth.Cancel() |
| 82 | // A second app answering the same link is refused: the offer is |
| 83 | // taken once. |
| 84 | if _, err := callscore.BeginOfferAuthentication(ctx, address, offer); !errors.Is(err, callscore.ErrOfferExpired) { |
| 85 | t.Fatalf("an offer was taken twice: %v", err) |
| 86 | } |
| 87 | done := b.do(t, map[string]any{"cmd": "approve", "code": auth.ConfirmationCode()}) |
| 88 | if !strings.Contains(done["text"].(string), "Return to the Calls app") { |
| 89 | t.Fatalf("no completion: %v", done) |
| 90 | } |
| 91 | result, err := auth.Wait() |
| 92 | if err != nil { |
| 93 | t.Fatal(err) |
| 94 | } |
| 95 | if result.Room != room || result.Origin != rig.origin || len(result.Cookies) != 1 { |
| 96 | t.Fatalf("wrong destination: %+v", result) |
| 97 | } |
| 98 | return info(t, result.Cookies[0].Value) |
| 99 | } |
| 100 | |
| 101 | t.Run("member on the web, app signed in", func(t *testing.T) { |
| 102 | // The case that always worked: the app's own session opens it, |
| 103 | // with no browser at all. |
| 104 | b := newApplinkBrowser(t, rig.origin) |
| 105 | b.do(t, map[string]any{"cmd": "context", "cookie": cookie(rig.cookieA)}) |
| 106 | if got := b.do(t, map[string]any{"cmd": "goto", "path": "/m/" + room + "/join"}); got["appHref"] != "/m/"+room+"/app" { |
| 107 | t.Fatalf("the holding screen's Join in the app: %v", got["appHref"]) |
| 108 | } |
| 109 | _, appSession := rig.signIn(t, memberA) |
| 110 | got, err := appOpens(t, appSession, rig.origin+"/m/"+room) |
| 111 | if err != nil || got != room { |
| 112 | t.Fatalf("OpenLink %q %v", got, err) |
| 113 | } |
| 114 | if who := info(t, appSession); who["name"] != "paul" || who["standing"] != "member" { |
| 115 | t.Fatalf("the app is %v", who) |
| 116 | } |
| 117 | }) |
| 118 | |
| 119 | t.Run("member on the web, app signed out", func(t *testing.T) { |
| 120 | b := newApplinkBrowser(t, rig.origin) |
| 121 | b.do(t, map[string]any{"cmd": "context", "cookie": cookie(rig.cookieA)}) |
| 122 | b.do(t, map[string]any{"cmd": "goto", "path": "/m/" + room + "/join"}) |
| 123 | app := b.do(t, map[string]any{"cmd": "click-app"}) |
| 124 | if who := handOver(t, b, app["app"].(string)); who["name"] != "paul" || who["standing"] != "member" { |
| 125 | t.Fatalf("the app is %v", who) |
| 126 | } |
| 127 | }) |
| 128 | |
| 129 | t.Run("member on the web, app signed out, no script", func(t *testing.T) { |
| 130 | // The waiting page refreshes itself with no script and arrives at |
| 131 | // the same confirmation. |
| 132 | b := newApplinkBrowser(t, rig.origin) |
| 133 | b.do(t, map[string]any{"cmd": "context", "cookie": cookie(rig.cookieA), "js": false}) |
| 134 | b.do(t, map[string]any{"cmd": "goto", "path": "/m/" + room + "/join"}) |
| 135 | app := b.do(t, map[string]any{"cmd": "click-app"}) |
| 136 | if who := handOver(t, b, app["app"].(string)); who["name"] != "paul" { |
| 137 | t.Fatalf("the app is %v", who) |
| 138 | } |
| 139 | }) |
| 140 | |
| 141 | t.Run("guest signed in on the web", func(t *testing.T) { |
| 142 | // Bea, not on this team, signed in by email and admitted through |
| 143 | // the invitation (a pending join, spec §3.2). The old /m/ link's |
| 144 | // sign-in ran in the app's default browser; anywhere she was not |
| 145 | // signed in it was a sign-in page that sends a non-member no mail. |
| 146 | rig.ctl.SetAllowlist(memberA) |
| 147 | defer rig.ctl.SetAllowlist(memberA, memberB) |
| 148 | b := newApplinkBrowser(t, rig.origin) |
| 149 | b.do(t, map[string]any{"cmd": "context", "cookie": cookie(rig.cookieB)}) |
| 150 | landed := b.do(t, map[string]any{"cmd": "goto", "path": "/j/" + token}) |
| 151 | if !strings.HasSuffix(landed["url"].(string), "/m/"+room+"/join") { |
| 152 | t.Fatalf("the invitation did not admit her: %v", landed["url"]) |
| 153 | } |
| 154 | app := b.do(t, map[string]any{"cmd": "click-app"}) |
| 155 | if who := handOver(t, b, app["app"].(string)); who["name"] != "bea" || who["standing"] != "guest" { |
| 156 | t.Fatalf("the app is %v", who) |
| 157 | } |
| 158 | // And the app signed in as somebody else on this server is told |
| 159 | // it cannot use its own session for this meeting, rather than |
| 160 | // joining as them or failing at the join. |
| 161 | _, other := rig.signIn(t, memberA) |
| 162 | rig.ctl.SetAllowlist(memberB) // A is now nobody here |
| 163 | if _, err := appOpens(t, other, rig.origin+"/m/"+room); !errors.Is(err, callscore.ErrNoAccess) && !errors.Is(err, callscore.ErrSignedOut) { |
| 164 | t.Fatalf("a session without standing opened the meeting: %v", err) |
| 165 | } |
| 166 | }) |
| 167 | |
| 168 | t.Run("guest from the invitation page", func(t *testing.T) { |
| 169 | // Nobody signed in: the invitation's own page offers the app with |
| 170 | // the invitation itself, and the app's sign-in in a fresh browser |
| 171 | // is the guest name form, then the confirmation. |
| 172 | b := newApplinkBrowser(t, rig.origin) |
| 173 | b.do(t, map[string]any{"cmd": "context", "cookie": nil}) |
| 174 | page := b.do(t, map[string]any{"cmd": "goto", "path": "/j/" + token}) |
| 175 | u, _ := url.Parse(page["appHref"].(string)) |
| 176 | address := u.Query().Get("url") |
| 177 | if u.Scheme != "oficina-calls" || address != rig.origin+"/j/"+token { |
| 178 | t.Fatalf("the invitation page's app link: %v", page["appHref"]) |
| 179 | } |
| 180 | ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) |
| 181 | defer cancel() |
| 182 | auth, err := callscore.BeginAuthentication(ctx, address) |
| 183 | if err != nil { |
| 184 | t.Fatal(err) |
| 185 | } |
| 186 | defer auth.Cancel() |
| 187 | fresh := newApplinkBrowser(t, rig.origin) |
| 188 | fresh.do(t, map[string]any{"cmd": "context", "cookie": nil}) |
| 189 | start := strings.TrimPrefix(auth.BrowserURL(), rig.origin) |
| 190 | fresh.do(t, map[string]any{"cmd": "goto", "path": start}) |
| 191 | fresh.do(t, map[string]any{"cmd": "guest", "name": "Dee"}) |
| 192 | fresh.do(t, map[string]any{"cmd": "approve", "code": auth.ConfirmationCode()}) |
| 193 | result, err := auth.Wait() |
| 194 | if err != nil || result.Room != room { |
| 195 | t.Fatalf("%+v %v", result, err) |
| 196 | } |
| 197 | if who := info(t, result.Cookies[0].Value); who["name"] != "Dee" || who["standing"] != "guest" { |
| 198 | t.Fatalf("the app is %v", who) |
| 199 | } |
| 200 | }) |
| 201 | |
| 202 | t.Run("guest from the invitation page, continued in the browser", func(t *testing.T) { |
| 203 | // Gave a name and went on in the browser, then chose Join in the |
| 204 | // app on the holding screen: the case the /m/ link could not do |
| 205 | // at all outside this one browser. |
| 206 | b := newApplinkBrowser(t, rig.origin) |
| 207 | b.do(t, map[string]any{"cmd": "context", "cookie": nil}) |
| 208 | b.do(t, map[string]any{"cmd": "goto", "path": "/j/" + token}) |
| 209 | b.do(t, map[string]any{"cmd": "guest", "name": "Eve"}) |
| 210 | app := b.do(t, map[string]any{"cmd": "click-app"}) |
| 211 | if who := handOver(t, b, app["app"].(string)); who["name"] != "Eve" || who["standing"] != "guest" { |
| 212 | t.Fatalf("the app is %v", who) |
| 213 | } |
| 214 | }) |
| 215 | } |
| 216 | |
| 217 | // applinkBrowser is one node process running native-applink.mjs. |
| 218 | type applinkBrowser struct { |
| 219 | in io.WriteCloser |
| 220 | out *bufio.Scanner |
| 221 | } |
| 222 | |
| 223 | func newApplinkBrowser(t *testing.T, origin string) *applinkBrowser { |
| 224 | t.Helper() |
| 225 | ctx, cancel := context.WithTimeout(t.Context(), 3*time.Minute) |
| 226 | cmd := exec.CommandContext(ctx, "node", filepath.Join(repoRootForBrowser(t), "test/browser/native-applink.mjs")) |
| 227 | in, err := cmd.StdinPipe() |
| 228 | if err != nil { |
| 229 | t.Fatal(err) |
| 230 | } |
| 231 | out, err := cmd.StdoutPipe() |
| 232 | if err != nil { |
| 233 | t.Fatal(err) |
| 234 | } |
| 235 | var stderr bytes.Buffer |
| 236 | cmd.Stderr = &stderr |
| 237 | if err := cmd.Start(); err != nil { |
| 238 | t.Fatal(err) |
| 239 | } |
| 240 | b := &applinkBrowser{in: in, out: bufio.NewScanner(out)} |
| 241 | b.out.Buffer(make([]byte, 64*1024), 1<<20) |
| 242 | json.NewEncoder(in).Encode(map[string]string{"origin": origin}) |
| 243 | t.Cleanup(func() { |
| 244 | json.NewEncoder(in).Encode(map[string]string{"cmd": "quit"}) |
| 245 | in.Close() |
| 246 | done := make(chan struct{}) |
| 247 | go func() { cmd.Wait(); close(done) }() |
| 248 | select { |
| 249 | case <-done: |
| 250 | case <-time.After(10 * time.Second): |
| 251 | } |
| 252 | cancel() |
| 253 | if t.Failed() && stderr.Len() > 0 { |
| 254 | t.Logf("browser stderr: %s", stderr.String()) |
| 255 | } |
| 256 | }) |
| 257 | return b |
| 258 | } |
| 259 | |
| 260 | func (b *applinkBrowser) do(t *testing.T, c map[string]any) map[string]any { |
| 261 | t.Helper() |
| 262 | if err := json.NewEncoder(b.in).Encode(c); err != nil { |
| 263 | t.Fatal(err) |
| 264 | } |
| 265 | if !b.out.Scan() { |
| 266 | t.Fatalf("browser gave no answer to %v", c["cmd"]) |
| 267 | } |
| 268 | var got map[string]any |
| 269 | if err := json.Unmarshal(b.out.Bytes(), &got); err != nil { |
| 270 | t.Fatalf("browser answered %q", b.out.Text()) |
| 271 | } |
| 272 | if e, ok := got["error"]; ok { |
| 273 | t.Fatalf("browser, %v: %v", c["cmd"], e) |
| 274 | } |
| 275 | return got |
| 276 | } |
| 277 | |