package authsession import ( "encoding/json" "io" "net/http" "net/http/httptest" "sync" "testing" "time" mailadapter "galaxy/authsession/internal/adapters/mail" "galaxy/authsession/internal/adapters/userservice" "galaxy/authsession/internal/api/publichttp" "galaxy/authsession/internal/domain/common" "galaxy/authsession/internal/domain/userresolution" "galaxy/authsession/internal/service/confirmemailcode" "galaxy/authsession/internal/service/sendemailcode" "galaxy/authsession/internal/testkit" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap" ) func TestMailServiceRESTCompatibilitySendEmailCodeSent(t *testing.T) { t.Parallel() harness := newMailServiceRESTCompatibilityHarness(t, mailServiceRESTCompatibilityOptions{ MailStatusCode: http.StatusOK, MailResponse: `{"outcome":"sent"}`, }) response := gatewayCompatibilityPostJSON(t, harness.publicBaseURL+"/api/v1/public/auth/send-email-code", `{"email":"pilot@example.com"}`) assert.Equal(t, http.StatusOK, response.StatusCode) assert.JSONEq(t, `{"challenge_id":"challenge-1"}`, response.Body) assert.Equal(t, 1, harness.mailServer.CallCount()) } func TestMailServiceRESTCompatibilitySendEmailCodeSuppressed(t *testing.T) { t.Parallel() harness := newMailServiceRESTCompatibilityHarness(t, mailServiceRESTCompatibilityOptions{ MailStatusCode: http.StatusOK, MailResponse: `{"outcome":"suppressed"}`, }) response := gatewayCompatibilityPostJSON(t, harness.publicBaseURL+"/api/v1/public/auth/send-email-code", `{"email":"pilot@example.com"}`) assert.Equal(t, http.StatusOK, response.StatusCode) assert.JSONEq(t, `{"challenge_id":"challenge-1"}`, response.Body) assert.Equal(t, 1, harness.mailServer.CallCount()) } func TestMailServiceRESTCompatibilitySendEmailCodeExplicitFailure(t *testing.T) { t.Parallel() harness := newMailServiceRESTCompatibilityHarness(t, mailServiceRESTCompatibilityOptions{ MailStatusCode: http.StatusServiceUnavailable, MailResponse: `{"error":"temporary"}`, }) response := gatewayCompatibilityPostJSON(t, harness.publicBaseURL+"/api/v1/public/auth/send-email-code", `{"email":"pilot@example.com"}`) assert.Equal(t, http.StatusServiceUnavailable, response.StatusCode) assert.JSONEq(t, `{"error":{"code":"service_unavailable","message":"service is unavailable"}}`, response.Body) assert.Equal(t, 1, harness.mailServer.CallCount()) } func TestMailServiceRESTCompatibilityBlockedSendSkipsMailService(t *testing.T) { t.Parallel() harness := newMailServiceRESTCompatibilityHarness(t, mailServiceRESTCompatibilityOptions{ MailStatusCode: http.StatusOK, MailResponse: `{"outcome":"sent"}`, SeedBlockedEmail: true, }) response := gatewayCompatibilityPostJSON(t, harness.publicBaseURL+"/api/v1/public/auth/send-email-code", `{"email":"pilot@example.com"}`) assert.Equal(t, http.StatusOK, response.StatusCode) assert.JSONEq(t, `{"challenge_id":"challenge-1"}`, response.Body) assert.Equal(t, 0, harness.mailServer.CallCount()) } func TestMailServiceRESTCompatibilityThrottledSendSkipsMailService(t *testing.T) { t.Parallel() harness := newMailServiceRESTCompatibilityHarness(t, mailServiceRESTCompatibilityOptions{ MailStatusCode: http.StatusOK, MailResponse: `{"outcome":"sent"}`, AbuseProtector: &testkit.InMemorySendEmailCodeAbuseProtector{}, }) first := gatewayCompatibilityPostJSON(t, harness.publicBaseURL+"/api/v1/public/auth/send-email-code", `{"email":"pilot@example.com"}`) second := gatewayCompatibilityPostJSON(t, harness.publicBaseURL+"/api/v1/public/auth/send-email-code", `{"email":"pilot@example.com"}`) assert.Equal(t, http.StatusOK, first.StatusCode) assert.JSONEq(t, `{"challenge_id":"challenge-1"}`, first.Body) assert.Equal(t, http.StatusOK, second.StatusCode) assert.JSONEq(t, `{"challenge_id":"challenge-2"}`, second.Body) assert.Equal(t, 1, harness.mailServer.CallCount()) } type mailServiceRESTCompatibilityOptions struct { MailStatusCode int MailResponse string SeedBlockedEmail bool AbuseProtector *testkit.InMemorySendEmailCodeAbuseProtector } type mailServiceRESTCompatibilityHarness struct { publicBaseURL string mailServer *mailServiceStubServer } func newMailServiceRESTCompatibilityHarness(t *testing.T, options mailServiceRESTCompatibilityOptions) mailServiceRESTCompatibilityHarness { t.Helper() challengeStore := &testkit.InMemoryChallengeStore{} sessionStore := &testkit.InMemorySessionStore{} userDirectory := &userservice.StubDirectory{} if options.SeedBlockedEmail { require.NoError(t, userDirectory.SeedBlockedEmail(common.Email("pilot@example.com"), userresolution.BlockReasonCode("policy_blocked"))) } mailServer := newMailServiceStubServer(options.MailStatusCode, options.MailResponse) httpServer := httptest.NewServer(mailServer.Handler()) t.Cleanup(httpServer.Close) mailSender, err := mailadapter.NewRESTClient(mailadapter.Config{ BaseURL: httpServer.URL, RequestTimeout: 250 * time.Millisecond, }) require.NoError(t, err) t.Cleanup(func() { assert.NoError(t, mailSender.Close()) }) idGenerator := &testkit.SequenceIDGenerator{} codeGenerator := testkit.FixedCodeGenerator{Code: "123456"} codeHasher := testkit.DeterministicCodeHasher{} clock := testkit.FixedClock{Time: time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC)} configProvider := testkit.StaticConfigProvider{} projectionPublisher := &testkit.RecordingProjectionPublisher{} sendEmailCodeService, err := sendemailcode.NewWithObservability( challengeStore, userDirectory, idGenerator, codeGenerator, codeHasher, mailSender, options.AbuseProtector, clock, zap.NewNop(), nil, ) require.NoError(t, err) confirmEmailCodeService, err := confirmemailcode.NewWithObservability( challengeStore, sessionStore, userDirectory, configProvider, projectionPublisher, idGenerator, codeHasher, clock, zap.NewNop(), nil, ) require.NoError(t, err) publicCfg := publichttp.DefaultConfig() publicCfg.Addr = gatewayCompatibilityFreeAddr(t) publicServer, err := publichttp.NewServer(publicCfg, publichttp.Dependencies{ SendEmailCode: sendEmailCodeService, ConfirmEmailCode: confirmEmailCodeService, Logger: zap.NewNop(), }) require.NoError(t, err) gatewayCompatibilityRunServer(t, publicServer.Run, publicServer.Shutdown, publicCfg.Addr) return mailServiceRESTCompatibilityHarness{ publicBaseURL: "http://" + publicCfg.Addr, mailServer: mailServer, } } type mailServiceStubServer struct { mu sync.Mutex statusCode int response string callCount int } func newMailServiceStubServer(statusCode int, response string) *mailServiceStubServer { return &mailServiceStubServer{ statusCode: statusCode, response: response, } } func (s *mailServiceStubServer) Handler() http.Handler { return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { if request.Method != http.MethodPost || request.URL.Path != "/api/v1/internal/login-code-deliveries" { http.NotFound(writer, request) return } s.mu.Lock() s.callCount++ s.mu.Unlock() decoder := json.NewDecoder(request.Body) decoder.DisallowUnknownFields() var body struct { Email string `json:"email"` Code string `json:"code"` } if err := decoder.Decode(&body); err != nil { http.Error(writer, err.Error(), http.StatusBadRequest) return } if err := decoder.Decode(&struct{}{}); err != io.EOF { if err == nil { http.Error(writer, "unexpected trailing JSON input", http.StatusBadRequest) return } http.Error(writer, err.Error(), http.StatusBadRequest) return } writer.Header().Set("Content-Type", "application/json") writer.WriteHeader(s.statusCode) _, _ = io.WriteString(writer, s.response) }) } func (s *mailServiceStubServer) CallCount() int { s.mu.Lock() defer s.mu.Unlock() return s.callCount }