oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1//go:build browser
2
3package callstest
4
5import (
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.
37func 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.
218type applinkBrowser struct {
219 in io.WriteCloser
220 out *bufio.Scanner
221}
222
223func 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
260func (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