From d863d70f7a4c597cbcc9242777b6dfef1215dd2b Mon Sep 17 00:00:00 2001 From: Gabriel Radureau Date: Tue, 5 May 2026 11:44:20 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(bdd):=20magic-link=20BDD=20sce?= =?UTF-8?q?narios=20+=20bcrypt=20overflow=20fix=20(ADR-0028=20Phase=20A.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 4 BDD scenarios covering the passwordless magic-link flow: - Happy path (request -> email arrives -> consume -> JWT) - Token cannot be consumed twice (single-use guarantee) - Missing token returns 400 - Unknown token returns 401 Implementation: - features/auth/magic_link.feature with the gherkin spec - pkg/bdd/steps/magic_link_steps.go: per-scenario unique recipient (`-<8hex>@bdd.local`, ADR-0030), Mailpit-driven token extraction, regex parse of the consume URL - pkg/bdd/steps/scenario_state.go: 2 fields added (MagicLinkEmail, MagicLinkToken) - pkg/bdd/steps/steps.go: register 5 new step regexes Bug fix exposed by the BDD run: - pkg/user/api/magic_link_handler.go: passwordless-signup random password was 96 hex chars (48 bytes) which overflowed bcrypt's 72-byte input limit, breaking first-link signup. Reduced to 64 hex chars (32 bytes, 256 bits entropy). Test infra fix: - pkg/bdd/testserver/server.go: createTestConfig() builds the Config literal directly (no Viper defaults), so add explicit Email + MagicLink config so the From address is set when the handler sends via local Mailpit. Mistral wrote the feature file, magic_link_steps.go, scenario_state.go edit, and steps.go edit autonomously in a worktree workspace. Claude fixed the bcrypt overflow + the test-config gap exposed during verification. Most authoring by Mistral Vibe (mistral-vibe-cli-latest). --- features/auth/magic_link.feature | 34 +++++++ pkg/bdd/steps/magic_link_steps.go | 145 +++++++++++++++++++++++++++++ pkg/bdd/steps/scenario_state.go | 12 ++- pkg/bdd/steps/steps.go | 16 ++++ pkg/bdd/testserver/server.go | 14 +++ pkg/user/api/magic_link_handler.go | 5 +- 6 files changed, 220 insertions(+), 6 deletions(-) create mode 100644 features/auth/magic_link.feature create mode 100644 pkg/bdd/steps/magic_link_steps.go diff --git a/features/auth/magic_link.feature b/features/auth/magic_link.feature new file mode 100644 index 0000000..cce997d --- /dev/null +++ b/features/auth/magic_link.feature @@ -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 diff --git a/pkg/bdd/steps/magic_link_steps.go b/pkg/bdd/steps/magic_link_steps.go new file mode 100644 index 0000000..069ba3f --- /dev/null +++ b/pkg/bdd/steps/magic_link_steps.go @@ -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: -<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= +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) +} diff --git a/pkg/bdd/steps/scenario_state.go b/pkg/bdd/steps/scenario_state.go index 53268f6..7e939cb 100644 --- a/pkg/bdd/steps/scenario_state.go +++ b/pkg/bdd/steps/scenario_state.go @@ -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 } diff --git a/pkg/bdd/steps/steps.go b/pkg/bdd/steps/steps.go index c3d1b49..9b535f3 100644 --- a/pkg/bdd/steps/steps.go +++ b/pkg/bdd/steps/steps.go @@ -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) diff --git a/pkg/bdd/testserver/server.go b/pkg/bdd/testserver/server.go index 67c5636..cba0a70 100644 --- a/pkg/bdd/testserver/server.go +++ b/pkg/bdd/testserver/server.go @@ -815,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, diff --git a/pkg/user/api/magic_link_handler.go b/pkg/user/api/magic_link_handler.go index 59bef5e..227ec96 100644 --- a/pkg/user/api/magic_link_handler.go +++ b/pkg/user/api/magic_link_handler.go @@ -56,7 +56,10 @@ func NewMagicLinkHandler( validator: validator, clock: time.Now, newPassword: func() (string, error) { - var raw [48]byte + // 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 } -- 2.49.1