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

@@ -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)