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).
BDD Steps Organization
This folder contains the step definitions for the BDD tests, organized by domain for better maintainability and scalability.
Structure
pkg/bdd/steps/
├── steps.go # Main registration file that ties everything together
├── scenario_state.go # Per-scenario state isolation manager
├── common_steps.go # Shared steps used across multiple domains
├── auth_steps.go # Authentication and user management steps
├── config_steps.go # Configuration and hot-reloading steps
├── greet_steps.go # Greet-related steps (v1 and v2 API)
├── health_steps.go # Health check and server status steps
├── jwt_retention_steps.go # JWT secret retention policy steps
└── README.md # This file
Design Principles
- Domain Separation: Steps are grouped by functional domain
- Single Responsibility: Each file focuses on a specific area of functionality
- Reusability: Common steps are shared via
common_steps.go - Scalability: Easy to add new domains as the application grows
- State Isolation: Use per-scenario state to prevent pollution between test scenarios
Adding New Steps
- For new domains: Create a new
*_steps.gofile following the existing pattern - For existing domains: Add to the appropriate domain file
- For shared functionality: Add to
common_steps.go - Register all steps: Update
steps.goto include the new steps
Step Naming Convention
- Use descriptive, action-oriented names
- Follow the pattern:
i[Action][Object]orthe[Object][State] - Example:
iRequestAGreetingFor,theAuthenticationShouldBeSuccessful - Use present tense for actions: "I authenticate", "the server reloads"
State Isolation Pattern
Problem: Step definition structs (AuthSteps, GreetSteps, etc.) maintain state in their fields (e.g., lastToken, lastUserID). This state persists across all scenarios in a test process, causing pollution even with database schema isolation.
Solution: Use the ScenarioState manager for per-scenario state isolation.
How It Works
The scenario_state.go provides a thread-safe mechanism to store and retrieve state that is isolated per scenario:
// Get scenario-specific state
state := steps.GetScenarioState(scenarioName)
// Store scenario-specific data
state.LastToken = token
state.LastUserID = userID
// Retrieve scenario-specific data
token := state.LastToken
Usage in Step Definitions
Instead of storing state in struct fields:
// ❌ NOT RECOMMENDED - state shared across all scenarios
type AuthSteps struct {
client *testserver.Client
lastToken string // Shared across all scenarios!
lastUserID uint // Shared across all scenarios!
}
func (s *AuthSteps) iShouldReceiveAValidJWTToken() error {
s.lastToken = extractedToken // Pollutes other scenarios
return nil
}
Use per-scenario state:
// ✅ RECOMMENDED - state isolated per scenario
type AuthSteps struct {
client *testserver.Client
scenarioName string // Track current scenario for state isolation
}
func (s *AuthSteps) iShouldReceiveAValidJWTToken() error {
state := steps.GetScenarioState(s.scenarioName)
state.LastToken = extractedToken // Isolated to this scenario
return nil
}
Integration with Suite Hooks
Clear state in AfterScenario to prevent memory growth:
sc.AfterScenario(func(s *godog.Scenario, err error) {
scenarioKey := s.Name
if s.Uri != "" {
scenarioKey = fmt.Sprintf("%s:%s", s.Uri, s.Name)
}
steps.ClearScenarioState(scenarioKey)
})
ScenarioState Structure
The ScenarioState struct contains common fields needed across step definitions:
type ScenarioState struct {
LastToken string
FirstToken string
LastUserID uint
// Add more fields as needed for other step types
}
If you need additional scenario-scoped fields, add them to the ScenarioState struct.
Testing the Steps
Run BDD tests with:
# Run all features
go test ./features/... -v
# Run specific feature
go test ./features/auth -v
# Run with state tracing enabled
BDD_TRACE_STATE=1 go test ./features/auth -v
# Validate full test suite
./scripts/validate-test-suite.sh 1
State Cleanup Strategy
| Cleanup Level | When | What | Implementation |
|---|---|---|---|
| Per-Scenario | After each scenario | Step struct fields | ClearScenarioState() |
| Per-Scenario | After each scenario | Database state | CleanupDatabase() (if no schema isolation) |
| Per-Scenario | After each scenario | Schema | DROP SCHEMA (if schema isolation enabled) |
| Per-Process | After each feature test | Server-level state | ResetJWTSecrets() |
| Per-Suite | After all scenarios | All state | Server restart |
Best Practices
1. Use Per-Scenario State for Shared Data
Any data that:
- Is modified during scenario execution
- Affects subsequent steps in the same scenario
- Should NOT affect other scenarios
Use: GetScenarioState(scenarioName).Field
2. Keep Step Definitions Stateless Where Possible
If a step doesn't need to store intermediate state, don't store it:
// ✅ Good - stateless
func (s *GreetSteps) iRequestAGreetingFor(name string) error {
return s.client.Request("GET", fmt.Sprintf("/api/v1/greet/%s", name), nil)
}
// ❌ Avoid - unnecessary state
func (s *GreetSteps) iRequestAGreetingFor(name string) error {
s.lastGreetedName = name // Unnecessary unless used later
return s.client.Request("GET", fmt.Sprintf("/api/v1/greet/%s", name), nil)
}
3. Prefix Config Files Per-Scenario
If your scenario modifies config files, use scenario-specific paths:
configPath := fmt.Sprintf("features/%s/%s-scenario-%s.yaml",
feature, feature, scenarioKey)
4. Document Dependencies
If a step depends on state set by another step, document it:
// Step: The user should have a valid JWT token
// Requires: iAuthenticateWithUsernameAndPassword to have been called first
func (s *AuthSteps) theUserShouldHaveAValidJWTToken() error {
state := steps.GetScenarioState(s.scenarioName)
if state.LastToken == "" {
return fmt.Errorf("no token found - did you authenticate first?")
}
// Verify token is valid...
}
Future Domains
As the application grows, consider adding:
payment_steps.go- Payment processing stepsnotification_steps.go- Notification and email stepsadmin_steps.go- Admin-specific functionality stepsapi_steps.go- General API interaction patternsuser_steps.go- User profile and management steps (if auth gets complex)
Troubleshooting
State Pollution Between Scenarios
Symptom: Tests pass individually but fail when run together
Check:
- Are you using struct fields to store state? → Use
ScenarioStateinstead - Are database tables being cleaned up? → Verify
CleanupDatabase()or schema isolation - Are JWT secrets being reset? → Verify
ResetJWTSecrets()is called
Debug: Enable state tracing:
BDD_TRACE_STATE=1 go test ./features/auth -v
Timeout or Delay Issues
Symptom: Config reloading tests fail intermittently
Cause: Server monitors config files every 1 second
Fix: Add delays >1100ms after config file changes:
time.Sleep(1100 * time.Millisecond) // Wait for monitoring cycle
Missing Step Definitions
Symptom: undefined step error
Check:
- Step is defined in the appropriate
*_steps.gofile - Step is registered in
steps.go - Step regex matches the feature file text exactly
- No typos in the step name
Tip: Run with -v to see which step is undefined