8 Commits

Author SHA1 Message Date
5bc97545f4 feat(user): magic-link expired-token cleanup loop (ADR-0028 Phase A consequence)
Periodically delete magic-link tokens past their expires_at — the rows
accumulate without cleanup since the consume endpoint only marks them
consumed but never removes them, and DeleteExpiredMagicLinkTokens (added
in Phase A.3, PR #61) was never wired up.

Mirrors the JWT secret cleanup pattern (ADR-0021).

- pkg/user/magic_link_cleanup.go: MagicLinkCleanupRunner with
  StartCleanupLoop(ctx, interval) and an inner runOnce(ctx) for testability
- pkg/user/magic_link_cleanup_test.go: unit tests cover happy path,
  error propagation, ctx-cancel termination, zero-interval no-op
- pkg/server/server.go: start the loop right after the JWT cleanup loop
- pkg/config/config.go: auth.magic_link.cleanup_interval (default 1h),
  env DLC_AUTH_MAGIC_LINK_CLEANUP_INTERVAL, getter GetMagicLinkCleanupInterval

Mostly authored by Mistral Vibe in batch1-task-b worktree (parallel with
batch1-task-a OIDC config). Mistral hit --max-price 1.50 right before the
final commit step, Claude finished the ship via trainer takeover (Q-045
pattern, 2nd recurrence in two batches — to be addressed in Phase 1bis).
2026-05-05 13:05:15 +02:00
9072b3e246 feat(bdd): magic-link BDD scenarios + bcrypt overflow fix (ADR-0028 Phase A.5) (#63)
All checks were successful
CI/CD Pipeline / Build Docker Cache (push) Successful in 9s
CI/CD Pipeline / CI Pipeline (push) Successful in 5m0s
CI/CD Pipeline / Trigger Docker Push (push) Successful in 5s
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-05 11:44:41 +02:00
f39acf5de5 feat(auth): magic-link request + consume HTTP handlers (ADR-0028 Phase A.4) (#62)
All checks were successful
CI/CD Pipeline / Build Docker Cache (push) Successful in 8s
CI/CD Pipeline / CI Pipeline (push) Successful in 4m56s
CI/CD Pipeline / Trigger Docker Push (push) Successful in 6s
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-05 11:32:12 +02:00
c9ab876dfe feat(user): magic_link_tokens table + repository (ADR-0028 Phase A.3) (#61)
All checks were successful
CI/CD Pipeline / Build Docker Cache (push) Successful in 8s
CI/CD Pipeline / CI Pipeline (push) Successful in 5m11s
CI/CD Pipeline / Trigger Docker Push (push) Successful in 6s
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-05 11:24:06 +02:00
b3027d2669 feat(bdd): pkg/bdd/mailpit/ HTTP client + integration tests (ADR-0030 Phase A.2) (#60)
All checks were successful
CI/CD Pipeline / Build Docker Cache (push) Successful in 11s
CI/CD Pipeline / CI Pipeline (push) Successful in 5m23s
CI/CD Pipeline / Trigger Docker Push (push) Successful in 5s
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-05 10:51:33 +02:00
ef32e750ed feat(email): pkg/email + Mailpit docker-compose service (ADR-0029 Phase A.1) (#59)
All checks were successful
CI/CD Pipeline / Build Docker Cache (push) Successful in 13s
CI/CD Pipeline / CI Pipeline (push) Successful in 4m3s
CI/CD Pipeline / Trigger Docker Push (push) Successful in 4s
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-05 10:47:03 +02:00
235cc41f68 📝 docs(adr): ADR-0028/0029/0030 — passwordless auth + Mailpit + BDD email strategy (#58)
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-05 10:42:35 +02:00
3b4b40c1cf 🐛 fix(bdd): shouldEnableV2 wrongly matched ~@v2 as @v2 substring + new gate regression scenario (#57)
Some checks failed
CI/CD Pipeline / Build Docker Cache (push) Successful in 14s
CI/CD Pipeline / CI Pipeline (push) Failing after 6m31s
CI/CD Pipeline / Trigger Docker Push (push) Has been skipped
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-05 10:38:08 +02:00
27 changed files with 2784 additions and 15 deletions

View File

@@ -0,0 +1,147 @@
# 28. Passwordless authentication: magic link → OpenID Connect
**Date:** 2026-05-05
**Status:** Proposed
**Authors:** Gabriel Radureau, AI Agent
## Context and Problem Statement
ADR-0018 (now Implemented) shipped a username + password authentication system with bcrypt hashing, JWT tokens, admin master password, and admin-assisted password reset. It works, but it carries the cost-of-passwords : we store password hashes, support password reset flows, and maintain a credential-rotation policy. Users hate passwords ; ops and security pay for them.
Two industry-standard alternatives exist :
1. **Magic link by email** — user enters their email, receives a one-time token in a clickable link, link consumes the token and issues a session JWT. No password stored.
2. **OpenID Connect Authorization Code flow** — delegate authentication to an external Identity Provider (e.g. Authelia, Keycloak, Auth0, Google) ; our app receives an `id_token` after the OIDC dance.
We want to **migrate to passwordless** for new sign-ups while keeping the existing username/password code path operational during the transition (no flag-day breakage). The two passwordless mechanisms above complement each other : magic link is simpler for first-party users on day 1 ; OIDC is the right answer for second-party users (other ARCODANGE products, partner integrations) and for admin SSO.
A third constraint : ARCODANGE local development must use HTTPS for OAuth callbacks to be valid (most OIDC providers reject `http://localhost` redirect URIs in their default config). `mkcert` is the canonical local-CA tool for this.
## Decision Drivers
* **Reduce password-related attack surface** — no hash storage, no breach-and-reuse risk, no password reset abuse vectors
* **User experience** — passwordless is faster for the user (1 click in email vs typing/remembering password)
* **Operational simplicity** — no password reset flow to maintain ; the password-reset code can be removed once migration is complete
* **Multi-product readiness** — OIDC is the prerequisite for cross-product SSO across the ARCODANGE portfolio
* **Backwards compatibility** — must not break existing tokens or BDD scenarios mid-migration
* **Local dev parity** — HTTPS in dev so OAuth flows can be tested locally without provider-specific workarounds
## Considered Options
### Option 1 (Chosen): Sequenced — magic link first, OIDC second
Deliver in two phases :
* **Phase A — Magic link**
- Add `POST /api/v1/auth/magic-link/request` (body: `{email}`) — generates token, stores it (TTL ~15 min), sends email via SMTP
- Add `GET /api/v1/auth/magic-link/consume?token=<...>` — single-use consumption, issues a JWT, returns it as cookie + JSON body
- Reuse the existing JWT issuance + secret retention infrastructure (ADR-0021)
- Existing `/api/v1/auth/login` (username/password) stays operational during transition
* **Phase B — OpenID Connect Authorization Code with PKCE**
- Add `GET /api/v1/auth/oidc/start` — generates state + PKCE verifier, redirects to provider's `authorization_endpoint`
- Add `GET /api/v1/auth/oidc/callback` — exchanges code for tokens, validates `id_token` signature against provider's JWKS, issues internal JWT
- Provider URL configurable per environment (`auth.oidc.issuer_url`, `auth.oidc.client_id`, `auth.oidc.client_secret`)
- Allow multiple providers in config (key by provider name, e.g. `arcodange-sso`)
- Local dev requires HTTPS — `mkcert` setup documented in `documentation/DEV_SETUP.md`
* **Phase C (later, separate ADR) — Decommission password auth**
- Once all users have migrated, remove the password endpoints, remove the password_hash column, mark ADR-0018 as Superseded by this ADR
### Option 2: All-at-once OIDC, no magic link
Skip magic link, jump straight to OIDC.
* Good — single migration, no intermediate state
* Bad — requires an OIDC provider operational on day 1, which we don't have configured
* Bad — magic link has zero infra dependencies (just SMTP) ; OIDC requires running an IdP or paying for one
### Option 3: Magic link only, no OIDC
Stop at Phase A.
* Good — simplest implementation
* Bad — doesn't solve cross-product SSO ; we'd re-do this work later for the broader ARCODANGE portfolio
### Option 4: Status quo (do nothing)
Keep username + password.
* Good — zero effort
* Bad — passwords stay forever ; ARCODANGE locks itself out of integration scenarios that expect OIDC
## Decision Outcome
Chosen option : **Option 1, sequenced magic link → OIDC**.
Rationale :
- Magic link is implementable today with zero infra dependencies beyond the email infrastructure (ADR-0029)
- OIDC requires running an IdP locally (Authelia or Keycloak) — that's another container in the dev stack and another ADR's worth of decision work, but the magic-link work is the natural prerequisite (token-by-email plumbing is reused)
- Sequenced delivery means we never have to roll back : Phase A works alone, Phase B layers on top, Phase C cleans up
## Implementation Plan
### Phase A — Magic link (target: 2-3 PRs)
1. **A.1 — Storage** : add a `magic_link_tokens` table (id, email, token_hash, expires_at, consumed_at). Repository pattern alongside `pkg/user/postgres_repository.go`.
2. **A.2 — Token endpoint** : `POST /api/v1/auth/magic-link/request` generates a token, stores it (hashed), enqueues an email send. Rate-limited (cf. ADR-0022) by email address.
3. **A.3 — Consume endpoint** : `GET /api/v1/auth/magic-link/consume?token=...` validates + marks consumed + issues JWT. Returns `Set-Cookie` and `{token: jwt}` body.
4. **A.4 — Sign-up via magic link** : if the email is unknown, the consume endpoint creates the user record. (No separate "sign-up" flow needed — first magic link IS the sign-up.)
5. **A.5 — BDD coverage** : scenarios for happy path, expired token, double-consume, wrong-email, rate-limit. Cf. ADR-0030 for the email assertion strategy.
### Phase B — OIDC Code flow with PKCE (target: 3-4 PRs)
1. **B.1 — Local IdP** : choose Authelia or Keycloak for local development. Add to `docker-compose.yml` with default test configuration.
2. **B.2 — mkcert** : document local HTTPS setup in `documentation/DEV_SETUP.md`, add `make cert` target.
3. **B.3 — OIDC client** : `pkg/auth/oidc.go` — discovery, JWKS cache, code exchange with PKCE.
4. **B.4 — Endpoints** : `/oidc/start` and `/oidc/callback`.
5. **B.5 — Provider config** : `auth.oidc.providers` map in config (cf. ADR-0006 Viper) ; multi-provider supported.
6. **B.6 — BDD coverage** : end-to-end scenarios using a mock OIDC server (or the local Authelia instance with deterministic users).
### Phase C — Decommission password (separate ADR after A+B in production)
Out of scope for this ADR. Will be ADR-NNNN when migration is complete.
## Pros and Cons of the Options
### Option 1 (Chosen — Sequenced)
* Good — incremental, no flag day, each phase shippable on its own
* Good — reuses existing JWT infrastructure (ADR-0021 secret retention)
* Good — magic link work is a prerequisite for OIDC anyway (email plumbing, mkcert)
* Bad — total work spans 2 sprints, longer time-to-OIDC than Option 2
* Mitigation: after Phase A, the team can stop if priorities shift — magic link alone is a complete improvement
### Option 2 (All OIDC)
* Good — single migration
* Bad — requires IdP operational from day 1
* Bad — local dev environment more complex than necessary for the magic link case
### Option 3 (Magic link only)
* Good — minimal scope
* Bad — re-work later for SSO
### Option 4 (Status quo)
* Good — zero effort
* Bad — accumulating tech debt
## Consequences
* `pkg/auth/` package created (currently auth code lives in `pkg/user/`) — separation is now justified by the multi-mechanism scope
* `pkg/user/api/auth_handler.go` continues to serve username/password during transition (Phase A and B), removed in Phase C
* `documentation/DEV_SETUP.md` becomes a load-bearing doc for new contributors (mkcert + docker-compose with mailpit + Authelia)
* The 4 new endpoints (`magic-link/request`, `magic-link/consume`, `oidc/start`, `oidc/callback`) require their own ADR entries in the API doc + Swagger annotations
* Phase A's magic link plumbing depends on **ADR-0029** (email infrastructure decision) — that ADR ships first
* BDD scenarios for Phase A depend on **ADR-0030** (email testing strategy with parallel BDD) — that ADR ships before any Phase A scenario lands
## Links
* Email infrastructure : [ADR-0029](0029-email-infrastructure-mailpit.md)
* BDD email testing strategy : [ADR-0030](0030-bdd-email-parallel-strategy.md)
* Existing user auth (to be partially superseded by Phase C) : [ADR-0018](0018-user-management-auth-system.md)
* JWT secret retention reused : [ADR-0021](0021-jwt-secret-retention-policy.md)
* Rate limiting reused : [ADR-0022](0022-rate-limiting-cache-strategy.md)
* OAuth 2.0 Authorization Code with PKCE : [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636)
* OpenID Connect Core : [OpenID Foundation](https://openid.net/specs/openid-connect-core-1_0.html)

View File

@@ -0,0 +1,142 @@
# 29. Email infrastructure: Mailpit local + production deferred
**Date:** 2026-05-05
**Status:** Proposed
**Authors:** Gabriel Radureau, AI Agent
## Context and Problem Statement
ADR-0028 (passwordless auth) requires the application to send emails — magic-link tokens specifically. Email is a substrate decision : the choice of SMTP provider, the abstraction in code, and the local development experience all depend on it.
Two separate concerns :
1. **Local development + BDD tests** : we need a local SMTP receiver that captures emails and exposes them for inspection. Real email providers (Gmail, SES, SendGrid) are unsuitable for local dev — they cost money, leak test data, and rate-limit aggressively.
2. **Production** : the application needs to actually deliver mail to user inboxes. This decision is deferred — see "Out of scope" below.
ARCODANGE already has the **Mailpit** docker image pulled locally (`axllent/mailpit:latest`, 51 MB). Mailpit captures SMTP submissions on a port, stores them in-memory, exposes them via HTTP UI (default :8025) and an HTTP API (`/api/v1/messages`). It's the de-facto choice for Go projects needing local SMTP capture.
The application code needs to be **provider-agnostic** : a `pkg/email` package with a `Sender` interface, a Mailpit-compatible SMTP implementation, and a contract that production can swap for a real provider's adapter without changing call sites.
## Decision Drivers
* **Local dev and CI must work without internet** — emails should never leave the docker network in tests
* **Test inspection must be programmatic** — BDD tests assert on email content, not just "an email was sent"
* **Production decision deferred** — we don't know the volume / SLA / compliance requirements yet ; over-committing now is premature
* **Provider portability** — `pkg/email` interface lets us swap implementations without touching auth code
* **Cost** — Mailpit is free, runs in a container, no API quota concerns
## Considered Options
### Option 1 (Chosen): Mailpit for local + tests, production via a production-grade provider TBD
* Add Mailpit to `docker-compose.yml` (SMTP :1025, HTTP API :8025)
* `pkg/email` package with a `Sender` interface
* Default implementation : `SMTPSender` configured against the local Mailpit in dev/CI
* Tests query Mailpit's HTTP API to inspect captured messages
* Production deployment will add a separate `pkg/email/<provider>_sender.go` implementing the same interface — that decision is its own ADR
### Option 2: MailHog instead of Mailpit
MailHog is the older, well-known alternative. Mailpit is its modern successor, written in Go, with a richer API and active maintenance.
* Bad — abandoned upstream (last commit 2020). Mailpit is the natural replacement.
### Option 3: In-process mock email sender
Write a `MockSender` that captures emails in a Go slice. No SMTP at all.
* Good — fastest tests, zero infra
* Bad — doesn't validate the actual SMTP wire format, the From/To/Subject headers, the encoding of multi-byte content, or the DKIM/Reply-To setup
* Bad — doesn't double as a manual-inspection tool for the developer (no UI to look at the email)
### Option 4: Send to a real but throwaway provider (Mailtrap, Mailosaur)
External services that capture-and-display emails.
* Good — production-similar paths
* Bad — costs money, requires an account, leaks test data, doesn't work offline
## Decision Outcome
Chosen option : **Option 1 — Mailpit for local + tests, production deferred**.
Rationale :
- Mailpit is the modern, maintained successor to MailHog ; image is already on the dev machine
- The interface-first design (`pkg/email.Sender`) means production swap is a future ADR, not a refactor
- BDD tests have a real wire-format path to assert on (cf. ADR-0030)
- Zero monthly cost in dev/CI
## Implementation Plan
1. **`pkg/email/sender.go`** — define the `Sender` interface :
```go
type Sender interface {
Send(ctx context.Context, msg Message) error
}
type Message struct {
To string
From string
Subject string
BodyText string
BodyHTML string
Headers map[string]string // for trace correlation, e.g. X-Test-Scenario-ID
}
```
2. **`pkg/email/smtp_sender.go`** — implementation using `net/smtp` (stdlib) configured by `auth.email.smtp_host`, `smtp_port`, `smtp_username`, `smtp_password`, `smtp_use_tls`. For Mailpit defaults : `smtp_host=localhost smtp_port=1025 smtp_use_tls=false`.
3. **`pkg/email/sender_test.go`** — unit tests using `httptest`-style fake SMTP, plus a `*_integration_test.go` (build tag `integration`) hitting the live Mailpit.
4. **`docker-compose.yml`** — add the `mailpit` service :
```yaml
mailpit:
image: axllent/mailpit:latest
ports:
- "1025:1025" # SMTP
- "8025:8025" # HTTP UI / API
environment:
MP_MAX_MESSAGES: 5000
```
5. **`pkg/config/config.go`** — add the `auth.email.*` config keys with defaults pointing at local Mailpit.
6. **Documentation** : `documentation/EMAIL.md` covering local setup, message inspection via UI (http://localhost:8025), API queries.
## Pros and Cons of the Options
### Option 1 (Chosen — Mailpit)
* Good — already locally available, free, modern, maintained
* Good — provider-agnostic interface decouples from prod choice
* Good — full SMTP wire format = realistic test path
* Good — UI for manual inspection during dev
* Bad — requires Mailpit running (one more docker-compose service)
* Bad — production decision still pending
### Option 2 (MailHog)
* Bad — unmaintained, choosing it would create immediate tech debt
### Option 3 (Mock only)
* Bad — too much abstraction loss, can't catch wire-level bugs
### Option 4 (Mailtrap / Mailosaur)
* Bad — cost, network dependency, account management
## Consequences
* New service in `docker-compose.yml` — developers run `docker compose up -d` once and Mailpit is on
* New `pkg/email` package — auth code (ADR-0028 magic link) calls `Sender.Send()` rather than direct SMTP
* New `auth.email.*` config keys, new env vars (`DLC_AUTH_EMAIL_SMTP_HOST` etc.)
* Mailpit's HTTP API becomes part of the BDD test contract — tests use it to assert messages were sent (cf. ADR-0030)
* Production sender ADR (TBD) will be a separate decision — this ADR explicitly does NOT pick a vendor for prod
## Out of scope
* **Production email provider selection** — separate ADR when we know volume / SLA / compliance constraints. Likely candidates: AWS SES, Postmark, SendGrid, Mailjet. Magic-link emails are transactional + low-volume — most providers handle that easily.
* **DKIM/SPF/DMARC setup** — production deliverability concern, not a local-dev concern
* **HTML email templating** — we'll start with plain-text emails ; HTML can be added with a template package (e.g. `html/template`) when ARCODANGE branding requires it
## Links
* Auth migration that requires this : [ADR-0028](0028-passwordless-auth-migration.md)
* BDD test strategy that consumes Mailpit : [ADR-0030](0030-bdd-email-parallel-strategy.md)
* Mailpit homepage : https://mailpit.axllent.org/
* Mailpit API reference : https://mailpit.axllent.org/docs/api-v1/

View File

@@ -0,0 +1,187 @@
# 30. BDD email assertions with parallel test execution
**Date:** 2026-05-05
**Status:** Proposed
**Authors:** Gabriel Radureau, AI Agent
## Context and Problem Statement
ADR-0028 introduces magic-link auth, which requires the application to send emails. ADR-0029 chose **Mailpit** as the local SMTP receiver for dev and BDD tests. The remaining decision : **how do BDD scenarios assert on the email content while running in parallel ?**
Today (since [PR #35](https://gitea.arcodange.lab/arcodange/dance-lessons-coach/pulls/35)), the BDD suite runs in parallel via per-package PostgreSQL schema isolation (cf. [ADR-0025](0025-bdd-scenario-isolation-strategies.md)). Each Go test package has its own schema ; tests inside a package run serially within that schema. This works because Postgres has named schemas with strong isolation. **Mailpit has no equivalent** — there is one inbox per Mailpit instance, shared across all senders.
A naive integration would have parallel scenarios fight over each other's emails :
- Scenario A : "request magic link for `test@example.com`" → email arrives
- Scenario B (in parallel) : "request magic link for `test@example.com`" → email arrives
- Both scenarios query Mailpit for `test@example.com` — they see each other's messages, assertions become flaky.
We need a way to scope each scenario's emails so it only sees its own messages.
## Decision Drivers
* **No regression on parallelism** — BDD-isolation Phase 3 (PR #35) achieved a 2.85x speedup ; the email-assertion solution must not undo that
* **No new container per test** — running one Mailpit per scenario would defeat the simplicity that made us choose Mailpit
* **Determinism** — a scenario's email assertions must succeed regardless of how many other scenarios are running
* **Realistic SMTP path** — we still want the full SMTP wire format exercised (cf. ADR-0029) ; we don't want to bypass Mailpit
* **Cleanup hygiene** — old messages from previous test runs must not leak into a new run
## Considered Options
### Option 1 (Chosen): Per-test recipient scoping with deterministic addresses
Each BDD scenario generates a unique email address for its test user, derived from the scenario key + a random suffix. Examples :
- Scenario `magic-link-happy-path``magic-link-happy-path-<8hex>@bdd.local`
- Scenario `magic-link-expired-token``magic-link-expired-token-<8hex>@bdd.local`
The application code accepts any email format. The BDD scenario asserts on Mailpit's HTTP API filtering by the `to` address. Two parallel scenarios with different addresses can NEVER see each other's emails.
**Cleanup** : at the start of each scenario, the BDD framework calls `DELETE /api/v1/search?query=to:<scenario-address>` on Mailpit to purge any leftover messages from prior runs.
### Option 2: One Mailpit instance per Go test package
Spawn a fresh Mailpit container in `TestMain` of each `features/<area>/` package. Each gets its own port range.
* Good — strong isolation
* Bad — heavyweight (one container per package = 5+ containers running)
* Bad — port allocation complexity (similar to existing `pkg/bdd/parallel/port_manager.go`, but applied to Mailpit)
* Bad — slow startup (Mailpit boot is ~200ms but adds up)
### Option 3: One Mailpit instance, scenario-scoped via custom SMTP header
Add a custom header `X-BDD-Scenario-ID: <key>` to outgoing emails. Tests query Mailpit filtered on that header.
* Good — same single Mailpit
* Bad — requires the application code to know the scenario ID at email-send time, which means a test-only path in production code
* Bad — header propagation is fragile (gets stripped by some SMTP relays — not Mailpit, but real production providers might) ; we don't want a different code path between dev and prod
### Option 4: Sequence parallel scenarios via per-scenario Mailpit lock
Use a mutex / queue so no two scenarios that send email run concurrently.
* Good — minimal code change
* Bad — gives up the parallel speedup for any feature that involves email — that's most auth-related features going forward
## Decision Outcome
Chosen option : **Option 1 — per-test recipient scoping**.
Rationale :
- Recipient scoping is the simplest abstraction : the address IS the identity ; Mailpit's HTTP API natively supports filtering by recipient
- Application code stays clean : it just sends to whatever address it's given. No test-mode branching.
- Parallel-safe by construction : two scenarios cannot collide if they don't share an address
- Cheap to implement : a few helper functions in `pkg/bdd/steps/email_steps.go` and a `mailpit.Client` package wrapping the HTTP API
- Cleanup is per-scenario, not global — no "delete all messages" race between scenarios
## Implementation Plan
### Helper package : `pkg/bdd/mailpit/client.go`
```go
type Client struct {
BaseURL string // default: http://localhost:8025
HTTP *http.Client
}
// AwaitMessageTo polls Mailpit's HTTP API for a message addressed
// to the given recipient, with a deadline. Returns the most recent
// matching message or an error on timeout.
func (c *Client) AwaitMessageTo(ctx context.Context, to string, timeout time.Duration) (*Message, error)
// PurgeMessagesTo removes all messages addressed to the given
// recipient. Idempotent and parallel-safe.
func (c *Client) PurgeMessagesTo(ctx context.Context, to string) error
type Message struct {
ID string
From string
To []string
Subject string
Text string
HTML string
Headers map[string][]string
}
```
### Helper steps : `pkg/bdd/steps/email_steps.go`
```go
func (s *EmailSteps) iHaveAnEmailAddressForThisScenario() error
// Generates `<scenario-key>-<8hex>@bdd.local`, stores it in the scenario state.
func (s *EmailSteps) iShouldReceiveAnEmailWithSubject(subject string) error
// Polls AwaitMessageTo on the scenario's address, asserts subject equality.
func (s *EmailSteps) theEmailShouldContain(snippet string) error
// Re-fetches the most recent message and checks for substring in body.
func (s *EmailSteps) theEmailContainsAMagicLinkToken() (string, error)
// Extracts the token from the magic-link URL via regex, returns it.
```
### Scenario lifecycle
- **Before each scenario** : `iHaveAnEmailAddressForThisScenario` is called (either explicitly via Background, or implicitly via a hook). The unique address is stored in the scenario's state. PurgeMessagesTo is called to clear any leftovers from prior runs of the same address (defensive — should be impossible since the suffix is random, but cheap).
- **During the scenario** : the application sends to that address. Tests query for it.
- **After each scenario** : no global cleanup needed — addresses are per-scenario unique, so they don't accumulate beyond Mailpit's `MP_MAX_MESSAGES=5000` cap.
### Race-free deletion
Mailpit's `DELETE /api/v1/search?query=to:<addr>` is atomic per recipient. Two concurrent scenarios with different addresses cannot interfere.
### Sample scenario (auth-magic-link.feature)
```gherkin
@critical @magic-link
Scenario: User receives a magic link by email
Given I have an email address for this scenario
When I request a magic link for my email address
Then I should receive an email with subject "Your magic link"
And the email contains a magic link token
When I consume the magic link token
Then I should receive a JWT
```
## Pros and Cons of the Options
### Option 1 (Chosen)
* Good — parallel-safe by construction
* Good — application code unchanged ; test-only logic stays in the BDD layer
* Good — Mailpit API supports the filter natively
* Good — cleanup is fine-grained, no race
* Bad — requires cooperative scenarios (each must request a unique address)
* Mitigation : Background steps in feature files make it automatic
### Option 2 (Mailpit per package)
* Bad — operational complexity not justified for the test-only concern
### Option 3 (Custom header scoping)
* Bad — production code dirtied by test concerns
### Option 4 (Lock-and-sequence)
* Bad — gives up parallelism (the whole point of PR #35 + ADR-0025)
## Consequences
* `pkg/bdd/mailpit/` package is created with HTTP client + helper types
* `pkg/bdd/steps/email_steps.go` package is created and registered in `steps.go`
* `features/auth/` and any other email-using features have new BDD steps available
* The local development docker-compose must run Mailpit before BDD tests run — to be added to the BDD test runner script `scripts/run-bdd-tests.sh`
* Mailpit message TTL is governed by `MP_MAX_MESSAGES` (5000) — at parallel BDD volumes, that's enough headroom for ~50 scenarios × 100 messages each before any pruning kicks in
## Out of scope
* **Visual regression on email rendering** — text body assertions only ; HTML rendering checks belong in a separate Storybook-style harness
* **Attachment handling** — magic-link emails are text-only ; ADRs for attachments will come if/when needed
* **Email volume / rate-limit testing** — that's a load-test concern, not a BDD concern
## Links
* Auth migration depending on this : [ADR-0028](0028-passwordless-auth-migration.md)
* Email infrastructure choice : [ADR-0029](0029-email-infrastructure-mailpit.md)
* BDD parallelism foundation : [ADR-0025](0025-bdd-scenario-isolation-strategies.md), [PR #35](https://gitea.arcodange.lab/arcodange/dance-lessons-coach/pulls/35)
* Mailpit API : https://mailpit.axllent.org/docs/api-v1/

View File

@@ -31,6 +31,9 @@ This directory contains the Architecture Decision Records (ADRs) for the dance-l
| [0025](0025-bdd-scenario-isolation-strategies.md) | BDD Scenario Isolation Strategies | Implemented |
| [0026](0026-composite-info-endpoint.md) | Composite Info Endpoint vs Separate Calls | Implemented |
| [0027](0027-ollama-tier1-onboarding.md) | Ollama Tier 1 onboarding via meta-trainer-bootstrap | Proposed |
| [0028](0028-passwordless-auth-migration.md) | Passwordless authentication: magic link → OpenID Connect | Proposed |
| [0029](0029-email-infrastructure-mailpit.md) | Email infrastructure: Mailpit local + production deferred | Proposed |
| [0030](0030-bdd-email-parallel-strategy.md) | BDD email assertions with parallel test execution | Proposed |
> **Note** : numbers `0011` and `0014` are not currently in use. Reserved for future ADRs or representing previously deleted entries.

View File

@@ -19,6 +19,23 @@ services:
- dance-lessons-coach-network
restart: unless-stopped
# Mailpit — local SMTP capture for dev + BDD parallel email tests.
# Cf. ADR-0029 (email infrastructure) and ADR-0030 (BDD parallel strategy).
# SMTP submission on :1025 (used by the app), HTTP UI + API on :8025
# (used by tests + manual inspection at http://localhost:8025).
mailpit:
image: axllent/mailpit:latest
container_name: dance-lessons-coach-mailpit
ports:
- "1025:1025" # SMTP submission
- "8025:8025" # HTTP UI / API
environment:
MP_MAX_MESSAGES: 5000
MP_SMTP_AUTH_ALLOW_INSECURE: 1 # local dev only - no TLS, no real auth
networks:
- dance-lessons-coach-network
restart: unless-stopped
# Application service (for reference)
# app:
# build: .

107
documentation/EMAIL.md Normal file
View File

@@ -0,0 +1,107 @@
# Email infrastructure
Outgoing email transport. Per [ADR-0029](../adr/0029-email-infrastructure-mailpit.md): Mailpit for local dev + BDD tests, production sender deferred.
## Local setup (one-time)
Mailpit is part of `docker-compose.yml`:
```bash
docker compose up -d # starts postgres + mailpit
docker compose ps # confirm both running
```
Mailpit listens on:
- **SMTP submission** — `localhost:1025` (the app sends here)
- **HTTP UI / API** — http://localhost:8025 (you inspect captured messages here)
No real emails leave the docker network. No internet required.
## Application configuration
The application's outgoing transport is configured under `auth.email.*` in `config.yaml` (or via `DLC_AUTH_EMAIL_*` env vars). Defaults already match local Mailpit:
```yaml
auth:
email:
from: noreply@dance-lessons-coach.local
smtp_host: localhost
smtp_port: 1025
smtp_use_tls: false
timeout: 10s
# smtp_username + smtp_password left empty for local Mailpit
```
For production, override these to point at the chosen provider (SES, Postmark, etc.).
## Inspecting messages
### Web UI
http://localhost:8025 — list of all captured messages, search, raw view, HTML preview.
### HTTP API (for automation)
```bash
# Latest 10 messages (no filter — /api/v1/messages is for pagination)
curl -s 'http://localhost:8025/api/v1/messages?limit=10' | jq
# Messages for a specific recipient — use /api/v1/search, NOT /messages
# (the latter's `query` param is for pagination only, not filtering ;
# verified empirically 2026-05-05)
curl -s 'http://localhost:8025/api/v1/search?query=to:test-user@bdd.local' | jq
# Get a specific message by ID (full content, headers, attachments)
curl -s 'http://localhost:8025/api/v1/message/<id>' | jq
# Purge messages for a recipient (used in test cleanup) — also via /search
curl -X DELETE 'http://localhost:8025/api/v1/search?query=to:test-user@bdd.local'
```
Full API: https://mailpit.axllent.org/docs/api-v1/
## Sending email from Go code
```go
import "dance-lessons-coach/pkg/email"
sender := email.NewSMTPSender(email.SMTPConfig{
Host: cfg.GetEmailConfig().SMTPHost,
Port: cfg.GetEmailConfig().SMTPPort,
// username/password optional — empty means no AUTH (Mailpit local)
})
err := sender.Send(ctx, email.Message{
To: "alice@example.com",
From: cfg.GetEmailConfig().From,
Subject: "Your magic link",
BodyText: "Click: https://example.com/magic-link/consume?token=...",
Headers: map[string]string{
// optional — useful for BDD test correlation
"X-Trace-Id": "req-abc-123",
},
})
```
Or, when both text and HTML are needed (`multipart/alternative`):
```go
err := sender.Send(ctx, email.Message{
To: "alice@example.com", From: "...", Subject: "...",
BodyText: "Click: https://...",
BodyHTML: `<p>Click <a href="https://...">your magic link</a></p>`,
})
```
## Production sender (TBD)
Not chosen yet. When ready, implement another `email.Sender` in
`pkg/email/<provider>_sender.go` and wire it via the config. The
`Sender` interface is the swap point — call sites don't change.
## Cross-references
- [ADR-0028 — Passwordless auth migration](../adr/0028-passwordless-auth-migration.md) (consumes this infrastructure)
- [ADR-0029 — Email infrastructure decision](../adr/0029-email-infrastructure-mailpit.md)
- [ADR-0030 — BDD email parallel strategy](../adr/0030-bdd-email-parallel-strategy.md)
- [Mailpit docs](https://mailpit.axllent.org/docs/)

View File

@@ -0,0 +1,34 @@
@magic-link
Feature: Passwordless magic-link sign-in
As a user without a password
I want to sign in by clicking a link sent to my email
So I can access the system without typing a password
Scenario: Happy path - request, receive, consume
Given the server is running
And I have an email address for this scenario
When I request a magic link for my email
Then I should receive an email with subject "Your sign-in link"
And the email contains a magic link token
When I consume the magic link token
Then the consume should succeed and return a JWT
Scenario: Token cannot be consumed twice
Given the server is running
And I have an email address for this scenario
When I request a magic link for my email
And the email contains a magic link token
When I consume the magic link token
Then the consume should succeed and return a JWT
When I consume the magic link token
Then the consume should fail with 401
Scenario: Missing token returns 400
Given the server is running
When I consume an empty magic link token
Then the response should have status 400
Scenario: Unknown token returns 401
Given the server is running
When I consume an unknown magic link token
Then the consume should fail with 401

View File

@@ -15,6 +15,16 @@ Feature: Greet Service
When I request a greeting for "John"
Then the response should be "{\"message\":\"Hello John!\"}"
@critical @v2-gate
Scenario: v2 endpoint returns 404 when api.v2_enabled is disabled
# In the default tag-filter run (~@v2), the test server starts with
# v2_enabled=false. The v2EnabledGate middleware (ADR-0023 Phase 4)
# returns 404 with a JSON body explaining the flag state.
Given the server is running
When I send a POST request to v2 greet with name "John"
Then the status code should be 404
And the response should contain "v2 API is currently disabled"
@v2 @api
Scenario: v2 greeting with JSON POST request
Given the server is running with v2 enabled

180
pkg/bdd/mailpit/client.go Normal file
View File

@@ -0,0 +1,180 @@
// Package mailpit is a thin client for the local Mailpit HTTP API,
// used by BDD scenarios to assert on emails sent during a test.
//
// Per ADR-0030 (BDD email parallel strategy), each scenario uses a
// unique recipient address so parallel scenarios cannot interfere.
// The client exposes per-recipient query + delete + await operations.
//
// Production code MUST NOT depend on this package. It lives under
// pkg/bdd/ specifically to signal "test-only".
package mailpit
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
// DefaultBaseURL is the local Mailpit HTTP API root used by the
// docker-compose service (cf. ADR-0029).
const DefaultBaseURL = "http://localhost:8025"
// Client is a Mailpit HTTP API client. Safe for concurrent use.
type Client struct {
BaseURL string
HTTP *http.Client
}
// NewClient returns a Client pointing at the local Mailpit. The HTTP
// client has a 5-second per-call timeout to fail fast in test setups
// where Mailpit is down.
func NewClient() *Client {
return &Client{
BaseURL: DefaultBaseURL,
HTTP: &http.Client{Timeout: 5 * time.Second},
}
}
// Message is the metadata + body view returned by the Mailpit detail
// endpoint. Fields are a subset of what Mailpit returns — only what
// BDD scenarios need to assert on.
type Message struct {
ID string `json:"ID"`
From Address `json:"From"`
To []Address `json:"To"`
Subject string `json:"Subject"`
Text string `json:"Text"`
HTML string `json:"HTML"`
Date time.Time `json:"Date"`
Headers map[string]interface{} `json:"-"` // populated only via the Headers() helper
}
// Address is a Mailpit-formatted email address.
type Address struct {
Name string `json:"Name"`
Address string `json:"Address"`
}
// listResponse is the shape of GET /api/v1/messages.
type listResponse struct {
Messages []messageSummary `json:"messages"`
Total int `json:"total"`
}
type messageSummary struct {
ID string `json:"ID"`
Subject string `json:"Subject"`
Created time.Time `json:"Created"`
}
// MessagesTo returns the list of message IDs currently in Mailpit
// addressed to the given recipient. Empty slice + nil error means
// "no messages yet".
func (c *Client) MessagesTo(ctx context.Context, to string) ([]string, error) {
// Mailpit's /api/v1/search supports the to:<addr> filter ; the more
// obvious-looking /api/v1/messages does NOT (the `query` param there
// is for pagination, not filtering — verified empirically 2026-05-05).
u := fmt.Sprintf("%s/api/v1/search?query=%s",
strings.TrimRight(c.BaseURL, "/"),
url.QueryEscape("to:"+to))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("mailpit list: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("mailpit list: HTTP %d", resp.StatusCode)
}
var list listResponse
if err := json.NewDecoder(resp.Body).Decode(&list); err != nil {
return nil, fmt.Errorf("mailpit list decode: %w", err)
}
ids := make([]string, 0, len(list.Messages))
for _, m := range list.Messages {
ids = append(ids, m.ID)
}
return ids, nil
}
// Get fetches the full content of the message with the given ID.
func (c *Client) Get(ctx context.Context, id string) (*Message, error) {
u := fmt.Sprintf("%s/api/v1/message/%s",
strings.TrimRight(c.BaseURL, "/"),
url.PathEscape(id))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("mailpit get: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("mailpit get %s: HTTP %d", id, resp.StatusCode)
}
var m Message
if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
return nil, fmt.Errorf("mailpit get decode: %w", err)
}
return &m, nil
}
// AwaitMessageTo polls Mailpit for a message addressed to the given
// recipient. Returns the most recent matching message ; errors out if
// the timeout elapses with no match. Polls every 50ms — Mailpit is
// fast enough that this is rarely the limiting factor.
//
// Use this in BDD steps "Then I should receive an email ...".
func (c *Client) AwaitMessageTo(ctx context.Context, to string, timeout time.Duration) (*Message, error) {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
ids, err := c.MessagesTo(ctx, to)
if err == nil && len(ids) > 0 {
// Most recent first per Mailpit's default sort
return c.Get(ctx, ids[0])
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(50 * time.Millisecond):
}
}
return nil, fmt.Errorf("mailpit: no message for %s within %s", to, timeout)
}
// PurgeMessagesTo deletes every message addressed to the given recipient.
// Idempotent: calling against an empty inbox is fine.
//
// Use this at the start of a BDD scenario to clear leftovers from
// prior runs of the same scenario (rare given the random suffix per
// scenario, but defensive).
func (c *Client) PurgeMessagesTo(ctx context.Context, to string) error {
// Mailpit's /api/v1/search supports the to:<addr> filter ; the more
// obvious-looking /api/v1/messages does NOT (the `query` param there
// is for pagination, not filtering — verified empirically 2026-05-05).
u := fmt.Sprintf("%s/api/v1/search?query=%s",
strings.TrimRight(c.BaseURL, "/"),
url.QueryEscape("to:"+to))
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u, nil)
if err != nil {
return err
}
resp, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("mailpit delete: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
return fmt.Errorf("mailpit delete: HTTP %d", resp.StatusCode)
}
return nil
}

View File

@@ -0,0 +1,133 @@
//go:build integration
// Integration tests for the Mailpit client. Run with:
//
// go test -tags integration ./pkg/bdd/mailpit/...
//
// Requires a running Mailpit reachable at http://localhost:8025
// (the docker-compose service from ADR-0029).
package mailpit
import (
"context"
"crypto/rand"
"encoding/hex"
"net/smtp"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// uniqueRecipient returns an address unique to this test run, using the
// per-scenario-recipient pattern from ADR-0030. Two parallel test runs
// generate different suffixes so they never see each other's messages.
func uniqueRecipient(t *testing.T) string {
t.Helper()
var raw [4]byte
_, err := rand.Read(raw[:])
require.NoError(t, err)
return "integ-" + t.Name() + "-" + hex.EncodeToString(raw[:]) + "@bdd.local"
}
// sendViaSMTP submits a small email through Mailpit's SMTP port.
// Real-wire-format path : same as the application code will use.
func sendViaSMTP(t *testing.T, to, subject, body string) {
t.Helper()
from := "integ-test@bdd.local"
msg := []byte(
"From: " + from + "\r\n" +
"To: " + to + "\r\n" +
"Subject: " + subject + "\r\n" +
"\r\n" +
body + "\r\n",
)
err := smtp.SendMail("localhost:1025", nil, from, []string{to}, msg)
require.NoError(t, err, "SMTP send to local Mailpit")
}
// TestIntegration_RoundTrip validates the full path : SMTP submit →
// Mailpit captures → client lists → client gets full body. This is
// the smoke test for the BDD-helper contract.
func TestIntegration_RoundTrip(t *testing.T) {
c := NewClient()
to := uniqueRecipient(t)
// Defensive cleanup before the test (in case the recipient was reused)
require.NoError(t, c.PurgeMessagesTo(context.Background(), to))
subject := "Integration roundtrip"
body := "Token: integ-token-" + strings.ReplaceAll(to, "@", "-at-")
sendViaSMTP(t, to, subject, body)
msg, err := c.AwaitMessageTo(context.Background(), to, 3*time.Second)
require.NoError(t, err)
require.NotNil(t, msg)
assert.Equal(t, subject, msg.Subject)
assert.Contains(t, msg.Text, "Token: integ-token-")
if assert.Len(t, msg.To, 1) {
assert.Equal(t, to, msg.To[0].Address)
}
// Cleanup so subsequent runs of this same test name don't accumulate
require.NoError(t, c.PurgeMessagesTo(context.Background(), to))
}
// TestIntegration_AwaitTimeoutWhenNoMessage confirms AwaitMessageTo
// returns an error within the timeout when no message arrives.
func TestIntegration_AwaitTimeoutWhenNoMessage(t *testing.T) {
c := NewClient()
to := uniqueRecipient(t) // never sent to → must time out
start := time.Now()
_, err := c.AwaitMessageTo(context.Background(), to, 200*time.Millisecond)
elapsed := time.Since(start)
require.Error(t, err)
assert.Contains(t, err.Error(), "no message")
assert.GreaterOrEqual(t, elapsed, 150*time.Millisecond, "should poll until close to timeout")
assert.Less(t, elapsed, 1*time.Second, "should not exceed timeout substantially")
}
// TestIntegration_PurgeIsolation proves the per-recipient query/delete
// model from ADR-0030 : two unique recipients can have their own
// messages without one's purge affecting the other.
func TestIntegration_PurgeIsolation(t *testing.T) {
c := NewClient()
// Build two distinct, well-formed addresses (separate local-parts,
// same domain). Avoid mutating uniqueRecipient's output post-@.
var rawA, rawB [4]byte
_, _ = rand.Read(rawA[:])
_, _ = rand.Read(rawB[:])
toA := "iso-a-" + hex.EncodeToString(rawA[:]) + "@bdd.local"
toB := "iso-b-" + hex.EncodeToString(rawB[:]) + "@bdd.local"
sendViaSMTP(t, toA, "for A", "body A")
sendViaSMTP(t, toB, "for B", "body B")
// Both messages should exist
idsA, err := c.MessagesTo(context.Background(), toA)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(idsA), 1, "A should have its message")
idsB, err := c.MessagesTo(context.Background(), toB)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(idsB), 1, "B should have its message")
// Purge A only
require.NoError(t, c.PurgeMessagesTo(context.Background(), toA))
// A is empty, B is untouched
idsA, err = c.MessagesTo(context.Background(), toA)
require.NoError(t, err)
assert.Empty(t, idsA, "A should be empty after purge")
idsB, err = c.MessagesTo(context.Background(), toB)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(idsB), 1, "B should still have its message")
// Cleanup B
require.NoError(t, c.PurgeMessagesTo(context.Background(), toB))
}

View File

@@ -0,0 +1,145 @@
package steps
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"dance-lessons-coach/pkg/bdd/mailpit"
"dance-lessons-coach/pkg/bdd/testserver"
)
type MagicLinkSteps struct {
client *testserver.Client
mailpit *mailpit.Client
scenarioKey string
}
func NewMagicLinkSteps(client *testserver.Client) *MagicLinkSteps {
return &MagicLinkSteps{client: client, mailpit: mailpit.NewClient()}
}
func (s *MagicLinkSteps) SetScenarioKey(key string) { s.scenarioKey = key }
func (s *MagicLinkSteps) state() *ScenarioState {
if s.scenarioKey == "" {
s.scenarioKey = "default"
}
return GetScenarioState(s.scenarioKey)
}
// sanitizeForEmail keeps only [a-z0-9-] from the scenario key
func sanitizeForEmail(s string) string {
if s == "" {
return "scn"
}
var b strings.Builder
for _, r := range strings.ToLower(s) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
b.WriteRune(r)
}
}
if b.Len() == 0 {
return "scn"
}
if b.Len() > 24 {
return b.String()[:24]
}
return b.String()
}
// IHaveAnEmailAddressForThisScenario generates per-scenario unique recipient and stashes it in state.
// Defensively purges Mailpit for that address.
// Format: <scenario-key>-<8hex>@bdd.local (cf. ADR-0030)
func (s *MagicLinkSteps) IHaveAnEmailAddressForThisScenario() error {
var raw [4]byte
if _, err := rand.Read(raw[:]); err != nil {
return err
}
addr := fmt.Sprintf("ml-%s-%s@bdd.local",
sanitizeForEmail(s.scenarioKey), hex.EncodeToString(raw[:]))
s.state().MagicLinkEmail = addr
return s.mailpit.PurgeMessagesTo(context.Background(), addr)
}
// IRequestAMagicLinkForMyEmail POSTs to /api/v1/auth/magic-link/request with the scenario's email.
func (s *MagicLinkSteps) IRequestAMagicLinkForMyEmail() error {
return s.client.Request("POST", "/api/v1/auth/magic-link/request",
map[string]string{"email": s.state().MagicLinkEmail})
}
// IShouldReceiveAnEmailWithSubject waits for an email at the scenario's address; asserts subject equality.
func (s *MagicLinkSteps) IShouldReceiveAnEmailWithSubject(subject string) error {
msg, err := s.mailpit.AwaitMessageTo(context.Background(), s.state().MagicLinkEmail, 5*time.Second)
if err != nil {
return fmt.Errorf("mailpit await: %w", err)
}
if msg.Subject != subject {
return fmt.Errorf("expected subject %q, got %q", subject, msg.Subject)
}
return nil
}
// TheEmailContainsAMagicLinkToken re-fetches most recent message, extracts token via regex, stashes in state.
var tokenRe = regexp.MustCompile(`\?token=([A-Za-z0-9_\-]+)`)
func (s *MagicLinkSteps) TheEmailContainsAMagicLinkToken() error {
msg, err := s.mailpit.AwaitMessageTo(context.Background(), s.state().MagicLinkEmail, 5*time.Second)
if err != nil {
return err
}
m := tokenRe.FindStringSubmatch(msg.Text)
if m == nil {
return fmt.Errorf("no token in email body: %q", msg.Text)
}
s.state().MagicLinkToken = m[1]
return nil
}
// IConsumeTheMagicLinkToken GETs /api/v1/auth/magic-link/consume?token=<plain>
func (s *MagicLinkSteps) IConsumeTheMagicLinkToken() error {
return s.client.Request("GET",
"/api/v1/auth/magic-link/consume?token="+s.state().MagicLinkToken, nil)
}
// TheConsumeShouldSucceedAndReturnAJWT asserts 200 + JWT body.
func (s *MagicLinkSteps) TheConsumeShouldSucceedAndReturnAJWT() error {
if c := s.client.GetLastStatusCode(); c != http.StatusOK {
return fmt.Errorf("expected 200, got %d body=%s", c, s.client.GetLastBody())
}
var resp struct {
Token string `json:"token"`
}
if err := json.Unmarshal(s.client.GetLastBody(), &resp); err != nil {
return err
}
if resp.Token == "" {
return fmt.Errorf("empty JWT in response")
}
s.state().LastToken = resp.Token
return nil
}
// TheConsumeShouldFailWith401 asserts 401.
func (s *MagicLinkSteps) TheConsumeShouldFailWith401() error {
if c := s.client.GetLastStatusCode(); c != http.StatusUnauthorized {
return fmt.Errorf("expected 401, got %d body=%s", c, s.client.GetLastBody())
}
return nil
}
// IConsumeAnEmptyMagicLinkToken consumes with an empty token
func (s *MagicLinkSteps) IConsumeAnEmptyMagicLinkToken() error {
return s.client.Request("GET", "/api/v1/auth/magic-link/consume?token=", nil)
}
// IConsumeAnUnknownMagicLinkToken consumes with a non-existent token
func (s *MagicLinkSteps) IConsumeAnUnknownMagicLinkToken() error {
return s.client.Request("GET", "/api/v1/auth/magic-link/consume?token=unknown-token-12345", nil)
}

View File

@@ -9,11 +9,13 @@ import (
// ScenarioState holds per-scenario state for step definitions
// This prevents state pollution between scenarios running in the same test process
type ScenarioState struct {
LastToken string
FirstToken string
LastUserID uint
LastSecret string
LastError string
LastToken string
FirstToken string
LastUserID uint
LastSecret string
LastError string
MagicLinkEmail string
MagicLinkToken string
// Add more fields as needed for other step types
}

View File

@@ -17,6 +17,7 @@ type StepContext struct {
jwtRetentionSteps *JWTRetentionSteps
configSteps *ConfigSteps
rateLimitSteps *RateLimitSteps
magicLinkSteps *MagicLinkSteps
}
// NewStepContext creates a new step context
@@ -30,6 +31,7 @@ func NewStepContext(client *testserver.Client) *StepContext {
jwtRetentionSteps: NewJWTRetentionSteps(client),
configSteps: NewConfigSteps(client),
rateLimitSteps: NewRateLimitSteps(client),
magicLinkSteps: NewMagicLinkSteps(client),
}
}
@@ -67,6 +69,9 @@ func SetScenarioKeyForAllSteps(sc *StepContext, key string) {
if sc.rateLimitSteps != nil {
sc.rateLimitSteps.SetScenarioKey(key)
}
if sc.magicLinkSteps != nil {
sc.magicLinkSteps.SetScenarioKey(key)
}
}
}
@@ -317,6 +322,17 @@ func InitializeAllSteps(ctx *godog.ScenarioContext, client *testserver.Client, s
ctx.Step(`^the response body should contain "([^"]*)"$`, sc.rateLimitSteps.theResponseBodyShouldContain)
ctx.Step(`^the response should have header "([^"]*)"$`, sc.rateLimitSteps.theResponseShouldHaveHeader)
// Magic link steps
ctx.Step(`^I have an email address for this scenario$`, sc.magicLinkSteps.IHaveAnEmailAddressForThisScenario)
ctx.Step(`^I request a magic link for my email$`, sc.magicLinkSteps.IRequestAMagicLinkForMyEmail)
ctx.Step(`^I should receive an email with subject "([^"]*)"$`, sc.magicLinkSteps.IShouldReceiveAnEmailWithSubject)
ctx.Step(`^the email contains a magic link token$`, sc.magicLinkSteps.TheEmailContainsAMagicLinkToken)
ctx.Step(`^I consume the magic link token$`, sc.magicLinkSteps.IConsumeTheMagicLinkToken)
ctx.Step(`^the consume should succeed and return a JWT$`, sc.magicLinkSteps.TheConsumeShouldSucceedAndReturnAJWT)
ctx.Step(`^the consume should fail with 401$`, sc.magicLinkSteps.TheConsumeShouldFailWith401)
ctx.Step(`^I consume an empty magic link token$`, sc.magicLinkSteps.IConsumeAnEmptyMagicLinkToken)
ctx.Step(`^I consume an unknown magic link token$`, sc.magicLinkSteps.IConsumeAnUnknownMagicLinkToken)
// Common steps
ctx.Step(`^the response should be "{\\"([^"]*)":\\"([^"]*)"}"$`, sc.commonSteps.theResponseShouldBe)
ctx.Step(`^the response should contain error "([^"]*)"$`, sc.commonSteps.theResponseShouldContainError)

View File

@@ -741,8 +741,14 @@ func (s *Server) waitForServerReady() error {
}
}
// shouldEnableV2 determines if v2 API should be enabled for this test server
// This is the ONLY place that reads FEATURE and GODOG_TAGS env vars
// shouldEnableV2 determines if v2 API should be enabled for this test server.
// This is the ONLY place that reads FEATURE and GODOG_TAGS env vars.
//
// 2026-05-05: previous version used strings.Contains(tags, "@v2") which
// wrongly matched the negation `~@v2` as well. This made the "v1" greet
// sub-test (tags `~@v2 && ~@skip`) actually run with v2 enabled, masking
// the gate behavior we now test in feature `@v2-gate` scenario. Fixed
// here by inspecting each && clause and checking for positive inclusion.
func (s *Server) shouldEnableV2() bool {
feature := os.Getenv("FEATURE")
@@ -753,9 +759,19 @@ func (s *Server) shouldEnableV2() bool {
return false
}
// For greet feature: enable v2 if tags include @v2
// For greet feature: enable v2 if tags include `@v2` as a POSITIVE clause.
// Godog tag expression syntax: clauses separated by `&&` or `||`, negation
// via leading `~`. A positive clause matches exactly `@v2` (after trim).
tags := os.Getenv("GODOG_TAGS")
return strings.Contains(tags, "@v2")
for _, clause := range strings.FieldsFunc(tags, func(r rune) bool {
return r == '&' || r == '|' || r == ' '
}) {
clause = strings.TrimSpace(clause)
if clause == "@v2" {
return true
}
}
return false
}
// createTestConfig creates a test configuration
@@ -799,6 +815,20 @@ func createTestConfig(port int, v2Enabled bool) *config.Config {
JWT: config.JWTConfig{
TTL: 24 * time.Hour,
},
// Email + MagicLink defaults so the magic-link BDD scenarios
// (ADR-0028 Phase A.5) can send to local Mailpit. Without these
// the literal Config skips Viper's SetDefault and From stays
// empty — pkg/email then rejects the message.
Email: config.EmailConfig{
From: "noreply@bdd.local",
SMTPHost: "localhost",
SMTPPort: 1025,
Timeout: 5 * time.Second,
},
MagicLink: config.MagicLinkConfig{
TTL: 15 * time.Minute,
BaseURL: "http://localhost:8080",
},
},
API: config.APIConfig{
V2Enabled: v2Enabled,

View File

@@ -104,9 +104,30 @@ type APIConfig struct {
// AuthConfig holds authentication configuration
type AuthConfig struct {
JWTSecret string `mapstructure:"jwt_secret"`
AdminMasterPassword string `mapstructure:"admin_master_password"`
JWT JWTConfig `mapstructure:"jwt"`
JWTSecret string `mapstructure:"jwt_secret"`
AdminMasterPassword string `mapstructure:"admin_master_password"`
JWT JWTConfig `mapstructure:"jwt"`
Email EmailConfig `mapstructure:"email"`
MagicLink MagicLinkConfig `mapstructure:"magic_link"`
}
// MagicLinkConfig holds passwordless-auth magic-link parameters (ADR-0028 Phase A).
type MagicLinkConfig struct {
TTL time.Duration `mapstructure:"ttl"`
BaseURL string `mapstructure:"base_url"`
CleanupInterval time.Duration `mapstructure:"cleanup_interval"`
}
// EmailConfig holds outgoing email transport configuration.
// Defaults match local Mailpit (cf. ADR-0029) so dev needs no extra setup.
type EmailConfig struct {
From string `mapstructure:"from"`
SMTPHost string `mapstructure:"smtp_host"`
SMTPPort int `mapstructure:"smtp_port"`
SMTPUsername string `mapstructure:"smtp_username"`
SMTPPassword string `mapstructure:"smtp_password"`
SMTPUseTLS bool `mapstructure:"smtp_use_tls"`
Timeout time.Duration `mapstructure:"timeout"`
}
// JWTConfig holds JWT-specific configuration
@@ -256,6 +277,18 @@ func LoadConfig() (*Config, error) {
v.SetDefault("auth.jwt.secret_retention.max_retention", 72*time.Hour)
v.SetDefault("auth.jwt.secret_retention.cleanup_interval", 1*time.Hour)
// Email defaults — match local Mailpit (ADR-0029).
v.SetDefault("auth.email.from", "noreply@dance-lessons-coach.local")
v.SetDefault("auth.email.smtp_host", "localhost")
v.SetDefault("auth.email.smtp_port", 1025)
v.SetDefault("auth.email.smtp_use_tls", false)
v.SetDefault("auth.email.timeout", 10*time.Second)
// Magic-link defaults (ADR-0028 Phase A).
v.SetDefault("auth.magic_link.ttl", 15*time.Minute)
v.SetDefault("auth.magic_link.base_url", "http://localhost:8080")
v.SetDefault("auth.magic_link.cleanup_interval", 1*time.Hour)
// Check for custom config file path via environment variable
if configFile := os.Getenv("DLC_CONFIG_FILE"); configFile != "" {
v.SetConfigFile(configFile)
@@ -301,6 +334,18 @@ func LoadConfig() (*Config, error) {
v.BindEnv("auth.jwt.secret_retention.retention_factor", "DLC_AUTH_JWT_SECRET_RETENTION_FACTOR")
v.BindEnv("auth.jwt.secret_retention.max_retention", "DLC_AUTH_JWT_SECRET_MAX_RETENTION")
v.BindEnv("auth.jwt.secret_retention.cleanup_interval", "DLC_AUTH_JWT_SECRET_CLEANUP_INTERVAL")
v.BindEnv("auth.email.from", "DLC_AUTH_EMAIL_FROM")
v.BindEnv("auth.email.smtp_host", "DLC_AUTH_EMAIL_SMTP_HOST")
v.BindEnv("auth.email.smtp_port", "DLC_AUTH_EMAIL_SMTP_PORT")
v.BindEnv("auth.email.smtp_username", "DLC_AUTH_EMAIL_SMTP_USERNAME")
v.BindEnv("auth.email.smtp_password", "DLC_AUTH_EMAIL_SMTP_PASSWORD")
v.BindEnv("auth.email.smtp_use_tls", "DLC_AUTH_EMAIL_SMTP_USE_TLS")
v.BindEnv("auth.email.timeout", "DLC_AUTH_EMAIL_TIMEOUT")
// Magic-link environment variables (ADR-0028 Phase A).
v.BindEnv("auth.magic_link.ttl", "DLC_AUTH_MAGIC_LINK_TTL")
v.BindEnv("auth.magic_link.base_url", "DLC_AUTH_MAGIC_LINK_BASE_URL")
v.BindEnv("auth.magic_link.cleanup_interval", "DLC_AUTH_MAGIC_LINK_CLEANUP_INTERVAL")
v.BindEnv("telemetry.sampler.type", "DLC_TELEMETRY_SAMPLER_TYPE")
v.BindEnv("telemetry.sampler.ratio", "DLC_TELEMETRY_SAMPLER_RATIO")
@@ -432,6 +477,34 @@ func (c *Config) GetAdminMasterPassword() string {
return c.Auth.AdminMasterPassword
}
// GetEmailConfig returns the outgoing email transport configuration.
// Defaults match local Mailpit (localhost:1025, no TLS, no auth) per
// ADR-0029. Used by pkg/email.NewSMTPSender.
func (c *Config) GetEmailConfig() EmailConfig {
return c.Auth.Email
}
// GetMagicLinkConfig returns the passwordless-auth magic-link parameters
// (ADR-0028 Phase A). TTL defaults to 15m, BaseURL to http://localhost:8080.
func (c *Config) GetMagicLinkConfig() MagicLinkConfig {
out := c.Auth.MagicLink
if out.TTL <= 0 {
out.TTL = 15 * time.Minute
}
if out.BaseURL == "" {
out.BaseURL = "http://localhost:8080"
}
return out
}
// GetMagicLinkCleanupInterval returns the magic-link cleanup interval (ADR-0028 Phase A consequence).
func (c *Config) GetMagicLinkCleanupInterval() time.Duration {
if c.Auth.MagicLink.CleanupInterval <= 0 {
return 1 * time.Hour
}
return c.Auth.MagicLink.CleanupInterval
}
// GetJWTTTL returns the JWT TTL
func (c *Config) GetJWTTTL() time.Duration {
if c.Auth.JWT.TTL == 0 {

45
pkg/email/sender.go Normal file
View File

@@ -0,0 +1,45 @@
// Package email provides the abstraction over outgoing email transport.
//
// ADR-0029 picked Mailpit for local dev and BDD ; production sender is
// deferred. The Sender interface is the swap point : a future production
// adapter (AWS SES, Postmark, SendGrid) implements the same contract
// without touching call sites.
package email
import "context"
// Sender sends email messages. Implementations must be safe for
// concurrent use — multiple goroutines may call Send simultaneously.
type Sender interface {
Send(ctx context.Context, msg Message) error
}
// Message is the wire-level representation of an outgoing email.
// Headers is for trace correlation (e.g. X-Test-Scenario-ID for BDD)
// and arbitrary application-specific tags. Implementations include
// these as RFC 5322 header fields.
type Message struct {
// To is the recipient address (single recipient ; we don't currently
// support multi-recipient broadcasts — keeps the contract simple
// and matches the magic-link use case which is always 1:1).
To string
// From is the sender address. Required.
From string
// Subject is the RFC 5322 Subject. Required for non-empty body.
Subject string
// BodyText is the plain-text body. At least one of BodyText or
// BodyHTML must be non-empty.
BodyText string
// BodyHTML is the optional HTML body. When both are set, the
// SMTP-level message is multipart/alternative.
BodyHTML string
// Headers are extra RFC 5322 header fields. Keys are case-insensitive ;
// implementations canonicalise via textproto.CanonicalMIMEHeaderKey.
// Useful for BDD test correlation (X-BDD-Scenario, etc.).
Headers map[string]string
}

155
pkg/email/smtp_sender.go Normal file
View File

@@ -0,0 +1,155 @@
package email
import (
"context"
"errors"
"fmt"
"net/smtp"
"net/textproto"
"strings"
"time"
)
// SMTPConfig configures an SMTPSender. Defaults match local Mailpit
// (host=localhost, port=1025, no TLS, no auth).
type SMTPConfig struct {
Host string
Port int
Username string // empty means no AUTH
Password string // empty means no AUTH
UseTLS bool // STARTTLS — false for Mailpit local
// Timeout bounds Send to avoid hanging forever on a stuck server.
// Defaults to 10s when zero.
Timeout time.Duration
}
// SMTPSender sends mail through an SMTP server. Configured by SMTPConfig
// with sensible Mailpit-friendly defaults.
type SMTPSender struct {
cfg SMTPConfig
}
// NewSMTPSender returns a Sender backed by SMTP. The SMTPConfig is copied —
// mutating the caller's struct after this call has no effect.
func NewSMTPSender(cfg SMTPConfig) *SMTPSender {
if cfg.Host == "" {
cfg.Host = "localhost"
}
if cfg.Port == 0 {
cfg.Port = 1025
}
if cfg.Timeout == 0 {
cfg.Timeout = 10 * time.Second
}
return &SMTPSender{cfg: cfg}
}
// Send delivers the message via SMTP. Returns an error if the message is
// invalid (missing required fields), if connecting / authenticating fails,
// or if the SMTP server rejects the envelope or data.
func (s *SMTPSender) Send(ctx context.Context, msg Message) error {
if err := validateMessage(msg); err != nil {
return err
}
addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port)
body := buildRFC5322(msg)
var auth smtp.Auth
if s.cfg.Username != "" {
auth = smtp.PlainAuth("", s.cfg.Username, s.cfg.Password, s.cfg.Host)
}
// Run the SMTP exchange in a goroutine so we can honour the
// context's cancellation independently of net/smtp's own timeouts.
done := make(chan error, 1)
go func() {
done <- smtp.SendMail(addr, auth, msg.From, []string{msg.To}, body)
}()
select {
case err := <-done:
if err != nil {
return fmt.Errorf("smtp send to %s: %w", msg.To, err)
}
return nil
case <-ctx.Done():
return ctx.Err()
case <-time.After(s.cfg.Timeout):
return errors.New("smtp send: timeout")
}
}
// validateMessage checks the minimum required fields for an outgoing email.
func validateMessage(msg Message) error {
if msg.To == "" {
return errors.New("email: To is required")
}
if msg.From == "" {
return errors.New("email: From is required")
}
if msg.BodyText == "" && msg.BodyHTML == "" {
return errors.New("email: at least one of BodyText or BodyHTML is required")
}
return nil
}
// buildRFC5322 builds an RFC 5322 message body from Message. Plain-text
// only when BodyHTML is empty ; multipart/alternative when both are set.
//
// Keep in mind: this is the body sent to net/smtp.SendMail, which adds
// no headers itself. We add the canonical From, To, Subject, MIME-Version,
// Content-Type, and any caller-provided custom headers.
func buildRFC5322(msg Message) []byte {
var b strings.Builder
// Custom headers first so they show up early in the message.
for k, v := range msg.Headers {
// Normalise header name case (Foo-Bar, not foo-BAR)
k = textproto.CanonicalMIMEHeaderKey(k)
fmt.Fprintf(&b, "%s: %s\r\n", k, v)
}
fmt.Fprintf(&b, "From: %s\r\n", msg.From)
fmt.Fprintf(&b, "To: %s\r\n", msg.To)
fmt.Fprintf(&b, "Subject: %s\r\n", msg.Subject)
fmt.Fprintf(&b, "MIME-Version: 1.0\r\n")
if msg.BodyHTML == "" {
// Plain text only
fmt.Fprintf(&b, "Content-Type: text/plain; charset=utf-8\r\n")
fmt.Fprintf(&b, "\r\n")
b.WriteString(msg.BodyText)
return []byte(b.String())
}
// multipart/alternative — boundary is deterministic for tests but unique
// enough not to collide with body content. We don't put random bytes in
// the boundary because the alphabetical "alt-" prefix is recognisably
// ours and the rest is timestamped.
boundary := fmt.Sprintf("alt-%d", time.Now().UnixNano())
fmt.Fprintf(&b, "Content-Type: multipart/alternative; boundary=%q\r\n", boundary)
fmt.Fprintf(&b, "\r\n")
// Plain part (always first — clients that only render text/plain see
// it ; clients preferring HTML go to the HTML part)
fmt.Fprintf(&b, "--%s\r\n", boundary)
fmt.Fprintf(&b, "Content-Type: text/plain; charset=utf-8\r\n\r\n")
if msg.BodyText != "" {
b.WriteString(msg.BodyText)
} else {
b.WriteString("(see HTML version)")
}
fmt.Fprintf(&b, "\r\n")
// HTML part
fmt.Fprintf(&b, "--%s\r\n", boundary)
fmt.Fprintf(&b, "Content-Type: text/html; charset=utf-8\r\n\r\n")
b.WriteString(msg.BodyHTML)
fmt.Fprintf(&b, "\r\n")
// Close boundary
fmt.Fprintf(&b, "--%s--\r\n", boundary)
return []byte(b.String())
}

View File

@@ -0,0 +1,123 @@
package email
import (
"context"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestValidateMessage_RejectsMissingFields confirms the contract gate:
// To, From, and at least one body are required.
func TestValidateMessage_RejectsMissingFields(t *testing.T) {
cases := []struct {
name string
msg Message
wantErr string
}{
{"empty To", Message{From: "f@x", BodyText: "b"}, "To is required"},
{"empty From", Message{To: "t@x", BodyText: "b"}, "From is required"},
{"empty body", Message{To: "t@x", From: "f@x"}, "BodyText or BodyHTML"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateMessage(tc.msg)
require.Error(t, err)
assert.Contains(t, err.Error(), tc.wantErr)
})
}
}
// TestValidateMessage_AcceptsMinimal confirms the happy path: To, From,
// and BodyText alone is a valid message.
func TestValidateMessage_AcceptsMinimal(t *testing.T) {
err := validateMessage(Message{To: "t@x", From: "f@x", BodyText: "b"})
assert.NoError(t, err)
}
// TestBuildRFC5322_PlainText verifies that a text-only message produces
// a single-part text/plain RFC 5322 body with the expected headers.
func TestBuildRFC5322_PlainText(t *testing.T) {
msg := Message{
To: "alice@example.com", From: "system@x", Subject: "Hi",
BodyText: "Hello Alice",
}
body := string(buildRFC5322(msg))
assert.Contains(t, body, "To: alice@example.com\r\n")
assert.Contains(t, body, "From: system@x\r\n")
assert.Contains(t, body, "Subject: Hi\r\n")
assert.Contains(t, body, "MIME-Version: 1.0\r\n")
assert.Contains(t, body, "Content-Type: text/plain; charset=utf-8\r\n")
assert.Contains(t, body, "\r\nHello Alice")
assert.NotContains(t, body, "multipart/alternative", "no HTML => no multipart")
}
// TestBuildRFC5322_Multipart verifies that text + HTML produces a
// multipart/alternative body with both parts and a boundary close.
func TestBuildRFC5322_Multipart(t *testing.T) {
msg := Message{
To: "alice@example.com", From: "system@x", Subject: "Hi",
BodyText: "Plain hello", BodyHTML: "<p>HTML hello</p>",
}
body := string(buildRFC5322(msg))
assert.Contains(t, body, "Content-Type: multipart/alternative;")
assert.Contains(t, body, "Plain hello", "plain part included")
assert.Contains(t, body, "<p>HTML hello</p>", "HTML part included")
// Boundary close marker
assert.True(t, strings.Contains(body, "--alt-") && strings.HasSuffix(strings.TrimRight(body, "\r\n"), "--"),
"multipart message should end with closing boundary")
}
// TestBuildRFC5322_CustomHeaders verifies that user-supplied headers
// appear in the output with canonicalised case.
func TestBuildRFC5322_CustomHeaders(t *testing.T) {
msg := Message{
To: "alice@example.com", From: "system@x", Subject: "Hi",
BodyText: "b",
Headers: map[string]string{
"x-bdd-scenario": "magic-link-happy-path",
"X-Trace-Id": "abc-123",
},
}
body := string(buildRFC5322(msg))
assert.Contains(t, body, "X-Bdd-Scenario: magic-link-happy-path\r\n",
"lowercase key should be canonicalised to title-case")
assert.Contains(t, body, "X-Trace-Id: abc-123\r\n",
"already-canonical key should pass through unchanged")
}
// TestSMTPSender_DefaultsAreMailpitFriendly verifies that NewSMTPSender
// fills in localhost:1025 and a non-zero timeout when the caller passes
// a zero-valued SMTPConfig — which is the recommended default.
func TestSMTPSender_DefaultsAreMailpitFriendly(t *testing.T) {
s := NewSMTPSender(SMTPConfig{})
assert.Equal(t, "localhost", s.cfg.Host)
assert.Equal(t, 1025, s.cfg.Port)
assert.Greater(t, int64(s.cfg.Timeout), int64(0), "timeout must be set, default 10s")
assert.Empty(t, s.cfg.Username, "default no AUTH")
}
// TestSMTPSender_ContextCancelAbortsSend confirms a cancelled ctx
// short-circuits Send rather than waiting for the SMTP timeout.
// We point at a port no SMTP server is listening on so the goroutine
// will hang ; ctx cancel is what wins.
func TestSMTPSender_ContextCancelAbortsSend(t *testing.T) {
s := NewSMTPSender(SMTPConfig{
Host: "127.0.0.1",
Port: 1, // privileged port, definitely no SMTP server here
// keep the default 10s timeout — we want ctx to win
})
ctx, cancel := context.WithCancel(context.Background())
cancel() // pre-cancelled
err := s.Send(ctx, Message{To: "t@x", From: "f@x", BodyText: "b"})
require.Error(t, err)
// The error is ctx.Err() OR the net/smtp connect error if the goroutine
// happened to fail before the ctx select ran. Both are acceptable —
// the only unacceptable outcome is the test taking 10 seconds.
}

View File

@@ -20,6 +20,7 @@ import (
"dance-lessons-coach/pkg/cache"
"dance-lessons-coach/pkg/config"
"dance-lessons-coach/pkg/email"
"dance-lessons-coach/pkg/greet"
"dance-lessons-coach/pkg/middleware"
"dance-lessons-coach/pkg/telemetry"
@@ -252,6 +253,29 @@ func (s *Server) registerApiV1Routes(r chi.Router) {
handler := userapi.NewAuthHandler(s.userService, s.userService, s.validator)
r.Route("/auth", func(r chi.Router) {
handler.RegisterRoutes(r)
// Magic-link routes (ADR-0028 Phase A). Mounted only when the
// userRepo also implements MagicLinkRepository (PostgresRepository does).
if mlRepo, ok := s.userRepo.(user.MagicLinkRepository); ok {
emailCfg := s.config.GetEmailConfig()
sender := email.NewSMTPSender(email.SMTPConfig{
Host: emailCfg.SMTPHost,
Port: emailCfg.SMTPPort,
Username: emailCfg.SMTPUsername,
Password: emailCfg.SMTPPassword,
UseTLS: emailCfg.SMTPUseTLS,
Timeout: emailCfg.Timeout,
})
mlHandler := userapi.NewMagicLinkHandler(
mlRepo,
s.userService,
s.userRepo,
sender,
s.config.GetMagicLinkConfig(),
emailCfg.From,
s.validator,
)
mlHandler.RegisterRoutes(r)
}
})
// Register admin routes
@@ -740,6 +764,12 @@ func (s *Server) Run() error {
s.userService.StartJWTSecretCleanupLoop(rootCtx, s.config.GetJWTSecretCleanupInterval())
}
// Start the magic-link expired-token cleanup loop (ADR-0028 Phase A consequence).
if mlRepo, ok := s.userRepo.(user.MagicLinkRepository); ok {
runner := user.NewMagicLinkCleanupRunner(mlRepo)
runner.StartCleanupLoop(rootCtx, s.config.GetMagicLinkCleanupInterval())
}
// Wire the sampler hot-reload callback (ADR-0023 Phase 3, sub-phase 3.3).
// telemetrySetup is non-nil only when telemetry was successfully initialized
// at startup — hot-reloading telemetry-on is out of scope (see ADR-0023).

View File

@@ -0,0 +1,277 @@
package api
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"dance-lessons-coach/pkg/config"
"dance-lessons-coach/pkg/email"
"dance-lessons-coach/pkg/user"
"dance-lessons-coach/pkg/validation"
"github.com/go-chi/chi/v5"
"github.com/rs/zerolog/log"
)
// MagicLinkHandler exposes the passwordless-auth endpoints described
// in ADR-0028 Phase A : `POST /magic-link/request` and
// `GET /magic-link/consume?token=...`.
type MagicLinkHandler struct {
tokens user.MagicLinkRepository
users user.UserService
repo user.UserRepository // for GetUserByUsername (sign-up flow)
sender email.Sender
cfg config.MagicLinkConfig
emailFrom string
validator *validation.Validator
clock func() time.Time
newPassword func() (string, error)
}
// NewMagicLinkHandler wires the handler. emailFrom must be the From
// address (typically cfg.GetEmailConfig().From).
func NewMagicLinkHandler(
tokens user.MagicLinkRepository,
users user.UserService,
repo user.UserRepository,
sender email.Sender,
cfg config.MagicLinkConfig,
emailFrom string,
validator *validation.Validator,
) *MagicLinkHandler {
return &MagicLinkHandler{
tokens: tokens,
users: users,
repo: repo,
sender: sender,
cfg: cfg,
emailFrom: emailFrom,
validator: validator,
clock: time.Now,
newPassword: func() (string, error) {
// 32 bytes = 256 bits of entropy. Encoded as 64 hex chars
// (well under bcrypt's 72-byte input limit; 48 bytes -> 96
// hex chars overflowed and broke first-link signup).
var raw [32]byte
if _, err := rand.Read(raw[:]); err != nil {
return "", err
}
return hex.EncodeToString(raw[:]), nil
},
}
}
// RegisterRoutes mounts the two endpoints on the provided router.
func (h *MagicLinkHandler) RegisterRoutes(router chi.Router) {
router.Post("/magic-link/request", h.handleRequest)
router.Get("/magic-link/consume", h.handleConsume)
}
// MagicLinkRequest is the body of POST /magic-link/request.
type MagicLinkRequest struct {
Email string `json:"email" validate:"required,email,max=255"`
}
// MagicLinkResponse is the response shape for both endpoints.
type MagicLinkResponse struct {
Message string `json:"message"`
Token string `json:"token,omitempty"`
}
// handleRequest godoc
//
// @Summary Request a magic link
// @Description Generates a passwordless-auth one-time token and emails it. Always 200 to prevent email enumeration.
// @Tags API/v1/User
// @Accept json
// @Produce json
// @Param request body MagicLinkRequest true "Email address"
// @Success 200 {object} MagicLinkResponse "Email queued (or silently dropped)"
// @Failure 400 {object} map[string]string "Invalid request body"
// @Router /v1/auth/magic-link/request [post]
func (h *MagicLinkHandler) handleRequest(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req MagicLinkRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid_request","message":"Invalid JSON request body"}`, http.StatusBadRequest)
return
}
if h.validator != nil {
if err := h.validator.Validate(req); err != nil {
h.writeValidationError(w, err)
return
}
}
addr := strings.ToLower(strings.TrimSpace(req.Email))
plain, hashHex, err := user.GenerateMagicLinkToken()
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("magic link request: rand failed")
http.Error(w, `{"error":"server_error","message":"Failed to generate token"}`, http.StatusInternalServerError)
return
}
now := h.clock()
tok := &user.MagicLinkToken{
Email: addr,
TokenHash: hashHex,
ExpiresAt: now.Add(h.cfg.TTL),
}
if err := h.tokens.CreateMagicLinkToken(ctx, tok); err != nil {
log.Error().Ctx(ctx).Err(err).Str("email", addr).Msg("magic link request: persist failed")
writeJSON(w, http.StatusOK, MagicLinkResponse{Message: "If that email is valid, a link has been sent."})
return
}
link := buildMagicLinkURL(h.cfg.BaseURL, plain)
subject := "Your sign-in link"
bodyText := fmt.Sprintf("Sign in by clicking the link below.\n\n%s\n\nThe link is valid for %s and can only be used once.\nIf you did not request this, ignore this email.\n", link, h.cfg.TTL)
bodyHTML := fmt.Sprintf(`<p>Sign in by clicking the link below.</p><p><a href="%s">%s</a></p><p>The link is valid for %s and can only be used once.<br>If you did not request this, ignore this email.</p>`, link, link, h.cfg.TTL)
msg := email.Message{
From: h.emailFrom,
To: addr,
Subject: subject,
BodyText: bodyText,
BodyHTML: bodyHTML,
}
if err := h.sender.Send(ctx, msg); err != nil {
log.Error().Ctx(ctx).Err(err).Str("to", addr).Msg("magic link request: email send failed")
}
writeJSON(w, http.StatusOK, MagicLinkResponse{Message: "If that email is valid, a link has been sent."})
}
// handleConsume validates the token, marks it consumed, ensures a
// matching User row exists (sign-up on first link), and issues a JWT.
//
// All failure modes (missing, expired, already-consumed) collapse to a
// single 401 to prevent attackers distinguishing them.
//
// @Summary Consume a magic link
// @Description Validates the magic-link token, ensures the user exists (signup-on-first-use), issues a JWT.
// @Tags API/v1/User
// @Produce json
// @Param token query string true "The magic-link token"
// @Success 200 {object} MagicLinkResponse "Signed in"
// @Failure 400 {object} map[string]string "Missing token"
// @Failure 401 {object} map[string]string "Invalid or expired token"
// @Router /v1/auth/magic-link/consume [get]
func (h *MagicLinkHandler) handleConsume(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
plain := strings.TrimSpace(r.URL.Query().Get("token"))
if plain == "" {
writeJSONError(w, http.StatusBadRequest, "invalid_request", "missing token")
return
}
tok, err := h.tokens.GetMagicLinkTokenByHash(ctx, user.HashMagicLinkToken(plain))
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("magic link consume: lookup failed")
writeJSONError(w, http.StatusInternalServerError, "server_error", "lookup failed")
return
}
if tok == nil || tok.ConsumedAt != nil || h.clock().After(tok.ExpiresAt) {
writeJSONError(w, http.StatusUnauthorized, "invalid_token", "magic link is invalid or expired")
return
}
if err := h.tokens.MarkMagicLinkTokenConsumed(ctx, tok.ID, h.clock()); err != nil {
log.Error().Ctx(ctx).Err(err).Uint("id", tok.ID).Msg("magic link consume: mark failed")
writeJSONError(w, http.StatusInternalServerError, "server_error", "consume failed")
return
}
u, err := h.ensureUser(ctx, tok.Email)
if err != nil {
log.Error().Ctx(ctx).Err(err).Str("email", tok.Email).Msg("magic link consume: user upsert failed")
writeJSONError(w, http.StatusInternalServerError, "server_error", "user upsert failed")
return
}
jwt, err := h.users.GenerateJWT(ctx, u)
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("magic link consume: JWT generation failed")
writeJSONError(w, http.StatusInternalServerError, "server_error", "jwt generation failed")
return
}
writeJSON(w, http.StatusOK, MagicLinkResponse{Message: "signed in", Token: jwt})
}
// ensureUser returns the user keyed on email (stored as Username),
// creating them if absent. Newly-created users get a random unguessable
// bcrypt-hashed password so the password endpoints stay locked out.
func (h *MagicLinkHandler) ensureUser(ctx context.Context, email string) (*user.User, error) {
if h.repo != nil {
existing, err := h.repo.GetUserByUsername(ctx, email)
if err != nil {
return nil, err
}
if existing != nil {
return existing, nil
}
}
rawPass, err := h.newPassword()
if err != nil {
return nil, fmt.Errorf("magic link signup rand: %w", err)
}
hash, err := h.users.HashPassword(ctx, rawPass)
if err != nil {
return nil, fmt.Errorf("magic link signup hash: %w", err)
}
u := &user.User{
Username: email,
PasswordHash: hash,
IsAdmin: false,
}
if err := h.users.CreateUser(ctx, u); err != nil {
return nil, fmt.Errorf("magic link signup create: %w", err)
}
if h.repo != nil {
return h.repo.GetUserByUsername(ctx, email)
}
return u, nil
}
func (h *MagicLinkHandler) writeValidationError(w http.ResponseWriter, err error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
var ve *validation.ValidationError
if errors.As(err, &ve) {
_ = json.NewEncoder(w).Encode(map[string]any{
"error": "validation_failed",
"message": "Invalid request data",
"details": ve.Messages,
})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"error": "validation_failed",
"message": err.Error(),
})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeJSONError(w http.ResponseWriter, status int, code, msg string) {
writeJSON(w, status, map[string]string{"error": code, "message": msg})
}
func buildMagicLinkURL(baseURL, token string) string {
base := strings.TrimRight(baseURL, "/")
return fmt.Sprintf("%s/api/v1/auth/magic-link/consume?token=%s", base, token)
}

View File

@@ -0,0 +1,371 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"dance-lessons-coach/pkg/config"
"dance-lessons-coach/pkg/email"
"dance-lessons-coach/pkg/user"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fakeMLRepo is an in-memory MagicLinkRepository for the handler tests.
type fakeMLRepo struct {
mu sync.Mutex
tokens map[string]*user.MagicLinkToken // key: TokenHash
nextID uint
failOn string // "create" / "get" / "mark" / "" (none)
}
func newFakeMLRepo() *fakeMLRepo {
return &fakeMLRepo{tokens: map[string]*user.MagicLinkToken{}}
}
func (r *fakeMLRepo) CreateMagicLinkToken(_ context.Context, t *user.MagicLinkToken) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.failOn == "create" {
return errors.New("simulated create failure")
}
r.nextID++
t.ID = r.nextID
r.tokens[t.TokenHash] = t
return nil
}
func (r *fakeMLRepo) GetMagicLinkTokenByHash(_ context.Context, h string) (*user.MagicLinkToken, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.failOn == "get" {
return nil, errors.New("simulated get failure")
}
t, ok := r.tokens[h]
if !ok {
return nil, nil
}
cp := *t
return &cp, nil
}
func (r *fakeMLRepo) MarkMagicLinkTokenConsumed(_ context.Context, id uint, when time.Time) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.failOn == "mark" {
return errors.New("simulated mark failure")
}
for _, t := range r.tokens {
if t.ID == id {
t.ConsumedAt = &when
return nil
}
}
return errors.New("not found")
}
func (r *fakeMLRepo) DeleteExpiredMagicLinkTokens(_ context.Context, _ time.Time) (int64, error) {
return 0, nil
}
// fakeUserSvc is a minimal user.UserService stub.
type fakeUserSvc struct {
createdUsers []*user.User
jwtForID map[uint]string
hashCalls int
failOn string // "create" / "hash" / "jwt"
}
func newFakeUserSvc() *fakeUserSvc { return &fakeUserSvc{jwtForID: map[uint]string{}} }
func (s *fakeUserSvc) Authenticate(_ context.Context, _, _ string) (*user.User, error) {
return nil, errors.New("not used in magic-link tests")
}
func (s *fakeUserSvc) GenerateJWT(_ context.Context, u *user.User) (string, error) {
if s.failOn == "jwt" {
return "", errors.New("simulated jwt failure")
}
return "jwt-for-user-" + u.Username, nil
}
func (s *fakeUserSvc) ValidateJWT(_ context.Context, _ string) (*user.User, error) {
return nil, errors.New("not used")
}
func (s *fakeUserSvc) AdminAuthenticate(_ context.Context, _ string) (*user.User, error) {
return nil, errors.New("not used")
}
func (s *fakeUserSvc) AddJWTSecret(_ string, _ bool, _ time.Duration) {}
func (s *fakeUserSvc) RotateJWTSecret(_ string) {}
func (s *fakeUserSvc) GetJWTSecretByIndex(_ int) (string, bool) { return "", false }
func (s *fakeUserSvc) ResetJWTSecrets() {}
func (s *fakeUserSvc) StartJWTSecretCleanupLoop(_ context.Context, _ time.Duration) {}
func (s *fakeUserSvc) RemoveExpiredJWTSecrets() int { return 0 }
func (s *fakeUserSvc) ListJWTSecretsInfo() []user.JWTSecretInfo { return nil }
func (s *fakeUserSvc) UserExists(_ context.Context, username string) (bool, error) {
for _, u := range s.createdUsers {
if u.Username == username {
return true, nil
}
}
return false, nil
}
func (s *fakeUserSvc) CreateUser(_ context.Context, u *user.User) error {
if s.failOn == "create" {
return errors.New("simulated create failure")
}
u.ID = uint(len(s.createdUsers) + 1)
cp := *u
s.createdUsers = append(s.createdUsers, &cp)
return nil
}
func (s *fakeUserSvc) HashPassword(_ context.Context, p string) (string, error) {
s.hashCalls++
if s.failOn == "hash" {
return "", errors.New("simulated hash failure")
}
return "hash:" + p, nil
}
func (s *fakeUserSvc) RequestPasswordReset(_ context.Context, _ string) error { return nil }
func (s *fakeUserSvc) CompletePasswordReset(_ context.Context, _, _ string) error {
return nil
}
// fakeUserRepo implements user.UserRepository using fakeUserSvc's slice.
type fakeUserRepo struct{ svc *fakeUserSvc }
func (r *fakeUserRepo) CreateUser(_ context.Context, u *user.User) error {
return r.svc.CreateUser(context.Background(), u)
}
func (r *fakeUserRepo) GetUserByUsername(_ context.Context, name string) (*user.User, error) {
for _, u := range r.svc.createdUsers {
if u.Username == name {
cp := *u
return &cp, nil
}
}
return nil, nil
}
func (r *fakeUserRepo) GetUserByID(_ context.Context, _ uint) (*user.User, error) { return nil, nil }
func (r *fakeUserRepo) UpdateUser(_ context.Context, _ *user.User) error { return nil }
func (r *fakeUserRepo) DeleteUser(_ context.Context, _ uint) error { return nil }
func (r *fakeUserRepo) AllowPasswordReset(_ context.Context, _ string) error { return nil }
func (r *fakeUserRepo) CompletePasswordReset(_ context.Context, _, _ string) error {
return nil
}
func (r *fakeUserRepo) UserExists(_ context.Context, name string) (bool, error) {
return r.svc.UserExists(context.Background(), name)
}
func (r *fakeUserRepo) CheckDatabaseHealth(_ context.Context) error { return nil }
// recordingSender captures email.Send calls without sending anything.
type recordingSender struct {
mu sync.Mutex
messages []email.Message
failNext bool
}
func (s *recordingSender) Send(_ context.Context, m email.Message) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.failNext {
return errors.New("simulated send failure")
}
s.messages = append(s.messages, m)
return nil
}
func newHandler(t *testing.T) (*MagicLinkHandler, *fakeMLRepo, *fakeUserSvc, *recordingSender) {
t.Helper()
mlRepo := newFakeMLRepo()
svc := newFakeUserSvc()
repo := &fakeUserRepo{svc: svc}
sender := &recordingSender{}
h := NewMagicLinkHandler(
mlRepo, svc, repo, sender,
config.MagicLinkConfig{TTL: 15 * time.Minute, BaseURL: "http://test.local"},
"noreply@test.local",
nil,
)
return h, mlRepo, svc, sender
}
func mountAndRequest(h *MagicLinkHandler, method, path, body string) *httptest.ResponseRecorder {
r := chi.NewRouter()
h.RegisterRoutes(r)
req := httptest.NewRequest(method, path, strings.NewReader(body))
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
return rr
}
// TestRequest_HappyPath confirms POST /magic-link/request stores a token,
// sends an email containing the link, and returns 200 with a generic body.
func TestRequest_HappyPath(t *testing.T) {
h, mlRepo, _, sender := newHandler(t)
rr := mountAndRequest(h, http.MethodPost, "/magic-link/request", `{"email":"alice@example.com"}`)
require.Equal(t, http.StatusOK, rr.Code)
assert.Contains(t, rr.Body.String(), "If that email is valid")
// One token persisted, email lower-cased.
require.Len(t, mlRepo.tokens, 1)
for _, tok := range mlRepo.tokens {
assert.Equal(t, "alice@example.com", tok.Email)
assert.Greater(t, tok.ExpiresAt.Unix(), time.Now().Unix())
}
// One email sent to the same address, link points at our test base URL.
require.Len(t, sender.messages, 1)
assert.Equal(t, "alice@example.com", sender.messages[0].To)
assert.Contains(t, sender.messages[0].BodyText, "http://test.local/api/v1/auth/magic-link/consume?token=")
}
// TestRequest_NormalizesEmail confirms the email is lower-cased + trimmed.
func TestRequest_NormalizesEmail(t *testing.T) {
h, mlRepo, _, sender := newHandler(t)
rr := mountAndRequest(h, http.MethodPost, "/magic-link/request", `{"email":" Alice@Example.COM "}`)
require.Equal(t, http.StatusOK, rr.Code)
require.Len(t, mlRepo.tokens, 1)
for _, tok := range mlRepo.tokens {
assert.Equal(t, "alice@example.com", tok.Email)
}
assert.Equal(t, "alice@example.com", sender.messages[0].To)
}
// TestRequest_BadJSON returns 400.
func TestRequest_BadJSON(t *testing.T) {
h, _, _, _ := newHandler(t)
rr := mountAndRequest(h, http.MethodPost, "/magic-link/request", `not json`)
assert.Equal(t, http.StatusBadRequest, rr.Code)
}
// TestRequest_PersistFailureStillReturns200 — a DB error must NOT leak
// to the user (would let attackers detect storage outages).
func TestRequest_PersistFailureStillReturns200(t *testing.T) {
h, mlRepo, _, sender := newHandler(t)
mlRepo.failOn = "create"
rr := mountAndRequest(h, http.MethodPost, "/magic-link/request", `{"email":"bob@example.com"}`)
assert.Equal(t, http.StatusOK, rr.Code)
// No email was sent because no token was persisted.
assert.Empty(t, sender.messages)
}
// TestConsume_HappyPath_NewUser exercises sign-up-on-first-link.
func TestConsume_HappyPath_NewUser(t *testing.T) {
h, mlRepo, svc, _ := newHandler(t)
// Seed one token by going through the request flow.
mountAndRequest(h, http.MethodPost, "/magic-link/request", `{"email":"alice@example.com"}`)
require.Len(t, mlRepo.tokens, 1)
// We need the plaintext to consume — derive it from the only token in the
// repo by reverse trick : the request handler doesn't expose it. So we
// drive consume with a fresh known-plaintext we put into the repo
// directly.
plain, hashHex, err := user.GenerateMagicLinkToken()
require.NoError(t, err)
mlRepo.tokens = map[string]*user.MagicLinkToken{
hashHex: {ID: 99, Email: "alice@example.com", TokenHash: hashHex, ExpiresAt: time.Now().Add(5 * time.Minute)},
}
mlRepo.nextID = 99
rr := mountAndRequest(h, http.MethodGet, "/magic-link/consume?token="+plain, "")
require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
var resp MagicLinkResponse
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
assert.Equal(t, "signed in", resp.Message)
assert.Equal(t, "jwt-for-user-alice@example.com", resp.Token)
// User was created.
require.Len(t, svc.createdUsers, 1)
assert.Equal(t, "alice@example.com", svc.createdUsers[0].Username)
assert.NotEmpty(t, svc.createdUsers[0].PasswordHash, "passwordless user must still have a non-empty hash (random unguessable value)")
assert.Equal(t, 1, svc.hashCalls)
// Token marked consumed.
for _, tok := range mlRepo.tokens {
require.NotNil(t, tok.ConsumedAt, "consumed_at must be set after consume")
}
}
// TestConsume_HappyPath_ExistingUser confirms no new user is created
// when the email is already known.
func TestConsume_HappyPath_ExistingUser(t *testing.T) {
h, mlRepo, svc, _ := newHandler(t)
// Pre-seed the user.
require.NoError(t, svc.CreateUser(context.Background(), &user.User{Username: "carol@example.com", PasswordHash: "x"}))
require.Len(t, svc.createdUsers, 1)
preCount := len(svc.createdUsers)
plain, hashHex, err := user.GenerateMagicLinkToken()
require.NoError(t, err)
mlRepo.tokens[hashHex] = &user.MagicLinkToken{ID: 1, Email: "carol@example.com", TokenHash: hashHex, ExpiresAt: time.Now().Add(5 * time.Minute)}
rr := mountAndRequest(h, http.MethodGet, "/magic-link/consume?token="+plain, "")
require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
// No new user.
assert.Len(t, svc.createdUsers, preCount)
assert.Equal(t, 0, svc.hashCalls, "no hash call when user exists")
}
// TestConsume_MissingToken returns 400.
func TestConsume_MissingToken(t *testing.T) {
h, _, _, _ := newHandler(t)
rr := mountAndRequest(h, http.MethodGet, "/magic-link/consume", "")
assert.Equal(t, http.StatusBadRequest, rr.Code)
}
// TestConsume_UnknownToken returns 401 (single generic shape).
func TestConsume_UnknownToken(t *testing.T) {
h, _, _, _ := newHandler(t)
rr := mountAndRequest(h, http.MethodGet, "/magic-link/consume?token=neverissued", "")
assert.Equal(t, http.StatusUnauthorized, rr.Code)
}
// TestConsume_ExpiredToken returns 401.
func TestConsume_ExpiredToken(t *testing.T) {
h, mlRepo, _, _ := newHandler(t)
plain, hashHex, err := user.GenerateMagicLinkToken()
require.NoError(t, err)
mlRepo.tokens[hashHex] = &user.MagicLinkToken{
ID: 1, Email: "x@example.com", TokenHash: hashHex,
ExpiresAt: time.Now().Add(-1 * time.Minute), // already expired
}
rr := mountAndRequest(h, http.MethodGet, "/magic-link/consume?token="+plain, "")
assert.Equal(t, http.StatusUnauthorized, rr.Code)
}
// TestConsume_AlreadyConsumed returns 401 — single-use guarantee.
func TestConsume_AlreadyConsumed(t *testing.T) {
h, mlRepo, _, _ := newHandler(t)
plain, hashHex, err := user.GenerateMagicLinkToken()
require.NoError(t, err)
now := time.Now()
mlRepo.tokens[hashHex] = &user.MagicLinkToken{
ID: 1, Email: "x@example.com", TokenHash: hashHex,
ExpiresAt: now.Add(5 * time.Minute), ConsumedAt: &now,
}
rr := mountAndRequest(h, http.MethodGet, "/magic-link/consume?token="+plain, "")
assert.Equal(t, http.StatusUnauthorized, rr.Code)
}
// TestBuildMagicLinkURL_TrailingSlash exercises the small helper.
func TestBuildMagicLinkURL_TrailingSlash(t *testing.T) {
got := buildMagicLinkURL("http://x.local/", "abc")
assert.Equal(t, "http://x.local/api/v1/auth/magic-link/consume?token=abc", got)
}

150
pkg/user/magic_link.go Normal file
View File

@@ -0,0 +1,150 @@
package user
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"time"
"go.opentelemetry.io/otel/attribute"
"gorm.io/gorm"
)
// MagicLinkToken is the persistent record of a passwordless-auth token.
//
// Per ADR-0028 Phase A: the token VALUE is never stored. Only its SHA-256
// hash sits in the DB ; if the table leaks, the attacker has no usable
// tokens (mirrors ADR-0021 secret retention via fingerprint approach).
//
// The plaintext token is delivered to the user exactly once via email and
// must be supplied back through the consume endpoint to re-derive the
// hash and find the row.
type MagicLinkToken struct {
ID uint `gorm:"primaryKey"`
CreatedAt time.Time `gorm:"autoCreateTime;not null;index"`
Email string `gorm:"not null;index"`
TokenHash string `gorm:"not null;uniqueIndex;size:64"` // hex-encoded sha256 = 64 chars
ExpiresAt time.Time `gorm:"not null;index"`
ConsumedAt *time.Time `gorm:""`
}
// MagicLinkRepository is the persistence contract for magic-link tokens.
// PostgresRepository implements it ; tests can use a fake.
type MagicLinkRepository interface {
CreateMagicLinkToken(ctx context.Context, token *MagicLinkToken) error
GetMagicLinkTokenByHash(ctx context.Context, tokenHash string) (*MagicLinkToken, error)
MarkMagicLinkTokenConsumed(ctx context.Context, id uint, consumedAt time.Time) error
DeleteExpiredMagicLinkTokens(ctx context.Context, before time.Time) (int64, error)
}
// GenerateMagicLinkToken returns a fresh url-safe random token suitable
// for inclusion in an email link, plus its SHA-256 hex digest for storage.
//
// The plaintext is what gets emailed ; the hash is what gets persisted.
// 32 bytes of entropy = 256 bits ; collision-resistant for our scale.
func GenerateMagicLinkToken() (plaintext, hashHex string, err error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", "", fmt.Errorf("magic link rand: %w", err)
}
plaintext = base64.RawURLEncoding.EncodeToString(buf)
hashHex = HashMagicLinkToken(plaintext)
return plaintext, hashHex, nil
}
// HashMagicLinkToken returns the lowercase hex sha256 of token. Stable
// over time : the same plaintext always maps to the same hash, so
// consume can re-derive and look up the row.
func HashMagicLinkToken(plaintext string) string {
sum := sha256.Sum256([]byte(plaintext))
return hex.EncodeToString(sum[:])
}
// CreateMagicLinkToken persists a magic-link token. The caller is
// responsible for hashing the plaintext (cf. HashMagicLinkToken) and
// setting ExpiresAt ; this method does not generate either.
func (r *PostgresRepository) CreateMagicLinkToken(ctx context.Context, token *MagicLinkToken) error {
ctx, span := r.createSpan(ctx, "create_magic_link_token")
if span != nil {
defer span.End()
span.SetAttributes(attribute.String("email", token.Email))
}
if err := r.db.WithContext(ctx).Create(token).Error; err != nil {
if span != nil {
span.RecordError(err)
}
return fmt.Errorf("failed to create magic link token: %w", err)
}
return nil
}
// GetMagicLinkTokenByHash looks up a magic-link token by its hex sha256.
// Returns (nil, nil) when no row matches — callers must treat that as
// "invalid token" and respond with the same generic error as "expired"
// or "consumed" to avoid leaking which condition failed.
func (r *PostgresRepository) GetMagicLinkTokenByHash(ctx context.Context, tokenHash string) (*MagicLinkToken, error) {
ctx, span := r.createSpan(ctx, "get_magic_link_token_by_hash")
if span != nil {
defer span.End()
}
var t MagicLinkToken
err := r.db.WithContext(ctx).Where("token_hash = ?", tokenHash).First(&t).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if span != nil {
span.RecordError(err)
}
return nil, fmt.Errorf("failed to get magic link token: %w", err)
}
return &t, nil
}
// MarkMagicLinkTokenConsumed sets consumed_at on the row with the given
// ID. Idempotent only at the SQL-engine level — the consume handler is
// responsible for refusing to act when consumed_at is already set.
func (r *PostgresRepository) MarkMagicLinkTokenConsumed(ctx context.Context, id uint, consumedAt time.Time) error {
ctx, span := r.createSpan(ctx, "mark_magic_link_token_consumed")
if span != nil {
defer span.End()
}
res := r.db.WithContext(ctx).
Model(&MagicLinkToken{}).
Where("id = ?", id).
Update("consumed_at", consumedAt)
if res.Error != nil {
if span != nil {
span.RecordError(res.Error)
}
return fmt.Errorf("failed to mark magic link token consumed: %w", res.Error)
}
if res.RowsAffected == 0 {
return fmt.Errorf("no magic link token with id=%d", id)
}
return nil
}
// DeleteExpiredMagicLinkTokens removes rows whose expires_at is strictly
// before the given cutoff. Returns the count deleted. Used by the
// scheduled cleanup job.
func (r *PostgresRepository) DeleteExpiredMagicLinkTokens(ctx context.Context, before time.Time) (int64, error) {
ctx, span := r.createSpan(ctx, "delete_expired_magic_link_tokens")
if span != nil {
defer span.End()
}
res := r.db.WithContext(ctx).
Where("expires_at < ?", before).
Delete(&MagicLinkToken{})
if res.Error != nil {
if span != nil {
span.RecordError(res.Error)
}
return 0, fmt.Errorf("failed to delete expired magic link tokens: %w", res.Error)
}
return res.RowsAffected, nil
}

View File

@@ -0,0 +1,56 @@
package user
import (
"context"
"time"
"github.com/rs/zerolog/log"
)
// MagicLinkCleanupRunner periodically deletes expired magic-link tokens
// (ADR-0028 Phase A consequence — the rows accumulate without cleanup
// otherwise, and stale rows are pure overhead since the token plaintext
// is never stored).
type MagicLinkCleanupRunner struct {
repo MagicLinkRepository
}
// NewMagicLinkCleanupRunner creates a new cleanup runner.
func NewMagicLinkCleanupRunner(repo MagicLinkRepository) *MagicLinkCleanupRunner {
return &MagicLinkCleanupRunner{repo: repo}
}
// StartCleanupLoop runs the cleanup pass every `interval`. Stops when ctx
// is cancelled. interval <= 0 disables the loop.
func (r *MagicLinkCleanupRunner) StartCleanupLoop(ctx context.Context, interval time.Duration) {
if interval <= 0 {
return
}
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
_, _ = r.runOnce(ctx)
}
}
}()
}
// runOnce performs a single cleanup pass. Returns the count of deleted rows.
// Exposed for testing — tests drive runOnce directly instead of waiting on
// the ticker.
func (r *MagicLinkCleanupRunner) runOnce(ctx context.Context) (int64, error) {
n, err := r.repo.DeleteExpiredMagicLinkTokens(ctx, time.Now())
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("magic-link cleanup: delete failed")
return 0, err
}
if n > 0 {
log.Trace().Ctx(ctx).Int64("deleted", n).Msg("magic-link cleanup: removed expired tokens")
}
return n, nil
}

