feat: mail service

This commit is contained in:
Ilia Denisov
2026-04-17 18:39:16 +02:00
committed by GitHub
parent 23ffcb7535
commit 5b7593e6f6
183 changed files with 31215 additions and 248 deletions
@@ -149,13 +149,14 @@ func RunChallengeStoreContractTests(t *testing.T, newStore ChallengeStoreFactory
func contractPendingChallenge(now time.Time) challenge.Challenge {
record := challenge.Challenge{
ID: common.ChallengeID("challenge-pending"),
Email: common.Email("pilot@example.com"),
CodeHash: []byte("hashed-pending-code"),
Status: challenge.StatusPendingSend,
DeliveryState: challenge.DeliveryPending,
CreatedAt: now,
ExpiresAt: now.Add(challenge.InitialTTL),
ID: common.ChallengeID("challenge-pending"),
Email: common.Email("pilot@example.com"),
CodeHash: []byte("hashed-pending-code"),
PreferredLanguage: "en",
Status: challenge.StatusPendingSend,
DeliveryState: challenge.DeliveryPending,
CreatedAt: now,
ExpiresAt: now.Add(challenge.InitialTTL),
}
if err := record.Validate(); err != nil {
panic(err)
@@ -176,13 +177,14 @@ func contractConfirmedChallenge(t *testing.T, now time.Time) challenge.Challenge
require.NoError(t, err)
record := challenge.Challenge{
ID: common.ChallengeID("challenge-confirmed"),
Email: common.Email("pilot@example.com"),
CodeHash: []byte("hashed-code"),
Status: challenge.StatusConfirmedPendingExpire,
DeliveryState: challenge.DeliverySent,
CreatedAt: now,
ExpiresAt: now.Add(challenge.ConfirmedRetention),
ID: common.ChallengeID("challenge-confirmed"),
Email: common.Email("pilot@example.com"),
CodeHash: []byte("hashed-code"),
PreferredLanguage: "en",
Status: challenge.StatusConfirmedPendingExpire,
DeliveryState: challenge.DeliverySent,
CreatedAt: now,
ExpiresAt: now.Add(challenge.ConfirmedRetention),
Attempts: challenge.AttemptCounters{
Send: 1,
Confirm: 2,
@@ -95,9 +95,10 @@ func (c *RESTClient) SendLoginCode(ctx context.Context, input ports.SendLoginCod
return ports.SendLoginCodeResult{}, fmt.Errorf("send login code: %w", err)
}
payload, statusCode, err := c.doRequest(ctx, "send login code", map[string]string{
"email": input.Email.String(),
"code": input.Code,
payload, statusCode, err := c.doRequest(ctx, "send login code", input.IdempotencyKey, map[string]string{
"email": input.Email.String(),
"code": input.Code,
"locale": input.Locale,
})
if err != nil {
return ports.SendLoginCodeResult{}, err
@@ -121,7 +122,7 @@ func (c *RESTClient) SendLoginCode(ctx context.Context, input ports.SendLoginCod
return result, nil
}
func (c *RESTClient) doRequest(ctx context.Context, operation string, requestBody any) ([]byte, int, error) {
func (c *RESTClient) doRequest(ctx context.Context, operation string, idempotencyKey string, requestBody any) ([]byte, int, error) {
bodyBytes, err := json.Marshal(requestBody)
if err != nil {
return nil, 0, fmt.Errorf("%s: marshal request body: %w", operation, err)
@@ -135,6 +136,7 @@ func (c *RESTClient) doRequest(ctx context.Context, operation string, requestBod
return nil, 0, fmt.Errorf("%s: build request: %w", operation, err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Idempotency-Key", idempotencyKey)
response, err := c.httpClient.Do(request)
if err != nil {
@@ -128,7 +128,8 @@ func TestRESTClientSendLoginCodeSuccessCases(t *testing.T) {
assert.Equal(t, http.MethodPost, requests[0].Method)
assert.Equal(t, sendLoginCodePath, requests[0].Path)
assert.Equal(t, "application/json", requests[0].ContentType)
assert.JSONEq(t, `{"email":"pilot@example.com","code":"654321"}`, requests[0].Body)
assert.Equal(t, "challenge-1", requests[0].IdempotencyKey)
assert.JSONEq(t, `{"email":"pilot@example.com","code":"654321","locale":"en"}`, requests[0].Body)
})
}
}
@@ -136,9 +137,9 @@ func TestRESTClientSendLoginCodeSuccessCases(t *testing.T) {
func TestRESTClientPreservesNormalizedEmailAndCodeExactly(t *testing.T) {
t.Parallel()
var captured string
var captured capturedRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
captured = captureRequest(t, r).Body
captured = captureRequest(t, r)
writeJSON(t, w, http.StatusOK, map[string]string{"outcome": "sent"})
}))
defer server.Close()
@@ -146,12 +147,15 @@ func TestRESTClientPreservesNormalizedEmailAndCodeExactly(t *testing.T) {
client := newTestRESTClient(t, server.URL, 250*time.Millisecond)
result, err := client.SendLoginCode(context.Background(), ports.SendLoginCodeInput{
Email: common.Email("Pilot+Alias@Example.com"),
Code: "123456",
Email: common.Email("Pilot+Alias@Example.com"),
IdempotencyKey: "challenge-1",
Code: "123456",
Locale: "fr-FR",
})
require.NoError(t, err)
assert.Equal(t, ports.SendLoginCodeOutcomeSent, result.Outcome)
assert.JSONEq(t, `{"email":"Pilot+Alias@Example.com","code":"123456"}`, captured)
assert.Equal(t, "challenge-1", captured.IdempotencyKey)
assert.JSONEq(t, `{"email":"Pilot+Alias@Example.com","code":"123456","locale":"fr-FR"}`, captured.Body)
}
func TestRESTClientSendLoginCodeDoesNotRetry(t *testing.T) {
@@ -311,8 +315,10 @@ func TestRESTClientContextAndValidation(t *testing.T) {
name: "invalid email",
run: func() error {
_, err := client.SendLoginCode(context.Background(), ports.SendLoginCodeInput{
Email: common.Email(" bad@example.com "),
Code: "123456",
Email: common.Email(" bad@example.com "),
IdempotencyKey: "challenge-1",
Code: "123456",
Locale: "en",
})
return err
},
@@ -321,8 +327,34 @@ func TestRESTClientContextAndValidation(t *testing.T) {
name: "invalid code",
run: func() error {
_, err := client.SendLoginCode(context.Background(), ports.SendLoginCodeInput{
Email: common.Email("pilot@example.com"),
Code: " 123456 ",
Email: common.Email("pilot@example.com"),
IdempotencyKey: "challenge-1",
Code: " 123456 ",
Locale: "en",
})
return err
},
},
{
name: "invalid locale",
run: func() error {
_, err := client.SendLoginCode(context.Background(), ports.SendLoginCodeInput{
Email: common.Email("pilot@example.com"),
IdempotencyKey: "challenge-1",
Code: "123456",
Locale: " en ",
})
return err
},
},
{
name: "invalid idempotency key",
run: func() error {
_, err := client.SendLoginCode(context.Background(), ports.SendLoginCodeInput{
Email: common.Email("pilot@example.com"),
IdempotencyKey: " challenge-1 ",
Code: "123456",
Locale: "en",
})
return err
},
@@ -340,10 +372,11 @@ func TestRESTClientContextAndValidation(t *testing.T) {
}
type capturedRequest struct {
Method string
Path string
ContentType string
Body string
Method string
Path string
ContentType string
IdempotencyKey string
Body string
}
func captureRequest(t *testing.T, request *http.Request) capturedRequest {
@@ -353,10 +386,11 @@ func captureRequest(t *testing.T, request *http.Request) capturedRequest {
require.NoError(t, err)
return capturedRequest{
Method: request.Method,
Path: request.URL.Path,
ContentType: request.Header.Get("Content-Type"),
Body: strings.TrimSpace(string(body)),
Method: request.Method,
Path: request.URL.Path,
ContentType: request.Header.Get("Content-Type"),
IdempotencyKey: request.Header.Get("Idempotency-Key"),
Body: strings.TrimSpace(string(body)),
}
}
@@ -60,7 +60,8 @@ func (step StubStep) Validate() error {
return nil
}
// Attempt records one validated delivery request handled by StubSender.
// Attempt records one validated delivery request handled by StubSender,
// including the auth challenge-derived idempotency key.
type Attempt struct {
// Input stores the validated cleartext mail-delivery request exactly as it
// was passed into SendLoginCode.
@@ -192,7 +192,9 @@ func TestStubSenderSendLoginCodeInvalidInput(t *testing.T) {
func validInput() ports.SendLoginCodeInput {
return ports.SendLoginCodeInput{
Email: common.Email("pilot@example.com"),
Code: "654321",
Email: common.Email("pilot@example.com"),
IdempotencyKey: "challenge-1",
Code: "654321",
Locale: "en",
}
}
@@ -24,6 +24,8 @@ import (
const expirationGracePeriod = 5 * time.Minute
const defaultPreferredLanguage = "en"
// Config configures one Redis-backed challenge store instance.
type Config struct {
// Addr is the Redis network address in host:port form.
@@ -59,6 +61,7 @@ type redisRecord struct {
ChallengeID string `json:"challenge_id"`
Email string `json:"email"`
CodeHashBase64 string `json:"code_hash_base64"`
PreferredLanguage string `json:"preferred_language,omitempty"`
Status challenge.Status `json:"status"`
DeliveryState challenge.DeliveryState `json:"delivery_state"`
CreatedAt string `json:"created_at"`
@@ -291,6 +294,7 @@ func redisRecordFromChallenge(record challenge.Challenge) (redisRecord, error) {
ChallengeID: record.ID.String(),
Email: record.Email.String(),
CodeHashBase64: base64.StdEncoding.EncodeToString(record.CodeHash),
PreferredLanguage: record.PreferredLanguage,
Status: record.Status,
DeliveryState: record.DeliveryState,
CreatedAt: formatTimestamp(record.CreatedAt),
@@ -354,13 +358,14 @@ func challengeFromRedisRecord(stored redisRecord) (challenge.Challenge, error) {
}
record := challenge.Challenge{
ID: common.ChallengeID(stored.ChallengeID),
Email: common.Email(stored.Email),
CodeHash: codeHash,
Status: stored.Status,
DeliveryState: stored.DeliveryState,
CreatedAt: createdAt,
ExpiresAt: expiresAt,
ID: common.ChallengeID(stored.ChallengeID),
Email: common.Email(stored.Email),
CodeHash: codeHash,
PreferredLanguage: normalizeStoredPreferredLanguage(stored.PreferredLanguage),
Status: stored.Status,
DeliveryState: stored.DeliveryState,
CreatedAt: createdAt,
ExpiresAt: expiresAt,
Attempts: challenge.AttemptCounters{
Send: stored.SendAttemptCount,
Confirm: stored.ConfirmAttemptCount,
@@ -459,6 +464,15 @@ func formatOptionalTimestamp(value *time.Time) *string {
return &formatted
}
func normalizeStoredPreferredLanguage(value string) string {
preferredLanguage := strings.TrimSpace(value)
if preferredLanguage == "" {
return defaultPreferredLanguage
}
return preferredLanguage
}
func redisTTL(expiresAt time.Time) time.Duration {
ttl := time.Until(expiresAt.UTC())
if ttl < 0 {
@@ -451,13 +451,14 @@ func newTestStore(t *testing.T, server *miniredis.Miniredis, cfg Config) *Store
func testPendingChallenge(now time.Time) challenge.Challenge {
return challenge.Challenge{
ID: common.ChallengeID("challenge-pending"),
Email: common.Email("pilot@example.com"),
CodeHash: []byte("hashed-pending-code"),
Status: challenge.StatusPendingSend,
DeliveryState: challenge.DeliveryPending,
CreatedAt: now,
ExpiresAt: now.Add(challenge.InitialTTL),
ID: common.ChallengeID("challenge-pending"),
Email: common.Email("pilot@example.com"),
CodeHash: []byte("hashed-pending-code"),
PreferredLanguage: "en",
Status: challenge.StatusPendingSend,
DeliveryState: challenge.DeliveryPending,
CreatedAt: now,
ExpiresAt: now.Add(challenge.InitialTTL),
}
}
@@ -473,13 +474,14 @@ func testChallenge(now time.Time) challenge.Challenge {
}
return challenge.Challenge{
ID: common.ChallengeID("challenge-confirmed"),
Email: common.Email("pilot@example.com"),
CodeHash: []byte("hashed-code"),
Status: challenge.StatusConfirmedPendingExpire,
DeliveryState: challenge.DeliverySent,
CreatedAt: now,
ExpiresAt: now.Add(challenge.ConfirmedRetention),
ID: common.ChallengeID("challenge-confirmed"),
Email: common.Email("pilot@example.com"),
CodeHash: []byte("hashed-code"),
PreferredLanguage: "en",
Status: challenge.StatusConfirmedPendingExpire,
DeliveryState: challenge.DeliverySent,
CreatedAt: now,
ExpiresAt: now.Add(challenge.ConfirmedRetention),
Attempts: challenge.AttemptCounters{
Send: 1,
Confirm: 2,
@@ -495,6 +497,36 @@ func testChallenge(now time.Time) challenge.Challenge {
}
}
func TestStoreGetDefaultsMissingPreferredLanguageToEnglish(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
store := newTestStore(t, server, Config{})
now := time.Unix(1_775_130_250, 0).UTC()
record := testPendingChallenge(now)
stored, err := redisRecordFromChallenge(record)
require.NoError(t, err)
stored.PreferredLanguage = ""
payload := mustMarshalJSON(t, map[string]any{
"challenge_id": stored.ChallengeID,
"email": stored.Email,
"code_hash_base64": stored.CodeHashBase64,
"status": stored.Status,
"delivery_state": stored.DeliveryState,
"created_at": stored.CreatedAt,
"expires_at": stored.ExpiresAt,
"send_attempt_count": stored.SendAttemptCount,
"confirm_attempt_count": stored.ConfirmAttemptCount,
})
server.Set(store.lookupKey(record.ID), payload)
got, err := store.Get(context.Background(), record.ID)
require.NoError(t, err)
assert.Equal(t, "en", got.PreferredLanguage)
}
func timePointer(value time.Time) *time.Time {
return &value
}
+16 -11
View File
@@ -271,10 +271,11 @@ type endToEndOptions struct {
}
type seedChallengeOptions struct {
ID string
Code string
Status challenge.Status
ExpiresAt time.Time
ID string
Code string
Status challenge.Status
ExpiresAt time.Time
PreferredLanguage string
}
type endToEndApp struct {
@@ -312,13 +313,17 @@ func newEndToEndApp(t *testing.T, options endToEndOptions) endToEndApp {
}
record := challenge.Challenge{
ID: common.ChallengeID(options.SeedChallenge.ID),
Email: common.Email("pilot@example.com"),
CodeHash: mustHashCode(t, options.SeedChallenge.Code),
Status: options.SeedChallenge.Status,
DeliveryState: deliveryStateForSeedChallenge(options.SeedChallenge.Status),
CreatedAt: now.Add(-time.Minute),
ExpiresAt: expiresAt,
ID: common.ChallengeID(options.SeedChallenge.ID),
Email: common.Email("pilot@example.com"),
CodeHash: mustHashCode(t, options.SeedChallenge.Code),
PreferredLanguage: options.SeedChallenge.PreferredLanguage,
Status: options.SeedChallenge.Status,
DeliveryState: deliveryStateForSeedChallenge(options.SeedChallenge.Status),
CreatedAt: now.Add(-time.Minute),
ExpiresAt: expiresAt,
}
if record.PreferredLanguage == "" {
record.PreferredLanguage = "en"
}
require.NoError(t, challengeStore.Create(context.Background(), record))
}
@@ -110,7 +110,10 @@ func handleSendEmailCode(useCase SendEmailCodeUseCase, timeout time.Duration) gi
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, sendemailcode.Input{Email: request.Email})
result, err := useCase.Execute(callCtx, sendemailcode.Input{
Email: request.Email,
AcceptLanguage: c.GetHeader("Accept-Language"),
})
if err != nil {
abortWithProjection(c, projectSendEmailCodeError(err))
return
@@ -25,7 +25,11 @@ func TestSendEmailCodeHandlerSuccess(t *testing.T) {
t.Parallel()
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
SendEmailCode: sendEmailCodeFunc(func(_ context.Context, input sendemailcode.Input) (sendemailcode.Result, error) {
assert.Equal(t, sendemailcode.Input{
Email: "pilot@example.com",
AcceptLanguage: "fr-FR, en;q=0.8",
}, input)
return sendemailcode.Result{ChallengeID: "challenge-123"}, nil
}),
ConfirmEmailCode: confirmEmailCodeFunc(func(context.Context, confirmemailcode.Input) (confirmemailcode.Result, error) {
@@ -40,6 +44,7 @@ func TestSendEmailCodeHandlerSuccess(t *testing.T) {
bytes.NewBufferString(`{"email":" pilot@example.com "}`),
)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept-Language", "fr-FR, en;q=0.8")
handler.ServeHTTP(recorder, request)
@@ -5,6 +5,7 @@ package challenge
import (
"errors"
"fmt"
"strings"
"time"
"galaxy/authsession/internal/domain/common"
@@ -239,6 +240,10 @@ type Challenge struct {
// CodeHash stores only the hashed confirmation code.
CodeHash []byte
// PreferredLanguage stores the canonical create-only preferred-language
// candidate derived when the challenge was created.
PreferredLanguage string
// Status reports the coarse challenge lifecycle state.
Status Status
@@ -279,6 +284,12 @@ func (c Challenge) Validate() error {
if len(c.CodeHash) == 0 {
return errors.New("challenge code hash must not be empty")
}
if strings.TrimSpace(c.PreferredLanguage) == "" {
return errors.New("challenge preferred language must not be empty")
}
if strings.TrimSpace(c.PreferredLanguage) != c.PreferredLanguage {
return errors.New("challenge preferred language must not contain surrounding whitespace")
}
if !c.Status.IsKnown() {
return fmt.Errorf("challenge status %q is unsupported", c.Status)
}
@@ -404,13 +404,14 @@ func validChallenge(t *testing.T) Challenge {
t.Helper()
return Challenge{
ID: common.ChallengeID("challenge-123"),
Email: common.Email("pilot@example.com"),
CodeHash: []byte("hash-123"),
Status: StatusPendingSend,
DeliveryState: DeliveryPending,
CreatedAt: time.Unix(1_775_121_600, 0).UTC(),
ExpiresAt: time.Unix(1_775_121_900, 0).UTC(),
ID: common.ChallengeID("challenge-123"),
Email: common.Email("pilot@example.com"),
CodeHash: []byte("hash-123"),
PreferredLanguage: "en",
Status: StatusPendingSend,
DeliveryState: DeliveryPending,
CreatedAt: time.Unix(1_775_121_600, 0).UTC(),
ExpiresAt: time.Unix(1_775_121_900, 0).UTC(),
Attempts: AttemptCounters{
Send: 0,
Confirm: 0,
+16
View File
@@ -24,8 +24,16 @@ type SendLoginCodeInput struct {
// Email identifies the normalized target e-mail address.
Email common.Email
// IdempotencyKey stores the raw challenge_id value sent to Mail Service as
// the required Idempotency-Key header.
IdempotencyKey string
// Code stores the cleartext login code that should be delivered to Email.
Code string
// Locale stores the canonical BCP 47 language tag that selects the auth
// mail template locale.
Locale string
}
// Validate reports whether SendLoginCodeInput contains a complete delivery
@@ -35,10 +43,18 @@ func (i SendLoginCodeInput) Validate() error {
return fmt.Errorf("send login code input email: %w", err)
}
switch {
case strings.TrimSpace(i.IdempotencyKey) == "":
return errors.New("send login code input idempotency key must not be empty")
case strings.TrimSpace(i.IdempotencyKey) != i.IdempotencyKey:
return errors.New("send login code input idempotency key must not contain surrounding whitespace")
case strings.TrimSpace(i.Code) == "":
return errors.New("send login code input code must not be empty")
case strings.TrimSpace(i.Code) != i.Code:
return errors.New("send login code input code must not contain surrounding whitespace")
case strings.TrimSpace(i.Locale) == "":
return errors.New("send login code input locale must not be empty")
case strings.TrimSpace(i.Locale) != i.Locale:
return errors.New("send login code input locale must not contain surrounding whitespace")
default:
return nil
}
+12 -9
View File
@@ -310,8 +310,10 @@ func TestSendLoginCodeInputAndResultValidate(t *testing.T) {
t.Parallel()
input := SendLoginCodeInput{
Email: common.Email("pilot@example.com"),
Code: "654321",
Email: common.Email("pilot@example.com"),
IdempotencyKey: "challenge-1",
Code: "654321",
Locale: "en",
}
if err := input.Validate(); err != nil {
require.Failf(t, "test failed", "SendLoginCodeInput.Validate() returned error: %v", err)
@@ -339,13 +341,14 @@ func TestValidateComparableChallenges(t *testing.T) {
func challengeFixture() challenge.Challenge {
timestamp := time.Unix(10, 0).UTC()
return challenge.Challenge{
ID: common.ChallengeID("challenge-1"),
Email: common.Email("pilot@example.com"),
CodeHash: []byte("hash"),
Status: challenge.StatusPendingSend,
DeliveryState: challenge.DeliveryPending,
CreatedAt: timestamp,
ExpiresAt: timestamp.Add(5 * time.Minute),
ID: common.ChallengeID("challenge-1"),
Email: common.Email("pilot@example.com"),
CodeHash: []byte("hash"),
PreferredLanguage: "en",
Status: challenge.StatusPendingSend,
DeliveryState: challenge.DeliveryPending,
CreatedAt: timestamp,
ExpiresAt: timestamp.Add(5 * time.Minute),
}
}
@@ -19,10 +19,9 @@ import (
)
const (
revokeReasonConfirmRace common.RevokeReasonCode = "confirm_race_repair"
revokeActorTypeService common.RevokeActorType = "service"
revokeActorIDService = "confirmemailcode"
defaultPreferredLanguage = "en"
revokeReasonConfirmRace common.RevokeReasonCode = "confirm_race_repair"
revokeActorTypeService common.RevokeActorType = "service"
revokeActorIDService = "confirmemailcode"
)
// Input describes one public confirm-email-code request.
@@ -249,7 +248,7 @@ func (s *Service) Execute(ctx context.Context, input Input) (result Result, err
ensureUserResult, err := s.userDirectory.EnsureUserByEmail(ctx, ports.EnsureUserInput{
Email: current.Email,
RegistrationContext: &ports.RegistrationContext{
PreferredLanguage: defaultPreferredLanguage,
PreferredLanguage: shared.ResolvePreferredLanguage(current.PreferredLanguage),
TimeZone: timeZone,
},
})
@@ -70,7 +70,12 @@ func TestExecuteConfirmsChallengeByCreatingUser(t *testing.T) {
if err := deps.userDirectory.QueueCreatedUserIDs(common.UserID("user-created")); err != nil {
require.Failf(t, "test failed", "QueueCreatedUserIDs() returned error: %v", err)
}
if err := deps.challengeStore.Create(context.Background(), sentChallengeFixture(t, deps.hasher, "challenge-1", "new@example.com", "654321", deps.now.Add(-time.Minute), deps.now.Add(time.Minute))); err != nil {
record := sentChallengeFixture(t, deps.hasher, "challenge-1", "new@example.com", "654321", deps.now.Add(-time.Minute), deps.now.Add(time.Minute))
record.PreferredLanguage = "fr-FR"
if err := record.Validate(); err != nil {
require.Failf(t, "test failed", "Validate() returned error: %v", err)
}
if err := deps.challengeStore.Create(context.Background(), record); err != nil {
require.Failf(t, "test failed", "Create() returned error: %v", err)
}
@@ -88,12 +93,12 @@ func TestExecuteConfirmsChallengeByCreatingUser(t *testing.T) {
require.Failf(t, "test failed", "Execute().DeviceSessionID = %q, want %q", result.DeviceSessionID, "device-session-1")
}
record, err := deps.sessionStore.Get(context.Background(), common.DeviceSessionID("device-session-1"))
session, err := deps.sessionStore.Get(context.Background(), common.DeviceSessionID("device-session-1"))
if err != nil {
require.Failf(t, "test failed", "Get() returned error: %v", err)
}
if record.UserID != common.UserID("user-created") {
require.Failf(t, "test failed", "session user id = %q, want %q", record.UserID, common.UserID("user-created"))
if session.UserID != common.UserID("user-created") {
require.Failf(t, "test failed", "session user id = %q, want %q", session.UserID, common.UserID("user-created"))
}
}
@@ -556,7 +561,12 @@ func TestExecutePassesRegistrationContextToUserDirectory(t *testing.T) {
if err := recordingDirectory.delegate.QueueCreatedUserIDs(common.UserID("user-created")); err != nil {
require.Failf(t, "test failed", "QueueCreatedUserIDs() returned error: %v", err)
}
if err := deps.challengeStore.Create(context.Background(), sentChallengeFixture(t, deps.hasher, "challenge-1", "new@example.com", "654321", deps.now.Add(-time.Minute), deps.now.Add(time.Minute))); err != nil {
record := sentChallengeFixture(t, deps.hasher, "challenge-1", "new@example.com", "654321", deps.now.Add(-time.Minute), deps.now.Add(time.Minute))
record.PreferredLanguage = "fr-FR"
if err := record.Validate(); err != nil {
require.Failf(t, "test failed", "Validate() returned error: %v", err)
}
if err := deps.challengeStore.Create(context.Background(), record); err != nil {
require.Failf(t, "test failed", "Create() returned error: %v", err)
}
@@ -589,8 +599,8 @@ func TestExecutePassesRegistrationContextToUserDirectory(t *testing.T) {
if recordingDirectory.lastEnsureInput.RegistrationContext == nil {
require.FailNow(t, "last ensure registration context = nil, want value")
}
if recordingDirectory.lastEnsureInput.RegistrationContext.PreferredLanguage != "en" {
require.Failf(t, "test failed", "preferred language = %q, want %q", recordingDirectory.lastEnsureInput.RegistrationContext.PreferredLanguage, "en")
if recordingDirectory.lastEnsureInput.RegistrationContext.PreferredLanguage != "fr-FR" {
require.Failf(t, "test failed", "preferred language = %q, want %q", recordingDirectory.lastEnsureInput.RegistrationContext.PreferredLanguage, "fr-FR")
}
if recordingDirectory.lastEnsureInput.RegistrationContext.TimeZone != confirmEmailCodeTimeZone {
require.Failf(t, "test failed", "time zone = %q, want %q", recordingDirectory.lastEnsureInput.RegistrationContext.TimeZone, confirmEmailCodeTimeZone)
@@ -700,13 +710,14 @@ func sentChallengeFixture(
}
record := challenge.Challenge{
ID: common.ChallengeID(challengeID),
Email: common.Email(email),
CodeHash: codeHash,
Status: challenge.StatusSent,
DeliveryState: challenge.DeliverySent,
CreatedAt: createdAt,
ExpiresAt: expiresAt,
ID: common.ChallengeID(challengeID),
Email: common.Email(email),
CodeHash: codeHash,
PreferredLanguage: "en",
Status: challenge.StatusSent,
DeliveryState: challenge.DeliverySent,
CreatedAt: createdAt,
ExpiresAt: expiresAt,
}
if err := record.Validate(); err != nil {
require.Failf(t, "test failed", "Validate() returned error: %v", err)
@@ -20,6 +20,11 @@ type Input struct {
// Email is the user-supplied e-mail address that should receive the login
// code.
Email string
// AcceptLanguage stores the optional public Accept-Language header forwarded
// by gateway for auth-mail localization and create-only registration
// context.
AcceptLanguage string
}
// Result describes one public send-email-code response.
@@ -160,6 +165,7 @@ func (s *Service) Execute(ctx context.Context, input Input) (result Result, err
if err != nil {
return Result{}, err
}
preferredLanguage := shared.ResolvePreferredLanguage(input.AcceptLanguage)
now := s.clock.Now().UTC()
abuseResult, err := s.abuseProtector.CheckAndReserve(ctx, ports.SendEmailCodeAbuseInput{
@@ -191,13 +197,14 @@ func (s *Service) Execute(ctx context.Context, input Input) (result Result, err
return Result{}, shared.InternalError(err)
}
pending := challenge.Challenge{
ID: challengeID,
Email: email,
CodeHash: codeHash,
Status: pendingStatus,
DeliveryState: pendingDeliveryState,
CreatedAt: now,
ExpiresAt: now.Add(challenge.InitialTTL),
ID: challengeID,
Email: email,
CodeHash: codeHash,
PreferredLanguage: preferredLanguage,
Status: pendingStatus,
DeliveryState: pendingDeliveryState,
CreatedAt: now,
ExpiresAt: now.Add(challenge.InitialTTL),
}
if err := pending.Validate(); err != nil {
return Result{}, shared.InternalError(err)
@@ -240,8 +247,10 @@ func (s *Service) Execute(ctx context.Context, input Input) (result Result, err
return result, err
default:
deliveryResult, err := s.mailSender.SendLoginCode(ctx, ports.SendLoginCodeInput{
Email: email,
Code: code,
Email: email,
IdempotencyKey: challengeID.String(),
Code: code,
Locale: preferredLanguage,
})
if err != nil {
final.Status = challenge.StatusFailed
@@ -72,6 +72,9 @@ func TestExecuteSendsChallengeForExistingAndCreatableUsers(t *testing.T) {
if len(mailSender.RecordedInputs()) != 1 {
require.Failf(t, "test failed", "RecordedInputs() length = %d, want 1", len(mailSender.RecordedInputs()))
}
if mailSender.RecordedInputs()[0].Locale != "en" {
require.Failf(t, "test failed", "mail locale = %q, want %q", mailSender.RecordedInputs()[0].Locale, "en")
}
record, err := challengeStore.Get(context.Background(), common.ChallengeID("challenge-1"))
if err != nil {
@@ -83,6 +86,9 @@ func TestExecuteSendsChallengeForExistingAndCreatableUsers(t *testing.T) {
if record.Attempts.Send != 1 {
require.Failf(t, "test failed", "Attempts.Send = %d, want 1", record.Attempts.Send)
}
if record.PreferredLanguage != "en" {
require.Failf(t, "test failed", "PreferredLanguage = %q, want %q", record.PreferredLanguage, "en")
}
if string(record.CodeHash) == "654321" {
require.FailNow(t, "CodeHash stored cleartext code")
}
@@ -131,6 +137,9 @@ func TestExecuteSuppressesDeliveryForBlockedEmail(t *testing.T) {
if record.Status != challenge.StatusDeliverySuppressed || record.DeliveryState != challenge.DeliverySuppressed {
require.Failf(t, "test failed", "challenge state = %q/%q", record.Status, record.DeliveryState)
}
if record.PreferredLanguage != "en" {
require.Failf(t, "test failed", "PreferredLanguage = %q, want %q", record.PreferredLanguage, "en")
}
}
func TestExecuteHandlesMailSenderSuppressedOutcome(t *testing.T) {
@@ -166,6 +175,9 @@ func TestExecuteHandlesMailSenderSuppressedOutcome(t *testing.T) {
if record.Status != challenge.StatusDeliverySuppressed || record.DeliveryState != challenge.DeliverySuppressed {
require.Failf(t, "test failed", "challenge state = %q/%q", record.Status, record.DeliveryState)
}
if record.PreferredLanguage != "en" {
require.Failf(t, "test failed", "PreferredLanguage = %q, want %q", record.PreferredLanguage, "en")
}
}
func TestExecuteMarksChallengeFailedWhenMailSenderFails(t *testing.T) {
@@ -199,6 +211,9 @@ func TestExecuteMarksChallengeFailedWhenMailSenderFails(t *testing.T) {
if record.Status != challenge.StatusFailed || record.DeliveryState != challenge.DeliveryFailed {
require.Failf(t, "test failed", "challenge state = %q/%q", record.Status, record.DeliveryState)
}
if record.PreferredLanguage != "en" {
require.Failf(t, "test failed", "PreferredLanguage = %q, want %q", record.PreferredLanguage, "en")
}
}
func TestExecuteReturnsInvalidRequestForBadEmail(t *testing.T) {
@@ -308,3 +323,69 @@ func TestExecuteSetsChallengeExpirationFromInitialTTL(t *testing.T) {
require.Failf(t, "test failed", "ExpiresAt = %s, want %s", record.ExpiresAt, wantExpiresAt)
}
}
func TestExecuteResolvesPreferredLanguageFromAcceptLanguage(t *testing.T) {
t.Parallel()
tests := []struct {
name string
acceptLanguage string
wantPreferredLang string
}{
{
name: "canonical valid tag wins",
acceptLanguage: "fr-FR, en;q=0.8",
wantPreferredLang: "fr-FR",
},
{
name: "wildcard falls back to english",
acceptLanguage: "*",
wantPreferredLang: "en",
},
{
name: "malformed header falls back to english",
acceptLanguage: "fr-FR, @@",
wantPreferredLang: "en",
},
{
name: "missing header falls back to english",
acceptLanguage: "",
wantPreferredLang: "en",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
challengeStore := &testkit.InMemoryChallengeStore{}
mailSender := &testkit.RecordingMailSender{}
service, err := New(
challengeStore,
&testkit.InMemoryUserDirectory{},
&testkit.SequenceIDGenerator{ChallengeIDs: []common.ChallengeID{"challenge-1"}},
testkit.FixedCodeGenerator{Code: "654321"},
testkit.DeterministicCodeHasher{},
mailSender,
testkit.FixedClock{Time: time.Unix(10, 0).UTC()},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), Input{
Email: "pilot@example.com",
AcceptLanguage: tt.acceptLanguage,
})
require.NoError(t, err)
record, err := challengeStore.Get(context.Background(), common.ChallengeID("challenge-1"))
require.NoError(t, err)
require.Equal(t, tt.wantPreferredLang, record.PreferredLanguage)
attempts := mailSender.RecordedInputs()
require.Len(t, attempts, 1)
require.Equal(t, tt.wantPreferredLang, attempts[0].Locale)
})
}
}
@@ -93,6 +93,7 @@ func TestExecuteWithStubSender(t *testing.T) {
require.Len(t, attempts, tt.wantRecordedAttempt)
assert.Equal(t, common.Email("pilot@example.com"), attempts[0].Input.Email)
assert.Equal(t, "654321", attempts[0].Input.Code)
assert.Equal(t, "en", attempts[0].Input.Locale)
})
}
}
@@ -0,0 +1,27 @@
package shared
import "golang.org/x/text/language"
const defaultPreferredLanguage = "en"
// ResolvePreferredLanguage returns the first canonical BCP 47 language tag
// accepted from value, or the stable "en" fallback when the input is absent,
// malformed, or too unspecific for auth registration purposes.
func ResolvePreferredLanguage(value string) string {
tags, _, err := language.ParseAcceptLanguage(value)
if err != nil {
return defaultPreferredLanguage
}
for _, tag := range tags {
canonical := tag.String()
switch canonical {
case "", "und", "mul":
continue
default:
return canonical
}
}
return defaultPreferredLanguage
}
@@ -0,0 +1,51 @@
package shared
import "testing"
func TestResolvePreferredLanguage(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value string
want string
}{
{
name: "canonical valid tag",
value: "fr-FR, en;q=0.8",
want: "fr-FR",
},
{
name: "quality ordering",
value: "en-US;q=0.9, fr",
want: "fr",
},
{
name: "wildcard falls back",
value: "*",
want: "en",
},
{
name: "malformed falls back",
value: "fr-FR, @@",
want: "en",
},
{
name: "missing falls back",
value: "",
want: "en",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := ResolvePreferredLanguage(tt.value); got != tt.want {
t.Fatalf("ResolvePreferredLanguage(%q) = %q, want %q", tt.value, got, tt.want)
}
})
}
}
@@ -69,12 +69,13 @@ func TestInMemoryChallengeStoreCompareAndSwapConflict(t *testing.T) {
func challengeFixture() challenge.Challenge {
timestamp := time.Unix(20, 0).UTC()
return challenge.Challenge{
ID: common.ChallengeID("challenge-1"),
Email: common.Email("pilot@example.com"),
CodeHash: []byte("hash"),
Status: challenge.StatusPendingSend,
DeliveryState: challenge.DeliveryPending,
CreatedAt: timestamp,
ExpiresAt: timestamp.Add(10 * time.Minute),
ID: common.ChallengeID("challenge-1"),
Email: common.Email("pilot@example.com"),
CodeHash: []byte("hash"),
PreferredLanguage: "en",
Status: challenge.StatusPendingSend,
DeliveryState: challenge.DeliveryPending,
CreatedAt: timestamp,
ExpiresAt: timestamp.Add(10 * time.Minute),
}
}
+2 -1
View File
@@ -8,7 +8,8 @@ import (
)
// RecordingMailSender is a deterministic MailSender double that records every
// delivery request and returns preconfigured outcomes or errors.
// delivery request, including the auth challenge-derived idempotency key, and
// returns preconfigured outcomes or errors.
type RecordingMailSender struct {
mu sync.Mutex
+4 -2
View File
@@ -97,8 +97,10 @@ func TestRecordingMailSender(t *testing.T) {
}
result, err := sender.SendLoginCode(context.Background(), ports.SendLoginCodeInput{
Email: common.Email("pilot@example.com"),
Code: "654321",
Email: common.Email("pilot@example.com"),
IdempotencyKey: "challenge-1",
Code: "654321",
Locale: "en",
})
if err != nil {
require.Failf(t, "test failed", "SendLoginCode() returned error: %v", err)