feat(bdd): magic-link BDD scenarios + bcrypt overflow fix (ADR-0028 Phase A.5)

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
  (`<scenario-key>-<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).
This commit is contained in:
2026-05-05 11:44:20 +02:00
parent f39acf5de5
commit d863d70f7a
6 changed files with 220 additions and 6 deletions

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

@@ -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 // ScenarioState holds per-scenario state for step definitions
// This prevents state pollution between scenarios running in the same test process // This prevents state pollution between scenarios running in the same test process
type ScenarioState struct { type ScenarioState struct {
LastToken string LastToken string
FirstToken string FirstToken string
LastUserID uint LastUserID uint
LastSecret string LastSecret string
LastError string LastError string
MagicLinkEmail string
MagicLinkToken string
// Add more fields as needed for other step types // Add more fields as needed for other step types
} }

View File

@@ -17,6 +17,7 @@ type StepContext struct {
jwtRetentionSteps *JWTRetentionSteps jwtRetentionSteps *JWTRetentionSteps
configSteps *ConfigSteps configSteps *ConfigSteps
rateLimitSteps *RateLimitSteps rateLimitSteps *RateLimitSteps
magicLinkSteps *MagicLinkSteps
} }
// NewStepContext creates a new step context // NewStepContext creates a new step context
@@ -30,6 +31,7 @@ func NewStepContext(client *testserver.Client) *StepContext {
jwtRetentionSteps: NewJWTRetentionSteps(client), jwtRetentionSteps: NewJWTRetentionSteps(client),
configSteps: NewConfigSteps(client), configSteps: NewConfigSteps(client),
rateLimitSteps: NewRateLimitSteps(client), rateLimitSteps: NewRateLimitSteps(client),
magicLinkSteps: NewMagicLinkSteps(client),
} }
} }
@@ -67,6 +69,9 @@ func SetScenarioKeyForAllSteps(sc *StepContext, key string) {
if sc.rateLimitSteps != nil { if sc.rateLimitSteps != nil {
sc.rateLimitSteps.SetScenarioKey(key) 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 body should contain "([^"]*)"$`, sc.rateLimitSteps.theResponseBodyShouldContain)
ctx.Step(`^the response should have header "([^"]*)"$`, sc.rateLimitSteps.theResponseShouldHaveHeader) 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 // Common steps
ctx.Step(`^the response should be "{\\"([^"]*)":\\"([^"]*)"}"$`, sc.commonSteps.theResponseShouldBe) ctx.Step(`^the response should be "{\\"([^"]*)":\\"([^"]*)"}"$`, sc.commonSteps.theResponseShouldBe)
ctx.Step(`^the response should contain error "([^"]*)"$`, sc.commonSteps.theResponseShouldContainError) ctx.Step(`^the response should contain error "([^"]*)"$`, sc.commonSteps.theResponseShouldContainError)

View File

@@ -815,6 +815,20 @@ func createTestConfig(port int, v2Enabled bool) *config.Config {
JWT: config.JWTConfig{ JWT: config.JWTConfig{
TTL: 24 * time.Hour, 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{ API: config.APIConfig{
V2Enabled: v2Enabled, V2Enabled: v2Enabled,

View File

@@ -56,7 +56,10 @@ func NewMagicLinkHandler(
validator: validator, validator: validator,
clock: time.Now, clock: time.Now,
newPassword: func() (string, error) { 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 { if _, err := rand.Read(raw[:]); err != nil {
return "", err return "", err
} }