package harness import ( "bytes" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "sync" "testing" "time" ) const mailStubPath = "/api/v1/internal/login-code-deliveries" // LoginCodeDelivery stores one mail-delivery request received by the external // mail stub. type LoginCodeDelivery struct { // Email identifies the target e-mail address requested by authsession. Email string // Code stores the cleartext login code requested by authsession. Code string } // MailBehavior overrides one external mail-stub response. type MailBehavior struct { // Delay waits before the stub writes its response. Delay time.Duration // StatusCode overrides the HTTP status returned by the stub. Zero keeps the // default `200 OK`. StatusCode int // RawBody overrides the exact response body returned by the stub. Empty // value keeps the default JSON payload for the chosen status. RawBody string } // MailStub provides one stateful external HTTP mail-service stub. type MailStub struct { server *httptest.Server mu sync.Mutex deliveries []LoginCodeDelivery behavior MailBehavior } // NewMailStub starts one stateful external HTTP mail-service stub. func NewMailStub(t testing.TB) *MailStub { t.Helper() stub := &MailStub{} stub.server = httptest.NewServer(http.HandlerFunc(stub.handle)) t.Cleanup(stub.server.Close) return stub } // BaseURL returns the stub base URL suitable for service runtime wiring. func (s *MailStub) BaseURL() string { if s == nil || s.server == nil { return "" } return s.server.URL } // SetBehavior replaces the current response behavior used by subsequent // requests. func (s *MailStub) SetBehavior(behavior MailBehavior) { s.mu.Lock() defer s.mu.Unlock() s.behavior = behavior } // RecordedDeliveries returns a snapshot of all delivery requests received by // the stub so far. func (s *MailStub) RecordedDeliveries() []LoginCodeDelivery { s.mu.Lock() defer s.mu.Unlock() cloned := make([]LoginCodeDelivery, len(s.deliveries)) copy(cloned, s.deliveries) return cloned } // Reset clears the recorded deliveries and restores default behavior. func (s *MailStub) Reset() { s.mu.Lock() defer s.mu.Unlock() s.deliveries = nil s.behavior = MailBehavior{} } func (s *MailStub) handle(writer http.ResponseWriter, request *http.Request) { if request.Method != http.MethodPost || request.URL.Path != mailStubPath { http.NotFound(writer, request) return } var payload struct { Email string `json:"email"` Code string `json:"code"` } if err := decodeStrictJSONRequest(request, &payload); err != nil { http.Error(writer, err.Error(), http.StatusBadRequest) return } s.mu.Lock() s.deliveries = append(s.deliveries, LoginCodeDelivery{ Email: payload.Email, Code: payload.Code, }) behavior := s.behavior s.mu.Unlock() if behavior.Delay > 0 { timer := time.NewTimer(behavior.Delay) defer timer.Stop() select { case <-request.Context().Done(): return case <-timer.C: } } statusCode := behavior.StatusCode if statusCode == 0 { statusCode = http.StatusOK } body := behavior.RawBody if body == "" { switch statusCode { case http.StatusOK: body = `{"outcome":"sent"}` default: body = `{"error":"stubbed mail failure"}` } } writer.Header().Set("Content-Type", "application/json") writer.WriteHeader(statusCode) _, _ = io.WriteString(writer, body) } func decodeStrictJSONRequest(request *http.Request, target any) error { decoder := json.NewDecoder(request.Body) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { return err } if err := decoder.Decode(&struct{}{}); err != io.EOF { if err == nil { return errors.New("unexpected trailing JSON input") } return err } return nil } func decodeStrictJSONPayload(payload []byte, target any) error { decoder := json.NewDecoder(bytes.NewReader(payload)) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { return err } if err := decoder.Decode(&struct{}{}); err != io.EOF { if err == nil { return errors.New("unexpected trailing JSON input") } return err } return nil }