View File

@@ -0,0 +1,64 @@
package user
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type fakeMLRepo struct {
deleteN int64
deleteErr error
cutoffSeen time.Time
}
func (r *fakeMLRepo) CreateMagicLinkToken(_ context.Context, _ *MagicLinkToken) error { return nil }
func (r *fakeMLRepo) GetMagicLinkTokenByHash(_ context.Context, _ string) (*MagicLinkToken, error) {
return nil, nil
}
func (r *fakeMLRepo) MarkMagicLinkTokenConsumed(_ context.Context, _ uint, _ time.Time) error {
return nil
}
func (r *fakeMLRepo) DeleteExpiredMagicLinkTokens(_ context.Context, before time.Time) (int64, error) {
r.cutoffSeen = before
return r.deleteN, r.deleteErr
}
func TestRunOnce_ReturnsCount(t *testing.T) {
repo := &fakeMLRepo{deleteN: 7}
r := NewMagicLinkCleanupRunner(repo)
n, err := r.runOnce(context.Background())
require.NoError(t, err)
assert.EqualValues(t, 7, n)
assert.WithinDuration(t, time.Now(), repo.cutoffSeen, time.Second)
}
func TestRunOnce_PropagatesError(t *testing.T) {
repo := &fakeMLRepo{deleteErr: errors.New("simulated")}
r := NewMagicLinkCleanupRunner(repo)
_, err := r.runOnce(context.Background())
require.Error(t, err)
}
func TestStartCleanupLoop_StopsOnContextCancel(t *testing.T) {
repo := &fakeMLRepo{}
r := NewMagicLinkCleanupRunner(repo)
ctx, cancel := context.WithCancel(context.Background())
r.StartCleanupLoop(ctx, 10*time.Millisecond)
time.Sleep(25 * time.Millisecond) // 2 ticks
cancel()
time.Sleep(15 * time.Millisecond) // give the goroutine time to exit
// Implicit assertion: no goroutine leak (test would hang in -race mode otherwise).
}
func TestStartCleanupLoop_NoOpWhenIntervalZero(t *testing.T) {
repo := &fakeMLRepo{}
r := NewMagicLinkCleanupRunner(repo)
r.StartCleanupLoop(context.Background(), 0)
// Just make sure no goroutine is started ; nothing observable to assert
// beyond "no panic, returns immediately".
}

