feat: support time_zone for user registration context

This commit is contained in:
Ilia Denisov
2026-04-09 09:00:06 +02:00
parent e6b73a8f55
commit 7043af4cb3
40 changed files with 3452 additions and 164 deletions
@@ -149,25 +149,42 @@ func (c *RESTClient) ExistsByUserID(ctx context.Context, userID common.UserID) (
return response.Exists, nil
}
// EnsureUserByEmail returns an existing user for email, creates a new user
// when registration is allowed, or reports a blocked outcome.
func (c *RESTClient) EnsureUserByEmail(ctx context.Context, email common.Email) (ports.EnsureUserResult, error) {
// EnsureUserByEmail returns an existing user for input.Email, creates a new
// user when registration is allowed, or reports a blocked outcome.
func (c *RESTClient) EnsureUserByEmail(ctx context.Context, input ports.EnsureUserInput) (ports.EnsureUserResult, error) {
if err := validateContext(ctx, "ensure user by email"); err != nil {
return ports.EnsureUserResult{}, err
}
if err := email.Validate(); err != nil {
if err := input.Validate(); err != nil {
return ports.EnsureUserResult{}, fmt.Errorf("ensure user by email: %w", err)
}
payload := struct {
Email string `json:"email"`
RegistrationContext *struct {
PreferredLanguage string `json:"preferred_language"`
TimeZone string `json:"time_zone"`
} `json:"registration_context,omitempty"`
}{
Email: input.Email.String(),
}
if input.RegistrationContext != nil {
payload.RegistrationContext = &struct {
PreferredLanguage string `json:"preferred_language"`
TimeZone string `json:"time_zone"`
}{
PreferredLanguage: input.RegistrationContext.PreferredLanguage,
TimeZone: input.RegistrationContext.TimeZone,
}
}
var response struct {
Outcome ports.EnsureUserOutcome `json:"outcome"`
UserID string `json:"user_id,omitempty"`
BlockReasonCode userresolution.BlockReasonCode `json:"block_reason_code,omitempty"`
}
if err := c.doJSON(ctx, "ensure user by email", http.MethodPost, ensureByEmailPath, map[string]string{
"email": email.String(),
}, &response, false); err != nil {
if err := c.doJSON(ctx, "ensure user by email", http.MethodPost, ensureByEmailPath, payload, &response, false); err != nil {
return ports.EnsureUserResult{}, err
}
@@ -20,6 +20,8 @@ import (
"github.com/stretchr/testify/require"
)
const restClientEnsureTimeZone = "Europe/Kaliningrad"
func TestNewRESTClient(t *testing.T) {
t.Parallel()
@@ -107,7 +109,13 @@ func TestRESTClientEndpointSuccessCases(t *testing.T) {
{
name: "ensure user by email",
run: func(t *testing.T, client *RESTClient) {
result, err := client.EnsureUserByEmail(context.Background(), common.Email("created@example.com"))
result, err := client.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{
Email: common.Email("created@example.com"),
RegistrationContext: &ports.RegistrationContext{
PreferredLanguage: "en",
TimeZone: restClientEnsureTimeZone,
},
})
require.NoError(t, err)
assert.Equal(t, ports.EnsureUserResult{
Outcome: ports.EnsureUserOutcomeCreated,
@@ -212,7 +220,7 @@ func TestRESTClientEndpointSuccessCases(t *testing.T) {
Method: http.MethodPost,
Path: ensureByEmailPath,
ContentType: "application/json",
Body: `{"email":"created@example.com"}`,
Body: `{"email":"created@example.com","registration_context":{"preferred_language":"en","time_zone":"Europe/Kaliningrad"}}`,
}, requests[0])
case "block by user id":
assert.Equal(t, capturedRequest{
@@ -331,7 +339,13 @@ func TestRESTClientMutationMethodsDoNotRetry(t *testing.T) {
{
name: "ensure user by email",
run: func(client *RESTClient) error {
_, err := client.EnsureUserByEmail(context.Background(), common.Email("pilot@example.com"))
_, err := client.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{
Email: common.Email("pilot@example.com"),
RegistrationContext: &ports.RegistrationContext{
PreferredLanguage: "en",
TimeZone: restClientEnsureTimeZone,
},
})
return err
},
},
@@ -405,7 +419,13 @@ func TestRESTClientStrictDecodingAndUnexpectedStatuses(t *testing.T) {
body: `{"outcome":"mystery"}`,
wantErrText: "unsupported",
run: func(client *RESTClient) error {
_, err := client.EnsureUserByEmail(context.Background(), common.Email("pilot@example.com"))
_, err := client.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{
Email: common.Email("pilot@example.com"),
RegistrationContext: &ports.RegistrationContext{
PreferredLanguage: "en",
TimeZone: restClientEnsureTimeZone,
},
})
return err
},
},
@@ -415,7 +435,13 @@ func TestRESTClientStrictDecodingAndUnexpectedStatuses(t *testing.T) {
body: `{"outcome":"created"}`,
wantErrText: "user id",
run: func(client *RESTClient) error {
_, err := client.EnsureUserByEmail(context.Background(), common.Email("pilot@example.com"))
_, err := client.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{
Email: common.Email("pilot@example.com"),
RegistrationContext: &ports.RegistrationContext{
PreferredLanguage: "en",
TimeZone: restClientEnsureTimeZone,
},
})
return err
},
},
@@ -516,7 +542,22 @@ func TestRESTClientContextAndValidation(t *testing.T) {
{
name: "invalid email",
run: func() error {
_, err := client.EnsureUserByEmail(context.Background(), common.Email(" bad@example.com "))
_, err := client.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{
Email: common.Email(" bad@example.com "),
})
return err
},
},
{
name: "invalid registration context",
run: func() error {
_, err := client.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{
Email: common.Email("pilot@example.com"),
RegistrationContext: &ports.RegistrationContext{
PreferredLanguage: " en ",
TimeZone: restClientEnsureTimeZone,
},
})
return err
},
},
@@ -69,13 +69,13 @@ func (d *StubDirectory) ExistsByUserID(ctx context.Context, userID common.UserID
return ok, nil
}
// EnsureUserByEmail returns an existing user for email, creates a new user
// when registration is allowed, or reports a blocked outcome.
func (d *StubDirectory) EnsureUserByEmail(ctx context.Context, email common.Email) (ports.EnsureUserResult, error) {
// EnsureUserByEmail returns an existing user for input.Email, creates a new
// user when registration is allowed, or reports a blocked outcome.
func (d *StubDirectory) EnsureUserByEmail(ctx context.Context, input ports.EnsureUserInput) (ports.EnsureUserResult, error) {
if err := validateContext(ctx, "ensure user by email"); err != nil {
return ports.EnsureUserResult{}, err
}
if err := email.Validate(); err != nil {
if err := input.Validate(); err != nil {
return ports.EnsureUserResult{}, fmt.Errorf("ensure user by email: %w", err)
}
@@ -84,7 +84,7 @@ func (d *StubDirectory) EnsureUserByEmail(ctx context.Context, email common.Emai
d.ensureMapsLocked()
stored, ok := d.byEmail[email]
stored, ok := d.byEmail[input.Email]
if ok {
if !stored.blockReasonCode.IsZero() {
result := ports.EnsureUserResult{
@@ -113,8 +113,8 @@ func (d *StubDirectory) EnsureUserByEmail(ctx context.Context, email common.Emai
if err != nil {
return ports.EnsureUserResult{}, fmt.Errorf("ensure user by email: %w", err)
}
d.byEmail[email] = entry{userID: userID}
d.emailByUserID[userID] = email
d.byEmail[input.Email] = entry{userID: userID}
d.emailByUserID[userID] = input.Email
result := ports.EnsureUserResult{
Outcome: ports.EnsureUserOutcomeCreated,
@@ -70,7 +70,13 @@ func TestStubDirectoryEnsureUserByEmail(t *testing.T) {
directory := &StubDirectory{}
require.NoError(t, directory.SeedExisting(common.Email("existing@example.com"), common.UserID("user-existing")))
result, err := directory.EnsureUserByEmail(context.Background(), common.Email("existing@example.com"))
result, err := directory.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{
Email: common.Email("existing@example.com"),
RegistrationContext: &ports.RegistrationContext{
PreferredLanguage: "en",
TimeZone: "Europe/Kaliningrad",
},
})
require.NoError(t, err)
assert.Equal(t, ports.EnsureUserOutcomeExisting, result.Outcome)
assert.Equal(t, common.UserID("user-existing"), result.UserID)
@@ -82,7 +88,13 @@ func TestStubDirectoryEnsureUserByEmail(t *testing.T) {
directory := &StubDirectory{}
require.NoError(t, directory.SeedBlockedEmail(common.Email("blocked@example.com"), userresolution.BlockReasonCode("policy_block")))
result, err := directory.EnsureUserByEmail(context.Background(), common.Email("blocked@example.com"))
result, err := directory.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{
Email: common.Email("blocked@example.com"),
RegistrationContext: &ports.RegistrationContext{
PreferredLanguage: "en",
TimeZone: "Europe/Kaliningrad",
},
})
require.NoError(t, err)
assert.Equal(t, ports.EnsureUserOutcomeBlocked, result.Outcome)
assert.Equal(t, userresolution.BlockReasonCode("policy_block"), result.BlockReasonCode)
@@ -94,12 +106,24 @@ func TestStubDirectoryEnsureUserByEmail(t *testing.T) {
directory := &StubDirectory{}
require.NoError(t, directory.QueueCreatedUserIDs(common.UserID("user-created")))
first, err := directory.EnsureUserByEmail(context.Background(), common.Email("created@example.com"))
first, err := directory.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{
Email: common.Email("created@example.com"),
RegistrationContext: &ports.RegistrationContext{
PreferredLanguage: "en",
TimeZone: "Europe/Kaliningrad",
},
})
require.NoError(t, err)
assert.Equal(t, ports.EnsureUserOutcomeCreated, first.Outcome)
assert.Equal(t, common.UserID("user-created"), first.UserID)
second, err := directory.EnsureUserByEmail(context.Background(), common.Email("created@example.com"))
second, err := directory.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{
Email: common.Email("created@example.com"),
RegistrationContext: &ports.RegistrationContext{
PreferredLanguage: "fr",
TimeZone: "Europe/Paris",
},
})
require.NoError(t, err)
assert.Equal(t, ports.EnsureUserOutcomeExisting, second.Outcome)
assert.Equal(t, common.UserID("user-created"), second.UserID)
@@ -110,7 +134,13 @@ func TestStubDirectoryEnsureUserByEmail(t *testing.T) {
directory := &StubDirectory{}
result, err := directory.EnsureUserByEmail(context.Background(), common.Email("fallback@example.com"))
result, err := directory.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{
Email: common.Email("fallback@example.com"),
RegistrationContext: &ports.RegistrationContext{
PreferredLanguage: "en",
TimeZone: "Europe/Kaliningrad",
},
})
require.NoError(t, err)
assert.Equal(t, ports.EnsureUserOutcomeCreated, result.Outcome)
assert.Equal(t, common.UserID("user-1"), result.UserID)
@@ -240,7 +270,9 @@ func TestStubDirectoryContextAndValidation(t *testing.T) {
{
name: "ensure cancelled context",
run: func() error {
_, err := directory.EnsureUserByEmail(cancelledCtx, common.Email("pilot@example.com"))
_, err := directory.EnsureUserByEmail(cancelledCtx, ports.EnsureUserInput{
Email: common.Email("pilot@example.com"),
})
return err
},
want: context.Canceled.Error(),
@@ -46,6 +46,7 @@ func TestPublicHTTPEndToEndSendThenConfirm(t *testing.T) {
"challenge_id": "challenge-1",
"code": attempts[0].Input.Code,
"client_public_key": validClientPublicKey,
"time_zone": publicConfirmTimeZone,
}
confirmResponse := postJSONValue(t, server.URL+"/api/v1/public/auth/confirm-email-code", confirmBody)
@@ -104,13 +105,36 @@ func TestPublicHTTPEndToEndInvalidClientPublicKey(t *testing.T) {
response := postJSON(
t,
server.URL+"/api/v1/public/auth/confirm-email-code",
`{"challenge_id":"challenge-123","code":"123456","client_public_key":"invalid"}`,
`{"challenge_id":"challenge-123","code":"123456","client_public_key":"invalid","time_zone":"`+publicConfirmTimeZone+`"}`,
)
assert.Equal(t, http.StatusBadRequest, response.StatusCode)
assert.JSONEq(t, `{"error":{"code":"invalid_client_public_key","message":"client_public_key is not a valid base64-encoded raw 32-byte Ed25519 public key"}}`, response.Body)
}
func TestPublicHTTPEndToEndInvalidTimeZone(t *testing.T) {
t.Parallel()
app := newEndToEndApp(t, endToEndOptions{
SeedChallenge: seedChallengeOptions{
ID: "challenge-123",
Code: "123456",
Status: challenge.StatusSent,
},
})
server := httptest.NewServer(app.handler)
defer server.Close()
response := postJSON(
t,
server.URL+"/api/v1/public/auth/confirm-email-code",
`{"challenge_id":"challenge-123","code":"123456","client_public_key":"`+validClientPublicKey+`","time_zone":"Mars/Olympus"}`,
)
assert.Equal(t, http.StatusBadRequest, response.StatusCode)
assert.JSONEq(t, `{"error":{"code":"invalid_request","message":"time_zone must be a valid IANA time zone name"}}`, response.Body)
}
func TestPublicHTTPEndToEndChallengeNotFound(t *testing.T) {
t.Parallel()
@@ -122,6 +146,7 @@ func TestPublicHTTPEndToEndChallengeNotFound(t *testing.T) {
"challenge_id": "missing",
"code": "123456",
"client_public_key": validClientPublicKey,
"time_zone": publicConfirmTimeZone,
})
assert.Equal(t, http.StatusNotFound, response.StatusCode)
@@ -146,6 +171,7 @@ func TestPublicHTTPEndToEndChallengeExpired(t *testing.T) {
"challenge_id": "challenge-123",
"code": "123456",
"client_public_key": validClientPublicKey,
"time_zone": publicConfirmTimeZone,
})
assert.Equal(t, http.StatusGone, response.StatusCode)
@@ -169,6 +195,7 @@ func TestPublicHTTPEndToEndInvalidCode(t *testing.T) {
"challenge_id": "challenge-123",
"code": "654321",
"client_public_key": validClientPublicKey,
"time_zone": publicConfirmTimeZone,
})
assert.Equal(t, http.StatusBadRequest, response.StatusCode)
@@ -192,6 +219,7 @@ func TestPublicHTTPEndToEndThrottledChallengeConfirmReturnsInvalidCode(t *testin
"challenge_id": "challenge-123",
"code": "123456",
"client_public_key": validClientPublicKey,
"time_zone": publicConfirmTimeZone,
})
assert.Equal(t, http.StatusBadRequest, response.StatusCode)
@@ -226,6 +254,7 @@ func TestPublicHTTPEndToEndSessionLimitExceeded(t *testing.T) {
"challenge_id": "challenge-1",
"code": attempts[0].Input.Code,
"client_public_key": validClientPublicKey,
"time_zone": publicConfirmTimeZone,
})
assert.Equal(t, http.StatusConflict, confirmResponse.StatusCode)
@@ -35,6 +35,7 @@ type confirmEmailCodeRequest struct {
ChallengeID string `json:"challenge_id"`
Code string `json:"code"`
ClientPublicKey string `json:"client_public_key"`
TimeZone string `json:"time_zone"`
}
type confirmEmailCodeResponse struct {
@@ -142,6 +143,7 @@ func handleConfirmEmailCode(useCase ConfirmEmailCodeUseCase, timeout time.Durati
ChallengeID: request.ChallengeID,
Code: request.Code,
ClientPublicKey: request.ClientPublicKey,
TimeZone: request.TimeZone,
})
if err != nil {
abortWithProjection(c, projectConfirmEmailCodeError(err))
@@ -195,6 +197,11 @@ func validateConfirmEmailCodeRequest(request *confirmEmailCodeRequest) error {
return errors.New("client_public_key must not be empty")
}
request.TimeZone = strings.TrimSpace(request.TimeZone)
if request.TimeZone == "" {
return errors.New("time_zone must not be empty")
}
return nil
}
@@ -19,6 +19,8 @@ import (
"go.uber.org/zap/zapcore"
)
const publicConfirmTimeZone = "Europe/Kaliningrad"
func TestSendEmailCodeHandlerSuccess(t *testing.T) {
t.Parallel()
@@ -58,6 +60,7 @@ func TestConfirmEmailCodeHandlerSuccess(t *testing.T) {
ChallengeID: "challenge-123",
Code: "123456",
ClientPublicKey: "public-key-material",
TimeZone: publicConfirmTimeZone,
}, input)
return confirmemailcode.Result{DeviceSessionID: "device-session-123"}, nil
}),
@@ -67,7 +70,7 @@ func TestConfirmEmailCodeHandlerSuccess(t *testing.T) {
request := httptest.NewRequest(
http.MethodPost,
"/api/v1/public/auth/confirm-email-code",
bytes.NewBufferString(`{"challenge_id":" challenge-123 ","code":" 123456 ","client_public_key":" public-key-material "}`),
bytes.NewBufferString(`{"challenge_id":" challenge-123 ","code":" 123456 ","client_public_key":" public-key-material ","time_zone":" `+publicConfirmTimeZone+` "}`),
)
request.Header.Set("Content-Type", "application/json")
@@ -133,10 +136,17 @@ func TestPublicAuthHandlersRejectInvalidRequests(t *testing.T) {
{
name: "empty code",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":" ","client_public_key":"public-key-material"}`,
body: `{"challenge_id":"challenge-123","code":" ","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"code must not be empty"}}`,
},
{
name: "empty time zone",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":" "}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"time_zone must not be empty"}}`,
},
}
handler := mustNewHandler(t, DefaultConfig(), Dependencies{
@@ -198,7 +208,7 @@ func TestPublicAuthHandlersMapServiceErrors(t *testing.T) {
{
name: "confirm invalid client public key",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`,
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
@@ -213,7 +223,7 @@ func TestPublicAuthHandlersMapServiceErrors(t *testing.T) {
{
name: "confirm challenge not found",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`,
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
@@ -228,7 +238,7 @@ func TestPublicAuthHandlersMapServiceErrors(t *testing.T) {
{
name: "confirm challenge expired",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`,
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
@@ -243,7 +253,7 @@ func TestPublicAuthHandlersMapServiceErrors(t *testing.T) {
{
name: "confirm blocked by policy",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`,
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
@@ -258,7 +268,7 @@ func TestPublicAuthHandlersMapServiceErrors(t *testing.T) {
{
name: "confirm session limit exceeded",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`,
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
@@ -273,7 +283,7 @@ func TestPublicAuthHandlersMapServiceErrors(t *testing.T) {
{
name: "confirm hides internal error",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`,
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
@@ -363,7 +373,7 @@ func TestPublicAuthHandlersRejectInvalidSuccessPayloads(t *testing.T) {
{
name: "confirm blank device session id",
target: "/api/v1/public/auth/confirm-email-code",
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`,
body: `{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + publicConfirmTimeZone + `"}`,
deps: Dependencies{
SendEmailCode: sendEmailCodeFunc(func(context.Context, sendemailcode.Input) (sendemailcode.Result, error) {
return sendemailcode.Result{}, errors.New("unexpected call")
@@ -413,7 +423,7 @@ func TestPublicAuthLogsDoNotContainSensitiveFields(t *testing.T) {
request := httptest.NewRequest(
http.MethodPost,
"/api/v1/public/auth/confirm-email-code",
bytes.NewBufferString(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`),
bytes.NewBufferString(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"`+publicConfirmTimeZone+`"}`),
)
request.Header.Set("Content-Type", "application/json")
+61 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"galaxy/authsession/internal/domain/common"
"galaxy/authsession/internal/domain/userresolution"
@@ -23,7 +24,7 @@ type UserDirectory interface {
// EnsureUserByEmail returns an existing user for email, creates a new user
// when registration is allowed, or reports a blocked outcome when the
// address may not continue through confirm flow.
EnsureUserByEmail(ctx context.Context, email common.Email) (EnsureUserResult, error)
EnsureUserByEmail(ctx context.Context, input EnsureUserInput) (EnsureUserResult, error)
// BlockByUserID applies a block state to the user identified by
// input.UserID. Implementations must wrap ErrNotFound when input.UserID does
@@ -35,6 +36,65 @@ type UserDirectory interface {
BlockByEmail(ctx context.Context, input BlockUserByEmailInput) (BlockUserResult, error)
}
// EnsureUserInput describes one user-directory ensure request keyed by the
// normalized e-mail address.
type EnsureUserInput struct {
// Email identifies the normalized e-mail address that should resolve to an
// existing user, a newly created user, or a blocked outcome.
Email common.Email
// RegistrationContext carries create-only user initialization fields. The
// user directory must ignore this context for existing users.
RegistrationContext *RegistrationContext
}
// Validate reports whether EnsureUserInput contains a complete request.
func (i EnsureUserInput) Validate() error {
if err := i.Email.Validate(); err != nil {
return fmt.Errorf("ensure user input email: %w", err)
}
if i.RegistrationContext != nil {
if err := i.RegistrationContext.Validate(); err != nil {
return fmt.Errorf("ensure user input registration context: %w", err)
}
}
return nil
}
// RegistrationContext describes create-only user initialization fields
// forwarded from the public confirm-email-code flow.
type RegistrationContext struct {
// PreferredLanguage stores the BCP 47 language tag that should initialize a
// newly created user. During the current rollout phase Auth / Session
// Service sends a temporary `"en"` default until gateway geoip derivation is
// deployed.
PreferredLanguage string
// TimeZone stores the client-selected IANA time zone name that should
// initialize a newly created user.
TimeZone string
}
// Validate reports whether RegistrationContext contains complete create-only
// initialization metadata.
func (c RegistrationContext) Validate() error {
if strings.TrimSpace(c.PreferredLanguage) == "" {
return errors.New("preferred language must not be empty")
}
if strings.TrimSpace(c.PreferredLanguage) != c.PreferredLanguage {
return errors.New("preferred language must not contain surrounding whitespace")
}
if strings.TrimSpace(c.TimeZone) == "" {
return errors.New("time zone must not be empty")
}
if strings.TrimSpace(c.TimeZone) != c.TimeZone {
return errors.New("time zone must not contain surrounding whitespace")
}
return nil
}
// EnsureUserOutcome identifies the coarse outcome of ensuring a user record
// for one normalized e-mail address.
type EnsureUserOutcome string
@@ -17,6 +17,7 @@ import (
)
const blockFlowPublicKey = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
const blockFlowTimeZone = "Europe/Kaliningrad"
func TestBlockUserAffectsLaterSendAndConfirmFlows(t *testing.T) {
t.Parallel()
@@ -81,6 +82,7 @@ func TestBlockUserAffectsLaterSendAndConfirmFlows(t *testing.T) {
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: blockFlowPublicKey,
TimeZone: blockFlowTimeZone,
})
require.Error(t, err)
assert.Equal(t, shared.ErrorCodeBlockedByPolicy, shared.CodeOf(err))
@@ -28,6 +28,7 @@ func TestExecuteReturnsInvalidCodeForThrottledChallengeWithoutConsumingAttempts(
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
require.Error(t, err)
assert.Equal(t, shared.ErrorCodeInvalidCode, shared.CodeOf(err))
@@ -31,6 +31,7 @@ func TestExecuteConfirmsChallengeAfterTransientProjectionPublishFailures(t *test
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
require.NoError(t, err)
assert.Equal(t, "device-session-1", result.DeviceSessionID)
@@ -57,6 +58,7 @@ func TestExecuteConfirmedRetryRepublishesAfterTransientProjectionPublishFailures
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
require.NoError(t, err)
assert.Equal(t, "device-session-1", result.DeviceSessionID)
@@ -79,6 +81,7 @@ func TestExecuteRepairsProjectionOnIdenticalRetryAfterExhaustedPublishRetries(t
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
require.Error(t, err)
assert.Equal(t, shared.ErrorCodeServiceUnavailable, shared.CodeOf(err))
@@ -99,6 +102,7 @@ func TestExecuteRepairsProjectionOnIdenticalRetryAfterExhaustedPublishRetries(t
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
require.NoError(t, err)
assert.Equal(t, "device-session-1", result.DeviceSessionID)
@@ -19,9 +19,10 @@ import (
)
const (
revokeReasonConfirmRace common.RevokeReasonCode = "confirm_race_repair"
revokeActorTypeService common.RevokeActorType = "service"
revokeActorIDService = "confirmemailcode"
revokeReasonConfirmRace common.RevokeReasonCode = "confirm_race_repair"
revokeActorTypeService common.RevokeActorType = "service"
revokeActorIDService = "confirmemailcode"
defaultPreferredLanguage = "en"
)
// Input describes one public confirm-email-code request.
@@ -35,6 +36,11 @@ type Input struct {
// ClientPublicKey is the base64-encoded raw 32-byte Ed25519 public key that
// should be registered for the created device session.
ClientPublicKey string
// TimeZone is the client-selected IANA time zone name that should be
// forwarded as create-only registration context when the user does not yet
// exist.
TimeZone string
}
// Result describes one public confirm-email-code response.
@@ -192,6 +198,10 @@ func (s *Service) Execute(ctx context.Context, input Input) (result Result, err
if err != nil {
return Result{}, err
}
timeZone, err := shared.ParseTimeZone(input.TimeZone)
if err != nil {
return Result{}, err
}
for attempt := 0; attempt < shared.MaxCompareAndSwapRetries; attempt++ {
current, err := s.challengeStore.Get(ctx, challengeID)
@@ -236,7 +246,13 @@ func (s *Service) Execute(ctx context.Context, input Input) (result Result, err
return Result{}, shared.InvalidCode()
}
ensureUserResult, err := s.userDirectory.EnsureUserByEmail(ctx, current.Email)
ensureUserResult, err := s.userDirectory.EnsureUserByEmail(ctx, ports.EnsureUserInput{
Email: current.Email,
RegistrationContext: &ports.RegistrationContext{
PreferredLanguage: defaultPreferredLanguage,
TimeZone: timeZone,
},
})
if err != nil {
return Result{}, shared.ServiceUnavailable(err)
}
@@ -11,10 +11,13 @@ import (
"galaxy/authsession/internal/domain/common"
"galaxy/authsession/internal/domain/devicesession"
"galaxy/authsession/internal/domain/userresolution"
"galaxy/authsession/internal/ports"
"galaxy/authsession/internal/service/shared"
"galaxy/authsession/internal/testkit"
)
const confirmEmailCodeTimeZone = "Europe/Kaliningrad"
func TestExecuteConfirmsChallengeForExistingUser(t *testing.T) {
t.Parallel()
@@ -31,6 +34,7 @@ func TestExecuteConfirmsChallengeForExistingUser(t *testing.T) {
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if err != nil {
require.Failf(t, "test failed", "Execute() returned error: %v", err)
@@ -75,6 +79,7 @@ func TestExecuteConfirmsChallengeByCreatingUser(t *testing.T) {
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if err != nil {
require.Failf(t, "test failed", "Execute() returned error: %v", err)
@@ -114,6 +119,7 @@ func TestExecuteConfirmsSuppressedChallenge(t *testing.T) {
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if err != nil {
require.Failf(t, "test failed", "Execute() returned error: %v", err)
@@ -132,6 +138,7 @@ func TestExecuteReturnsChallengeNotFound(t *testing.T) {
ChallengeID: "missing",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if shared.CodeOf(err) != shared.ErrorCodeChallengeNotFound {
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeChallengeNotFound)
@@ -152,6 +159,7 @@ func TestExecuteReturnsChallengeExpiredAndMarksExpired(t *testing.T) {
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if shared.CodeOf(err) != shared.ErrorCodeChallengeExpired {
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeChallengeExpired)
@@ -194,6 +202,7 @@ func TestExecuteReturnsChallengeExpiredForConfirmedChallengeAfterRetentionWindow
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if shared.CodeOf(err) != shared.ErrorCodeChallengeExpired {
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeChallengeExpired)
@@ -220,12 +229,32 @@ func TestExecuteReturnsInvalidClientPublicKey(t *testing.T) {
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: "invalid",
TimeZone: confirmEmailCodeTimeZone,
})
if shared.CodeOf(err) != shared.ErrorCodeInvalidClientPublicKey {
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeInvalidClientPublicKey)
}
}
func TestExecuteReturnsInvalidRequestForInvalidTimeZone(t *testing.T) {
t.Parallel()
service := mustNewConfirmService(t, newConfirmDeps(t))
_, err := service.Execute(context.Background(), Input{
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: "Mars/Olympus",
})
if shared.CodeOf(err) != shared.ErrorCodeInvalidRequest {
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeInvalidRequest)
}
if err == nil || err.Error() != "time_zone must be a valid IANA time zone name" {
require.Failf(t, "test failed", "Execute() error = %v, want invalid time_zone detail", err)
}
}
func TestExecuteInvalidCodeIncrementsAttempts(t *testing.T) {
t.Parallel()
@@ -239,6 +268,7 @@ func TestExecuteInvalidCodeIncrementsAttempts(t *testing.T) {
ChallengeID: "challenge-1",
Code: "000000",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if shared.CodeOf(err) != shared.ErrorCodeInvalidCode {
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeInvalidCode)
@@ -268,6 +298,7 @@ func TestExecuteFifthInvalidAttemptMarksChallengeFailed(t *testing.T) {
ChallengeID: "challenge-1",
Code: "000000",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if shared.CodeOf(err) != shared.ErrorCodeInvalidCode {
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeInvalidCode)
@@ -304,6 +335,7 @@ func TestExecuteDoesNotCreateSessionAfterTooManyAttempts(t *testing.T) {
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if shared.CodeOf(err) != shared.ErrorCodeInvalidCode {
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeInvalidCode)
@@ -337,6 +369,7 @@ func TestExecuteReturnsSameSessionIDForIdempotentRetryAndRepublishes(t *testing.
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if err != nil {
require.Failf(t, "test failed", "Execute() returned error: %v", err)
@@ -370,6 +403,7 @@ func TestExecuteReturnsInvalidCodeForDifferentKeyDuringIdempotentRetry(t *testin
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: alternatePublicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if shared.CodeOf(err) != shared.ErrorCodeInvalidCode {
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeInvalidCode)
@@ -425,6 +459,7 @@ func TestExecuteReturnsInvalidCodeForNonConfirmableStates(t *testing.T) {
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if shared.CodeOf(err) != shared.ErrorCodeInvalidCode {
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeInvalidCode)
@@ -457,6 +492,7 @@ func TestExecuteMarksChallengeFailedAndReturnsBlockedByPolicy(t *testing.T) {
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if shared.CodeOf(err) != shared.ErrorCodeBlockedByPolicy {
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeBlockedByPolicy)
@@ -492,6 +528,7 @@ func TestExecuteReturnsSessionLimitExceededWithoutConsumingChallenge(t *testing.
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if shared.CodeOf(err) != shared.ErrorCodeSessionLimitExceeded {
require.Failf(t, "test failed", "Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeSessionLimitExceeded)
@@ -509,6 +546,57 @@ func TestExecuteReturnsSessionLimitExceededWithoutConsumingChallenge(t *testing.
}
}
func TestExecutePassesRegistrationContextToUserDirectory(t *testing.T) {
t.Parallel()
deps := newConfirmDeps(t)
recordingDirectory := &recordingEnsureUserDirectory{delegate: deps.userDirectory}
deps.userDirectory = nil
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 {
require.Failf(t, "test failed", "Create() returned error: %v", err)
}
service, err := New(
deps.challengeStore,
deps.sessionStore,
recordingDirectory,
deps.configProvider,
deps.publisher,
deps.idGenerator,
deps.hasher,
testkit.FixedClock{Time: deps.now},
)
if err != nil {
require.Failf(t, "test failed", "New() returned error: %v", err)
}
_, err = service.Execute(context.Background(), Input{
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if err != nil {
require.Failf(t, "test failed", "Execute() returned error: %v", err)
}
if recordingDirectory.lastEnsureInput.Email != common.Email("new@example.com") {
require.Failf(t, "test failed", "last ensure email = %q, want %q", recordingDirectory.lastEnsureInput.Email, common.Email("new@example.com"))
}
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.TimeZone != confirmEmailCodeTimeZone {
require.Failf(t, "test failed", "time zone = %q, want %q", recordingDirectory.lastEnsureInput.RegistrationContext.TimeZone, confirmEmailCodeTimeZone)
}
}
func TestExecuteReturnsServiceUnavailableThenSucceedsIdempotentlyAfterPublishFailure(t *testing.T) {
t.Parallel()
@@ -526,6 +614,7 @@ func TestExecuteReturnsServiceUnavailableThenSucceedsIdempotentlyAfterPublishFai
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if shared.CodeOf(err) != shared.ErrorCodeServiceUnavailable {
require.Failf(t, "test failed", "first Execute() error code = %q, want %q", shared.CodeOf(err), shared.ErrorCodeServiceUnavailable)
@@ -536,6 +625,7 @@ func TestExecuteReturnsServiceUnavailableThenSucceedsIdempotentlyAfterPublishFai
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
if err != nil {
require.Failf(t, "test failed", "second Execute() returned error: %v", err)
@@ -680,3 +770,33 @@ func publicKeyString() string {
func alternatePublicKeyString() string {
return "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="
}
// recordingEnsureUserDirectory records the last ensure input while delegating
// behavior to the in-memory testkit directory.
type recordingEnsureUserDirectory struct {
delegate *testkit.InMemoryUserDirectory
lastEnsureInput ports.EnsureUserInput
}
func (d *recordingEnsureUserDirectory) ResolveByEmail(ctx context.Context, email common.Email) (userresolution.Result, error) {
return d.delegate.ResolveByEmail(ctx, email)
}
func (d *recordingEnsureUserDirectory) ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error) {
return d.delegate.ExistsByUserID(ctx, userID)
}
func (d *recordingEnsureUserDirectory) EnsureUserByEmail(ctx context.Context, input ports.EnsureUserInput) (ports.EnsureUserResult, error) {
d.lastEnsureInput = input
return d.delegate.EnsureUserByEmail(ctx, input)
}
func (d *recordingEnsureUserDirectory) BlockByUserID(ctx context.Context, input ports.BlockUserByIDInput) (ports.BlockUserResult, error) {
return d.delegate.BlockByUserID(ctx, input)
}
func (d *recordingEnsureUserDirectory) BlockByEmail(ctx context.Context, input ports.BlockUserByEmailInput) (ports.BlockUserResult, error) {
return d.delegate.BlockByEmail(ctx, input)
}
var _ ports.UserDirectory = (*recordingEnsureUserDirectory)(nil)
@@ -51,6 +51,7 @@ func TestExecuteWithRuntimeStubUserDirectory(t *testing.T) {
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
require.NoError(t, err)
assert.Equal(t, "device-session-1", result.DeviceSessionID)
@@ -92,6 +93,7 @@ func TestExecuteWithRuntimeStubUserDirectory(t *testing.T) {
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
require.Error(t, err)
assert.Equal(t, shared.ErrorCodeBlockedByPolicy, shared.CodeOf(err))
@@ -44,6 +44,7 @@ func TestExecuteRecordsInvalidCodeMetricForThrottledChallenge(t *testing.T) {
ChallengeID: "challenge-1",
Code: "654321",
ClientPublicKey: publicKeyString(),
TimeZone: confirmEmailCodeTimeZone,
})
require.Error(t, err)
@@ -154,7 +154,7 @@ func (d *countingUserDirectory) ExistsByUserID(context.Context, common.UserID) (
return false, nil
}
func (d *countingUserDirectory) EnsureUserByEmail(context.Context, common.Email) (ports.EnsureUserResult, error) {
func (d *countingUserDirectory) EnsureUserByEmail(context.Context, ports.EnsureUserInput) (ports.EnsureUserResult, error) {
return ports.EnsureUserResult{}, nil
}
@@ -91,6 +91,20 @@ func ParseClientPublicKey(value string) (common.ClientPublicKey, error) {
return key, nil
}
// ParseTimeZone trims value and validates it as an IANA time zone name.
func ParseTimeZone(value string) (string, error) {
timeZone := NormalizeString(value)
if timeZone == "" {
return "", InvalidRequest("time_zone must not be empty")
}
if _, err := time.LoadLocation(timeZone); err != nil {
return "", InvalidRequest("time_zone must be a valid IANA time zone name")
}
return timeZone, nil
}
// ParseRevokeReasonCode trims value and validates it as one machine-readable
// revoke reason code.
func ParseRevokeReasonCode(value string) (common.RevokeReasonCode, error) {
@@ -34,6 +34,19 @@ func TestParseClientPublicKey(t *testing.T) {
assert.Equal(t, ErrorCodeInvalidClientPublicKey, CodeOf(err))
}
func TestParseTimeZone(t *testing.T) {
t.Parallel()
timeZone, err := ParseTimeZone(" Europe/Kaliningrad ")
require.NoError(t, err)
assert.Equal(t, "Europe/Kaliningrad", timeZone)
_, err = ParseTimeZone("Mars/Olympus")
require.Error(t, err)
assert.Equal(t, ErrorCodeInvalidRequest, CodeOf(err))
assert.Equal(t, "time_zone must be a valid IANA time zone name", err.Error())
}
func TestToSession(t *testing.T) {
t.Parallel()
@@ -63,13 +63,13 @@ func (d *InMemoryUserDirectory) ExistsByUserID(ctx context.Context, userID commo
return ok, nil
}
// EnsureUserByEmail returns an existing user for email, creates a new user
// when registration is allowed, or reports a blocked outcome.
func (d *InMemoryUserDirectory) EnsureUserByEmail(ctx context.Context, email common.Email) (ports.EnsureUserResult, error) {
// EnsureUserByEmail returns an existing user for input.Email, creates a new
// user when registration is allowed, or reports a blocked outcome.
func (d *InMemoryUserDirectory) EnsureUserByEmail(ctx context.Context, input ports.EnsureUserInput) (ports.EnsureUserResult, error) {
if err := ctx.Err(); err != nil {
return ports.EnsureUserResult{}, err
}
if err := email.Validate(); err != nil {
if err := input.Validate(); err != nil {
return ports.EnsureUserResult{}, fmt.Errorf("ensure user by email: %w", err)
}
@@ -83,7 +83,7 @@ func (d *InMemoryUserDirectory) EnsureUserByEmail(ctx context.Context, email com
d.emailByUserID = make(map[common.UserID]common.Email)
}
entry, ok := d.byEmail[email]
entry, ok := d.byEmail[input.Email]
if ok {
if !entry.BlockReasonCode.IsZero() {
result := ports.EnsureUserResult{
@@ -104,8 +104,8 @@ func (d *InMemoryUserDirectory) EnsureUserByEmail(ctx context.Context, email com
if err != nil {
return ports.EnsureUserResult{}, err
}
d.byEmail[email] = userDirectoryEntry{UserID: userID}
d.emailByUserID[userID] = email
d.byEmail[input.Email] = userDirectoryEntry{UserID: userID}
d.emailByUserID[userID] = input.Email
result := ports.EnsureUserResult{
Outcome: ports.EnsureUserOutcomeCreated,
@@ -94,7 +94,13 @@ func TestInMemoryUserDirectoryEnsureUserExistingCreatedAndBlocked(t *testing.T)
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := directory.EnsureUserByEmail(context.Background(), tt.email)
got, err := directory.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{
Email: tt.email,
RegistrationContext: &ports.RegistrationContext{
PreferredLanguage: "en",
TimeZone: "Europe/Kaliningrad",
},
})
if err != nil {
require.Failf(t, "test failed", "EnsureUserByEmail() returned error: %v", err)
}