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
@@ -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()