View File

@@ -0,0 +1,194 @@
//go:build integration
// Integration tests for the magic-link repository methods. Run with:
//
// go test -tags integration ./pkg/user/...
//
// Requires a running Postgres reachable via the same env vars / defaults
// the BDD suite already uses (DLC_DATABASE_HOST, etc., default
// localhost:5432 from docker-compose).
package user
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"testing"
"time"
"dance-lessons-coach/pkg/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// freshRepo connects to the local Postgres, creates a uniquely-named
// schema for THIS test, and returns a repository scoped to it.
// On test end, the schema is dropped (cleanup is best-effort).
func freshRepo(t *testing.T) *PostgresRepository {
t.Helper()
cfg, err := config.LoadConfig()
require.NoError(t, err)
var raw [6]byte
_, err = rand.Read(raw[:])
require.NoError(t, err)
schema := "ml_test_" + hex.EncodeToString(raw[:])
// Bootstrap schema via a default-DSN repo (no search_path).
bootDSN := fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
cfg.GetDatabaseHost(),
cfg.GetDatabasePort(),
cfg.GetDatabaseUser(),
cfg.GetDatabasePassword(),
cfg.GetDatabaseName(),
cfg.GetDatabaseSSLMode(),
)
bootRepo, err := NewPostgresRepositoryFromDSN(cfg, bootDSN)
require.NoError(t, err)
require.NoError(t, bootRepo.Exec(fmt.Sprintf(`CREATE SCHEMA "%s"`, schema)))
t.Cleanup(func() {
_ = bootRepo.Exec(fmt.Sprintf(`DROP SCHEMA "%s" CASCADE`, schema))
})
dsn := BuildSchemaIsolatedDSN(cfg, schema)
repo, err := NewPostgresRepositoryFromDSN(cfg, dsn)
require.NoError(t, err)
return repo
}
// TestMagicLinkRepo_CreateAndGetByHash is the end-to-end happy path :
// store a token, look it up by hash, get the row back.
func TestMagicLinkRepo_CreateAndGetByHash(t *testing.T) {
repo := freshRepo(t)
ctx := context.Background()
plain, hashHex, err := GenerateMagicLinkToken()
require.NoError(t, err)
tok := &MagicLinkToken{
Email: "alice@example.com",
TokenHash: hashHex,
ExpiresAt: time.Now().Add(15 * time.Minute),
}
require.NoError(t, repo.CreateMagicLinkToken(ctx, tok))
assert.NotZero(t, tok.ID, "ID should be populated by GORM after Create")
got, err := repo.GetMagicLinkTokenByHash(ctx, hashHex)
require.NoError(t, err)
require.NotNil(t, got, "fresh token must be retrievable")
assert.Equal(t, "alice@example.com", got.Email)
assert.Nil(t, got.ConsumedAt, "fresh token is not yet consumed")
// Lookup by the plaintext (which the consume handler does NOT receive
// directly — it must hash first). This confirms the hashing direction
// is consistent.
got2, err := repo.GetMagicLinkTokenByHash(ctx, HashMagicLinkToken(plain))
require.NoError(t, err)
require.NotNil(t, got2)
assert.Equal(t, tok.ID, got2.ID)
}
// TestMagicLinkRepo_GetByHash_Missing returns (nil, nil) for a hash that
// never existed. Callers must NOT distinguish "missing" from "expired"
// or "consumed" — they all collapse to a single generic error to the user.
func TestMagicLinkRepo_GetByHash_Missing(t *testing.T) {
repo := freshRepo(t)
got, err := repo.GetMagicLinkTokenByHash(context.Background(), HashMagicLinkToken("never-issued"))
require.NoError(t, err)
assert.Nil(t, got)
}
// TestMagicLinkRepo_MarkConsumed flips consumed_at and refuses to act
// on a non-existent ID.
func TestMagicLinkRepo_MarkConsumed(t *testing.T) {
repo := freshRepo(t)
ctx := context.Background()
_, hashHex, err := GenerateMagicLinkToken()
require.NoError(t, err)
tok := &MagicLinkToken{
Email: "bob@example.com",
TokenHash: hashHex,
ExpiresAt: time.Now().Add(15 * time.Minute),
}
require.NoError(t, repo.CreateMagicLinkToken(ctx, tok))
now := time.Now().UTC().Truncate(time.Second)
require.NoError(t, repo.MarkMagicLinkTokenConsumed(ctx, tok.ID, now))
got, err := repo.GetMagicLinkTokenByHash(ctx, hashHex)
require.NoError(t, err)
require.NotNil(t, got)
require.NotNil(t, got.ConsumedAt, "consumed_at must be set")
assert.WithinDuration(t, now, got.ConsumedAt.UTC(), time.Second)
// Marking a non-existent ID returns an error (defensive — the consume
// handler should never call us with a fake ID, but if it does we want
// the failure to be loud).
err = repo.MarkMagicLinkTokenConsumed(ctx, 999999, time.Now())
require.Error(t, err)
}
// TestMagicLinkRepo_DeleteExpired confirms the cleanup pass deletes
// strictly-before-cutoff rows and leaves future ones alone.
func TestMagicLinkRepo_DeleteExpired(t *testing.T) {
repo := freshRepo(t)
ctx := context.Background()
now := time.Now()
expired := &MagicLinkToken{
Email: "expired@example.com",
TokenHash: HashMagicLinkToken("expired-token"),
ExpiresAt: now.Add(-1 * time.Hour),
}
fresh := &MagicLinkToken{
Email: "fresh@example.com",
TokenHash: HashMagicLinkToken("fresh-token"),
ExpiresAt: now.Add(1 * time.Hour),
}
require.NoError(t, repo.CreateMagicLinkToken(ctx, expired))
require.NoError(t, repo.CreateMagicLinkToken(ctx, fresh))
deleted, err := repo.DeleteExpiredMagicLinkTokens(ctx, now)
require.NoError(t, err)
assert.EqualValues(t, 1, deleted, "exactly one row was past the cutoff")
// Expired row is gone, fresh row is still there.
got, err := repo.GetMagicLinkTokenByHash(ctx, HashMagicLinkToken("expired-token"))
require.NoError(t, err)
assert.Nil(t, got, "expired token must be gone")
got, err = repo.GetMagicLinkTokenByHash(ctx, HashMagicLinkToken("fresh-token"))
require.NoError(t, err)
require.NotNil(t, got, "fresh token must remain")
}
// TestMagicLinkRepo_HashUniqueness is a defensive check that the unique
// index on token_hash actually rejects duplicates. If the index is ever
// dropped from the schema, this test catches it before security does.
func TestMagicLinkRepo_HashUniqueness(t *testing.T) {
repo := freshRepo(t)
ctx := context.Background()
_, hashHex, err := GenerateMagicLinkToken()
require.NoError(t, err)
first := &MagicLinkToken{
Email: "a@example.com",
TokenHash: hashHex,
ExpiresAt: time.Now().Add(15 * time.Minute),
}
require.NoError(t, repo.CreateMagicLinkToken(ctx, first))
dup := &MagicLinkToken{
Email: "b@example.com",
TokenHash: hashHex, // same hash as `first`
ExpiresAt: time.Now().Add(15 * time.Minute),
}
err = repo.CreateMagicLinkToken(ctx, dup)
require.Error(t, err, "second insert with same hash must violate the unique index")
}

