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).
103 lines
2.5 KiB
Go
103 lines
2.5 KiB
Go
package steps
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"sync"
|
|
)
|
|
|
|
// 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
|
|
MagicLinkEmail string
|
|
MagicLinkToken string
|
|
// Add more fields as needed for other step types
|
|
}
|
|
|
|
// scenarioStateManager manages per-scenario state isolation
|
|
type scenarioStateManager struct {
|
|
mu sync.RWMutex
|
|
states map[string]*ScenarioState
|
|
}
|
|
|
|
var globalStateManager *scenarioStateManager
|
|
var once sync.Once
|
|
|
|
// GetScenarioStateManager returns the singleton scenario state manager
|
|
func GetScenarioStateManager() *scenarioStateManager {
|
|
once.Do(func() {
|
|
globalStateManager = &scenarioStateManager{
|
|
states: make(map[string]*ScenarioState),
|
|
}
|
|
})
|
|
return globalStateManager
|
|
}
|
|
|
|
// scenarioKey generates a unique key for a scenario
|
|
func scenarioKey(scenario string) string {
|
|
// Use SHA256 hash to create a consistent, bounded-length key
|
|
hash := sha256.Sum256([]byte(scenario))
|
|
return hex.EncodeToString(hash[:])
|
|
}
|
|
|
|
// GetState returns the state for a given scenario, creating it if necessary
|
|
func (sm *scenarioStateManager) GetState(scenario string) *ScenarioState {
|
|
sm.mu.RLock()
|
|
key := scenarioKey(scenario)
|
|
state, exists := sm.states[key]
|
|
sm.mu.RUnlock()
|
|
|
|
if exists {
|
|
return state
|
|
}
|
|
|
|
sm.mu.Lock()
|
|
defer sm.mu.Unlock()
|
|
|
|
// Double-check after acquiring write lock
|
|
if state, exists = sm.states[key]; exists {
|
|
return state
|
|
}
|
|
|
|
state = &ScenarioState{}
|
|
sm.states[key] = state
|
|
return state
|
|
}
|
|
|
|
// ClearState removes the state for a given scenario
|
|
func (sm *scenarioStateManager) ClearState(scenario string) {
|
|
sm.mu.Lock()
|
|
defer sm.mu.Unlock()
|
|
key := scenarioKey(scenario)
|
|
delete(sm.states, key)
|
|
}
|
|
|
|
// ClearAllStates removes all scenario states
|
|
func (sm *scenarioStateManager) ClearAllStates() {
|
|
sm.mu.Lock()
|
|
defer sm.mu.Unlock()
|
|
sm.states = make(map[string]*ScenarioState)
|
|
}
|
|
|
|
// Package-level convenience functions
|
|
|
|
// GetScenarioState returns the state for the current scenario
|
|
func GetScenarioState(scenario string) *ScenarioState {
|
|
return GetScenarioStateManager().GetState(scenario)
|
|
}
|
|
|
|
// ClearScenarioState removes the state for the current scenario
|
|
func ClearScenarioState(scenario string) {
|
|
GetScenarioStateManager().ClearState(scenario)
|
|
}
|
|
|
|
// ClearAllScenarioStates removes all scenario states
|
|
func ClearAllScenarioStates() {
|
|
GetScenarioStateManager().ClearAllStates()
|
|
}
|