✨ 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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user