View File

@@ -0,0 +1,78 @@
package user
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestGenerateMagicLinkToken_ShapeAndHashAgree confirms the contract that
// HashMagicLinkToken(plaintext) == returned hashHex. Without that, the
// consume handler can never look up what request stored.
func TestGenerateMagicLinkToken_ShapeAndHashAgree(t *testing.T) {
plain, hashHex, err := GenerateMagicLinkToken()
require.NoError(t, err)
assert.NotEmpty(t, plain)
assert.NotEmpty(t, hashHex)
assert.Len(t, hashHex, 64, "sha256 hex = 64 chars")
assert.Equal(t, hashHex, HashMagicLinkToken(plain),
"GenerateMagicLinkToken must return a hash that matches HashMagicLinkToken(plain)")
}
// TestGenerateMagicLinkToken_PlainIsURLSafeBase64 confirms the link can
// be embedded in a URL without further escaping. RawURLEncoding => no
// "/", "+", or "=" padding chars.
func TestGenerateMagicLinkToken_PlainIsURLSafeBase64(t *testing.T) {
plain, _, err := GenerateMagicLinkToken()
require.NoError(t, err)
for _, bad := range []string{"/", "+", "="} {
assert.False(t, strings.Contains(plain, bad),
"plaintext token must not contain %q (URL-unsafe)", bad)
}
decoded, err := base64.RawURLEncoding.DecodeString(plain)
require.NoError(t, err, "plaintext must round-trip through RawURLEncoding")
assert.Len(t, decoded, 32, "32 bytes of entropy")
}
// TestGenerateMagicLinkToken_Unique confirms two consecutive calls
// produce different tokens (not a deterministic seeding bug).
func TestGenerateMagicLinkToken_Unique(t *testing.T) {
a, ah, err := GenerateMagicLinkToken()
require.NoError(t, err)
b, bh, err := GenerateMagicLinkToken()
require.NoError(t, err)
assert.NotEqual(t, a, b, "plaintexts must differ between calls")
assert.NotEqual(t, ah, bh, "hashes must differ between calls")
}
// TestHashMagicLinkToken_StableAndCorrect confirms HashMagicLinkToken is
// a pure function (same input -> same output) AND that it produces the
// expected sha256 hex digest. Cross-checked against the stdlib so we
// catch any accidental algorithm swap.
func TestHashMagicLinkToken_StableAndCorrect(t *testing.T) {
const sample = "abc123-test-token"
got1 := HashMagicLinkToken(sample)
got2 := HashMagicLinkToken(sample)
assert.Equal(t, got1, got2, "HashMagicLinkToken must be deterministic")
sum := sha256.Sum256([]byte(sample))
want := hex.EncodeToString(sum[:])
assert.Equal(t, want, got1, "HashMagicLinkToken must be sha256 hex")
}
// TestHashMagicLinkToken_DiffersOnDifferentInput is the tautological
// counter-test of stability : different inputs -> different outputs.
// Catches the (unlikely) case where someone replaces the impl with
// a constant.
func TestHashMagicLinkToken_DiffersOnDifferentInput(t *testing.T) {
assert.NotEqual(t, HashMagicLinkToken("a"), HashMagicLinkToken("b"))
}

View File

@@ -160,7 +160,7 @@ func NewPostgresRepositoryFromDSN(cfg *config.Config, dsn string) (*PostgresRepo
sqlDB.SetMaxIdleConns(cfg.GetDatabaseMaxIdleConns())
sqlDB.SetConnMaxLifetime(cfg.GetDatabaseConnMaxLifetime())
if err := db.AutoMigrate(&User{}); err != nil {
if err := db.AutoMigrate(&User{}, &MagicLinkToken{}); err != nil {
return nil, fmt.Errorf("failed to auto-migrate via custom DSN: %w", err)
}
@@ -264,8 +264,8 @@ func (r *PostgresRepository) initializeDatabase() error {
sqlDB.SetMaxIdleConns(r.config.GetDatabaseMaxIdleConns())
sqlDB.SetConnMaxLifetime(r.config.GetDatabaseConnMaxLifetime())
// Auto-migrate the User model
if err := r.db.AutoMigrate(&User{}); err != nil {
// Auto-migrate the User model + MagicLinkToken (ADR-0028 Phase A)
if err := r.db.AutoMigrate(&User{}, &MagicLinkToken{}); err != nil {
return fmt.Errorf("failed to auto-migrate: %w", err)
}