oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1package calls
2
3import (
4 "amadan.net/oficina/home/connect"
5 "amadan.net/rastrillo/rastrillo/assertion"
6 assertioncrypto "amadan.net/rastrillo/rastrillo/crypto"
7 "amadan.net/rastrillo/rastrillo/db"
8 "bytes"
9 "context"
10 "crypto/sha256"
11 "encoding/base64"
12 "encoding/hex"
13 "encoding/json"
14 "html"
15 "io"
16 "log/slog"
17 "net/http"
18 "net/http/httptest"
19 "net/url"
20 "path/filepath"
21 "regexp"
22 "strings"
23 "testing"
24 "time"
25)
26
27const homeTestOrigin = "https://meet.example.test"
28const homeTestIssuer = "https://id.example.test"
29
30type homeTestMail struct{ body string }
31
32func (m *homeTestMail) Send(_ context.Context, _, _, body string) error { m.body = body; return nil }
33
34type homeRouterFixture struct {
35 app *app
36 handler http.Handler
37 key *assertioncrypto.Keypair
38 mail *homeTestMail
39 cookies map[string]*http.Cookie
40}
41
42func newHomeRouterFixture(t *testing.T) *homeRouterFixture {
43 t.Helper()
44 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
45 d, err := db.Open(filepath.Join(t.TempDir(), "calls.db"), logger)
46 if err != nil {
47 t.Fatal(err)
48 }
49 t.Cleanup(func() { d.Close() })
50 key, err := assertioncrypto.Generate()
51 if err != nil {
52 t.Fatal(err)
53 }
54 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))})
55}
56
57// homeKeysBase64 is the public-key manifest Home publishes for key.
58func homeKeysBase64(key *assertioncrypto.Keypair) string {
59 sum := sha256.Sum256(key.SignPub())
60 id := hex.EncodeToString(sum[:])
61 raw, _ := json.Marshal(map[string]string{id: base64.StdEncoding.EncodeToString(key.SignPub())})
62 return base64.StdEncoding.EncodeToString(raw)
63}
64
65// newHomeRouterFixtureWith builds the app with Home configured by creds,
66// whose assertions key signs.
67func newHomeRouterFixtureWith(t *testing.T, d *db.DB, logger *slog.Logger, key *assertioncrypto.Keypair, creds *connect.Credentials) *homeRouterFixture {
68 t.Helper()
69 mail := &homeTestMail{}
70 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})
71 if err != nil {
72 t.Fatal(err)
73 }
74 return &homeRouterFixture{a, mux, key, mail, map[string]*http.Cookie{}}
75}
76
77// mailBody is the last sign-in mail, once the queue has sent it
78// (links go out after the response: signin_mail.go).
79func (f *homeRouterFixture) mailBody() string {
80 f.app.signinMail.wait()
81 return f.mail.body
82}
83
84func homeKeyID(key *assertioncrypto.Keypair) string {
85 sum := sha256.Sum256(key.SignPub())
86 return hex.EncodeToString(sum[:])
87}
88func (f *homeRouterFixture) do(method, path, body string) *httptest.ResponseRecorder {
89 return f.doWith(method, path, body, nil)
90}
91
92// doWith is do with extra request headers, set after do's own.
93func (f *homeRouterFixture) doWith(method, path, body string, extra http.Header) *httptest.ResponseRecorder {
94 r := httptest.NewRequest(method, homeTestOrigin+path, strings.NewReader(body))
95 for _, c := range f.cookies {
96 r.AddCookie(c)
97 }
98 if method == "POST" {
99 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
100 r.Header.Set("Origin", homeTestOrigin)
101 if path == "/auth/assertion" {
102 r.Header.Set("Origin", homeTestIssuer)
103 }
104 }
105 for name, values := range extra {
106 r.Header[name] = values
107 }
108 w := httptest.NewRecorder()
109 f.handler.ServeHTTP(w, r)
110 for _, c := range w.Result().Cookies() {
111 if c.MaxAge < 0 {
112 delete(f.cookies, c.Name)
113 } else {
114 f.cookies[c.Name] = c
115 }
116 }
117 return w
118}
119
120// confirmForm is the confirm door's one form, as a browser reads it.
121var confirmForm = regexp.MustCompile(`<form[^>]*method="post" action="([^"]+)">\s*<input type="hidden" name="token" value="([^"]+)">`)
122
123// readConfirm checks w is the confirm door an emailed link opens on (a
124// 200 that mints nothing) and returns its form's action and the token it
125// carries, read off the page rather than the link, so a door that
126// dropped or mangled the token fails here.
127func readConfirm(t *testing.T, w *httptest.ResponseRecorder) (action, token string) {
128 t.Helper()
129 if w.Code != http.StatusOK || len(w.Result().Cookies()) != 0 {
130 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())
131 }
132 m := confirmForm.FindStringSubmatch(w.Body.String())
133 if m == nil {
134 t.Fatalf("no confirm form on the page:\n%s", w.Body.String())
135 }
136 return html.UnescapeString(m[1]), html.UnescapeString(m[2])
137}
138
139// followLink opens an emailed link in this browser, then presses the
140// confirm door's Sign in: the token POSTed back with a browser's
141// same-origin evidence, which is the request that spends the link.
142func (f *homeRouterFixture) followLink(t *testing.T, link string) *httptest.ResponseRecorder {
143 t.Helper()
144 u, err := url.Parse(link)
145 if err != nil || link == "" {
146 t.Fatalf("emailed link %q: %v", link, err)
147 }
148 action, token := readConfirm(t, f.do("GET", u.RequestURI(), ""))
149 return f.doWith("POST", action, url.Values{"token": {token}}.Encode(), http.Header{"Sec-Fetch-Site": {"same-origin"}})
150}
151
152func homeRedirect(t *testing.T, w *httptest.ResponseRecorder) string {
153 t.Helper()
154 if w.Code != 303 {
155 t.Fatalf("redirect status %d: %s", w.Code, w.Body.String())
156 }
157 return w.Header().Get("Location")
158}
159
160func (f *homeRouterFixture) local(t *testing.T) {
161 t.Helper()
162 homeRedirect(t, f.do("POST", "/signin", "address=alice%40example.test&force=1"))
163 link := regexp.MustCompile(`https://[^\s<>\"]+/auth/verify\?[^\s<>\"]+`).FindString(f.mailBody())
164 if link == "" {
165 t.Fatal("no captured local verification link")
166 }
167 landing := homeRedirect(t, f.followLink(t, link))
168 if landing != "/auth/local/complete" {
169 t.Fatal("wrong local landing", landing)
170 }
171 landing = homeRedirect(t, f.do("GET", landing, ""))
172 if landing != "/continue" {
173 t.Fatal("lost local continue", landing)
174 }
175 homeRedirect(t, f.do("GET", landing, ""))
176}
177func (f *homeRouterFixture) exchange(t *testing.T, start, subject string) *httptest.ResponseRecorder {
178 t.Helper()
179 target := homeRedirect(t, f.do("GET", start, ""))
180 u, err := url.Parse(target)
181 if err != nil || u.Query().Get("consumer") != "calls-test" {
182 t.Fatal("bad issuer redirect")
183 }
184 nonce := u.Query().Get("nonce")
185 now := time.Now().Unix()
186 jti, err := assertion.RandomID()
187 if err != nil {
188 t.Fatal(err)
189 }
190 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})
191 if err != nil {
192 t.Fatal(err)
193 }
194 callback := f.do("POST", "/auth/assertion", url.Values{"assertion": {token}}.Encode())
195 finish := homeRedirect(t, callback)
196 return f.do("GET", finish, "")
197}
198func (f *homeRouterFixture) link(t *testing.T) {
199 f.local(t)
200 if w := f.do("GET", "/account/home", ""); w.Code != 200 {
201 t.Fatal("account", w.Code)
202 }
203 start := homeRedirect(t, f.do("POST", "/account/home", ""))
204 if next := homeRedirect(t, f.exchange(t, start, "opaque-home-alice")); next != "/account/home/confirm" {
205 t.Fatal("confirmation", next)
206 }
207 homeRedirect(t, f.do("POST", "/account/home/confirm", ""))
208 homeSignout(t, f.do("POST", "/signout", ""))
209 start = homeRedirect(t, f.do("GET", "/signin", ""))
210 homeRedirect(t, f.exchange(t, start, "opaque-home-alice"))
211}
212func TestHomeLinksExistingMemberAndKeepsMeetings(t *testing.T) {
213 f := newHomeRouterFixture(t)
214 f.local(t)
215 created := f.do("POST", "/meetings", "title=Existing+private+meeting")
216 if created.Code != 200 && created.Code != 303 {
217 t.Fatal(created.Code, created.Body.String())
218 }
219 var before string
220 if err := f.app.db.Writer().QueryRow("SELECT created_by FROM meetings LIMIT 1").Scan(&before); err != nil {
221 t.Fatal(err)
222 }
223 homeSignout(t, f.do("POST", "/signout", ""))
224 f.link(t)
225 w := f.do("GET", "/", "")
226 if w.Code != 200 || !strings.Contains(w.Body.String(), "Existing private meeting") {
227 t.Fatal("lost existing meeting", w.Code, w.Body.String())
228 }
229 var after string
230 f.app.db.Writer().QueryRow("SELECT created_by FROM meetings LIMIT 1").Scan(&after)
231 if after != before {
232 t.Fatal("changed attribution")
233 }
234 var count int
235 f.app.db.Writer().QueryRow("SELECT count(*) FROM persons").Scan(&count)
236 if count != 1 {
237 t.Fatal("Home created person", count)
238 }
239 f.app.cfg.allow = allowlist{}
240 // A removed member's page request ends the session and says why, on
241 // the local form: a 404 there would offer "Sign in", which connect
242 // passes straight back through the same live cookie (Docs #65).
243 w = f.do("GET", "/", "")
244 if w.Code != 303 || w.Header().Get("Location") != "/signin/local?err=removed" {
245 t.Fatal("removed member accepted", w.Code, w.Header().Get("Location"))
246 }
247 if w = f.do("GET", "/settings", ""); w.Code != 303 || !strings.HasPrefix(w.Header().Get("Location"), "/signin") {
248 t.Fatal("the removed member's session survived", w.Code, w.Header().Get("Location"))
249 }
250}
251func TestUnknownHomeDoesNotCreatePerson(t *testing.T) {
252 f := newHomeRouterFixture(t)
253 start := homeRedirect(t, f.do("GET", "/signin", ""))
254 w := f.exchange(t, start, "unknown-home-person")
255 if w.Code != 403 {
256 t.Fatal(w.Code)
257 }
258 var n int
259 f.app.db.Writer().QueryRow("SELECT count(*) FROM persons").Scan(&n)
260 if n != 0 {
261 t.Fatal("Home allocated person", n)
262 }
263}
264func TestHomeSessionFailsWhenDisabled(t *testing.T) {
265 f := newHomeRouterFixture(t)
266 f.link(t)
267 f.app.home = nil
268 if w := f.do("GET", "/", ""); w.Code != 404 {
269 t.Fatal("disabled accepted", w.Code)
270 }
271}
272func TestHomeEnabledPreservesInvitedGuestFlow(t *testing.T) {
273 f := newHomeRouterFixture(t)
274 f.local(t)
275 f.do("POST", "/settings/defaults", "require_signin=on")
276 made := f.do("POST", "/meetings", "title=Guest+meeting")
277 token := regexp.MustCompile(`/j/([A-Za-z0-9_-]{43})`).FindStringSubmatch(made.Body.String())
278 sqid := regexp.MustCompile(`/m/([23456789a-z]{10})/join`).FindStringSubmatch(made.Body.String())
279 if token == nil || sqid == nil {
280 t.Fatal("missing meeting links")
281 }
282 homeSignout(t, f.do("POST", "/signout", ""))
283 path := homeRedirect(t, f.do("GET", "/j/"+token[1], ""))
284 if path != "/signin" {
285 t.Fatal(path)
286 }
287 if w := f.do("GET", path, ""); w.Code != 200 {
288 t.Fatal("guest redirected to Home", w.Code)
289 }
290 homeRedirect(t, f.do("POST", "/signin", "address=guest%40example.test"))
291 link := regexp.MustCompile(`https://[^\s<>\"]+/auth/verify\?[^\s<>\"]+`).FindString(f.mailBody())
292 landing := homeRedirect(t, f.followLink(t, link))
293 if landing != "/auth/local/complete" {
294 t.Fatal(landing)
295 }
296 landing = homeRedirect(t, f.do("GET", landing, ""))
297 if landing != "/continue" {
298 t.Fatal("lost pending guest landing", landing)
299 }
300 landing = homeRedirect(t, f.do("GET", landing, ""))
301 if landing != "/m/"+sqid[1]+"/join" {
302 t.Fatal("lost grant", landing)
303 }
304 if w := f.do("GET", landing, ""); w.Code != 200 {
305 t.Fatal("holding", w.Code)
306 }
307 if w := f.do("GET", "/", ""); w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/welcome" {
308 t.Fatal("guest start page did not lead to welcome", w.Code)
309 }
310 for _, path := range []string{"/account/home"} {
311 if w := f.do("GET", path, ""); w.Code != 404 {
312 t.Fatal("guest gained member privilege", path, w.Code)
313 }
314 }
315}
316func TestHomePublicJoinResolvesExistingMember(t *testing.T) {
317 f := newHomeRouterFixture(t)
318 f.link(t)
319 made := f.do("POST", "/meetings", "title=Home+meeting")
320 token := regexp.MustCompile(`/j/([A-Za-z0-9_-]{43})`).FindStringSubmatch(made.Body.String())
321 if token == nil {
322 t.Fatal("missing link")
323 }
324 if w := f.do("GET", "/j/"+token[1], ""); w.Code != 303 {
325 t.Fatal("Home member follow", w.Code)
326 }
327 var n int
328 f.app.db.Writer().QueryRow("SELECT count(*) FROM persons").Scan(&n)
329 if n != 1 {
330 t.Fatal("opaque Home subject became person", n)
331 }
332 f.app.cfg.allow = allowlist{}
333 if w := f.do("GET", "/j/"+token[1], ""); w.Code != 404 {
334 t.Fatal("removed member gained guest grant", w.Code)
335 }
336 f.app.db.Writer().QueryRow("SELECT count(*) FROM guest_grants").Scan(&n)
337 if n != 0 {
338 t.Fatal("removed Home member acquired grant", n)
339 }
340}
341func TestHomeRemovedMemberSigninIsRefusedWithoutServiceError(t *testing.T) {
342 f := newHomeRouterFixture(t)
343 f.link(t)
344 homeSignout(t, f.do("POST", "/signout", ""))
345 f.app.cfg.allow = allowlist{}
346 start := homeRedirect(t, f.do("GET", "/signin", ""))
347 if w := f.exchange(t, start, "opaque-home-alice"); w.Code != 403 {
348 t.Fatal("removed member should be unknown, not unavailable", w.Code)
349 }
350}
351func TestHomeLookupDatabaseFailureKeepsSession(t *testing.T) {
352 f := newHomeRouterFixture(t)
353 f.link(t)
354 var before int
355 f.app.db.Writer().QueryRow("SELECT count(*) FROM sessions").Scan(&before)
356 if _, err := f.app.db.Writer().Exec("ALTER TABLE persons RENAME TO temporarily_unavailable_persons"); err != nil {
357 t.Fatal(err)
358 }
359 w := f.do("GET", "/", "")
360 if w.Code == 200 {
361 t.Fatal("database failure admitted")
362 }
363 var after int
364 f.app.db.Writer().QueryRow("SELECT count(*) FROM sessions").Scan(&after)
365 if before != after {
366 t.Fatal("transient lookup failure revoked session")
367 }
368 if _, err := f.app.db.Writer().Exec("ALTER TABLE temporarily_unavailable_persons RENAME TO persons"); err != nil {
369 t.Fatal(err)
370 }
371 if w = f.do("GET", "/", ""); w.Code != 200 {
372 t.Fatal("recovered lookup did not preserve session", w.Code)
373 }
374}
375
376func TestHomeSigninClearsOnlyDefinitivelyStaleSession(t *testing.T) {
377 f := newHomeRouterFixture(t)
378 f.local(t)
379 cookieName := f.app.home.sessions.CookieName()
380 saved := f.cookies[cookieName].Value
381 if _, err := f.app.db.Writer().Exec("ALTER TABLE sessions RENAME TO unavailable_sessions"); err != nil {
382 t.Fatal(err)
383 }
384 homeRedirect(t, f.do("GET", "/signin", ""))
385 if f.cookies[cookieName] == nil || f.cookies[cookieName].Value != saved {
386 t.Fatal("temporary storage failure cleared valid cookie")
387 }
388 if _, err := f.app.db.Writer().Exec("ALTER TABLE unavailable_sessions RENAME TO sessions"); err != nil {
389 t.Fatal(err)
390 }
391 if _, err := f.app.db.Writer().Exec("DELETE FROM sessions"); err != nil {
392 t.Fatal(err)
393 }
394 start := homeRedirect(t, f.do("GET", "/signin", ""))
395 if f.cookies[cookieName] != nil {
396 t.Fatal("stale cookie not cleared")
397 }
398 target := homeRedirect(t, f.do("GET", start, ""))
399 if !strings.HasPrefix(target, homeTestIssuer+"/") {
400 t.Fatal("stale cookie prevented Home sign-in", target)
401 }
402}
403
404// With Home connected, Sign out lands on the Oficina door's signed-out
405// page, in the door's own words (not connect's, which name the hub), and
406// its one link, "Sign in", goes back to connect's sign-in action.
407func TestHomeSignedOutIsTheOficinaDoor(t *testing.T) {
408 f := newHomeRouterFixture(t)
409 w := httptest.NewRecorder()
410 f.handler.ServeHTTP(w, httptest.NewRequest("GET", homeTestOrigin+"/signed-out", nil))
411 body := w.Body.String()
412 if w.Code != http.StatusOK || w.Header().Get("Cache-Control") != "no-store" {
413 t.Fatalf("GET /signed-out: %d %q", w.Code, w.Header().Get("Cache-Control"))
414 }
415 for _, want := range []string{"<div estilo-door", "<span>Calls</span>", "estilo-sky-layer",
416 "<title>Signed out · Calls</title>", "<h1>You’re signed out</h1>", "You’ve signed out of Calls on this device.",
417 `<a rst-btn="primary" href="/signin">Sign in</a>`} {
418 if !strings.Contains(body, want) {
419 t.Errorf("signed-out page is missing %q:\n%s", want, body)
420 }
421 }
422 // "Sign in with Home" is the retired label: connect.English now says
423 // "Sign in" itself (Home #79), so asserting its absence by key would
424 // contradict the "Sign in" button required above.
425 for _, gone := range []string{connect.English["signedout_body"], "Sign in with Home", "Home"} {
426 if strings.Contains(body, gone) {
427 t.Errorf("signed-out page still carries %q:\n%s", gone, body)
428 }
429 }
430}
431
432// The revoked-user loop Astra found in Docs (#65, 1) and Sheets (#39, 2):
433// connect's Signin skips the exchange for any live session cookie and
434// sends it back to the page, which refuses it again. A removed member's
435// GET /signin must end that session and say why, and the next /signin
436// must start a real sign-in.
437func TestARemovedHomeMemberIsNotBouncedBetweenSigninAndThePage(t *testing.T) {
438 f := newHomeRouterFixture(t)
439 f.link(t)
440 f.app.cfg.allow = allowlist{}
441 w := f.do("GET", "/signin?return_to=%2Fsettings", "")
442 if w.Code != 303 || !strings.Contains(w.Header().Get("Location"), "err=removed") {
443 t.Fatalf("a removed member's /signin: %d %q, want the removed notice", w.Code, w.Header().Get("Location"))
444 }
445 if next := homeRedirect(t, f.do("GET", "/signin", "")); !strings.HasPrefix(next, "/auth/start") {
446 t.Fatalf("after the notice, /signin did not start a sign-in: %q", next)
447 }
448}
449
450// The account link is "Connect your Oficina account" (spec §13 Q3: the
451// hub is never named), in the account menu, offered to a local session
452// only: "Connect Home account" on the list page is retired.
453func TestTheAccountMenuOffersToConnectYourOficinaAccount(t *testing.T) {
454 f := newHomeRouterFixture(t)
455 f.local(t)
456 body := f.do("GET", "/", "").Body.String()
457 if !strings.Contains(body, `<a estilo-menu-item href="/account/home"><span estilo-menu-label>Connect your Oficina account</span></a>`) || strings.Contains(body, "Connect Home account") {
458 t.Fatalf("the list page's account link:\n%s", body)
459 }
460 f.link(t)
461 if body := f.do("GET", "/", "").Body.String(); strings.Contains(body, "/account/home") {
462 t.Fatal("a Home session is offered to connect the account it already is")
463 }
464}
465