Co-authored-by: Gabriel Radureau <arcodange@gmail.com> Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
146 lines
4.4 KiB
Go
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)
|
|
}
|