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
+26 -5
View File
@@ -84,7 +84,8 @@ The service is not responsible for:
- downstream business authorization
- direct push delivery to clients
- long-lived hot-path session caching inside gateway
- mail-service implementation details beyond the mail-delivery contract
- mail-service implementation details beyond the dedicated login-code delivery
REST contract
## Position in the System
@@ -140,15 +141,23 @@ The effective DTO contract is:
| `POST /api/v1/public/auth/send-email-code` | `{ "email": string }` | `{ "challenge_id": string }` |
| `POST /api/v1/public/auth/confirm-email-code` | `{ "challenge_id": string, "code": string, "client_public_key": string, "time_zone": string }` | `{ "device_session_id": string }` |
`send-email-code` may additionally receive the optional public
`Accept-Language` header through gateway. Auth resolves the first supported
BCP 47 language tag from that header, falls back to `en` when no supported
value is available, uses the resolved value as the auth-mail locale for the
dedicated `Mail Service` REST contract, and stores it on the challenge as the
create-only preferred-language candidate for a later first-user ensure step.
The created `challenge_id` is sent to `Mail Service` as the raw
`Idempotency-Key` header value of that dedicated REST call.
`client_public_key` is the standard base64-encoded raw 32-byte Ed25519 public
key registered for the created device session.
`time_zone` is the client-selected IANA time zone name. During the current
rollout phase, successful confirms forward create-only user registration
context to `User Service` as `preferred_language="en"` and the supplied
`time_zone` until gateway geoip-based language derivation is deployed.
context to `User Service` as the stored preferred-language candidate from
`send-email-code` and the supplied `time_zone`.
`User Service` now validates `preferred_language` as BCP 47 and canonicalizes
the stored value on creation, so any future derived language must already be a
valid BCP 47 tag before auth forwards it.
the stored value on creation, so the derived public language value must
already be a valid BCP 47 tag before auth forwards it.
Public boundary rules:
@@ -162,6 +171,9 @@ Public boundary rules:
IANA time zone name
- `send-email-code` remains success-shaped for existing, new, blocked, and
throttled e-mail paths
- `send-email-code` may use optional public `Accept-Language` to derive and
store the auth-mail locale plus future create-only `preferred_language`
candidate; unsupported or missing values fall back to `en`
- `confirm-email-code` returns a ready `device_session_id` synchronously on
success
@@ -236,6 +248,7 @@ Core fields:
- creation and expiration timestamps
- send and confirm attempt counters
- minimal abuse metadata
- stored preferred-language candidate derived at send time
- optional confirmation metadata used for idempotent retry
### Challenge States
@@ -259,6 +272,14 @@ Supported `challenge.DeliveryState` values:
- `throttled`
- `failed`
For the dedicated `Mail Service` REST contract, `delivery_state=sent` means
auth successfully handed the request off to
`POST /api/v1/internal/login-code-deliveries` and the mail-delivery pipeline.
That call uses the created `challenge_id` as the raw `Idempotency-Key` header
value.
It does not require that the SMTP provider exchange already completed before
`challenge_id` was returned to the caller.
Policy rules:
- initial challenge TTL is `5m`
+20
View File
@@ -36,7 +36,15 @@ paths:
Accepts one client e-mail address and starts the public challenge flow.
The outward result remains success-shaped even when the underlying
policy suppresses mail delivery for anti-enumeration purposes.
The JSON body stays unchanged. Gateway may additionally forward the
optional public `Accept-Language` header so auth can derive the
auth-mail locale and the create-only preferred-language candidate used
later during first-user creation. Missing or unsupported values fall
back to `en`.
security: []
parameters:
- $ref: "#/components/parameters/AcceptLanguage"
requestBody:
required: true
content:
@@ -111,6 +119,18 @@ paths:
"503":
$ref: "#/components/responses/ServiceUnavailableError"
components:
parameters:
AcceptLanguage:
name: Accept-Language
in: header
required: false
description: |
Optional RFC 9110 `Accept-Language` header forwarded by gateway so
auth can derive the auth-mail locale and create-only
preferred-language candidate. The first supported BCP 47 tag wins;
unsupported or missing values fall back to `en`.
schema:
type: string
schemas:
SendEmailCodeRequest:
type: object
+52
View File
@@ -62,12 +62,37 @@ func TestPublicOpenAPISpecMatchesGatewayPublicAuthContract(t *testing.T) {
responseSchemaRef(t, gatewayOperation, http.StatusOK),
"path "+path+" success response schema",
)
compareParameterRefs(
t,
authOperation.Parameters,
gatewayOperation.Parameters,
"path "+path+" parameters",
)
for _, status := range publicErrorStatuses(path) {
assertSchemaRef(t, responseSchemaRef(t, authOperation, status), errorResponseRef, "path "+path+" error response "+http.StatusText(status)+" envelope")
}
}
assertOperationParameterRefs(
t,
getOperation(t, authDoc, "/api/v1/public/auth/send-email-code", http.MethodPost),
"#/components/parameters/AcceptLanguage",
)
assertOperationParameterRefs(
t,
getOperation(t, gatewayDoc, "/api/v1/public/auth/send-email-code", http.MethodPost),
"#/components/parameters/AcceptLanguage",
)
assertOperationParameterRefs(
t,
getOperation(t, authDoc, "/api/v1/public/auth/confirm-email-code", http.MethodPost),
)
assertOperationParameterRefs(
t,
getOperation(t, gatewayDoc, "/api/v1/public/auth/confirm-email-code", http.MethodPost),
)
compareSchemaRefs(
t,
authErrorEnvelope,
@@ -352,6 +377,16 @@ func compareSchemaRefs(t *testing.T, got *openapi3.SchemaRef, want *openapi3.Sch
}
}
func compareParameterRefs(t *testing.T, got openapi3.Parameters, want openapi3.Parameters, name string) {
t.Helper()
gotJSON := mustJSON(t, got)
wantJSON := mustJSON(t, want)
if !bytes.Equal(gotJSON, wantJSON) {
require.Failf(t, "test failed", "%s mismatch:\n got: %s\nwant: %s", name, gotJSON, wantJSON)
}
}
func assertSchemaRef(t *testing.T, schemaRef *openapi3.SchemaRef, want string, name string) {
t.Helper()
@@ -360,6 +395,23 @@ func assertSchemaRef(t *testing.T, schemaRef *openapi3.SchemaRef, want string, n
}
}
func assertOperationParameterRefs(t *testing.T, operation *openapi3.Operation, refs ...string) {
t.Helper()
if len(operation.Parameters) != len(refs) {
require.Failf(t, "test failed", "operation parameter count = %d, want %d", len(operation.Parameters), len(refs))
}
for index, want := range refs {
if operation.Parameters[index] == nil {
require.Failf(t, "test failed", "operation parameter %d is nil", index)
}
if operation.Parameters[index].Ref != want {
require.Failf(t, "test failed", "operation parameter %d ref = %q, want %q", index, operation.Parameters[index].Ref, want)
}
}
}
func assertRequiredFields(t *testing.T, schemaRef *openapi3.SchemaRef, fields ...string) {
t.Helper()
+13 -6
View File
@@ -9,14 +9,14 @@ sequenceDiagram
participant Auth
participant Abuse as Resend throttle
participant User as UserDirectory
participant Mail as MailSender
participant Mail as Mail Service REST
participant Challenge as ChallengeStore
participant Session as SessionStore
participant Config as ConfigProvider
participant Projection as Gateway projection publisher
Client->>Gateway: POST /api/v1/public/auth/send-email-code
Gateway->>Auth: POST /api/v1/public/auth/send-email-code
Client->>Gateway: POST /api/v1/public/auth/send-email-code + Accept-Language
Gateway->>Auth: POST /api/v1/public/auth/send-email-code + Accept-Language
Auth->>Abuse: check and reserve cooldown
alt throttled
Abuse-->>Auth: throttled
@@ -30,8 +30,8 @@ sequenceDiagram
alt blocked
Auth->>Challenge: mark delivery_suppressed
else not blocked
Auth->>Mail: SendLoginCode(email, code)
Mail-->>Auth: sent / suppressed / failure
Auth->>Mail: POST /api/v1/internal/login-code-deliveries + Idempotency-Key=challenge_id
Mail-->>Auth: 200 {outcome=sent|suppressed} / 503
Auth->>Challenge: persist final delivery outcome
end
Auth-->>Gateway: 200 {challenge_id}
@@ -40,7 +40,7 @@ sequenceDiagram
Client->>Gateway: POST /api/v1/public/auth/confirm-email-code
Gateway->>Auth: POST /api/v1/public/auth/confirm-email-code
Auth->>Challenge: load and validate challenge
Auth->>User: EnsureUserByEmail(email, registration_context)
Auth->>User: EnsureUserByEmail(email, stored preferred_language + time_zone)
User-->>Auth: existing / created / blocked
Auth->>Config: LoadSessionLimit()
Auth->>Session: CountActiveByUserID(user_id)
@@ -51,6 +51,13 @@ sequenceDiagram
Auth-->>Gateway: 200 {device_session_id}
```
Auth uses the dedicated trusted `Mail Service` REST route
`POST /api/v1/internal/login-code-deliveries`.
It sends the created `challenge_id` as the raw `Idempotency-Key` header
value.
For this boundary, `sent` means durable acceptance into the mail-delivery
pipeline; SMTP completion may still happen later in `Mail Service` workers.
## Revoke and Block Flow
```mermaid
+21
View File
@@ -659,9 +659,21 @@ type gatewayCompatibilityHTTPResponse struct {
func gatewayCompatibilityPostJSON(t *testing.T, url string, body string) gatewayCompatibilityHTTPResponse {
t.Helper()
return gatewayCompatibilityPostJSONWithHeaders(t, url, body, nil)
}
func gatewayCompatibilityPostJSONWithHeaders(t *testing.T, url string, body string, headers map[string]string) gatewayCompatibilityHTTPResponse {
t.Helper()
request, err := http.NewRequest(http.MethodPost, url, bytes.NewBufferString(body))
require.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
for key, value := range headers {
if strings.TrimSpace(value) == "" {
continue
}
request.Header.Set(key, value)
}
response, err := http.DefaultClient.Do(request)
require.NoError(t, err)
@@ -685,6 +697,15 @@ func gatewayCompatibilityPostJSONValue(t *testing.T, url string, value any) gate
return gatewayCompatibilityPostJSON(t, url, string(payload))
}
func gatewayCompatibilityPostJSONValueWithHeaders(t *testing.T, url string, value any, headers map[string]string) gatewayCompatibilityHTTPResponse {
t.Helper()
payload, err := json.Marshal(value)
require.NoError(t, err)
return gatewayCompatibilityPostJSONWithHeaders(t, url, string(payload), headers)
}
func gatewayCompatibilityActiveSession(
t *testing.T,
deviceSessionID string,
+1 -1
View File
@@ -22,6 +22,7 @@ require (
go.opentelemetry.io/otel/trace v1.43.0
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.49.0
golang.org/x/text v0.36.0
)
require (
@@ -75,7 +76,6 @@ require (
golang.org/x/arch v0.25.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect
+1 -2
View File
@@ -152,8 +152,7 @@ golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
@@ -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)
@@ -5,6 +5,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
@@ -35,6 +36,10 @@ func TestMailServiceRESTCompatibilitySendEmailCodeSent(t *testing.T) {
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) {
@@ -99,6 +104,29 @@ func TestMailServiceRESTCompatibilityThrottledSendSkipsMailService(t *testing.T)
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
@@ -191,6 +219,14 @@ type mailServiceStubServer struct {
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 {
@@ -206,17 +242,18 @@ func (s *mailServiceStubServer) Handler() http.Handler {
http.NotFound(writer, request)
return
}
s.mu.Lock()
s.callCount++
s.mu.Unlock()
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"`
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)
@@ -231,6 +268,16 @@ func (s *mailServiceStubServer) Handler() http.Handler {
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)
@@ -243,3 +290,12 @@ func (s *mailServiceStubServer) CallCount() int {
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
}
+8 -7
View File
@@ -732,13 +732,14 @@ func TestProductionHardeningExpiredChallengeReturnsExpiredDuringGraceAndNotFound
require.NoError(t, err)
record := challenge.Challenge{
ID: common.ChallengeID("challenge-expired"),
Email: common.Email(gatewayCompatibilityEmail),
CodeHash: codeHash,
Status: challenge.StatusSent,
DeliveryState: challenge.DeliverySent,
CreatedAt: env.now.Add(-2 * time.Minute),
ExpiresAt: env.now.Add(-time.Second),
ID: common.ChallengeID("challenge-expired"),
Email: common.Email(gatewayCompatibilityEmail),
CodeHash: codeHash,
PreferredLanguage: "en",
Status: challenge.StatusSent,
DeliveryState: challenge.DeliverySent,
CreatedAt: env.now.Add(-2 * time.Minute),
ExpiresAt: env.now.Add(-time.Second),
}
require.NoError(t, record.Validate())
require.NoError(t, app.challengeStore.Create(context.Background(), record))
@@ -10,6 +10,7 @@ import (
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
@@ -57,7 +58,9 @@ func TestUserServiceRESTCompatibilityPublicSendUsesResolveByEmailOutcomes(t *tes
attempts := harness.mailSender.RecordedAttempts()
require.Len(t, attempts, 2)
assert.Equal(t, common.Email("existing@example.com"), attempts[0].Input.Email)
assert.Equal(t, "en", attempts[0].Input.Locale)
assert.Equal(t, common.Email("creatable@example.com"), attempts[1].Input.Email)
assert.Equal(t, "en", attempts[1].Input.Locale)
}
func TestUserServiceRESTCompatibilityPublicConfirmUsesEnsureOutcomes(t *testing.T) {
@@ -162,12 +165,34 @@ func TestUserServiceRESTCompatibilityInternalBlockUserUsesRESTClient(t *testing.
})
}
func TestUserServiceRESTCompatibilityAcceptLanguageDrivesMailLocaleAndRegistrationContext(t *testing.T) {
t.Parallel()
harness := newUserServiceRESTCompatibilityHarness(t)
require.NoError(t, harness.directory.QueueCreatedUserIDs(common.UserID("user-created")))
challengeID := harness.sendChallengeIDWithAcceptLanguage(t, "localized@example.com", "fr-FR, en;q=0.8", "fr-FR")
attempts := harness.mailSender.RecordedAttempts()
require.Len(t, attempts, 1)
assert.Equal(t, "fr-FR", attempts[0].Input.Locale)
response := gatewayCompatibilityPostJSONValue(
t,
harness.publicBaseURL+"/api/v1/public/auth/confirm-email-code",
gatewayCompatibilityConfirmRequest(challengeID, userServiceRESTCompatibilityCode, gatewayCompatibilityClientPublicKey),
)
assert.Equal(t, http.StatusOK, response.StatusCode)
assert.JSONEq(t, `{"device_session_id":"device-session-1"}`, response.Body)
}
type userServiceRESTCompatibilityHarness struct {
publicBaseURL string
internalBaseURL string
mailSender *mail.StubSender
sessionStore *testkit.InMemorySessionStore
directory *userservice.StubDirectory
publicBaseURL string
internalBaseURL string
mailSender *mail.StubSender
sessionStore *testkit.InMemorySessionStore
directory *userservice.StubDirectory
preferredLanguageExpectations *preferredLanguageExpectationStore
}
func newUserServiceRESTCompatibilityHarness(t *testing.T) userServiceRESTCompatibilityHarness {
@@ -176,8 +201,9 @@ func newUserServiceRESTCompatibilityHarness(t *testing.T) userServiceRESTCompati
challengeStore := &testkit.InMemoryChallengeStore{}
sessionStore := &testkit.InMemorySessionStore{}
directory := &userservice.StubDirectory{}
preferredLanguageExpectations := newPreferredLanguageExpectationStore()
userServiceServer := httptest.NewServer(newUserServiceStubHandler(directory))
userServiceServer := httptest.NewServer(newUserServiceStubHandler(directory, preferredLanguageExpectations))
t.Cleanup(userServiceServer.Close)
userDirectory, err := userservice.NewRESTClient(userservice.Config{
@@ -261,18 +287,31 @@ func newUserServiceRESTCompatibilityHarness(t *testing.T) userServiceRESTCompati
gatewayCompatibilityRunServer(t, internalServer.Run, internalServer.Shutdown, internalCfg.Addr)
return userServiceRESTCompatibilityHarness{
publicBaseURL: "http://" + publicCfg.Addr,
internalBaseURL: "http://" + internalCfg.Addr,
mailSender: mailSender,
sessionStore: sessionStore,
directory: directory,
publicBaseURL: "http://" + publicCfg.Addr,
internalBaseURL: "http://" + internalCfg.Addr,
mailSender: mailSender,
sessionStore: sessionStore,
directory: directory,
preferredLanguageExpectations: preferredLanguageExpectations,
}
}
func (h userServiceRESTCompatibilityHarness) sendChallengeID(t *testing.T, email string) string {
t.Helper()
response := gatewayCompatibilityPostJSON(t, h.publicBaseURL+"/api/v1/public/auth/send-email-code", fmt.Sprintf(`{"email":"%s"}`, email))
return h.sendChallengeIDWithAcceptLanguage(t, email, "", "en")
}
func (h userServiceRESTCompatibilityHarness) sendChallengeIDWithAcceptLanguage(t *testing.T, email string, acceptLanguage string, expectedPreferredLanguage string) string {
t.Helper()
h.preferredLanguageExpectations.Set(email, expectedPreferredLanguage)
response := gatewayCompatibilityPostJSONWithHeaders(
t,
h.publicBaseURL+"/api/v1/public/auth/send-email-code",
fmt.Sprintf(`{"email":"%s"}`, email),
map[string]string{"Accept-Language": acceptLanguage},
)
assert.Equal(t, http.StatusOK, response.StatusCode)
var body struct {
@@ -284,7 +323,7 @@ func (h userServiceRESTCompatibilityHarness) sendChallengeID(t *testing.T, email
return body.ChallengeID
}
func newUserServiceStubHandler(directory *userservice.StubDirectory) http.Handler {
func newUserServiceStubHandler(directory *userservice.StubDirectory, preferredLanguageExpectations *preferredLanguageExpectationStore) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch {
case request.Method == http.MethodPost && request.URL.Path == "/api/v1/internal/user-resolutions/by-email":
@@ -349,8 +388,13 @@ func newUserServiceStubHandler(directory *userservice.StubDirectory) http.Handle
writeUserServiceStubError(writer, http.StatusBadRequest, errors.New("registration_context must be present"))
return
}
if ensureInput.RegistrationContext.PreferredLanguage != "en" {
writeUserServiceStubError(writer, http.StatusBadRequest, errors.New("registration_context.preferred_language must equal en during rollout"))
expectedPreferredLanguage := preferredLanguageExpectations.Expected(input.Email)
if ensureInput.RegistrationContext.PreferredLanguage != expectedPreferredLanguage {
writeUserServiceStubError(
writer,
http.StatusBadRequest,
fmt.Errorf("registration_context.preferred_language must equal %s", expectedPreferredLanguage),
)
return
}
if ensureInput.RegistrationContext.TimeZone != gatewayCompatibilityTimeZone {
@@ -434,6 +478,44 @@ func newUserServiceStubHandler(directory *userservice.StubDirectory) http.Handle
})
}
type preferredLanguageExpectationStore struct {
mu sync.Mutex
byEmail map[string]string
}
func newPreferredLanguageExpectationStore() *preferredLanguageExpectationStore {
return &preferredLanguageExpectationStore{
byEmail: make(map[string]string),
}
}
func (s *preferredLanguageExpectationStore) Set(email string, preferredLanguage string) {
if s == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.byEmail[email] = preferredLanguage
}
func (s *preferredLanguageExpectationStore) Expected(email string) string {
if s == nil {
return "en"
}
s.mu.Lock()
defer s.mu.Unlock()
preferredLanguage := s.byEmail[email]
if preferredLanguage == "" {
return "en"
}
return preferredLanguage
}
func decodeUserServiceStubRequest(writer http.ResponseWriter, request *http.Request, target any) bool {
decoder := json.NewDecoder(request.Body)
decoder.DisallowUnknownFields()