Files
galaxy-game/authsession/mail_service_rest_compatibility_test.go
T
2026-04-17 18:39:16 +02:00

302 lines
9.5 KiB
Go

package authsession
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"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())
deliveries := harness.mailServer.RecordedDeliveries()
require.Len(t, deliveries, 1)
assert.Equal(t, "en", deliveries[0].Locale)
assert.Equal(t, "challenge-1", deliveries[0].IdempotencyKey)
}
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())
}
func TestMailServiceRESTCompatibilitySendEmailCodeForwardsLocalizedLocale(t *testing.T) {
t.Parallel()
harness := newMailServiceRESTCompatibilityHarness(t, mailServiceRESTCompatibilityOptions{
MailStatusCode: http.StatusOK,
MailResponse: `{"outcome":"sent"}`,
})
response := gatewayCompatibilityPostJSONWithHeaders(
t,
harness.publicBaseURL+"/api/v1/public/auth/send-email-code",
`{"email":"pilot@example.com"}`,
map[string]string{"Accept-Language": "fr-FR, en;q=0.8"},
)
assert.Equal(t, http.StatusOK, response.StatusCode)
assert.JSONEq(t, `{"challenge_id":"challenge-1"}`, response.Body)
deliveries := harness.mailServer.RecordedDeliveries()
require.Len(t, deliveries, 1)
assert.Equal(t, "fr-FR", deliveries[0].Locale)
assert.Equal(t, "challenge-1", deliveries[0].IdempotencyKey)
}
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
deliveries []mailServiceStubDelivery
}
type mailServiceStubDelivery struct {
Email string
Code string
Locale string
IdempotencyKey string
}
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
}
if strings.TrimSpace(request.Header.Get("Idempotency-Key")) == "" {
http.Error(writer, "Idempotency-Key header must not be empty", http.StatusBadRequest)
return
}
decoder := json.NewDecoder(request.Body)
decoder.DisallowUnknownFields()
var body struct {
Email string `json:"email"`
Code string `json:"code"`
Locale string `json:"locale"`
}
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
}
s.mu.Lock()
s.callCount++
s.deliveries = append(s.deliveries, mailServiceStubDelivery{
Email: body.Email,
Code: body.Code,
Locale: body.Locale,
IdempotencyKey: request.Header.Get("Idempotency-Key"),
})
s.mu.Unlock()
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
}
func (s *mailServiceStubServer) RecordedDeliveries() []mailServiceStubDelivery {
s.mu.Lock()
defer s.mu.Unlock()
cloned := make([]mailServiceStubDelivery, len(s.deliveries))
copy(cloned, s.deliveries)
return cloned
}