feat: mail service
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user