Files
dance-lessons-coach/pkg/bdd/steps/magic_link_steps.go
Gabriel Radureau 9072b3e246
All checks were successful
CI/CD Pipeline / Build Docker Cache (push) Successful in 9s
CI/CD Pipeline / CI Pipeline (push) Successful in 5m0s
CI/CD Pipeline / Trigger Docker Push (push) Successful in 5s
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>
2026-05-05 11:44:41 +02:00

146 lines
4.4 KiB
Go

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