fix: sign-in review round 1
Closes review round 1 findings on Task 1 (magic-link sign-in):
Important 1 (gateBegin no-membership-oracle guarantee did not hold
under rate limiting): wrap the ResponseWriter (sameAnswer) and rewrite
any ?err=* Location rauth's Begin sets back to ?sent=1, so a permitted
address cannot be told apart from a denied one once its per-address
budget is spent. Doc comment now states the guarantee precisely and
what it costs. Covered by
TestSigninAnswerIsIdenticalOverTheRateLimitBudget (7 POSTs of an
allowed address, each byte-identical to a denied address's response).
Important 2 (app.cfg stored the raw Config, not the resolved one):
config.go's new resolve() returns the fully-resolved Config (Origin,
InstanceKey, Mailer, Env, Logger) alongside the parsed allowlist;
app.go's newApp assigns it back before constructing *app. Covered by
TestDevelopmentStoresTheResolvedConfig and
TestSuppliedConfigSurvivesResolution.
Important 3 (ensureUser upserted on every authenticated request):
read first (Take), fall through to upsert-then-read only on
ErrRecordNotFound. Covered by TestEnsureUserWritesOnceOnly, which
counts INSERT statements across three calls and asserts exactly one.
Ruling (SMTP env vars): cmd/slides/main.go's smtpSetting/smtpSecret
read $SLIDES_SMTP_* first and fall back to $CARLOS_SMTP_* per
variable (not per set), since `carlos email enable` delivers
credentials under the CARLOS_SMTP_* prefix; documented in both the
main.go doc comment and Config.Mailer's.
Minor 4: added postLegacy and
TestSigninAcceptsALegacyOriginOnlyPost, which omits Sec-Fetch-Site so
the Origin header (otherwise dead behind Sec-Fetch-Site in every other
test) is actually exercised against the app's resolved origin.
Minor 5: newAuthPlugin now passes SigninPath explicitly rather than
relying on rauth's matching default.
Minor 6: index's failure path now calls ErrorPage (ref logged) rather
than http.Error, matching the app's own error shell.
Minor 7: extractEnvFlag now refuses a valueless trailing -env instead
of silently reading it as "".
Verified: go build ./..., go vet ./..., gofmt -l . clean; go test
./internal/slidestest/ -run
'Signin|SignedOut|Verify|Development|User|Oracle' and go test
./internal/slides/... both pass in full. `make ci`'s vet and test
targets pass; its migration-check target fails
("expected exactly one app package under ./internal, found [core
slides]"), confirmed pre-existing on this branch's HEAD (b745b2f, Task
2's cherry-picked internal/core) before any of this round's changes —
out of scope here (core/, internal/core/ are off limits this round).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01563XGCgxcSZToEXwuTBsKE
9 files changed,
+691
−103
cmd/slides/main.go+70 −18internal/slides/app.go+50 −52internal/slides/auth.go+122 −27internal/slides/auth_test.go+137 −0internal/slides/config.go+54 −0internal/slides/config_test.go+140 −0internal/slides/handlers.go+17 −6internal/slidestest/harness_test.go+19 −0internal/slidestest/signin_test.go+82 −0
diff --git a/cmd/slides/main.go b/cmd/slides/main.go| index 945173b..1e6db24 100644 |
| --- a/cmd/slides/main.go |
| +++ b/cmd/slides/main.go |
| @@ -3,6 +3,7 @@ |
| package main |
| import ( |
| + "fmt" |
| "log/slog" |
| "os" |
| "path/filepath" |
| @@ -24,15 +25,23 @@ import ( |
| // with its own flag.FlagSet, which knows only -socket/-addr/-db and |
| // errors on anything else, so a flag this binary adds on top must be |
| // stripped out first. |
| -func extractEnvFlag(args []string) (env string, rest []string) { |
| +// |
| +// A trailing "-env" with nothing after it is an error, not an empty |
| +// value. Silently reading as "" would make chooseEnv fall through to |
| +// $SLIDES_ENV and then to production — so `slides -env` (a typo for |
| +// `-env development`, or a truncated argv) would boot a production app |
| +// while its operator believed they had asked for a development one. |
| +// Refusing is the only answer that cannot be misread. |
| +func extractEnvFlag(args []string) (env string, rest []string, err error) { |
| for i := 0; i < len(args); i++ { |
| a := args[i] |
| switch { |
| case a == "-env" || a == "--env": |
| - if i+1 < len(args) { |
| - env = args[i+1] |
| - i++ |
| + if i+1 >= len(args) { |
| + return "", nil, fmt.Errorf("%s needs a value, for example: %s development", a, a) |
| } |
| + env = args[i+1] |
| + i++ |
| continue |
| case strings.HasPrefix(a, "-env="): |
| env = strings.TrimPrefix(a, "-env=") |
| @@ -43,7 +52,7 @@ func extractEnvFlag(args []string) (env string, rest []string) { |
| } |
| rest = append(rest, a) |
| } |
| - return env, rest |
| + return env, rest, nil |
| } |
| // chooseEnv decides slides.Config.Env from -env's extracted value |
| @@ -74,21 +83,60 @@ func chooseEnv(flagEnv, osEnv string) string { |
| return slides.EnvProduction |
| } |
| -// newMailer builds the magic-link sender from $SLIDES_SMTP_* — the |
| -// same vars rastrillo/mail.FromEnv reads (HOST, PORT, USER, PASS, |
| -// FROM). It is built only when the two values with no sane default are |
| -// both set: FromEnv itself falls back to a warning-and-log sender when |
| -// they are missing, which is the wrong answer outside development, so |
| -// that decision is left to slides.New. A nil return here is what lets |
| -// New refuse to start rather than quietly logging live credentials. |
| -func newMailer(logger *slog.Logger) mail.Sender { |
| - if strings.TrimSpace(os.Getenv("SLIDES_SMTP_HOST")) == "" { |
| - return nil |
| +// smtpSetting reads one SMTP setting, this app's own variable first and |
| +// the platform's as the fallback: $SLIDES_SMTP_HOST, else |
| +// $CARLOS_SMTP_HOST. |
| +// |
| +// Per variable, not per set. `carlos email enable` delivers |
| +// CARLOS_SMTP_{HOST,PORT,USER,PASS,FROM}, and the common case is |
| +// wanting every one of them except FROM — the address this app sends |
| +// as — which $SLIDES_SMTP_FROM then overrides on its own. An |
| +// all-or-nothing choice between the two prefixes would make that |
| +// ordinary case impossible to express without restating the platform's |
| +// own credentials in this app's variables, which is exactly the kind of |
| +// copy that goes stale after a credential rotation. |
| +// |
| +// Trimmed, because these five arrive from a deploy-time env block or a |
| +// secret store, where a trailing newline is a routine accident and a |
| +// leading space in a hostname is never meant. |
| +func smtpSetting(name string) string { |
| + if v := strings.TrimSpace(os.Getenv("SLIDES_SMTP_" + name)); v != "" { |
| + return v |
| + } |
| + return strings.TrimSpace(os.Getenv("CARLOS_SMTP_" + name)) |
| +} |
| + |
| +// smtpSecret is smtpSetting for the two values that are credentials: |
| +// same per-variable fallback, no trimming. rastrillo/mail.FromEnv does |
| +// not trim these either, and for the same reason — a password's |
| +// surrounding whitespace may be part of the password, and silently |
| +// eating it would fail authentication with no way to see why. |
| +func smtpSecret(name string) string { |
| + if v := os.Getenv("SLIDES_SMTP_" + name); v != "" { |
| + return v |
| } |
| - if strings.TrimSpace(os.Getenv("SLIDES_SMTP_FROM")) == "" { |
| + return os.Getenv("CARLOS_SMTP_" + name) |
| +} |
| + |
| +// newMailer builds the magic-link sender from the SMTP settings above. |
| +// |
| +// mail.SMTP directly rather than mail.FromEnv, because FromEnv reads |
| +// one prefix and this needs the per-variable SLIDES-then-CARLOS |
| +// fallback. It also falls back to a warning-and-log sender when HOST or |
| +// FROM is missing, which is the wrong answer outside development: a nil |
| +// return here is what lets slides.New refuse to start rather than |
| +// quietly logging live credentials in a real deployment's log. |
| +// |
| +// HOST and FROM are the two values with no sane default, so either one |
| +// missing means this instance has no relay. PORT defaults to 587 inside |
| +// mail.SMTP; USER empty means anonymous submission. |
| +func newMailer(logger *slog.Logger) mail.Sender { |
| + host, from := smtpSetting("HOST"), smtpSetting("FROM") |
| + if host == "" || from == "" { |
| return nil |
| } |
| - return mail.FromEnv("SLIDES", logger) |
| + logger.Info("slides: SMTP configured", "host", host, "from", from) |
| + return mail.SMTP(host, smtpSetting("PORT"), from, smtpSecret("USER"), smtpSecret("PASS")) |
| } |
| // newBlobs binds the object store: the platform's bucket when it |
| @@ -126,7 +174,11 @@ func main() { |
| // -env is stripped before Resolve sees argv (extractEnvFlag), and |
| // it is the only thing that can ever select "development" |
| // (chooseEnv). |
| - envFlag, rest := extractEnvFlag(os.Args[1:]) |
| + envFlag, rest, err := extractEnvFlag(os.Args[1:]) |
| + if err != nil { |
| + logger.Error("read flags", "err", err) |
| + os.Exit(1) |
| + } |
| os.Args = append(os.Args[:1], rest...) |
| env := chooseEnv(envFlag, os.Getenv("SLIDES_ENV")) |
diff --git a/internal/slides/app.go b/internal/slides/app.go| index 49cec6c..4f25d94 100644 |
| --- a/internal/slides/app.go |
| +++ b/internal/slides/app.go |
| @@ -2,11 +2,11 @@ package slides |
| import ( |
| "context" |
| - "log/slog" |
| "net/http" |
| "github.com/go-chi/chi/v5" |
| + rauth "amadan.net/rastrillo/rastrillo/auth" |
| "amadan.net/rastrillo/rastrillo/csrf" |
| "amadan.net/rastrillo/rastrillo/db" |
| "amadan.net/rastrillo/rastrillo/migrate" |
| @@ -20,69 +20,24 @@ import ( |
| // logger arguments the scaffold took have grown into Config (config.go), |
| // because sign-in needs four more settings and each of them is a |
| // startup refusal when it is missing outside development. Every one of |
| -// those refusals happens here, before the database is touched, so a |
| +// those refusals happens before the database is touched, so a |
| // misconfigured instance never reaches the point of serving a request. |
| // |
| // The database handle is the caller's (cmd/slides/main.go's db.Open), |
| // not one this opens: SKILL.md §1's Resolve/Serve split depends on |
| // exactly one connection to the file. |
| func New(d *db.DB, cfg Config) (*http.ServeMux, error) { |
| - logger := cfg.Logger |
| - if logger == nil { |
| - logger = slog.Default() |
| - } |
| - if cfg.Env == "" { |
| - cfg.Env = EnvProduction |
| - } |
| - |
| - // Fail closed, loudly, and before anything opens: each of these |
| - // four decides whether sign-in is safe, and each may default only |
| - // when Env is exactly "development" (config.go's package comment). |
| - origin, err := resolveOrigin(cfg) |
| - if err != nil { |
| - return nil, err |
| - } |
| - instanceKey, err := resolveInstanceKey(cfg) |
| - if err != nil { |
| - return nil, err |
| - } |
| - mailer, err := resolveMailer(cfg, logger) |
| - if err != nil { |
| - return nil, err |
| - } |
| - list, err := resolveAllowlist(cfg) |
| - if err != nil { |
| - return nil, err |
| - } |
| - |
| - // Apply BootSchema, not Schema: BootSchema is everything this |
| - // app needs at boot (its own migrations plus sessions' and auth's), |
| - // while Schema (migrations.go) stays just this app's own so the |
| - // generator and its check never see a subsystem's tables and try |
| - // to drop them. |
| - migrated, err := migrate.Apply(context.Background(), d, BootSchema) |
| + a, authPlugin, err := newApp(d, cfg) |
| if err != nil { |
| return nil, err |
| } |
| - // One line per boot. Result exists so this can be logged, and |
| - // until something does, a fleet-wide adoption — every database |
| - // stamped, no DDL run, exactly once, never again — produces no |
| - // output at all, which is the last event you want to be silent. |
| - logger.Info("migrate", "applied", migrated.Applied, |
| - "skipped", migrated.Skipped, "adopted", migrated.Adopted) |
| - |
| - authPlugin, err := newAuthPlugin(origin, instanceKey, d.Writer(), mailer, list, logger) |
| - if err != nil { |
| - return nil, err |
| - } |
| - |
| - a := &app{db: d, logger: logger, cfg: cfg} |
| r := chi.NewRouter() |
| // App-wide, from day one: every state-changing route added below |
| // is born covered. Origin-checking, not tokens — nothing to mint |
| - // or forget in a form. |
| - r.Use(csrf.Protect(origin)) |
| + // or forget in a form. a.cfg.Origin, not the caller's cfg.Origin: |
| + // what this protects has to be the origin the app resolved. |
| + r.Use(csrf.Protect(a.cfg.Origin)) |
| // Unauthenticated: the front door and rauth's four handlers. The |
| // sign-in page has to be reachable signed out or nobody could ever |
| @@ -91,7 +46,7 @@ func New(d *db.DB, cfg Config) (*http.ServeMux, error) { |
| // gateBegin, not authPlugin.Begin directly — see its own doc |
| // comment: the allowlist has to sit in FRONT of Begin, because |
| // Begin is what sends the mail. |
| - r.Post(signinPath, gateBegin(authPlugin.Begin, list)) |
| + r.Post(signinPath, gateBegin(authPlugin.Begin, a.allowed, a.logger)) |
| r.Get("/auth/callback", authPlugin.Callback) |
| r.Get("/auth/verify", authPlugin.Verify) |
| r.Post("/signout", authPlugin.Signout) |
| @@ -113,3 +68,46 @@ func New(d *db.DB, cfg Config) (*http.ServeMux, error) { |
| mux.Handle("/", r) |
| return mux, nil |
| } |
| + |
| +// newApp is everything New does before the router: resolve the config |
| +// fail-closed, apply the schema, build the sign-in plugin. |
| +// |
| +// Separate from New so this package's own tests can assert on the |
| +// resolved *app — that a.cfg carries the origin the app is really |
| +// protecting rather than the empty string a development caller passed |
| +// in (config_test.go). New itself hands back only a mux, which cannot |
| +// answer that question. |
| +func newApp(d *db.DB, cfg Config) (*app, *rauth.Auth, error) { |
| + // Fail closed, loudly, and before anything opens: Origin, |
| + // InstanceKey, Mailer and AllowedAddresses each decide whether |
| + // sign-in is safe, and each may default only when Env is exactly |
| + // "development" (config.go's package comment). |
| + cfg, list, err := resolve(cfg) |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| + logger := cfg.Logger |
| + |
| + // Apply BootSchema, not Schema: BootSchema is everything this |
| + // app needs at boot (its own migrations plus sessions' and auth's), |
| + // while Schema (migrations.go) stays just this app's own so the |
| + // generator and its check never see a subsystem's tables and try |
| + // to drop them. |
| + migrated, err := migrate.Apply(context.Background(), d, BootSchema) |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| + // One line per boot. Result exists so this can be logged, and |
| + // until something does, a fleet-wide adoption — every database |
| + // stamped, no DDL run, exactly once, never again — produces no |
| + // output at all, which is the last event you want to be silent. |
| + logger.Info("migrate", "applied", migrated.Applied, |
| + "skipped", migrated.Skipped, "adopted", migrated.Adopted) |
| + |
| + authPlugin, err := newAuthPlugin(cfg, d.Writer(), list) |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| + |
| + return &app{db: d, logger: logger, cfg: cfg, allowed: list}, authPlugin, nil |
| +} |
diff --git a/internal/slides/auth.go b/internal/slides/auth.go| index fc84364..d547802 100644 |
| --- a/internal/slides/auth.go |
| +++ b/internal/slides/auth.go |
| @@ -71,7 +71,7 @@ func resolveMailer(cfg Config, logger *slog.Logger) (mail.Sender, error) { |
| return cfg.Mailer, nil |
| } |
| if !cfg.development() { |
| - return nil, fmt.Errorf("slides: mail is not configured (set $SLIDES_SMTP_HOST and $SLIDES_SMTP_FROM) and Env is %q, not %q — refusing to start: a sign-in that cannot send its link is not sign-in", cfg.Env, EnvDevelopment) |
| + return nil, fmt.Errorf("slides: mail is not configured and Env is %q, not %q — refusing to start: a sign-in that cannot send its link is not sign-in. Set $SLIDES_SMTP_HOST and $SLIDES_SMTP_FROM, or run `carlos email enable` for this app and let $CARLOS_SMTP_HOST supply the relay", cfg.Env, EnvDevelopment) |
| } |
| // mail.Logged labels every send loudly itself — see its own doc |
| // comment. Never reached outside development. |
| @@ -133,39 +133,103 @@ func resolveAllowlist(cfg Config) (allowlist, error) { |
| // sending identity an open relay for any address a stranger types into |
| // the form. |
| // |
| -// A non-permitted address gets exactly the response the permitted path |
| -// sends: the same 303, to the same signinPath+"?sent=1". That is |
| -// deliberate. Any observable difference — a different status, a |
| -// different body, even a differently-cased query — would turn this |
| -// endpoint into a membership oracle, letting anyone learn which |
| -// addresses this instance admits by watching how its answers differ. |
| +// THE GUARANTEE: every answer this endpoint gives is byte-identical — |
| +// `303 Location: /signin?sent=1`, no body — whatever address was |
| +// submitted and whatever happened next. Any observable difference at |
| +// all would turn this into a membership oracle, letting anyone learn |
| +// which addresses this instance admits by watching how its answers |
| +// differ. |
| +// |
| +// Making the denied path match the happy path is only half of that, and |
| +// the half that is easy to get wrong is the other one: the PERMITTED |
| +// path has outcomes the denied path can never reach. `signin.Flow.Begin` |
| +// enforces a per-address budget (5 in a rolling 15 minutes) and a |
| +// per-IP one (20), and over either budget rauth redirects to |
| +// `?err=rate` instead — so six POSTs of one candidate address would |
| +// separate "on the list" (`?err=rate` on the sixth) from "not on it" |
| +// (`?sent=1` forever), and the whole guarantee with it. `?err=address` |
| +// and `?err=1` are the same shape of leak: only a permitted address can |
| +// reach either. |
| +// |
| +// So the permitted branch runs under sameAnswer, which rewrites every |
| +// one of those outcomes back to `?sent=1` on the way out. What that |
| +// costs is inline feedback: a person over the budget, or one whose |
| +// address rauth could not parse, is told a link is on its way when it |
| +// is not. On a closed allowlist that was already true — such an address |
| +// is almost always denied and short-circuited above — so the loss is |
| +// confined to development's admit-everyone list, where the log carries |
| +// the real outcome instead. That is the trade, made deliberately: a |
| +// recoverable "wait five minutes and try again" that nobody sees, in |
| +// exchange for an allowlist nobody can enumerate. |
| +// |
| +// The denied branch deliberately spends no budget of its own. That is |
| +// not an oversight — a budget consumed only by denied addresses would |
| +// itself be observable, since a permitted address's own budget would |
| +// then behave differently depending on what else had been tried. |
| +// Rate limiting the front door as a whole belongs at the edge, not |
| +// here, and the per-IP budget inside Begin already bounds what a |
| +// permitted address can spend. |
| // |
| // force=1 is set unconditionally on the permitted branch, not left to |
| // what the caller's form said. It selects rauth's plain-magic-link path |
| // over keymail classification, which this app never wants (see this |
| // file's package comment). Without it a permitted keymail-hosted |
| // address would redirect out to keymail while a non-permitted one got |
| -// ?sent=1 — the same oracle, one level narrower. It also saves the |
| -// classification network call outright. |
| +// ?sent=1 — the same oracle again. It also saves the classification |
| +// network call outright. |
| // |
| // r.ParseForm is documented idempotent, so reading the address here and |
| // again inside Begin costs nothing and cannot disagree with itself. A |
| // malformed body reads back as address == "", which is never on a |
| // non-empty allowlist — the same answer Begin would eventually reach, |
| // just reached before any mail could go out. |
| -func gateBegin(next http.HandlerFunc, list allowlist) http.HandlerFunc { |
| +func gateBegin(next http.HandlerFunc, list allowlist, logger *slog.Logger) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| _ = r.ParseForm() |
| if !list.allows(r.FormValue("address")) { |
| - http.Redirect(w, r, signinPath+"?sent=1", http.StatusSeeOther) |
| + http.Redirect(w, r, sentLocation, http.StatusSeeOther) |
| return |
| } |
| if r.Form == nil { |
| r.Form = url.Values{} |
| } |
| r.Form.Set("force", "1") |
| - next(w, r) |
| + next(&sameAnswer{ResponseWriter: w, logger: logger}, r) |
| + } |
| +} |
| + |
| +// sentLocation is the one answer POST /signin ever gives — see |
| +// gateBegin's guarantee. Spelled once so the two paths that write it |
| +// cannot drift apart by a character. |
| +const sentLocation = signinPath + "?sent=1" |
| + |
| +// sameAnswer is the ResponseWriter gateBegin hands to Begin: it |
| +// rewrites any `?err=…` outcome back to sentLocation before the status |
| +// line goes out, so a permitted address cannot be told apart from a |
| +// denied one by its failure mode. See gateBegin's own doc comment for |
| +// what that buys and what it costs. |
| +// |
| +// It hooks WriteHeader rather than the Location header itself because |
| +// that is the last moment a header is still settable: http.Redirect |
| +// sets Location and then calls WriteHeader, so by intercepting the |
| +// second we see the first already written and can still change it. The |
| +// body is not a concern on this route — http.Redirect writes its little |
| +// HTML body only for GET and HEAD, and this handler is mounted on POST |
| +// alone. |
| +type sameAnswer struct { |
| + http.ResponseWriter |
| + logger *slog.Logger |
| +} |
| + |
| +func (s *sameAnswer) WriteHeader(status int) { |
| + if loc := s.Header().Get("Location"); loc != "" && loc != sentLocation && strings.HasPrefix(loc, signinPath+"?err=") { |
| + // Logged, not swallowed: the person who submitted the form is |
| + // told nothing, so the operator has to be told everything. |
| + s.logger.Warn("slides: sign-in refused; answering as if sent, to keep the allowlist unobservable", |
| + "outcome", loc) |
| + s.Header().Set("Location", sentLocation) |
| } |
| + s.ResponseWriter.WriteHeader(status) |
| } |
| // newAuthPlugin wires rastrillo/auth per SKILL.md §5: exactly one *Auth |
| @@ -180,14 +244,25 @@ func gateBegin(next http.HandlerFunc, list allowlist) http.HandlerFunc { |
| // issued for a non-permitted address (mailed before the allowlist |
| // changed, say). SecondFactor stays nil, per this file's package |
| // comment. |
| -func newAuthPlugin(origin, instanceKey string, writer *sql.DB, mailer mail.Sender, list allowlist, logger *slog.Logger) (*rauth.Auth, error) { |
| +// |
| +// cfg must be the resolved Config (config.go's resolve), not a |
| +// caller's raw one: Origin, InstanceKey and Mailer are all required |
| +// here and all three may have been filled in by a development default. |
| +// |
| +// SigninPath is passed explicitly even though signinPath is already |
| +// rauth's own zero-value default. rauth redirects every outcome to its |
| +// copy of that literal and app.go mounts the page at ours; saying it |
| +// once, out loud, is what keeps the two from drifting silently if |
| +// either default ever changes. |
| +func newAuthPlugin(cfg Config, writer *sql.DB, list allowlist) (*rauth.Auth, error) { |
| return rauth.New(rauth.Config{ |
| DB: writer, |
| - Origin: origin, |
| - InstanceKey: instanceKey, |
| - Mailer: mailer, |
| + Origin: cfg.Origin, |
| + InstanceKey: cfg.InstanceKey, |
| + Mailer: cfg.Mailer, |
| Authorize: list.allows, |
| - Logger: logger, |
| + SigninPath: signinPath, |
| + Logger: cfg.Logger, |
| }) |
| } |
| @@ -205,23 +280,43 @@ var presenceColours = []string{ |
| // sessions.UserID answers (0, false) and scoping on it would file every |
| // deck under user_id = 0. |
| // |
| -// ON CONFLICT DO NOTHING on the unique Email index rather than a read |
| -// followed by a write: two requests arriving together for a person's |
| -// very first sign-in would otherwise both see no row and both insert |
| -// one, and only the unique index would stop them — as an error on the |
| -// loser's request rather than as a shared row. |
| +// Read first, and only write when the read came back empty. This runs |
| +// on every authenticated request — currentUser calls it before any |
| +// handler does its own work — and SQLite has one writer: an |
| +// unconditional upsert here would open a write transaction per page |
| +// view, serialising every reader in the app behind a statement whose |
| +// answer is almost always "this row already exists". The read goes to |
| +// the reader pool (db.Open routes per statement), so the steady state |
| +// costs one indexed SELECT and no lock at all. |
| +// |
| +// The write, when it happens, is still ON CONFLICT DO NOTHING on the |
| +// unique Email index rather than a bare INSERT: the read above is not a |
| +// lock, so two requests arriving together on a person's very first |
| +// sign-in can both miss and both try to insert. DO NOTHING makes the |
| +// loser a no-op instead of an error, and the read-back below gives both |
| +// of them the same row. |
| func ensureUser(ctx context.Context, g *gorm.DB, address string) (User, error) { |
| email := normalizeAddress(address) |
| if email == "" { |
| return User{}, errors.New("slides: cannot map an empty address to a user") |
| } |
| - u := User{Email: email, Colour: colourFor(email)} |
| - if err := g.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&u).Error; err != nil { |
| + |
| + var found User |
| + err := g.WithContext(ctx).Where("email = ?", email).Take(&found).Error |
| + switch { |
| + case err == nil: |
| + return found, nil |
| + case !errors.Is(err, gorm.ErrRecordNotFound): |
| + return User{}, fmt.Errorf("slides: load user %q: %w", email, err) |
| + } |
| + |
| + // First sight of this address: create the row, then read it back |
| + // rather than trusting the insert's own return — DO NOTHING leaves |
| + // ID at zero when a concurrent request won the race. |
| + created := User{Email: email, Colour: colourFor(email)} |
| + if err := g.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&created).Error; err != nil { |
| return User{}, fmt.Errorf("slides: create user %q: %w", email, err) |
| } |
| - // DoNothing leaves ID at zero when the row already existed, so read |
| - // it back either way rather than trusting the insert's return. |
| - var found User |
| if err := g.WithContext(ctx).Where("email = ?", email).Take(&found).Error; err != nil { |
| return User{}, fmt.Errorf("slides: load user %q: %w", email, err) |
| } |
diff --git a/internal/slides/auth_test.go b/internal/slides/auth_test.go| new file mode 100644 |
| index 0000000..df1b1e5 |
| --- /dev/null |
| +++ b/internal/slides/auth_test.go |
| @@ -0,0 +1,137 @@ |
| +package slides |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + |
| + "gorm.io/gorm" |
| +) |
| + |
| +// countCreates registers a GORM callback that counts INSERT statements |
| +// on the handle, returning a function that reads the count. It is the |
| +// only honest way to answer "did that request write?": a row's id and |
| +// CreatedAt look identical whether the second call inserted nothing or |
| +// ran an upsert that conflicted, and SQLite's AUTOINCREMENT sequence |
| +// does not move for a DO NOTHING either. Counting the statement itself |
| +// is the observation that distinguishes them. |
| +func countCreates(t *testing.T, g *gorm.DB) func() int { |
| + t.Helper() |
| + var n int |
| + if err := g.Callback().Create().Before("gorm:create").Register("slides_test:count_creates", func(*gorm.DB) { |
| + n++ |
| + }); err != nil { |
| + t.Fatalf("register create callback: %v", err) |
| + } |
| + return func() int { return n } |
| +} |
| + |
| +// TestEnsureUserWritesOnceOnly is the regression test for a real |
| +// performance bug: ensureUser used to run INSERT ... ON CONFLICT DO |
| +// NOTHING followed by a SELECT on EVERY authenticated request, because |
| +// currentUser calls it before any handler does its own work. SQLite has |
| +// one writer, so that was a write transaction per page view, |
| +// serialising every reader in the app behind a statement whose answer |
| +// was almost always "this row already exists". |
| +// |
| +// It now reads first and writes only on a miss. Three calls, one |
| +// insert. |
| +func TestEnsureUserWritesOnceOnly(t *testing.T) { |
| + d := testDB(t) |
| + if _, _, err := newApp(d, Config{Env: EnvDevelopment, Logger: quietLogger()}); err != nil { |
| + t.Fatalf("newApp: %v", err) |
| + } |
| + creates := countCreates(t, d.G) |
| + |
| + ctx := context.Background() |
| + first, err := ensureUser(ctx, d.G, "paul@example.test") |
| + if err != nil { |
| + t.Fatalf("ensureUser (first): %v", err) |
| + } |
| + if first.ID == 0 { |
| + t.Fatal("ensureUser (first) returned a row with no id") |
| + } |
| + if got := creates(); got != 1 { |
| + t.Fatalf("first sign-in ran %d inserts, want 1", got) |
| + } |
| + |
| + for i := 2; i <= 3; i++ { |
| + again, err := ensureUser(ctx, d.G, "paul@example.test") |
| + if err != nil { |
| + t.Fatalf("ensureUser (call %d): %v", i, err) |
| + } |
| + if again.ID != first.ID { |
| + t.Errorf("call %d returned id %d, want the same row %d", i, again.ID, first.ID) |
| + } |
| + if !again.CreatedAt.Equal(first.CreatedAt) { |
| + t.Errorf("call %d rewrote CreatedAt (%v, was %v)", i, again.CreatedAt, first.CreatedAt) |
| + } |
| + if got := creates(); got != 1 { |
| + t.Fatalf("after call %d the app had run %d inserts, want the 1 from first sign-in", i, got) |
| + } |
| + } |
| +} |
| + |
| +// TestEnsureUserNormalizesTheAddress: the address is the identity, so |
| +// the same person typing capitals or a stray space is the same row — |
| +// and only one row, since a second would mean one person owning two |
| +// disjoint sets of decks depending on which one a request found. |
| +func TestEnsureUserNormalizesTheAddress(t *testing.T) { |
| + d := testDB(t) |
| + if _, _, err := newApp(d, Config{Env: EnvDevelopment, Logger: quietLogger()}); err != nil { |
| + t.Fatalf("newApp: %v", err) |
| + } |
| + ctx := context.Background() |
| + |
| + first, err := ensureUser(ctx, d.G, "Paul@Example.Test") |
| + if err != nil { |
| + t.Fatalf("ensureUser: %v", err) |
| + } |
| + if first.Email != "paul@example.test" { |
| + t.Errorf("stored Email = %q, want it normalized", first.Email) |
| + } |
| + again, err := ensureUser(ctx, d.G, " paul@example.test ") |
| + if err != nil { |
| + t.Fatalf("ensureUser (again): %v", err) |
| + } |
| + if again.ID != first.ID { |
| + t.Errorf("a differently-spelled address got id %d, want the same row %d", again.ID, first.ID) |
| + } |
| + |
| + var n int64 |
| + if err := d.G.Model(&User{}).Count(&n).Error; err != nil { |
| + t.Fatalf("count users: %v", err) |
| + } |
| + if n != 1 { |
| + t.Errorf("users table has %d rows, want 1", n) |
| + } |
| +} |
| + |
| +// TestEnsureUserRefusesAnEmptyAddress: an empty Subject would otherwise |
| +// create a row owning decks that nobody could ever sign in as. |
| +func TestEnsureUserRefusesAnEmptyAddress(t *testing.T) { |
| + d := testDB(t) |
| + if _, _, err := newApp(d, Config{Env: EnvDevelopment, Logger: quietLogger()}); err != nil { |
| + t.Fatalf("newApp: %v", err) |
| + } |
| + if _, err := ensureUser(context.Background(), d.G, " "); err == nil { |
| + t.Fatal("ensureUser accepted a blank address; want an error") |
| + } |
| +} |
| + |
| +// TestColourForIsStable: a presence colour is only worth recognising if |
| +// it is the same colour tomorrow, and on every instance. |
| +func TestColourForIsStable(t *testing.T) { |
| + first := colourFor("paul@example.test") |
| + if first != colourFor("paul@example.test") { |
| + t.Error("colourFor is not deterministic") |
| + } |
| + var found bool |
| + for _, c := range presenceColours { |
| + if c == first { |
| + found = true |
| + } |
| + } |
| + if !found { |
| + t.Errorf("colourFor returned %q, which is not in the palette", first) |
| + } |
| +} |
diff --git a/internal/slides/config.go b/internal/slides/config.go| index 32f003a..b71e399 100644 |
| --- a/internal/slides/config.go |
| +++ b/internal/slides/config.go |
| @@ -76,6 +76,11 @@ type Config struct { |
| // that cannot send its link is not sign-in, and quietly logging a |
| // live credential in a real deployment's log is the mistake that |
| // fallback exists to flag, not to absorb. |
| + // |
| + // cmd/slides/main.go builds it from $SLIDES_SMTP_{HOST,PORT,USER, |
| + // PASS,FROM}, falling back per variable to the $CARLOS_SMTP_* |
| + // equivalent that `carlos email enable` delivers — see smtpSetting |
| + // there for why the fallback is per variable rather than per set. |
| Mailer mail.Sender |
| // AllowedAddresses is the only set of addresses this instance ever |
| @@ -115,6 +120,55 @@ type Config struct { |
| // development reports whether cfg is in the one Env that may default. |
| func (cfg Config) development() bool { return cfg.Env == EnvDevelopment } |
| +// resolve turns the config a caller supplied into the config the app |
| +// actually runs on: Env, Logger, Origin, InstanceKey and Mailer each |
| +// either filled in or refused, plus the parsed allowlist. Every |
| +// fail-closed rule in this file is applied here, once, before anything |
| +// opens. |
| +// |
| +// It returns a Config rather than five values because what New stores |
| +// on the app must be the RESOLVED one. Storing the caller's raw Config |
| +// was a real bug: in development a.cfg.Origin was "" while the app was |
| +// protecting http://localhost:8091, and a.cfg.Mailer was nil while mail |
| +// was going to the log — so any later code reading its own config would |
| +// have read a description of the app that was not true. |
| +func resolve(cfg Config) (Config, allowlist, error) { |
| + if cfg.Env == "" { |
| + cfg.Env = EnvProduction |
| + } |
| + if cfg.Logger == nil { |
| + cfg.Logger = slog.Default() |
| + } |
| + |
| + origin, err := resolveOrigin(cfg) |
| + if err != nil { |
| + return Config{}, allowlist{}, err |
| + } |
| + cfg.Origin = origin |
| + |
| + instanceKey, err := resolveInstanceKey(cfg) |
| + if err != nil { |
| + return Config{}, allowlist{}, err |
| + } |
| + cfg.InstanceKey = instanceKey |
| + |
| + mailer, err := resolveMailer(cfg, cfg.Logger) |
| + if err != nil { |
| + return Config{}, allowlist{}, err |
| + } |
| + cfg.Mailer = mailer |
| + |
| + // AllowedAddresses is deliberately NOT rewritten onto cfg: the |
| + // parsed, normalized form is the allowlist type, and leaving the |
| + // raw slice in place keeps one obvious answer to "what was |
| + // configured" beside the derived answer to "what is admitted". |
| + list, err := resolveAllowlist(cfg) |
| + if err != nil { |
| + return Config{}, allowlist{}, err |
| + } |
| + return cfg, list, nil |
| +} |
| + |
| // resolveOrigin applies Config.Origin's own rule: a non-empty value is |
| // trimmed and validated, an empty one falls back to DefaultOrigin in |
| // development and is refused anywhere else. |
diff --git a/internal/slides/config_test.go b/internal/slides/config_test.go| new file mode 100644 |
| index 0000000..17060bc |
| --- /dev/null |
| +++ b/internal/slides/config_test.go |
| @@ -0,0 +1,140 @@ |
| +package slides |
| + |
| +import ( |
| + "io" |
| + "log/slog" |
| + "path/filepath" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/rastrillo/db" |
| + "amadan.net/rastrillo/rastrillo/mail" |
| +) |
| + |
| +// quietLogger keeps the suite's output pristine: newApp logs one |
| +// migrate line per call, which is right in a real boot and noise here. |
| +func quietLogger() *slog.Logger { |
| + return slog.New(slog.NewTextHandler(io.Discard, nil)) |
| +} |
| + |
| +func testDB(t *testing.T) *db.DB { |
| + t.Helper() |
| + d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), quietLogger()) |
| + if err != nil { |
| + t.Fatalf("db.Open: %v", err) |
| + } |
| + t.Cleanup(func() { d.Close() }) |
| + return d |
| +} |
| + |
| +// TestDevelopmentStoresTheResolvedConfig is the regression test for a |
| +// real bug: New resolved Origin/InstanceKey/Mailer into locals, handed |
| +// those to the auth plugin, and then stored the caller's RAW Config on |
| +// the app. So in development a.cfg.Origin was "" while the app was |
| +// protecting http://localhost:8091, and a.cfg.Mailer was nil while |
| +// mail was going to the log — the app's own description of itself was |
| +// false, and the first later task to read it would have been misled. |
| +// |
| +// Package-internal because a.cfg is unexported and this is precisely a |
| +// question about what New stored, which the *http.ServeMux New returns |
| +// cannot answer. |
| +func TestDevelopmentStoresTheResolvedConfig(t *testing.T) { |
| + a, _, err := newApp(testDB(t), Config{Env: EnvDevelopment, Logger: quietLogger()}) |
| + if err != nil { |
| + t.Fatalf("newApp: %v", err) |
| + } |
| + if a.cfg.Origin != DefaultOrigin { |
| + t.Errorf("a.cfg.Origin = %q, want the resolved %q", a.cfg.Origin, DefaultOrigin) |
| + } |
| + if a.cfg.InstanceKey != devInstanceKey { |
| + t.Errorf("a.cfg.InstanceKey = %q, want the resolved %q", a.cfg.InstanceKey, devInstanceKey) |
| + } |
| + if a.cfg.Mailer == nil { |
| + t.Error("a.cfg.Mailer is nil; want the resolved development sender") |
| + } else if mail.IsConfigured(a.cfg.Mailer) { |
| + t.Error("a.cfg.Mailer delivers for real in development; want mail.Logged") |
| + } |
| + if a.cfg.Env != EnvDevelopment { |
| + t.Errorf("a.cfg.Env = %q, want %q", a.cfg.Env, EnvDevelopment) |
| + } |
| + if a.cfg.Logger == nil { |
| + t.Error("a.cfg.Logger is nil; resolve must never leave it unset") |
| + } |
| + // The allowlist that actually admits, beside the raw list that was |
| + // configured: development's empty list means "admit everyone". |
| + if !a.allowed.allows("anyone@example.test") { |
| + t.Error("development allowlist refused an address; want admit-everyone") |
| + } |
| +} |
| + |
| +// TestSuppliedConfigSurvivesResolution is the other direction: what a |
| +// caller did set must come back unchanged, trimmed but not replaced. |
| +func TestSuppliedConfigSurvivesResolution(t *testing.T) { |
| + given := Config{ |
| + Origin: " https://slides.example.test ", |
| + Env: EnvProduction, |
| + InstanceKey: "a-real-key", |
| + Mailer: mail.SMTP("smtp.example.test", "", "slides@example.test", "", ""), |
| + AllowedAddresses: []string{"Paul@Example.Test "}, |
| + Logger: quietLogger(), |
| + } |
| + got, list, err := resolve(given) |
| + if err != nil { |
| + t.Fatalf("resolve: %v", err) |
| + } |
| + if got.Origin != "https://slides.example.test" { |
| + t.Errorf("Origin = %q, want it trimmed and kept", got.Origin) |
| + } |
| + if got.InstanceKey != given.InstanceKey { |
| + t.Errorf("InstanceKey = %q, want %q", got.InstanceKey, given.InstanceKey) |
| + } |
| + if got.Mailer == nil { |
| + t.Error("Mailer was dropped") |
| + } |
| + // The allowlist normalizes case and whitespace on both sides, so a |
| + // config file with spaces after its commas and a person typing |
| + // capitals both work. |
| + if !list.allows("paul@example.test") { |
| + t.Error("allowlist refused the normalized form of a configured address") |
| + } |
| + if list.allows("someone@else.test") { |
| + t.Error("allowlist admitted an address nobody configured") |
| + } |
| +} |
| + |
| +// TestProductionRefusesEachMissingSetting pins the fail-closed rule at |
| +// the resolve level, one setting at a time, so a regression names which |
| +// one stopped refusing. The HTTP-level counterpart lives in |
| +// internal/slidestest. |
| +func TestProductionRefusesEachMissingSetting(t *testing.T) { |
| + full := func() Config { |
| + return Config{ |
| + Origin: "https://slides.example.test", |
| + Env: EnvProduction, |
| + InstanceKey: "a-real-key", |
| + Mailer: mail.SMTP("smtp.example.test", "", "slides@example.test", "", ""), |
| + AllowedAddresses: []string{"paul@example.test"}, |
| + Logger: quietLogger(), |
| + } |
| + } |
| + for _, tc := range []struct { |
| + name string |
| + bend func(*Config) |
| + }{ |
| + {"Origin", func(c *Config) { c.Origin = "" }}, |
| + {"Origin whitespace only", func(c *Config) { c.Origin = " " }}, |
| + {"Origin with a path", func(c *Config) { c.Origin = "https://slides.example.test/app" }}, |
| + {"Origin with no host", func(c *Config) { c.Origin = "http://:8091" }}, |
| + {"InstanceKey", func(c *Config) { c.InstanceKey = "" }}, |
| + {"Mailer", func(c *Config) { c.Mailer = nil }}, |
| + {"AllowedAddresses", func(c *Config) { c.AllowedAddresses = nil }}, |
| + {"AllowedAddresses all blank", func(c *Config) { c.AllowedAddresses = []string{"", " "} }}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + cfg := full() |
| + tc.bend(&cfg) |
| + if _, _, err := resolve(cfg); err == nil { |
| + t.Fatalf("resolve with %s bent returned no error; want a startup refusal", tc.name) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/slides/handlers.go b/internal/slides/handlers.go| index cd7de25..2c964b7 100644 |
| --- a/internal/slides/handlers.go |
| +++ b/internal/slides/handlers.go |
| @@ -8,13 +8,17 @@ import ( |
| "amadan.net/rastrillo/rastrillo/db" |
| ) |
| -// app holds what every handler needs: the database, the logger, and |
| -// the resolved config — which is also where the object store arrives |
| +// app holds what every handler needs: the database, the logger, the |
| +// RESOLVED config — every fail-closed default already applied, so |
| +// a.cfg.Origin is the origin this app is really protecting and |
| +// a.cfg.Mailer is the sender it is really using — and the parsed |
| +// allowlist. Config is also where the object store arrives |
| // (Config.Blobs) for the deck screens that come next. |
| type app struct { |
| - db *db.DB |
| - logger *slog.Logger |
| - cfg Config |
| + db *db.DB |
| + logger *slog.Logger |
| + cfg Config |
| + allowed allowlist |
| } |
| // indexPage is GET /'s view data — the deck list. Every string on it is |
| @@ -43,7 +47,14 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| // Past RequireSession this means the users row could not be |
| // read (currentUser logged why), not that nobody is signed in. |
| - http.Error(w, rastrillo.T(r, "slides.error.generic"), http.StatusInternalServerError) |
| + // ErrorPage, not http.Error: a failure here gets the app's own |
| + // page in the app's own shell, the same one a panic gets, and |
| + // a ref a person can quote — plain text on a blank page is |
| + // what this app renders when the renderer itself is broken, |
| + // which is not the case here. |
| + ref := rastrillo.NewRef() |
| + a.logger.Error("slides: index: no current user past the session gate", "ref", ref) |
| + ErrorPage(w, r, http.StatusInternalServerError, ref) |
| return |
| } |
| person := u.Name |
diff --git a/internal/slidestest/harness_test.go b/internal/slidestest/harness_test.go| index b2721a3..b74a492 100644 |
| --- a/internal/slidestest/harness_test.go |
| +++ b/internal/slidestest/harness_test.go |
| @@ -180,6 +180,25 @@ func post(t *testing.T, h http.Handler, target string, form url.Values, cookies |
| return rec |
| } |
| +// postLegacy is post without the Sec-Fetch-Site header, with origin as |
| +// the only same-origin evidence. |
| +// |
| +// It exists because post sends both, and csrf.Protect checks |
| +// Sec-Fetch-Site FIRST and never consults Origin at all when it is |
| +// present — so every ordinary test here leaves the app's configured |
| +// Origin entirely unexercised, and would pass with a wrong one. This is |
| +// the shape a non-browser client (or an old browser) sends, and the |
| +// only shape that puts the app's own resolved origin under test. |
| +func postLegacy(t *testing.T, h http.Handler, target, origin string, form url.Values) *httptest.ResponseRecorder { |
| + t.Helper() |
| + req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode())) |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + req.Header.Set("Origin", origin) |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, req) |
| + return rec |
| +} |
| + |
| // signedIn runs the whole magic-link flow for address against the fake |
| // mailer — POST /signin, read the link out of the captured message, |
| // GET it — and returns the cookies the verify step set. Every later |
diff --git a/internal/slidestest/signin_test.go b/internal/slidestest/signin_test.go| index 58c79dd..dbe5ccb 100644 |
| --- a/internal/slidestest/signin_test.go |
| +++ b/internal/slidestest/signin_test.go |
| @@ -87,6 +87,88 @@ func TestSigninMailsAllowedAddressOnly(t *testing.T) { |
| } |
| } |
| +// TestSigninAnswerIsIdenticalOverTheRateLimitBudget is the sharp end of |
| +// the no-oracle guarantee, and the case the first version of gateBegin |
| +// got wrong. |
| +// |
| +// A denied address is short-circuited before rastrillo/auth's Begin, so |
| +// it can answer ?sent=1 forever. A permitted address reaches Begin, |
| +// which enforces a per-address budget of 5 in a rolling window and |
| +// redirects to ?err=rate once it is spent. So without the fix, six |
| +// POSTs of one candidate address separate "on the list" from "not on |
| +// it" — the whole allowlist, enumerable by anyone, one address at a |
| +// time. |
| +// |
| +// Seven POSTs, every one byte-identical to what a denied address gets. |
| +// The mailer stops at 5, which is the other half of the proof: the |
| +// answers are the same because the failure is hidden, not because the |
| +// budget quietly stopped being enforced. |
| +func TestSigninAnswerIsIdenticalOverTheRateLimitBudget(t *testing.T) { |
| + app := newApp(t) |
| + |
| + denied := post(t, app, "/signin", url.Values{"address": {deniedAddress}}) |
| + if denied.Code != http.StatusSeeOther { |
| + t.Fatalf("POST /signin (denied): status %d, want %d", denied.Code, http.StatusSeeOther) |
| + } |
| + wantLoc := denied.Header().Get("Location") |
| + wantBody := denied.Body.String() |
| + if wantLoc != "/signin?sent=1" { |
| + t.Fatalf("POST /signin (denied): Location = %q, want %q", wantLoc, "/signin?sent=1") |
| + } |
| + |
| + const attempts = 7 |
| + for i := 1; i <= attempts; i++ { |
| + rec := post(t, app, "/signin", url.Values{"address": {allowedAddress}}) |
| + if rec.Code != denied.Code { |
| + t.Errorf("POST /signin (allowed, attempt %d): status %d, want %d — identical to a denied address", i, rec.Code, denied.Code) |
| + } |
| + if got := rec.Header().Get("Location"); got != wantLoc { |
| + t.Errorf("POST /signin (allowed, attempt %d): Location = %q, want %q — identical to a denied address", i, got, wantLoc) |
| + } |
| + if got := rec.Body.String(); got != wantBody { |
| + t.Errorf("POST /signin (allowed, attempt %d): body %q, want %q — identical to a denied address", i, got, wantBody) |
| + } |
| + } |
| + |
| + // signin's per-address budget is 5; the two attempts past it are |
| + // the ones whose ?err=rate the guard rewrote. |
| + if n := len(app.mailer.messages()); n != 5 { |
| + t.Errorf("mailer captured %d messages over %d attempts, want the 5 the per-address budget allows", n, attempts) |
| + } |
| +} |
| + |
| +// TestSigninAcceptsALegacyOriginOnlyPost covers the path a |
| +// Sec-Fetch-Site header hides: csrf.Protect checks that header first |
| +// when a browser sends it, and only falls back to comparing Origin |
| +// against the app's configured origin when it is absent entirely — a |
| +// non-browser client, or an old one. The post helper sends both, so |
| +// without this test the Origin half of the app's config would never be |
| +// exercised at all, and a wrong resolved origin would pass the suite. |
| +// |
| +// It is also the one test that observes the RESOLVED origin from |
| +// outside the package: the app was configured with testOrigin, and this |
| +// is what proves that string is what csrf.Protect is holding requests |
| +// to. |
| +func TestSigninAcceptsALegacyOriginOnlyPost(t *testing.T) { |
| + app := newApp(t) |
| + |
| + ok := postLegacy(t, app, "/signin", testOrigin, url.Values{"address": {allowedAddress}}) |
| + if ok.Code != http.StatusSeeOther { |
| + t.Errorf("POST /signin with Origin %q and no Sec-Fetch-Site: status %d, want %d", testOrigin, ok.Code, http.StatusSeeOther) |
| + } |
| + if n := len(app.mailer.messages()); n != 1 { |
| + t.Errorf("a same-origin legacy POST mailed %d links, want 1", n) |
| + } |
| + |
| + refused := postLegacy(t, app, "/signin", "http://evil.test", url.Values{"address": {allowedAddress}}) |
| + if refused.Code == http.StatusSeeOther { |
| + t.Errorf("POST /signin with Origin %q and no Sec-Fetch-Site: status %d, want a refusal", "http://evil.test", refused.Code) |
| + } |
| + if n := len(app.mailer.messages()); n != 1 { |
| + t.Errorf("a cross-origin POST mailed a link; mailer has %d messages, want the 1 from the same-origin post", n) |
| + } |
| +} |
| + |
| // TestVerifyMintsSessionAndUserRow: following the emailed link both |
| // establishes a usable session and creates this app's own person row — |
| // the address-to-id mapping every later owner-scoped query needs, since |