✨ feat(bdd): magic-link BDD scenarios + bcrypt overflow fix (ADR-0028 Phase A.5) (#63)
Co-authored-by: Gabriel Radureau <arcodange@gmail.com> Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
This commit was merged in pull request #63.
This commit is contained in:
34
features/auth/magic_link.feature
Normal file
34
features/auth/magic_link.feature
Normal 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
|
||||||
145
pkg/bdd/steps/magic_link_steps.go
Normal file
145
pkg/bdd/steps/magic_link_steps.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user