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