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 "bytes"
5 "context"
6 "encoding/base64"
7 "fmt"
8 "html"
9 "io"
10 "log/slog"
11 "net/http"
12 "net/http/httptest"
13 "net/url"
14 "os"
15 "path/filepath"
16 "regexp"
17 "strings"
18 "sync"
19 "testing"
20 "time"
21
22 "amadan.net/oficina/calls/internal/calls/keymailtest"
23 "amadan.net/oficina/home/connect"
24 assertioncrypto "amadan.net/rastrillo/rastrillo/crypto"
25 "amadan.net/rastrillo/rastrillo/db"
26)
27
28// TestMain keeps Keymail classification inside the process: every DNS
29// query is answered NXDOMAIN and only the Keymail fixtures a test
30// registers answer HTTPS (keymailtest).
31func TestMain(m *testing.M) {
32 restore, err := keymailtest.Install()
33 if err != nil {
34 fmt.Fprintln(os.Stderr, "keymailtest.Install:", err)
35 os.Exit(1)
36 }
37 code := m.Run()
38 // No test may leave a sign-in mail worker running: every app a test
39 // builds has its queue closed in cleanup (Runtime.Close, a
40 // TestHandle's stop, or buildForTest).
41 if code == 0 {
42 deadline := time.Now().Add(5 * time.Second)
43 for mailWorkers() > 0 && time.Now().Before(deadline) {
44 time.Sleep(10 * time.Millisecond)
45 }
46 if n := mailWorkers(); n > 0 {
47 fmt.Fprintf(os.Stderr, "FAIL: %d sign-in mail queue goroutines still running after the tests\n", n)
48 code = 1
49 }
50 }
51 restore()
52 os.Exit(code)
53}
54
55const (
56 keymailHost = "keymail.calls.test"
57 keymailMember = "paul@" + keymailHost
58 keymailStranger = "stranger@" + keymailHost
59 plainMember = "paul@example.test"
60 keymailOrigin = "https://calls.example.test"
61)
62
63type countingMailer struct {
64 mu sync.Mutex
65 to []string
66 waits []func() // the sign-in mail queues in front of it
67}
68
69func (m *countingMailer) AttachSigninQueue(wait func()) {
70 m.mu.Lock()
71 m.waits = append(m.waits, wait)
72 m.mu.Unlock()
73}
74
75func (m *countingMailer) Send(_ context.Context, to, _, _ string) error {
76 m.mu.Lock()
77 defer m.mu.Unlock()
78 m.to = append(m.to, to)
79 return nil
80}
81
82func (m *countingMailer) sent() []string {
83 m.mu.Lock()
84 waits := append([]func(){}, m.waits...)
85 m.mu.Unlock()
86 for _, w := range waits {
87 w()
88 }
89 m.mu.Lock()
90 defer m.mu.Unlock()
91 return append([]string(nil), m.to...)
92}
93
94type keymailFixture struct {
95 handler http.Handler
96 mail *countingMailer
97 km *keymailtest.Server
98}
99
100// newKeymailFixture builds Calls with plainMember and keymailMember on
101// the allowlist, beside a Keymail deployment holding keymailMember and
102// keymailStranger; home configures Home sign-in.
103func newKeymailFixture(t *testing.T, home bool) *keymailFixture {
104 t.Helper()
105 km := keymailtest.Serve(t, keymailHost, keymailMember, keymailStranger)
106 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
107 d, err := db.Open(filepath.Join(t.TempDir(), "calls.db"), logger)
108 if err != nil {
109 t.Fatal(err)
110 }
111 t.Cleanup(func() { d.Close() })
112 mail := &countingMailer{}
113 cfg := Config{Origin: keymailOrigin, InstanceKey: "test-only-instance", DEK: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{3}, 32)),
114 AllowedAddresses: []string{plainMember, keymailMember}, Mailer: mail, Logger: logger}
115 if home {
116 key, err := assertioncrypto.Generate()
117 if err != nil {
118 t.Fatal(err)
119 }
120 cfg.Home = &connect.Credentials{Issuer: homeTestIssuer, ConsumerID: "calls-test", KeysBase64: homeKeysBase64(key), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))}
121 }
122 mux, _, err := buildForTest(t, d, cfg)
123 if err != nil {
124 t.Fatal(err)
125 }
126 return &keymailFixture{handler: mux, mail: mail, km: km}
127}
128
129func (f *keymailFixture) post(form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder {
130 r := httptest.NewRequest(http.MethodPost, keymailOrigin+"/signin", strings.NewReader(form.Encode()))
131 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
132 r.Header.Set("Origin", keymailOrigin)
133 r.Header.Set("Sec-Fetch-Site", "same-origin")
134 for _, c := range cookies {
135 r.AddCookie(c)
136 }
137 w := httptest.NewRecorder()
138 f.handler.ServeHTTP(w, r)
139 return w
140}
141
142func (f *keymailFixture) get(path string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
143 r := httptest.NewRequest(http.MethodGet, keymailOrigin+path, nil)
144 for _, c := range cookies {
145 r.AddCookie(c)
146 }
147 w := httptest.NewRecorder()
148 f.handler.ServeHTTP(w, r)
149 return w
150}
151
152var authorizeHref = regexp.MustCompile(`href="(https://` + regexp.QuoteMeta(keymailHost) + `/oauth/authorize\?[^"]+)"`)
153
154// continuation asserts rec is the page between the form and Keymail —
155// a 200 document with a meta refresh, never a 303 out of the form POST
156// (Safari applies form-action to that redirect) — and returns the
157// authorize URL it carries.
158func continuation(t *testing.T, label string, rec *httptest.ResponseRecorder) *url.URL {
159 t.Helper()
160 if rec.Code != http.StatusOK || rec.Header().Get("Location") != "" {
161 t.Fatalf("%s: %d %q, want 200 and the continuation page to Keymail", label, rec.Code, rec.Header().Get("Location"))
162 }
163 body := rec.Body.String()
164 m := authorizeHref.FindStringSubmatch(body)
165 if m == nil || !strings.Contains(body, `http-equiv="refresh"`) {
166 t.Fatalf("%s: no Keymail continuation on the page:\n%s", label, body)
167 }
168 u, err := url.Parse(html.UnescapeString(m[1]))
169 if err != nil {
170 t.Fatal(err)
171 }
172 return u
173}
174
175// TestKeymailAddressGoesToKeymailWhetherPermittedOrNot: in both modes,
176// whether an address goes to Keymail depends on its domain alone. A
177// member and a stranger at a Keymail domain get the same first answer,
178// the continuation page, and neither is mailed. Before, gateBegin set
179// force=1 on every member's POST, so Keymail was never used.
180func TestKeymailAddressGoesToKeymailWhetherPermittedOrNot(t *testing.T) {
181 for _, home := range []bool{false, true} {
182 t.Run(fmt.Sprintf("home=%v", home), func(t *testing.T) {
183 f := newKeymailFixture(t, home)
184 member := f.post(url.Values{"address": {keymailMember}})
185 stranger := f.post(url.Values{"address": {keymailStranger}})
186 mu := continuation(t, "member Keymail address", member)
187 su := continuation(t, "stranger Keymail address", stranger)
188 mask := func(rec *httptest.ResponseRecorder, u *url.URL) string {
189 return strings.ReplaceAll(rec.Body.String(), html.EscapeString(u.String()), "AUTHORIZE")
190 }
191 if a, b := mask(member, mu), mask(stranger, su); a != b {
192 t.Errorf("the two Keymail answers differ beyond the authorize URL:\n%s\n----\n%s", a, b)
193 }
194 if got := f.mail.sent(); len(got) != 0 {
195 t.Errorf("a Keymail address was mailed: %v", got)
196 }
197 if n := f.km.Lookups(); n != 2 {
198 t.Errorf("Keymail saw %d lookups, want 2", n)
199 }
200 })
201 }
202}
203
204// TestNonPermittedPlainAddressIsAnsweredSentAndNotMailed: an address
205// with no Keymail gets ?sent=1, member or not; only the member is
206// mailed.
207func TestNonPermittedPlainAddressIsAnsweredSentAndNotMailed(t *testing.T) {
208 f := newKeymailFixture(t, false)
209 for _, address := range []string{"someone@else.test", plainMember} {
210 rec := f.post(url.Values{"address": {address}})
211 if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" {
212 t.Fatalf("POST /signin %q: %d %q, want 303 /signin?sent=1", address, rec.Code, rec.Header().Get("Location"))
213 }
214 }
215 if got := f.mail.sent(); len(got) != 1 || got[0] != plainMember {
216 t.Fatalf("mail = %v, want one, to %s", got, plainMember)
217 }
218}
219
220// TestKeymailFailureFallsBackToAnEmailLink: the page rastrillo lands a
221// failed Keymail exchange on (?force=1&err=keymail) carries force, so
222// the retry mails a link instead of returning to Keymail; the ordinary
223// form carries none.
224func TestKeymailFailureFallsBackToAnEmailLink(t *testing.T) {
225 f := newKeymailFixture(t, false)
226 forced := `name="force" value="1"`
227 if strings.Contains(f.get("/signin").Body.String(), forced) {
228 t.Fatal("the ordinary form forces an email link")
229 }
230 if !strings.Contains(f.get("/signin?force=1&err=keymail").Body.String(), forced) {
231 t.Fatal("the err=keymail form does not carry force; a failed Keymail sign-in would loop back to Keymail")
232 }
233 rec := f.post(url.Values{"address": {keymailMember}, "force": {"1"}})
234 if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" {
235 t.Fatalf("forced POST: %d %q", rec.Code, rec.Header().Get("Location"))
236 }
237 if got := f.mail.sent(); len(got) != 1 || got[0] != keymailMember {
238 t.Fatalf("mail = %v, want one link to %s", got, keymailMember)
239 }
240 if f.km.Lookups() != 0 {
241 t.Error("a forced sign-in classified the address")
242 }
243}
244
245// TestKeymailRefusalIsTheDoor: a stranger Keymail verified is refused
246// after verification on the door, with the form for a different
247// address, not in rastrillo's bare text; a member is signed in. Both
248// modes.
249func TestKeymailRefusalIsTheDoor(t *testing.T) {
250 for _, home := range []bool{false, true} {
251 t.Run(fmt.Sprintf("home=%v", home), func(t *testing.T) {
252 f := newKeymailFixture(t, home)
253 form := "/signin"
254 if home {
255 form = "/signin/local"
256 }
257 roundTrip := func(address string) *httptest.ResponseRecorder {
258 begin := f.post(url.Values{"address": {address}})
259 u := continuation(t, address, begin)
260 return f.get("/auth/callback?"+url.Values{"state": {u.Query().Get("state")}, "code": {f.km.Approve(address)}}.Encode(), begin.Result().Cookies()...)
261 }
262 cb := roundTrip(keymailMember)
263 if cb.Code != http.StatusSeeOther || strings.Contains(cb.Header().Get("Location"), "err=") {
264 t.Fatalf("member callback: %d %q", cb.Code, cb.Header().Get("Location"))
265 }
266 // The member's session, carried through the landing redirects
267 // (/auth/local/complete with Home, then /continue), opens the
268 // meeting list: the continuation signed them in for real.
269 jar := map[string]*http.Cookie{}
270 keep := func(w *httptest.ResponseRecorder) {
271 for _, c := range w.Result().Cookies() {
272 if c.MaxAge < 0 {
273 delete(jar, c.Name)
274 } else {
275 jar[c.Name] = c
276 }
277 }
278 }
279 keep(cb)
280 next := cb.Header().Get("Location")
281 var landed *httptest.ResponseRecorder
282 for range 4 {
283 cookies := make([]*http.Cookie, 0, len(jar))
284 for _, c := range jar {
285 cookies = append(cookies, c)
286 }
287 landed = f.get(next, cookies...)
288 keep(landed)
289 if landed.Code != http.StatusSeeOther {
290 break
291 }
292 next = landed.Header().Get("Location")
293 }
294 if landed.Code != http.StatusOK || !strings.Contains(landed.Body.String(), "Meetings") {
295 t.Fatalf("member's Keymail session landed on %s with %d, want the meeting list", next, landed.Code)
296 }
297 cb = roundTrip(keymailStranger)
298 if cb.Code != http.StatusForbidden || cb.Header().Get("Location") != "" {
299 t.Fatalf("stranger callback: %d %q, want 403 and the door itself (bare text is the bug)\n%s", cb.Code, cb.Header().Get("Location"), cb.Body.String())
300 }
301 door := cb.Body.String()
302 for _, want := range []string{"estilo-door", `name="address"`, "can’t sign in to Calls"} {
303 if !strings.Contains(door, want) {
304 t.Errorf("refusal door lacks %q:\n%s", want, door)
305 }
306 }
307 // Nobody may summon the sentence by typing a URL (Astra, round 3).
308 if spoof := f.get(form + "?err=refused").Body.String(); strings.Contains(spoof, "can’t sign in to Calls") {
309 t.Fatalf("anybody can show the refusal by visiting %s?err=refused", form)
310 }
311 })
312 }
313}
314