| 1 | package calls |
| 2 | |
| 3 | import ( |
| 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). |
| 31 | func 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 | |
| 55 | const ( |
| 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 | |
| 63 | type countingMailer struct { |
| 64 | mu sync.Mutex |
| 65 | to []string |
| 66 | waits []func() // the sign-in mail queues in front of it |
| 67 | } |
| 68 | |
| 69 | func (m *countingMailer) AttachSigninQueue(wait func()) { |
| 70 | m.mu.Lock() |
| 71 | m.waits = append(m.waits, wait) |
| 72 | m.mu.Unlock() |
| 73 | } |
| 74 | |
| 75 | func (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 | |
| 82 | func (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 | |
| 94 | type 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. |
| 103 | func 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 | |
| 129 | func (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 | |
| 142 | func (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 | |
| 152 | var 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. |
| 158 | func 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. |
| 180 | func 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. |
| 207 | func 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. |
| 224 | func 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. |
| 249 | func 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 | |