package calls import ( "bytes" "context" "encoding/base64" "fmt" "html" "io" "log/slog" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "regexp" "strings" "sync" "testing" "time" "amadan.net/oficina/calls/internal/calls/keymailtest" "amadan.net/oficina/home/connect" assertioncrypto "amadan.net/rastrillo/rastrillo/crypto" "amadan.net/rastrillo/rastrillo/db" ) // TestMain keeps Keymail classification inside the process: every DNS // query is answered NXDOMAIN and only the Keymail fixtures a test // registers answer HTTPS (keymailtest). func TestMain(m *testing.M) { restore, err := keymailtest.Install() if err != nil { fmt.Fprintln(os.Stderr, "keymailtest.Install:", err) os.Exit(1) } code := m.Run() // No test may leave a sign-in mail worker running: every app a test // builds has its queue closed in cleanup (Runtime.Close, a // TestHandle's stop, or buildForTest). if code == 0 { deadline := time.Now().Add(5 * time.Second) for mailWorkers() > 0 && time.Now().Before(deadline) { time.Sleep(10 * time.Millisecond) } if n := mailWorkers(); n > 0 { fmt.Fprintf(os.Stderr, "FAIL: %d sign-in mail queue goroutines still running after the tests\n", n) code = 1 } } restore() os.Exit(code) } const ( keymailHost = "keymail.calls.test" keymailMember = "paul@" + keymailHost keymailStranger = "stranger@" + keymailHost plainMember = "paul@example.test" keymailOrigin = "https://calls.example.test" ) type countingMailer struct { mu sync.Mutex to []string waits []func() // the sign-in mail queues in front of it } func (m *countingMailer) AttachSigninQueue(wait func()) { m.mu.Lock() m.waits = append(m.waits, wait) m.mu.Unlock() } func (m *countingMailer) Send(_ context.Context, to, _, _ string) error { m.mu.Lock() defer m.mu.Unlock() m.to = append(m.to, to) return nil } func (m *countingMailer) sent() []string { m.mu.Lock() waits := append([]func(){}, m.waits...) m.mu.Unlock() for _, w := range waits { w() } m.mu.Lock() defer m.mu.Unlock() return append([]string(nil), m.to...) } type keymailFixture struct { handler http.Handler mail *countingMailer km *keymailtest.Server } // newKeymailFixture builds Calls with plainMember and keymailMember on // the allowlist, beside a Keymail deployment holding keymailMember and // keymailStranger; home configures Home sign-in. func newKeymailFixture(t *testing.T, home bool) *keymailFixture { t.Helper() km := keymailtest.Serve(t, keymailHost, keymailMember, keymailStranger) logger := slog.New(slog.NewTextHandler(io.Discard, nil)) d, err := db.Open(filepath.Join(t.TempDir(), "calls.db"), logger) if err != nil { t.Fatal(err) } t.Cleanup(func() { d.Close() }) mail := &countingMailer{} cfg := Config{Origin: keymailOrigin, InstanceKey: "test-only-instance", DEK: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{3}, 32)), AllowedAddresses: []string{plainMember, keymailMember}, Mailer: mail, Logger: logger} if home { key, err := assertioncrypto.Generate() if err != nil { t.Fatal(err) } cfg.Home = &connect.Credentials{Issuer: homeTestIssuer, ConsumerID: "calls-test", KeysBase64: homeKeysBase64(key), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))} } mux, _, err := buildForTest(t, d, cfg) if err != nil { t.Fatal(err) } return &keymailFixture{handler: mux, mail: mail, km: km} } func (f *keymailFixture) post(form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder { r := httptest.NewRequest(http.MethodPost, keymailOrigin+"/signin", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.Header.Set("Origin", keymailOrigin) r.Header.Set("Sec-Fetch-Site", "same-origin") for _, c := range cookies { r.AddCookie(c) } w := httptest.NewRecorder() f.handler.ServeHTTP(w, r) return w } func (f *keymailFixture) get(path string, cookies ...*http.Cookie) *httptest.ResponseRecorder { r := httptest.NewRequest(http.MethodGet, keymailOrigin+path, nil) for _, c := range cookies { r.AddCookie(c) } w := httptest.NewRecorder() f.handler.ServeHTTP(w, r) return w } var authorizeHref = regexp.MustCompile(`href="(https://` + regexp.QuoteMeta(keymailHost) + `/oauth/authorize\?[^"]+)"`) // continuation asserts rec is the page between the form and Keymail — // a 200 document with a meta refresh, never a 303 out of the form POST // (Safari applies form-action to that redirect) — and returns the // authorize URL it carries. func continuation(t *testing.T, label string, rec *httptest.ResponseRecorder) *url.URL { t.Helper() if rec.Code != http.StatusOK || rec.Header().Get("Location") != "" { t.Fatalf("%s: %d %q, want 200 and the continuation page to Keymail", label, rec.Code, rec.Header().Get("Location")) } body := rec.Body.String() m := authorizeHref.FindStringSubmatch(body) if m == nil || !strings.Contains(body, `http-equiv="refresh"`) { t.Fatalf("%s: no Keymail continuation on the page:\n%s", label, body) } u, err := url.Parse(html.UnescapeString(m[1])) if err != nil { t.Fatal(err) } return u } // TestKeymailAddressGoesToKeymailWhetherPermittedOrNot: in both modes, // whether an address goes to Keymail depends on its domain alone. A // member and a stranger at a Keymail domain get the same first answer, // the continuation page, and neither is mailed. Before, gateBegin set // force=1 on every member's POST, so Keymail was never used. func TestKeymailAddressGoesToKeymailWhetherPermittedOrNot(t *testing.T) { for _, home := range []bool{false, true} { t.Run(fmt.Sprintf("home=%v", home), func(t *testing.T) { f := newKeymailFixture(t, home) member := f.post(url.Values{"address": {keymailMember}}) stranger := f.post(url.Values{"address": {keymailStranger}}) mu := continuation(t, "member Keymail address", member) su := continuation(t, "stranger Keymail address", stranger) mask := func(rec *httptest.ResponseRecorder, u *url.URL) string { return strings.ReplaceAll(rec.Body.String(), html.EscapeString(u.String()), "AUTHORIZE") } if a, b := mask(member, mu), mask(stranger, su); a != b { t.Errorf("the two Keymail answers differ beyond the authorize URL:\n%s\n----\n%s", a, b) } if got := f.mail.sent(); len(got) != 0 { t.Errorf("a Keymail address was mailed: %v", got) } if n := f.km.Lookups(); n != 2 { t.Errorf("Keymail saw %d lookups, want 2", n) } }) } } // TestNonPermittedPlainAddressIsAnsweredSentAndNotMailed: an address // with no Keymail gets ?sent=1, member or not; only the member is // mailed. func TestNonPermittedPlainAddressIsAnsweredSentAndNotMailed(t *testing.T) { f := newKeymailFixture(t, false) for _, address := range []string{"someone@else.test", plainMember} { rec := f.post(url.Values{"address": {address}}) if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" { t.Fatalf("POST /signin %q: %d %q, want 303 /signin?sent=1", address, rec.Code, rec.Header().Get("Location")) } } if got := f.mail.sent(); len(got) != 1 || got[0] != plainMember { t.Fatalf("mail = %v, want one, to %s", got, plainMember) } } // TestKeymailFailureFallsBackToAnEmailLink: the page rastrillo lands a // failed Keymail exchange on (?force=1&err=keymail) carries force, so // the retry mails a link instead of returning to Keymail; the ordinary // form carries none. func TestKeymailFailureFallsBackToAnEmailLink(t *testing.T) { f := newKeymailFixture(t, false) forced := `name="force" value="1"` if strings.Contains(f.get("/signin").Body.String(), forced) { t.Fatal("the ordinary form forces an email link") } if !strings.Contains(f.get("/signin?force=1&err=keymail").Body.String(), forced) { t.Fatal("the err=keymail form does not carry force; a failed Keymail sign-in would loop back to Keymail") } rec := f.post(url.Values{"address": {keymailMember}, "force": {"1"}}) if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" { t.Fatalf("forced POST: %d %q", rec.Code, rec.Header().Get("Location")) } if got := f.mail.sent(); len(got) != 1 || got[0] != keymailMember { t.Fatalf("mail = %v, want one link to %s", got, keymailMember) } if f.km.Lookups() != 0 { t.Error("a forced sign-in classified the address") } } // TestKeymailRefusalIsTheDoor: a stranger Keymail verified is refused // after verification on the door, with the form for a different // address, not in rastrillo's bare text; a member is signed in. Both // modes. func TestKeymailRefusalIsTheDoor(t *testing.T) { for _, home := range []bool{false, true} { t.Run(fmt.Sprintf("home=%v", home), func(t *testing.T) { f := newKeymailFixture(t, home) form := "/signin" if home { form = "/signin/local" } roundTrip := func(address string) *httptest.ResponseRecorder { begin := f.post(url.Values{"address": {address}}) u := continuation(t, address, begin) return f.get("/auth/callback?"+url.Values{"state": {u.Query().Get("state")}, "code": {f.km.Approve(address)}}.Encode(), begin.Result().Cookies()...) } cb := roundTrip(keymailMember) if cb.Code != http.StatusSeeOther || strings.Contains(cb.Header().Get("Location"), "err=") { t.Fatalf("member callback: %d %q", cb.Code, cb.Header().Get("Location")) } // The member's session, carried through the landing redirects // (/auth/local/complete with Home, then /continue), opens the // meeting list: the continuation signed them in for real. jar := map[string]*http.Cookie{} keep := func(w *httptest.ResponseRecorder) { for _, c := range w.Result().Cookies() { if c.MaxAge < 0 { delete(jar, c.Name) } else { jar[c.Name] = c } } } keep(cb) next := cb.Header().Get("Location") var landed *httptest.ResponseRecorder for range 4 { cookies := make([]*http.Cookie, 0, len(jar)) for _, c := range jar { cookies = append(cookies, c) } landed = f.get(next, cookies...) keep(landed) if landed.Code != http.StatusSeeOther { break } next = landed.Header().Get("Location") } if landed.Code != http.StatusOK || !strings.Contains(landed.Body.String(), "Meetings") { t.Fatalf("member's Keymail session landed on %s with %d, want the meeting list", next, landed.Code) } cb = roundTrip(keymailStranger) if cb.Code != http.StatusForbidden || cb.Header().Get("Location") != "" { 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()) } door := cb.Body.String() for _, want := range []string{"estilo-door", `name="address"`, "can’t sign in to Calls"} { if !strings.Contains(door, want) { t.Errorf("refusal door lacks %q:\n%s", want, door) } } // Nobody may summon the sentence by typing a URL (Astra, round 3). if spoof := f.get(form + "?err=refused").Body.String(); strings.Contains(spoof, "can’t sign in to Calls") { t.Fatalf("anybody can show the refusal by visiting %s?err=refused", form) } }) } }