feat: mail service
This commit is contained in:
+26
-5
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user