package policysvc import ( "context" "testing" "time" "galaxy/user/internal/domain/account" "galaxy/user/internal/domain/common" "galaxy/user/internal/domain/entitlement" "galaxy/user/internal/domain/policy" "galaxy/user/internal/ports" "galaxy/user/internal/service/shared" "github.com/stretchr/testify/require" ) func TestApplySanctionServiceExecuteBuildsActiveRecord(t *testing.T) { t.Parallel() now := time.Unix(1_775_240_000, 0).UTC() userID := common.UserID("user-123") sanctionStore := newFakeSanctionStore() limitStore := newFakeLimitStore() service, err := NewApplySanctionService( fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}}, sanctionStore, limitStore, &fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore}, fixedClock{now: now}, fixedIDGenerator{sanctionRecordID: policy.SanctionRecordID("sanction-1")}, ) require.NoError(t, err) result, err := service.Execute(context.Background(), ApplySanctionInput{ UserID: userID.String(), SanctionCode: string(policy.SanctionCodeLoginBlock), Scope: "auth", ReasonCode: "policy_blocked", Actor: ActorInput{Type: "admin", ID: "admin-1"}, AppliedAt: now.Add(-time.Minute).Format(time.RFC3339Nano), ExpiresAt: now.Add(time.Hour).Format(time.RFC3339Nano), }) require.NoError(t, err) require.Equal(t, userID.String(), result.UserID) require.Len(t, result.ActiveSanctions, 1) require.Equal(t, string(policy.SanctionCodeLoginBlock), result.ActiveSanctions[0].SanctionCode) records, err := sanctionStore.ListByUserID(context.Background(), userID) require.NoError(t, err) require.Len(t, records, 1) require.Equal(t, policy.SanctionRecordID("sanction-1"), records[0].RecordID) } func TestApplySanctionServiceExecuteRejectsExpiredSanction(t *testing.T) { t.Parallel() now := time.Unix(1_775_240_000, 0).UTC() userID := common.UserID("user-123") sanctionStore := newFakeSanctionStore() limitStore := newFakeLimitStore() service, err := NewApplySanctionService( fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}}, sanctionStore, limitStore, &fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore}, fixedClock{now: now}, fixedIDGenerator{sanctionRecordID: policy.SanctionRecordID("sanction-1")}, ) require.NoError(t, err) _, err = service.Execute(context.Background(), ApplySanctionInput{ UserID: userID.String(), SanctionCode: string(policy.SanctionCodeLoginBlock), Scope: "auth", ReasonCode: "policy_blocked", Actor: ActorInput{Type: "admin", ID: "admin-1"}, AppliedAt: now.Add(-2 * time.Hour).Format(time.RFC3339Nano), ExpiresAt: now.Add(-time.Minute).Format(time.RFC3339Nano), }) require.Error(t, err) require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err)) } func TestApplySanctionServiceExecuteReturnsConflictWhenActiveSanctionExists(t *testing.T) { t.Parallel() now := time.Unix(1_775_240_000, 0).UTC() userID := common.UserID("user-123") sanctionStore := newFakeSanctionStore() existing := policy.SanctionRecord{ RecordID: policy.SanctionRecordID("sanction-existing"), UserID: userID, SanctionCode: policy.SanctionCodeLoginBlock, Scope: common.Scope("auth"), ReasonCode: common.ReasonCode("policy_blocked"), Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}, AppliedAt: now.Add(-time.Hour), } require.NoError(t, sanctionStore.Create(context.Background(), existing)) service, err := NewApplySanctionService( fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}}, sanctionStore, newFakeLimitStore(), &fakePolicyLifecycleStore{sanctions: sanctionStore, limits: newFakeLimitStore()}, fixedClock{now: now}, fixedIDGenerator{sanctionRecordID: policy.SanctionRecordID("sanction-1")}, ) require.NoError(t, err) _, err = service.Execute(context.Background(), ApplySanctionInput{ UserID: userID.String(), SanctionCode: string(policy.SanctionCodeLoginBlock), Scope: "auth", ReasonCode: "policy_blocked", Actor: ActorInput{Type: "admin", ID: "admin-2"}, AppliedAt: now.Add(-time.Minute).Format(time.RFC3339Nano), }) require.Error(t, err) require.Equal(t, shared.ErrorCodeConflict, shared.CodeOf(err)) } func TestApplySanctionServiceExecuteReturnsNotFoundForUnknownUser(t *testing.T) { t.Parallel() now := time.Unix(1_775_240_000, 0).UTC() service, err := NewApplySanctionService( fakeAccountStore{existsByUserID: map[common.UserID]bool{}}, newFakeSanctionStore(), newFakeLimitStore(), &fakePolicyLifecycleStore{sanctions: newFakeSanctionStore(), limits: newFakeLimitStore()}, fixedClock{now: now}, fixedIDGenerator{sanctionRecordID: policy.SanctionRecordID("sanction-1")}, ) require.NoError(t, err) _, err = service.Execute(context.Background(), ApplySanctionInput{ UserID: "user-missing", SanctionCode: string(policy.SanctionCodeLoginBlock), Scope: "auth", ReasonCode: "policy_blocked", Actor: ActorInput{Type: "admin"}, AppliedAt: now.Format(time.RFC3339Nano), }) require.Error(t, err) require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err)) } func TestRemoveSanctionServiceExecuteIsIdempotentWhenMissing(t *testing.T) { t.Parallel() now := time.Unix(1_775_240_000, 0).UTC() userID := common.UserID("user-123") sanctionStore := newFakeSanctionStore() limitStore := newFakeLimitStore() service, err := NewRemoveSanctionService( fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}}, sanctionStore, limitStore, &fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore}, fixedClock{now: now}, fixedIDGenerator{}, ) require.NoError(t, err) result, err := service.Execute(context.Background(), RemoveSanctionInput{ UserID: userID.String(), SanctionCode: string(policy.SanctionCodeLoginBlock), ReasonCode: "manual_remove", Actor: ActorInput{Type: "admin", ID: "admin-1"}, }) require.NoError(t, err) require.Equal(t, userID.String(), result.UserID) require.Empty(t, result.ActiveSanctions) } func TestRemoveSanctionServiceExecuteTreatsConcurrentRemovalAsSuccess(t *testing.T) { t.Parallel() now := time.Unix(1_775_240_000, 0).UTC() userID := common.UserID("user-123") sanctionStore := newFakeSanctionStore() limitStore := newFakeLimitStore() record := policy.SanctionRecord{ RecordID: policy.SanctionRecordID("sanction-1"), UserID: userID, SanctionCode: policy.SanctionCodeLoginBlock, Scope: common.Scope("auth"), ReasonCode: common.ReasonCode("policy_blocked"), Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}, AppliedAt: now.Add(-time.Hour), } require.NoError(t, sanctionStore.Create(context.Background(), record)) lifecycle := &fakePolicyLifecycleStore{ sanctions: sanctionStore, limits: limitStore, removeSanctionHook: func(input ports.RemoveSanctionInput) error { updated := input.ExpectedActiveRecord removedAt := now.Add(-time.Minute) updated.RemovedAt = &removedAt updated.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")} updated.RemovedReasonCode = common.ReasonCode("manual_remove") if err := sanctionStore.Update(context.Background(), updated); err != nil { return err } return ports.ErrConflict }, } service, err := NewRemoveSanctionService( fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}}, sanctionStore, limitStore, lifecycle, fixedClock{now: now}, fixedIDGenerator{}, ) require.NoError(t, err) result, err := service.Execute(context.Background(), RemoveSanctionInput{ UserID: userID.String(), SanctionCode: string(policy.SanctionCodeLoginBlock), ReasonCode: "manual_remove", Actor: ActorInput{Type: "admin", ID: "admin-1"}, }) require.NoError(t, err) require.Empty(t, result.ActiveSanctions) } func TestSetLimitServiceExecuteReplacesActiveLimit(t *testing.T) { t.Parallel() now := time.Unix(1_775_240_000, 0).UTC() userID := common.UserID("user-123") sanctionStore := newFakeSanctionStore() limitStore := newFakeLimitStore() current := policy.LimitRecord{ RecordID: policy.LimitRecordID("limit-existing"), UserID: userID, LimitCode: policy.LimitCodeMaxOwnedPrivateGames, Value: 3, ReasonCode: common.ReasonCode("manual_override"), Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}, AppliedAt: now.Add(-time.Hour), } require.NoError(t, limitStore.Create(context.Background(), current)) service, err := NewSetLimitService( fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}}, sanctionStore, limitStore, &fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore}, fixedClock{now: now}, fixedIDGenerator{limitRecordID: policy.LimitRecordID("limit-new")}, ) require.NoError(t, err) result, err := service.Execute(context.Background(), SetLimitInput{ UserID: userID.String(), LimitCode: string(policy.LimitCodeMaxOwnedPrivateGames), Value: 5, ReasonCode: "manual_override", Actor: ActorInput{Type: "admin", ID: "admin-2"}, AppliedAt: now.Format(time.RFC3339Nano), }) require.NoError(t, err) require.Len(t, result.ActiveLimits, 1) require.Equal(t, 5, result.ActiveLimits[0].Value) storedCurrent, err := limitStore.GetByRecordID(context.Background(), current.RecordID) require.NoError(t, err) require.NotNil(t, storedCurrent.RemovedAt) require.True(t, storedCurrent.RemovedAt.Equal(now)) } func TestSetLimitServiceExecuteRejectsRetroactiveReplacement(t *testing.T) { t.Parallel() now := time.Unix(1_775_240_000, 0).UTC() userID := common.UserID("user-123") limitStore := newFakeLimitStore() current := policy.LimitRecord{ RecordID: policy.LimitRecordID("limit-existing"), UserID: userID, LimitCode: policy.LimitCodeMaxOwnedPrivateGames, Value: 3, ReasonCode: common.ReasonCode("manual_override"), Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}, AppliedAt: now.Add(-time.Hour), } require.NoError(t, limitStore.Create(context.Background(), current)) service, err := NewSetLimitService( fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}}, newFakeSanctionStore(), limitStore, &fakePolicyLifecycleStore{sanctions: newFakeSanctionStore(), limits: limitStore}, fixedClock{now: now}, fixedIDGenerator{limitRecordID: policy.LimitRecordID("limit-new")}, ) require.NoError(t, err) _, err = service.Execute(context.Background(), SetLimitInput{ UserID: userID.String(), LimitCode: string(policy.LimitCodeMaxOwnedPrivateGames), Value: 5, ReasonCode: "manual_override", Actor: ActorInput{Type: "admin", ID: "admin-2"}, AppliedAt: now.Add(-2 * time.Hour).Format(time.RFC3339Nano), }) require.Error(t, err) require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err)) } func TestSetLimitServiceExecuteRejectsRetiredLimitCodes(t *testing.T) { t.Parallel() now := time.Unix(1_775_240_000, 0).UTC() userID := common.UserID("user-123") tests := []string{ string(policy.LimitCodeMaxActivePrivateGames), string(policy.LimitCodeMaxPendingPrivateJoinRequests), string(policy.LimitCodeMaxPendingPrivateInvitesSent), } for _, limitCode := range tests { limitCode := limitCode t.Run(limitCode, func(t *testing.T) { t.Parallel() service, err := NewSetLimitService( fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}}, newFakeSanctionStore(), newFakeLimitStore(), &fakePolicyLifecycleStore{sanctions: newFakeSanctionStore(), limits: newFakeLimitStore()}, fixedClock{now: now}, fixedIDGenerator{limitRecordID: policy.LimitRecordID("limit-new")}, ) require.NoError(t, err) _, err = service.Execute(context.Background(), SetLimitInput{ UserID: userID.String(), LimitCode: limitCode, Value: 5, ReasonCode: "manual_override", Actor: ActorInput{Type: "admin", ID: "admin-2"}, AppliedAt: now.Format(time.RFC3339Nano), }) require.Error(t, err) require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err)) }) } } func TestSetLimitServiceExecuteIgnoresRetiredRecordsDuringReload(t *testing.T) { t.Parallel() now := time.Unix(1_775_240_000, 0).UTC() userID := common.UserID("user-123") limitStore := newFakeLimitStore() require.NoError(t, limitStore.Create(context.Background(), policy.LimitRecord{ RecordID: policy.LimitRecordID("limit-legacy"), UserID: userID, LimitCode: policy.LimitCodeMaxActivePrivateGames, Value: 9, ReasonCode: common.ReasonCode("legacy_override"), Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}, AppliedAt: now.Add(-time.Hour), })) service, err := NewSetLimitService( fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}}, newFakeSanctionStore(), limitStore, &fakePolicyLifecycleStore{sanctions: newFakeSanctionStore(), limits: limitStore}, fixedClock{now: now}, fixedIDGenerator{limitRecordID: policy.LimitRecordID("limit-new")}, ) require.NoError(t, err) result, err := service.Execute(context.Background(), SetLimitInput{ UserID: userID.String(), LimitCode: string(policy.LimitCodeMaxOwnedPrivateGames), Value: 5, ReasonCode: "manual_override", Actor: ActorInput{Type: "admin", ID: "admin-2"}, AppliedAt: now.Format(time.RFC3339Nano), }) require.NoError(t, err) require.Len(t, result.ActiveLimits, 1) require.Equal(t, string(policy.LimitCodeMaxOwnedPrivateGames), result.ActiveLimits[0].LimitCode) } func TestRemoveLimitServiceExecuteIsIdempotentWhenMissing(t *testing.T) { t.Parallel() now := time.Unix(1_775_240_000, 0).UTC() userID := common.UserID("user-123") sanctionStore := newFakeSanctionStore() limitStore := newFakeLimitStore() service, err := NewRemoveLimitService( fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}}, sanctionStore, limitStore, &fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore}, fixedClock{now: now}, fixedIDGenerator{}, ) require.NoError(t, err) result, err := service.Execute(context.Background(), RemoveLimitInput{ UserID: userID.String(), LimitCode: string(policy.LimitCodeMaxOwnedPrivateGames), ReasonCode: "manual_remove", Actor: ActorInput{Type: "admin", ID: "admin-1"}, }) require.NoError(t, err) require.Empty(t, result.ActiveLimits) } func TestRemoveLimitServiceExecuteRejectsRetiredLimitCode(t *testing.T) { t.Parallel() now := time.Unix(1_775_240_000, 0).UTC() userID := common.UserID("user-123") service, err := NewRemoveLimitService( fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}}, newFakeSanctionStore(), newFakeLimitStore(), &fakePolicyLifecycleStore{sanctions: newFakeSanctionStore(), limits: newFakeLimitStore()}, fixedClock{now: now}, fixedIDGenerator{}, ) require.NoError(t, err) _, err = service.Execute(context.Background(), RemoveLimitInput{ UserID: userID.String(), LimitCode: string(policy.LimitCodeMaxPendingPrivateJoinRequests), ReasonCode: "manual_remove", Actor: ActorInput{Type: "admin", ID: "admin-1"}, }) require.Error(t, err) require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err)) } type fakeAccountStore struct { existsByUserID map[common.UserID]bool } func (store fakeAccountStore) Create(context.Context, ports.CreateAccountInput) error { return nil } func (store fakeAccountStore) GetByUserID(context.Context, common.UserID) (account.UserAccount, error) { return account.UserAccount{}, ports.ErrNotFound } func (store fakeAccountStore) GetByEmail(context.Context, common.Email) (account.UserAccount, error) { return account.UserAccount{}, ports.ErrNotFound } func (store fakeAccountStore) GetByRaceName(context.Context, common.RaceName) (account.UserAccount, error) { return account.UserAccount{}, ports.ErrNotFound } func (store fakeAccountStore) ExistsByUserID(_ context.Context, userID common.UserID) (bool, error) { return store.existsByUserID[userID], nil } func (store fakeAccountStore) RenameRaceName(context.Context, ports.RenameRaceNameInput) error { return nil } func (store fakeAccountStore) Update(context.Context, account.UserAccount) error { return nil } type fakeSanctionStore struct { byUserID map[common.UserID][]policy.SanctionRecord byRecordID map[policy.SanctionRecordID]policy.SanctionRecord } func newFakeSanctionStore() *fakeSanctionStore { return &fakeSanctionStore{ byUserID: make(map[common.UserID][]policy.SanctionRecord), byRecordID: make(map[policy.SanctionRecordID]policy.SanctionRecord), } } func (store *fakeSanctionStore) Create(_ context.Context, record policy.SanctionRecord) error { if err := record.Validate(); err != nil { return err } if _, exists := store.byRecordID[record.RecordID]; exists { return ports.ErrConflict } store.byRecordID[record.RecordID] = record store.byUserID[record.UserID] = append(store.byUserID[record.UserID], record) return nil } func (store *fakeSanctionStore) GetByRecordID(_ context.Context, recordID policy.SanctionRecordID) (policy.SanctionRecord, error) { record, ok := store.byRecordID[recordID] if !ok { return policy.SanctionRecord{}, ports.ErrNotFound } return record, nil } func (store *fakeSanctionStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.SanctionRecord, error) { records := store.byUserID[userID] cloned := make([]policy.SanctionRecord, len(records)) copy(cloned, records) return cloned, nil } func (store *fakeSanctionStore) Update(_ context.Context, record policy.SanctionRecord) error { if err := record.Validate(); err != nil { return err } if _, exists := store.byRecordID[record.RecordID]; !exists { return ports.ErrNotFound } store.byRecordID[record.RecordID] = record records := store.byUserID[record.UserID] for index := range records { if records[index].RecordID == record.RecordID { records[index] = record store.byUserID[record.UserID] = records return nil } } return ports.ErrNotFound } type fakeLimitStore struct { byUserID map[common.UserID][]policy.LimitRecord byRecordID map[policy.LimitRecordID]policy.LimitRecord } func newFakeLimitStore() *fakeLimitStore { return &fakeLimitStore{ byUserID: make(map[common.UserID][]policy.LimitRecord), byRecordID: make(map[policy.LimitRecordID]policy.LimitRecord), } } func (store *fakeLimitStore) Create(_ context.Context, record policy.LimitRecord) error { if err := record.Validate(); err != nil { return err } if _, exists := store.byRecordID[record.RecordID]; exists { return ports.ErrConflict } store.byRecordID[record.RecordID] = record store.byUserID[record.UserID] = append(store.byUserID[record.UserID], record) return nil } func (store *fakeLimitStore) GetByRecordID(_ context.Context, recordID policy.LimitRecordID) (policy.LimitRecord, error) { record, ok := store.byRecordID[recordID] if !ok { return policy.LimitRecord{}, ports.ErrNotFound } return record, nil } func (store *fakeLimitStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.LimitRecord, error) { records := store.byUserID[userID] cloned := make([]policy.LimitRecord, len(records)) copy(cloned, records) return cloned, nil } func (store *fakeLimitStore) Update(_ context.Context, record policy.LimitRecord) error { if err := record.Validate(); err != nil { return err } if _, exists := store.byRecordID[record.RecordID]; !exists { return ports.ErrNotFound } store.byRecordID[record.RecordID] = record records := store.byUserID[record.UserID] for index := range records { if records[index].RecordID == record.RecordID { records[index] = record store.byUserID[record.UserID] = records return nil } } return ports.ErrNotFound } type fakePolicyLifecycleStore struct { sanctions *fakeSanctionStore limits *fakeLimitStore applySanctionHook func(input ports.ApplySanctionInput) error removeSanctionHook func(input ports.RemoveSanctionInput) error setLimitHook func(input ports.SetLimitInput) error removeLimitHook func(input ports.RemoveLimitInput) error } func (store *fakePolicyLifecycleStore) ApplySanction(ctx context.Context, input ports.ApplySanctionInput) error { if store.applySanctionHook != nil { return store.applySanctionHook(input) } records, err := store.sanctions.ListByUserID(ctx, input.NewRecord.UserID) if err != nil { return err } active, err := policy.ActiveSanctionsAt(records, input.NewRecord.AppliedAt) if err != nil { return err } for _, record := range active { if record.SanctionCode == input.NewRecord.SanctionCode { return ports.ErrConflict } } return store.sanctions.Create(ctx, input.NewRecord) } func (store *fakePolicyLifecycleStore) RemoveSanction(ctx context.Context, input ports.RemoveSanctionInput) error { if store.removeSanctionHook != nil { return store.removeSanctionHook(input) } return store.sanctions.Update(ctx, input.UpdatedRecord) } func (store *fakePolicyLifecycleStore) SetLimit(ctx context.Context, input ports.SetLimitInput) error { if store.setLimitHook != nil { return store.setLimitHook(input) } if input.ExpectedActiveRecord != nil { if err := store.limits.Update(ctx, *input.UpdatedActiveRecord); err != nil { return err } } return store.limits.Create(ctx, input.NewRecord) } func (store *fakePolicyLifecycleStore) RemoveLimit(ctx context.Context, input ports.RemoveLimitInput) error { if store.removeLimitHook != nil { return store.removeLimitHook(input) } return store.limits.Update(ctx, input.UpdatedRecord) } type fixedClock struct { now time.Time } func (clock fixedClock) Now() time.Time { return clock.now } type fixedIDGenerator struct { sanctionRecordID policy.SanctionRecordID limitRecordID policy.LimitRecordID } func (generator fixedIDGenerator) NewUserID() (common.UserID, error) { return "", nil } func (generator fixedIDGenerator) NewInitialRaceName() (common.RaceName, error) { return "", nil } func (generator fixedIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) { return "", nil } func (generator fixedIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) { return generator.sanctionRecordID, nil } func (generator fixedIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) { return generator.limitRecordID, nil } var ( _ ports.UserAccountStore = fakeAccountStore{} _ ports.SanctionStore = (*fakeSanctionStore)(nil) _ ports.LimitStore = (*fakeLimitStore)(nil) _ ports.PolicyLifecycleStore = (*fakePolicyLifecycleStore)(nil) _ ports.Clock = fixedClock{} _ ports.IDGenerator = fixedIDGenerator{} )