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