package authdirectory import ( "context" "errors" "testing" "time" "galaxy/user/internal/domain/common" "galaxy/user/internal/domain/entitlement" "galaxy/user/internal/domain/policy" "galaxy/user/internal/ports" "galaxy/user/internal/service/shared" "galaxy/user/internal/telemetry" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel/attribute" sdkmetric "go.opentelemetry.io/otel/sdk/metric" "go.opentelemetry.io/otel/sdk/metric/metricdata" sdktrace "go.opentelemetry.io/otel/sdk/trace" ) func TestResolverExecute(t *testing.T) { t.Parallel() tests := []struct { name string store stubAuthDirectoryStore wantKind string wantUserID string wantBlock string }{ { name: "existing", store: stubAuthDirectoryStore{ resolveByEmail: func(_ context.Context, email common.Email) (ports.ResolveByEmailResult, error) { require.Equal(t, common.Email("pilot@example.com"), email) return ports.ResolveByEmailResult{ Kind: ports.AuthResolutionKindExisting, UserID: common.UserID("user-123"), }, nil }, }, wantKind: "existing", wantUserID: "user-123", }, { name: "creatable", store: stubAuthDirectoryStore{ resolveByEmail: func(_ context.Context, email common.Email) (ports.ResolveByEmailResult, error) { require.Equal(t, common.Email("pilot@example.com"), email) return ports.ResolveByEmailResult{ Kind: ports.AuthResolutionKindCreatable, }, nil }, }, wantKind: "creatable", }, { name: "blocked", store: stubAuthDirectoryStore{ resolveByEmail: func(_ context.Context, email common.Email) (ports.ResolveByEmailResult, error) { require.Equal(t, common.Email("pilot@example.com"), email) return ports.ResolveByEmailResult{ Kind: ports.AuthResolutionKindBlocked, BlockReasonCode: common.ReasonCode("policy_blocked"), }, nil }, }, wantKind: "blocked", wantBlock: "policy_blocked", }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() resolver, err := NewResolver(tt.store) require.NoError(t, err) result, err := resolver.Execute(context.Background(), ResolveByEmailInput{ Email: " pilot@example.com ", }) require.NoError(t, err) require.Equal(t, tt.wantKind, result.Kind) require.Equal(t, tt.wantUserID, result.UserID) require.Equal(t, tt.wantBlock, result.BlockReasonCode) }) } } func TestEnsurerExecuteCreatedBuildsInitialRecords(t *testing.T) { t.Parallel() now := time.Unix(1_775_240_000, 0).UTC() ensurer, err := NewEnsurer(stubAuthDirectoryStore{ ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) { require.Equal(t, common.Email("created@example.com"), input.Email) require.Equal(t, common.UserID("user-created"), input.Account.UserID) require.Equal(t, common.UserName("player-test123"), input.Account.UserName) require.Equal(t, common.LanguageTag("en-US"), input.Account.PreferredLanguage) require.Equal(t, common.TimeZoneName("Europe/Kaliningrad"), input.Account.TimeZone) require.Equal(t, entitlement.PlanCodeFree, input.Entitlement.PlanCode) require.False(t, input.Entitlement.IsPaid) require.Equal(t, input.Account.UserID, input.Entitlement.UserID) require.Equal(t, entitlement.EntitlementRecordID("entitlement-created"), input.EntitlementRecord.RecordID) require.Equal(t, input.Account.UserID, input.EntitlementRecord.UserID) require.Equal(t, input.Entitlement.PlanCode, input.EntitlementRecord.PlanCode) require.Equal(t, input.Entitlement.StartsAt, input.EntitlementRecord.StartsAt) require.Equal(t, input.Entitlement.Source, input.EntitlementRecord.Source) require.Equal(t, input.Entitlement.Actor, input.EntitlementRecord.Actor) require.Equal(t, input.Entitlement.ReasonCode, input.EntitlementRecord.ReasonCode) return ports.EnsureByEmailResult{ Outcome: ports.EnsureByEmailOutcomeCreated, UserID: input.Account.UserID, }, nil }, }, fixedClock{now: now}, fixedIDGenerator{ userID: common.UserID("user-created"), userName: common.UserName("player-test123"), entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"), }) require.NoError(t, err) result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{ Email: "created@example.com", RegistrationContext: &RegistrationContext{ PreferredLanguage: "en-us", TimeZone: "Europe/Kaliningrad", }, }) require.NoError(t, err) require.Equal(t, "created", result.Outcome) require.Equal(t, "user-created", result.UserID) } func TestEnsurerExecuteRejectsInvalidRegistrationContext(t *testing.T) { t.Parallel() tests := []struct { name string input EnsureByEmailInput wantErr string }{ { name: "invalid preferred language", input: EnsureByEmailInput{ Email: "pilot@example.com", RegistrationContext: &RegistrationContext{ PreferredLanguage: "bad@@tag", TimeZone: "Europe/Kaliningrad", }, }, wantErr: "registration_context.preferred_language must be a valid BCP 47 language tag", }, { name: "invalid time zone", input: EnsureByEmailInput{ Email: "pilot@example.com", RegistrationContext: &RegistrationContext{ PreferredLanguage: "en", TimeZone: "Mars/Olympus", }, }, wantErr: "registration_context.time_zone must be a valid IANA time zone name", }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() ensurer, err := NewEnsurer(stubAuthDirectoryStore{}, fixedClock{now: time.Unix(1_775_240_000, 0).UTC()}, fixedIDGenerator{ userID: common.UserID("user-created"), userName: common.UserName("player-test123"), entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"), }) require.NoError(t, err) _, err = ensurer.Execute(context.Background(), tt.input) require.Error(t, err) require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err)) require.Equal(t, tt.wantErr, err.Error()) }) } } func TestEnsurerExecuteRetriesConflicts(t *testing.T) { t.Parallel() attempt := 0 ensurer, err := NewEnsurer(stubAuthDirectoryStore{ ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) { attempt++ if attempt == 1 { return ports.EnsureByEmailResult{}, ports.ErrConflict } return ports.EnsureByEmailResult{ Outcome: ports.EnsureByEmailOutcomeCreated, UserID: input.Account.UserID, }, nil }, }, fixedClock{now: time.Unix(1_775_240_000, 0).UTC()}, &sequenceIDGenerator{ userIDs: []common.UserID{"user-first", "user-second"}, userNames: []common.UserName{"player-firstxyz", "player-secondxy"}, entitlementRecordIDs: []entitlement.EntitlementRecordID{"entitlement-first", "entitlement-second"}, }) require.NoError(t, err) result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{ Email: "retry@example.com", RegistrationContext: &RegistrationContext{ PreferredLanguage: "en", TimeZone: "UTC", }, }) require.NoError(t, err) require.Equal(t, 2, attempt) require.Equal(t, "user-second", result.UserID) } func TestEnsurerExecuteReturnsExistingAndBlocked(t *testing.T) { t.Parallel() tests := []struct { name string store stubAuthDirectoryStore want EnsureByEmailResult }{ { name: "existing", store: stubAuthDirectoryStore{ ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) { require.Equal(t, common.Email("pilot@example.com"), input.Email) return ports.EnsureByEmailResult{ Outcome: ports.EnsureByEmailOutcomeExisting, UserID: common.UserID("user-existing"), }, nil }, }, want: EnsureByEmailResult{ Outcome: "existing", UserID: "user-existing", }, }, { name: "blocked", store: stubAuthDirectoryStore{ ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) { require.Equal(t, common.Email("pilot@example.com"), input.Email) return ports.EnsureByEmailResult{ Outcome: ports.EnsureByEmailOutcomeBlocked, BlockReasonCode: common.ReasonCode("policy_blocked"), }, nil }, }, want: EnsureByEmailResult{ Outcome: "blocked", BlockReasonCode: "policy_blocked", }, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() ensurer, err := NewEnsurer(tt.store, fixedClock{now: time.Unix(1_775_240_000, 0).UTC()}, fixedIDGenerator{ userID: common.UserID("user-created"), userName: common.UserName("player-test123"), entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"), }) require.NoError(t, err) result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{ Email: "pilot@example.com", RegistrationContext: &RegistrationContext{ PreferredLanguage: "en", TimeZone: "UTC", }, }) require.NoError(t, err) require.Equal(t, tt.want, result) }) } } func TestEnsurerExecuteCreatedPublishesInitializedEvents(t *testing.T) { t.Parallel() now := time.Unix(1_775_240_000, 0).UTC() publisher := &recordingAuthDomainEventPublisher{} telemetryRuntime, reader := newObservedAuthTelemetryRuntime(t) ensurer, err := NewEnsurerWithObservability(stubAuthDirectoryStore{ ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) { return ports.EnsureByEmailResult{ Outcome: ports.EnsureByEmailOutcomeCreated, UserID: input.Account.UserID, }, nil }, }, fixedClock{now: now}, fixedIDGenerator{ userID: common.UserID("user-created"), userName: common.UserName("player-test123"), entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"), }, nil, telemetryRuntime, publisher, publisher, publisher) require.NoError(t, err) result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{ Email: "created@example.com", RegistrationContext: &RegistrationContext{ PreferredLanguage: "en-us", TimeZone: "Europe/Kaliningrad", }, }) require.NoError(t, err) require.Equal(t, "created", result.Outcome) require.Len(t, publisher.profileEvents, 1) require.Equal(t, ports.ProfileChangedOperationInitialized, publisher.profileEvents[0].Operation) require.Equal(t, common.Source("auth_registration"), publisher.profileEvents[0].Source) require.Len(t, publisher.settingsEvents, 1) require.Equal(t, ports.SettingsChangedOperationInitialized, publisher.settingsEvents[0].Operation) require.Len(t, publisher.entitlementEvents, 1) require.Equal(t, ports.EntitlementChangedOperationInitialized, publisher.entitlementEvents[0].Operation) assertMetricCount(t, reader, "user.user_creation.outcomes", map[string]string{ "outcome": "created", }, 1) } func TestEnsurerExecuteExistingBlockedAndFailedDoNotPublishEvents(t *testing.T) { t.Parallel() tests := []struct { name string store stubAuthDirectoryStore input EnsureByEmailInput wantMetric string wantErrCode string wantProfileLen int }{ { name: "existing", store: stubAuthDirectoryStore{ ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) { return ports.EnsureByEmailResult{ Outcome: ports.EnsureByEmailOutcomeExisting, UserID: common.UserID("user-existing"), }, nil }, }, input: EnsureByEmailInput{ Email: "pilot@example.com", RegistrationContext: &RegistrationContext{ PreferredLanguage: "en", TimeZone: "UTC", }, }, wantMetric: "existing", }, { name: "blocked", store: stubAuthDirectoryStore{ ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) { return ports.EnsureByEmailResult{ Outcome: ports.EnsureByEmailOutcomeBlocked, BlockReasonCode: common.ReasonCode("policy_blocked"), }, nil }, }, input: EnsureByEmailInput{ Email: "pilot@example.com", RegistrationContext: &RegistrationContext{ PreferredLanguage: "en", TimeZone: "UTC", }, }, wantMetric: "blocked", }, { name: "failed", store: stubAuthDirectoryStore{}, input: EnsureByEmailInput{ Email: "pilot@example.com", }, wantMetric: "failed", wantErrCode: shared.ErrorCodeInvalidRequest, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() publisher := &recordingAuthDomainEventPublisher{} telemetryRuntime, reader := newObservedAuthTelemetryRuntime(t) ensurer, err := NewEnsurerWithObservability(tt.store, fixedClock{now: time.Unix(1_775_240_000, 0).UTC()}, fixedIDGenerator{ userID: common.UserID("user-created"), userName: common.UserName("player-test123"), entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"), }, nil, telemetryRuntime, publisher, publisher, publisher) require.NoError(t, err) _, err = ensurer.Execute(context.Background(), tt.input) if tt.wantErrCode != "" { require.Error(t, err) require.Equal(t, tt.wantErrCode, shared.CodeOf(err)) } else { require.NoError(t, err) } require.Empty(t, publisher.profileEvents) require.Empty(t, publisher.settingsEvents) require.Empty(t, publisher.entitlementEvents) assertMetricCount(t, reader, "user.user_creation.outcomes", map[string]string{ "outcome": tt.wantMetric, }, 1) }) } } func TestEnsurerExecutePublishFailureDoesNotRollbackCreatedUser(t *testing.T) { t.Parallel() now := time.Unix(1_775_240_000, 0).UTC() publisher := &recordingAuthDomainEventPublisher{err: errors.New("publisher unavailable")} telemetryRuntime, reader := newObservedAuthTelemetryRuntime(t) ensurer, err := NewEnsurerWithObservability(stubAuthDirectoryStore{ ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) { return ports.EnsureByEmailResult{ Outcome: ports.EnsureByEmailOutcomeCreated, UserID: input.Account.UserID, }, nil }, }, fixedClock{now: now}, fixedIDGenerator{ userID: common.UserID("user-created"), userName: common.UserName("player-test123"), entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"), }, nil, telemetryRuntime, publisher, publisher, publisher) require.NoError(t, err) result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{ Email: "created@example.com", RegistrationContext: &RegistrationContext{ PreferredLanguage: "en-us", TimeZone: "Europe/Kaliningrad", }, }) require.NoError(t, err) require.Equal(t, "created", result.Outcome) require.Len(t, publisher.profileEvents, 1) require.Len(t, publisher.settingsEvents, 1) require.Len(t, publisher.entitlementEvents, 1) assertMetricCount(t, reader, "user.event_publication_failures", map[string]string{ "event_type": ports.ProfileChangedEventType, }, 1) assertMetricCount(t, reader, "user.event_publication_failures", map[string]string{ "event_type": ports.SettingsChangedEventType, }, 1) assertMetricCount(t, reader, "user.event_publication_failures", map[string]string{ "event_type": ports.EntitlementChangedEventType, }, 1) } func TestBlockByUserIDServiceMapsNotFound(t *testing.T) { t.Parallel() service, err := NewBlockByUserIDService(stubAuthDirectoryStore{ blockByUserID: func(context.Context, ports.BlockByUserIDInput) (ports.BlockResult, error) { return ports.BlockResult{}, ports.ErrNotFound }, }, fixedClock{now: time.Unix(1_775_240_000, 0).UTC()}) require.NoError(t, err) _, err = service.Execute(context.Background(), BlockByUserIDInput{ UserID: "user-missing", ReasonCode: "policy_blocked", }) require.Error(t, err) require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err)) } type stubAuthDirectoryStore struct { resolveByEmail func(context.Context, common.Email) (ports.ResolveByEmailResult, error) ensureByEmail func(context.Context, ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) existsByUserID func(context.Context, common.UserID) (bool, error) blockByUserID func(context.Context, ports.BlockByUserIDInput) (ports.BlockResult, error) blockByEmail func(context.Context, ports.BlockByEmailInput) (ports.BlockResult, error) } func (store stubAuthDirectoryStore) ResolveByEmail(ctx context.Context, email common.Email) (ports.ResolveByEmailResult, error) { if store.resolveByEmail == nil { return ports.ResolveByEmailResult{}, errors.New("unexpected ResolveByEmail call") } return store.resolveByEmail(ctx, email) } func (store stubAuthDirectoryStore) ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error) { if store.existsByUserID == nil { return false, errors.New("unexpected ExistsByUserID call") } return store.existsByUserID(ctx, userID) } func (store stubAuthDirectoryStore) EnsureByEmail(ctx context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) { if store.ensureByEmail == nil { return ports.EnsureByEmailResult{}, errors.New("unexpected EnsureByEmail call") } return store.ensureByEmail(ctx, input) } func (store stubAuthDirectoryStore) BlockByUserID(ctx context.Context, input ports.BlockByUserIDInput) (ports.BlockResult, error) { if store.blockByUserID == nil { return ports.BlockResult{}, errors.New("unexpected BlockByUserID call") } return store.blockByUserID(ctx, input) } func (store stubAuthDirectoryStore) BlockByEmail(ctx context.Context, input ports.BlockByEmailInput) (ports.BlockResult, error) { if store.blockByEmail == nil { return ports.BlockResult{}, errors.New("unexpected BlockByEmail call") } return store.blockByEmail(ctx, input) } type fixedClock struct { now time.Time } func (clock fixedClock) Now() time.Time { return clock.now } type fixedIDGenerator struct { userID common.UserID userName common.UserName entitlementRecordID entitlement.EntitlementRecordID sanctionRecordID policy.SanctionRecordID limitRecordID policy.LimitRecordID } func (generator fixedIDGenerator) NewUserID() (common.UserID, error) { return generator.userID, nil } func (generator fixedIDGenerator) NewUserName() (common.UserName, error) { return generator.userName, nil } func (generator fixedIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) { return generator.entitlementRecordID, nil } func (generator fixedIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) { return generator.sanctionRecordID, nil } func (generator fixedIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) { return generator.limitRecordID, nil } type sequenceIDGenerator struct { userIDs []common.UserID userNames []common.UserName entitlementRecordIDs []entitlement.EntitlementRecordID sanctionRecordIDs []policy.SanctionRecordID limitRecordIDs []policy.LimitRecordID } func (generator *sequenceIDGenerator) NewUserID() (common.UserID, error) { value := generator.userIDs[0] generator.userIDs = generator.userIDs[1:] return value, nil } func (generator *sequenceIDGenerator) NewUserName() (common.UserName, error) { value := generator.userNames[0] generator.userNames = generator.userNames[1:] return value, nil } func (generator *sequenceIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) { value := generator.entitlementRecordIDs[0] generator.entitlementRecordIDs = generator.entitlementRecordIDs[1:] return value, nil } func (generator *sequenceIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) { value := generator.sanctionRecordIDs[0] generator.sanctionRecordIDs = generator.sanctionRecordIDs[1:] return value, nil } func (generator *sequenceIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) { value := generator.limitRecordIDs[0] generator.limitRecordIDs = generator.limitRecordIDs[1:] return value, nil } type recordingAuthDomainEventPublisher struct { err error profileEvents []ports.ProfileChangedEvent settingsEvents []ports.SettingsChangedEvent entitlementEvents []ports.EntitlementChangedEvent } func (publisher *recordingAuthDomainEventPublisher) PublishProfileChanged(_ context.Context, event ports.ProfileChangedEvent) error { if err := event.Validate(); err != nil { return err } publisher.profileEvents = append(publisher.profileEvents, event) return publisher.err } func (publisher *recordingAuthDomainEventPublisher) PublishSettingsChanged(_ context.Context, event ports.SettingsChangedEvent) error { if err := event.Validate(); err != nil { return err } publisher.settingsEvents = append(publisher.settingsEvents, event) return publisher.err } func (publisher *recordingAuthDomainEventPublisher) PublishEntitlementChanged(_ context.Context, event ports.EntitlementChangedEvent) error { if err := event.Validate(); err != nil { return err } publisher.entitlementEvents = append(publisher.entitlementEvents, event) return publisher.err } func newObservedAuthTelemetryRuntime(t *testing.T) (*telemetry.Runtime, *sdkmetric.ManualReader) { t.Helper() reader := sdkmetric.NewManualReader() meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) tracerProvider := sdktrace.NewTracerProvider() runtime, err := telemetry.NewWithProviders(meterProvider, tracerProvider) require.NoError(t, err) return runtime, reader } func assertMetricCount(t *testing.T, reader *sdkmetric.ManualReader, metricName string, wantAttrs map[string]string, wantValue int64) { t.Helper() var resourceMetrics metricdata.ResourceMetrics require.NoError(t, reader.Collect(context.Background(), &resourceMetrics)) for _, scopeMetrics := range resourceMetrics.ScopeMetrics { for _, metric := range scopeMetrics.Metrics { if metric.Name != metricName { continue } sum, ok := metric.Data.(metricdata.Sum[int64]) require.True(t, ok) for _, point := range sum.DataPoints { if hasMetricAttributes(point.Attributes.ToSlice(), wantAttrs) { require.Equal(t, wantValue, point.Value) return } } } } require.Failf(t, "test failed", "metric %q with attrs %v not found", metricName, wantAttrs) } func hasMetricAttributes(values []attribute.KeyValue, want map[string]string) bool { if len(values) != len(want) { return false } for _, value := range values { if want[string(value.Key)] != value.Value.AsString() { return false } } return true } var ( _ ports.AuthDirectoryStore = stubAuthDirectoryStore{} _ ports.Clock = fixedClock{} _ ports.IDGenerator = fixedIDGenerator{} _ ports.IDGenerator = (*sequenceIDGenerator)(nil) _ ports.ProfileChangedPublisher = (*recordingAuthDomainEventPublisher)(nil) _ ports.SettingsChangedPublisher = (*recordingAuthDomainEventPublisher)(nil) _ ports.EntitlementChangedPublisher = (*recordingAuthDomainEventPublisher)(nil) )