package calendartest import ( "io" "log/slog" "net/http" "net/url" "path/filepath" "strings" "testing" calendar "amadan.net/oficina/calendar/internal/calendar" ) // Everything behind the session group is behind it. A test per route // would rot the moment somebody adds one, so this walks the list and // the list is the thing to extend. func TestSignInRequiredForEverything(t *testing.T) { h := newApp(t).Handler() for _, target := range []string{"/"} { rec := get(t, h, target) if rec.Code != http.StatusSeeOther && rec.Code != http.StatusFound { t.Errorf("GET %s signed out: status %d, want a redirect to sign-in", target, rec.Code) continue } if loc := rec.Header().Get("Location"); !strings.HasPrefix(loc, "/signin") { t.Errorf("GET %s signed out: Location = %q, want /signin", target, loc) } } } func TestSignInMintsASession(t *testing.T) { app, session := signedIn(t, "someone@example.com") if session == nil || session.Value == "" { t.Fatal("sign-in minted no session cookie") } if rec := follow(t, app.Handler(), session, "/"); rec.Code != http.StatusOK { t.Fatalf("GET / signed in: status %d, want 200", rec.Code) } } // The defect this pins is an open relay on our own mail-sending // identity: rastrillo/auth checks Config.Authorize at the CALLBACK, // after Begin has already emailed the link. Gating only Authorize // would let anyone POST any address and have this instance mail it a // working sign-in link. func TestSignInMailsNothingForANonPermittedAddress(t *testing.T) { app := newAppWithAllowlist(t, "allowed@example.com") h := app.Handler() post(t, h, "/signin", url.Values{"address": {"stranger@example.com"}}) if body, _ := app.LastMailForTest(); body != "" { t.Fatalf("a non-permitted address was mailed a sign-in link:\n%s", body) } post(t, h, "/signin", url.Values{"address": {"allowed@example.com"}}) body, err := app.LastMailForTest() if err != nil { t.Fatalf("LastMailForTest: %v", err) } if body == "" { t.Fatal("a permitted address was mailed nothing") } } // Any observable difference between the two responses turns this // endpoint into a membership oracle: an attacker could learn which // addresses this instance admits by watching how it answers each one. // Byte-for-byte, not merely "both are 303". func TestSignInAnswersIdenticallyForPermittedAndNot(t *testing.T) { h := newAppWithAllowlist(t, "allowed@example.com").Handler() yes := post(t, h, "/signin", url.Values{"address": {"allowed@example.com"}}) no := post(t, h, "/signin", url.Values{"address": {"stranger@example.com"}}) if yes.Code != no.Code { t.Errorf("status differs: permitted %d, non-permitted %d", yes.Code, no.Code) } if yes.Header().Get("Location") != no.Header().Get("Location") { t.Errorf("Location differs: permitted %q, non-permitted %q", yes.Header().Get("Location"), no.Header().Get("Location")) } if yes.Body.String() != no.Body.String() { t.Errorf("body differs:\npermitted: %q\nnon-permitted: %q", yes.Body.String(), no.Body.String()) } } // A link minted for an address that is no longer permitted must not // mint a session — the second of the two wirings, defending admission // itself rather than delivery. The refusal is the door, with the // refusal on it, in auth's 403 (auth.Config.Refused, handlers.refused): // not rastrillo's bare text, and not a redirect to a query anybody could // type. func TestASessionIsRefusedForANonPermittedAddress(t *testing.T) { dir := t.TempDir() app := newAppWithAllowlistAt(t, dir, "allowed@example.com") h := app.Handler() post(t, h, "/signin", url.Values{"address": {"allowed@example.com"}}) body, err := app.LastMailForTest() if err != nil || body == "" { t.Fatalf("no link was mailed: %v", err) } link := magicLink(t, body) app.Close() // The same database, reopened with a different allowlist: "the // allowlist changed between the link being mailed and clicked". other := newAppWithAllowlistAt(t, dir, "somebody-else@example.com") action, token := openLink(t, other.Handler(), link) rec := postLink(t, other.Handler(), action, token) if rec.Code != http.StatusForbidden || rec.Header().Get("Location") != "" { t.Fatalf("refused redemption = %d %q, want 403 and the door itself\n%s", rec.Code, rec.Header().Get("Location"), rec.Body.String()) } for _, c := range rec.Result().Cookies() { if c.Value != "" && c.MaxAge >= 0 && strings.Contains(c.Name, "session") { t.Fatalf("a refused sign-in set a session cookie %q", c.Name) } } page := rec.Body.String() for _, want := range []string{"estilo-door", `name="address"`, `action="/signin"`, "That address can’t sign in to Calendar."} { if !strings.Contains(page, want) { t.Errorf("refusal door lacks %q:\n%s", want, page) } } if rec.Header().Get("Cache-Control") != "no-store" { t.Errorf("refusal door Cache-Control = %q, want no-store", rec.Header().Get("Cache-Control")) } } // newAppWithAllowlist builds an app admitting only the given addresses. // Env stays "development" so mail is still recorded rather than sent; // the allowlist is what is under test, not the environment gate. func newAppWithAllowlist(t *testing.T, addresses ...string) *calendar.App { t.Helper() return newAppWithAllowlistAt(t, t.TempDir(), addresses...) } // newAppWithAllowlistAt is newAppWithAllowlist over the database in dir, // so a second app can reopen the first one's. func newAppWithAllowlistAt(t *testing.T, dir string, addresses ...string) *calendar.App { t.Helper() app, err := calendar.New(calendar.Config{ DBPath: filepath.Join(dir, "calendar.db"), Env: "development", Origin: testOrigin, AllowedAddresses: strings.Join(addresses, ","), Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), }) if err != nil { t.Fatalf("calendar.New: %v", err) } t.Cleanup(func() { app.Close() }) return app } // magicLink pulls the request target out of a mailed sign-in link. func magicLink(t *testing.T, body string) string { t.Helper() i := strings.Index(body, "/auth/verify?token=") if i < 0 { t.Fatalf("no sign-in link in mail body:\n%s", body) } rest := body[i:] if j := strings.IndexAny(rest, " \r\n\t"); j >= 0 { rest = rest[:j] } if _, err := url.Parse(rest); err != nil { t.Fatalf("unparseable sign-in link %q: %v", rest, err) } return rest }