931 lines
33 KiB
Go
931 lines
33 KiB
Go
package userstore
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"galaxy/user/internal/domain/account"
|
|
"galaxy/user/internal/domain/authblock"
|
|
"galaxy/user/internal/domain/common"
|
|
"galaxy/user/internal/domain/entitlement"
|
|
"galaxy/user/internal/domain/policy"
|
|
"galaxy/user/internal/ports"
|
|
|
|
"github.com/alicebob/miniredis/v2"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestAccountStoreCreateAndLookups(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := newTestStore(t)
|
|
accountStore := store.Accounts()
|
|
|
|
record := validAccountRecord()
|
|
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record)))
|
|
|
|
byUserID, err := accountStore.GetByUserID(context.Background(), record.UserID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, record, byUserID)
|
|
|
|
byEmail, err := accountStore.GetByEmail(context.Background(), record.Email)
|
|
require.NoError(t, err)
|
|
require.Equal(t, record, byEmail)
|
|
|
|
byRaceName, err := accountStore.GetByRaceName(context.Background(), record.RaceName)
|
|
require.NoError(t, err)
|
|
require.Equal(t, record, byRaceName)
|
|
|
|
exists, err := accountStore.ExistsByUserID(context.Background(), record.UserID)
|
|
require.NoError(t, err)
|
|
require.True(t, exists)
|
|
|
|
reservation, err := store.loadRaceNameReservation(context.Background(), store.client, canonicalKey(record.RaceName))
|
|
require.NoError(t, err)
|
|
require.Equal(t, record.UserID, reservation.UserID)
|
|
require.Equal(t, record.RaceName, reservation.RaceName)
|
|
}
|
|
|
|
func TestBlockedEmailStoreUpsertAndGet(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := newTestStore(t)
|
|
blockedEmailStore := store.BlockedEmails()
|
|
|
|
record := authblock.BlockedEmailSubject{
|
|
Email: common.Email("blocked@example.com"),
|
|
ReasonCode: common.ReasonCode("policy_blocked"),
|
|
BlockedAt: time.Unix(1_775_240_100, 0).UTC(),
|
|
ResolvedUserID: common.UserID("user-123"),
|
|
}
|
|
require.NoError(t, blockedEmailStore.Upsert(context.Background(), record))
|
|
|
|
got, err := blockedEmailStore.GetByEmail(context.Background(), record.Email)
|
|
require.NoError(t, err)
|
|
require.Equal(t, record, got)
|
|
}
|
|
|
|
func TestEnsureResolveAndBlockFlows(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := newTestStore(t)
|
|
now := time.Unix(1_775_240_000, 0).UTC()
|
|
accountRecord := validAccountRecord()
|
|
entitlementSnapshot := validEntitlementSnapshot(accountRecord.UserID, now)
|
|
|
|
created, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
|
|
Email: accountRecord.Email,
|
|
Account: accountRecord,
|
|
Entitlement: entitlementSnapshot,
|
|
EntitlementRecord: validEntitlementRecord(accountRecord.UserID, now),
|
|
Reservation: raceNameReservation(accountRecord.UserID, accountRecord.RaceName, accountRecord.UpdatedAt),
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, ports.EnsureByEmailOutcomeCreated, created.Outcome)
|
|
|
|
reservation, err := store.loadRaceNameReservation(context.Background(), store.client, canonicalKey(accountRecord.RaceName))
|
|
require.NoError(t, err)
|
|
require.Equal(t, accountRecord.UserID, reservation.UserID)
|
|
|
|
entitlementHistory, err := store.ListEntitlementRecordsByUserID(context.Background(), accountRecord.UserID)
|
|
require.NoError(t, err)
|
|
require.Len(t, entitlementHistory, 1)
|
|
require.Equal(t, validEntitlementRecord(accountRecord.UserID, now), entitlementHistory[0])
|
|
|
|
resolved, err := store.ResolveByEmail(context.Background(), accountRecord.Email)
|
|
require.NoError(t, err)
|
|
require.Equal(t, ports.AuthResolutionKindExisting, resolved.Kind)
|
|
|
|
blockedByUserID, err := store.BlockByUserID(context.Background(), ports.BlockByUserIDInput{
|
|
UserID: accountRecord.UserID,
|
|
ReasonCode: common.ReasonCode("policy_blocked"),
|
|
BlockedAt: now.Add(time.Minute),
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, ports.AuthBlockOutcomeBlocked, blockedByUserID.Outcome)
|
|
|
|
repeatedBlock, err := store.BlockByEmail(context.Background(), ports.BlockByEmailInput{
|
|
Email: accountRecord.Email,
|
|
ReasonCode: common.ReasonCode("policy_blocked"),
|
|
BlockedAt: now.Add(2 * time.Minute),
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, ports.AuthBlockOutcomeAlreadyBlocked, repeatedBlock.Outcome)
|
|
require.Equal(t, accountRecord.UserID, repeatedBlock.UserID)
|
|
|
|
blockedResolution, err := store.ResolveByEmail(context.Background(), accountRecord.Email)
|
|
require.NoError(t, err)
|
|
require.Equal(t, ports.AuthResolutionKindBlocked, blockedResolution.Kind)
|
|
|
|
ensureBlocked, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
|
|
Email: accountRecord.Email,
|
|
Account: accountRecord,
|
|
Entitlement: entitlementSnapshot,
|
|
EntitlementRecord: validEntitlementRecord(accountRecord.UserID, now),
|
|
Reservation: raceNameReservation(accountRecord.UserID, accountRecord.RaceName, accountRecord.UpdatedAt),
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, ports.EnsureByEmailOutcomeBlocked, ensureBlocked.Outcome)
|
|
}
|
|
|
|
func TestBlockedEmailWithoutUserPreventsEnsureCreate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := newTestStore(t)
|
|
now := time.Unix(1_775_240_000, 0).UTC()
|
|
accountRecord := validAccountRecord()
|
|
entitlementSnapshot := validEntitlementSnapshot(accountRecord.UserID, now)
|
|
|
|
blocked, err := store.BlockByEmail(context.Background(), ports.BlockByEmailInput{
|
|
Email: accountRecord.Email,
|
|
ReasonCode: common.ReasonCode("policy_blocked"),
|
|
BlockedAt: now,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, ports.AuthBlockOutcomeBlocked, blocked.Outcome)
|
|
require.True(t, blocked.UserID.IsZero())
|
|
|
|
resolved, err := store.ResolveByEmail(context.Background(), accountRecord.Email)
|
|
require.NoError(t, err)
|
|
require.Equal(t, ports.AuthResolutionKindBlocked, resolved.Kind)
|
|
|
|
ensured, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
|
|
Email: accountRecord.Email,
|
|
Account: accountRecord,
|
|
Entitlement: entitlementSnapshot,
|
|
EntitlementRecord: validEntitlementRecord(accountRecord.UserID, now),
|
|
Reservation: raceNameReservation(accountRecord.UserID, accountRecord.RaceName, accountRecord.UpdatedAt),
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, ports.EnsureByEmailOutcomeBlocked, ensured.Outcome)
|
|
|
|
exists, err := store.ExistsByUserID(context.Background(), accountRecord.UserID)
|
|
require.NoError(t, err)
|
|
require.False(t, exists)
|
|
}
|
|
|
|
func TestEnsureByEmailExistingDoesNotOverwriteStoredSettings(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := newTestStore(t)
|
|
createdAt := time.Unix(1_775_240_000, 0).UTC()
|
|
existingAccount := account.UserAccount{
|
|
UserID: common.UserID("user-existing"),
|
|
Email: common.Email("pilot@example.com"),
|
|
RaceName: common.RaceName("Pilot Nova"),
|
|
PreferredLanguage: common.LanguageTag("en"),
|
|
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
|
|
CreatedAt: createdAt,
|
|
UpdatedAt: createdAt,
|
|
}
|
|
require.NoError(t, store.Create(context.Background(), createAccountInput(existingAccount)))
|
|
|
|
result, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
|
|
Email: existingAccount.Email,
|
|
Account: account.UserAccount{
|
|
UserID: common.UserID("user-created"),
|
|
Email: existingAccount.Email,
|
|
RaceName: common.RaceName("player-new123"),
|
|
PreferredLanguage: common.LanguageTag("fr-FR"),
|
|
TimeZone: common.TimeZoneName("UTC"),
|
|
CreatedAt: createdAt.Add(time.Minute),
|
|
UpdatedAt: createdAt.Add(time.Minute),
|
|
},
|
|
Entitlement: validEntitlementSnapshot(common.UserID("user-created"), createdAt.Add(time.Minute)),
|
|
EntitlementRecord: validEntitlementRecord(common.UserID("user-created"), createdAt.Add(time.Minute)),
|
|
Reservation: raceNameReservation(common.UserID("user-created"), common.RaceName("player-new123"), createdAt.Add(time.Minute)),
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, ports.EnsureByEmailOutcomeExisting, result.Outcome)
|
|
require.Equal(t, existingAccount.UserID, result.UserID)
|
|
|
|
storedAccount, err := store.GetByEmail(context.Background(), existingAccount.Email)
|
|
require.NoError(t, err)
|
|
require.Equal(t, existingAccount, storedAccount)
|
|
}
|
|
|
|
func TestAccountStoreRenameRaceNameSwapsLookupAtomically(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := newTestStore(t)
|
|
accountStore := store.Accounts()
|
|
record := validAccountRecord()
|
|
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record)))
|
|
|
|
updatedAt := record.UpdatedAt.Add(time.Minute)
|
|
require.NoError(t, accountStore.RenameRaceName(context.Background(), renameRaceNameInput(record, common.RaceName("Nova Prime"), updatedAt)))
|
|
|
|
stored, err := accountStore.GetByUserID(context.Background(), record.UserID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, common.RaceName("Nova Prime"), stored.RaceName)
|
|
require.True(t, stored.UpdatedAt.Equal(updatedAt))
|
|
|
|
_, err = accountStore.GetByRaceName(context.Background(), record.RaceName)
|
|
require.ErrorIs(t, err, ports.ErrNotFound)
|
|
|
|
renamed, err := accountStore.GetByRaceName(context.Background(), common.RaceName("Nova Prime"))
|
|
require.NoError(t, err)
|
|
require.Equal(t, record.UserID, renamed.UserID)
|
|
|
|
_, err = store.loadRaceNameReservation(context.Background(), store.client, canonicalKey(record.RaceName))
|
|
require.ErrorIs(t, err, ports.ErrNotFound)
|
|
|
|
reservation, err := store.loadRaceNameReservation(context.Background(), store.client, canonicalKey(common.RaceName("Nova Prime")))
|
|
require.NoError(t, err)
|
|
require.Equal(t, common.RaceName("Nova Prime"), reservation.RaceName)
|
|
}
|
|
|
|
func TestAccountStoreRenameRaceNameAllowsSameOwnerCanonicalSlot(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := newTestStore(t)
|
|
accountStore := store.Accounts()
|
|
|
|
record := validAccountRecord()
|
|
record.RaceName = common.RaceName("Pilot Nova")
|
|
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record)))
|
|
|
|
updatedAt := record.UpdatedAt.Add(time.Minute)
|
|
require.NoError(t, accountStore.RenameRaceName(context.Background(), renameRaceNameInput(record, common.RaceName("P1lot Nova"), updatedAt)))
|
|
|
|
reservation, err := store.loadRaceNameReservation(context.Background(), store.client, canonicalKey(common.RaceName("P1lot Nova")))
|
|
require.NoError(t, err)
|
|
require.Equal(t, common.RaceName("P1lot Nova"), reservation.RaceName)
|
|
}
|
|
|
|
func TestAccountStoreRenameRaceNameReturnsConflictWhenTargetExists(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := newTestStore(t)
|
|
accountStore := store.Accounts()
|
|
|
|
first := validAccountRecord()
|
|
second := validAccountRecord()
|
|
second.UserID = common.UserID("user-456")
|
|
second.Email = common.Email("other@example.com")
|
|
second.RaceName = common.RaceName("Taken Name")
|
|
|
|
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(first)))
|
|
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(second)))
|
|
|
|
err := accountStore.RenameRaceName(context.Background(), renameRaceNameInput(first, second.RaceName, first.UpdatedAt.Add(time.Minute)))
|
|
require.ErrorIs(t, err, ports.ErrConflict)
|
|
|
|
stored, err := accountStore.GetByUserID(context.Background(), first.UserID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, first.RaceName, stored.RaceName)
|
|
}
|
|
|
|
func TestAccountStoreUpdateDeclaredCountryPreservesLookups(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := newTestStore(t)
|
|
accountStore := store.Accounts()
|
|
|
|
record := validAccountRecord()
|
|
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record)))
|
|
|
|
updated := record
|
|
updated.DeclaredCountry = common.CountryCode("FR")
|
|
updated.UpdatedAt = record.UpdatedAt.Add(time.Minute)
|
|
|
|
require.NoError(t, accountStore.Update(context.Background(), updated))
|
|
|
|
byUserID, err := accountStore.GetByUserID(context.Background(), record.UserID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, updated, byUserID)
|
|
|
|
byEmail, err := accountStore.GetByEmail(context.Background(), record.Email)
|
|
require.NoError(t, err)
|
|
require.Equal(t, updated, byEmail)
|
|
|
|
byRaceName, err := accountStore.GetByRaceName(context.Background(), record.RaceName)
|
|
require.NoError(t, err)
|
|
require.Equal(t, updated, byRaceName)
|
|
}
|
|
|
|
func TestAccountStoreCreateReturnsConflictWhenCanonicalReservationExists(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := newTestStore(t)
|
|
accountStore := store.Accounts()
|
|
|
|
first := validAccountRecord()
|
|
second := validAccountRecord()
|
|
second.UserID = common.UserID("user-456")
|
|
second.Email = common.Email("other@example.com")
|
|
second.RaceName = common.RaceName("P1lot Nova")
|
|
|
|
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(first)))
|
|
|
|
err := accountStore.Create(context.Background(), createAccountInput(second))
|
|
require.ErrorIs(t, err, ports.ErrConflict)
|
|
}
|
|
|
|
func TestBlockByUserIDRepeatedCallsStayIdempotent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := newTestStore(t)
|
|
now := time.Unix(1_775_240_000, 0).UTC()
|
|
accountRecord := validAccountRecord()
|
|
|
|
require.NoError(t, store.Create(context.Background(), createAccountInput(accountRecord)))
|
|
|
|
first, err := store.BlockByUserID(context.Background(), ports.BlockByUserIDInput{
|
|
UserID: accountRecord.UserID,
|
|
ReasonCode: common.ReasonCode("policy_blocked"),
|
|
BlockedAt: now,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, ports.AuthBlockOutcomeBlocked, first.Outcome)
|
|
|
|
second, err := store.BlockByUserID(context.Background(), ports.BlockByUserIDInput{
|
|
UserID: accountRecord.UserID,
|
|
ReasonCode: common.ReasonCode("policy_blocked"),
|
|
BlockedAt: now.Add(time.Minute),
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, ports.AuthBlockOutcomeAlreadyBlocked, second.Outcome)
|
|
require.Equal(t, accountRecord.UserID, second.UserID)
|
|
}
|
|
|
|
func TestBlockByUserIDUnknownUserReturnsNotFound(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := newTestStore(t)
|
|
|
|
_, err := store.BlockByUserID(context.Background(), ports.BlockByUserIDInput{
|
|
UserID: common.UserID("user-missing"),
|
|
ReasonCode: common.ReasonCode("policy_blocked"),
|
|
BlockedAt: time.Unix(1_775_240_000, 0).UTC(),
|
|
})
|
|
require.ErrorIs(t, err, ports.ErrNotFound)
|
|
}
|
|
|
|
func TestSanctionAndLimitStoresRoundTrip(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := newTestStore(t)
|
|
sanctionStore := store.Sanctions()
|
|
limitStore := store.Limits()
|
|
now := time.Unix(1_775_240_000, 0).UTC()
|
|
|
|
sanctionRecord := policy.SanctionRecord{
|
|
RecordID: policy.SanctionRecordID("sanction-1"),
|
|
UserID: common.UserID("user-123"),
|
|
SanctionCode: policy.SanctionCodeLoginBlock,
|
|
Scope: common.Scope("self_service"),
|
|
ReasonCode: common.ReasonCode("policy_enforced"),
|
|
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
|
|
AppliedAt: now,
|
|
}
|
|
require.NoError(t, sanctionStore.Create(context.Background(), sanctionRecord))
|
|
|
|
gotSanction, err := sanctionStore.GetByRecordID(context.Background(), sanctionRecord.RecordID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, sanctionRecord, gotSanction)
|
|
|
|
sanctions, err := sanctionStore.ListByUserID(context.Background(), sanctionRecord.UserID)
|
|
require.NoError(t, err)
|
|
require.Len(t, sanctions, 1)
|
|
|
|
expiresAt := now.Add(time.Hour)
|
|
sanctionRecord.ExpiresAt = &expiresAt
|
|
require.NoError(t, sanctionStore.Update(context.Background(), sanctionRecord))
|
|
|
|
gotSanction, err = sanctionStore.GetByRecordID(context.Background(), sanctionRecord.RecordID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, sanctionRecord.RecordID, gotSanction.RecordID)
|
|
require.Equal(t, sanctionRecord.UserID, gotSanction.UserID)
|
|
require.Equal(t, sanctionRecord.SanctionCode, gotSanction.SanctionCode)
|
|
require.Equal(t, sanctionRecord.Scope, gotSanction.Scope)
|
|
require.Equal(t, sanctionRecord.ReasonCode, gotSanction.ReasonCode)
|
|
require.Equal(t, sanctionRecord.Actor, gotSanction.Actor)
|
|
require.True(t, gotSanction.AppliedAt.Equal(sanctionRecord.AppliedAt))
|
|
require.NotNil(t, gotSanction.ExpiresAt)
|
|
require.True(t, gotSanction.ExpiresAt.Equal(*sanctionRecord.ExpiresAt))
|
|
|
|
limitRecord := policy.LimitRecord{
|
|
RecordID: policy.LimitRecordID("limit-1"),
|
|
UserID: common.UserID("user-123"),
|
|
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
|
|
Value: 3,
|
|
ReasonCode: common.ReasonCode("policy_enforced"),
|
|
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
|
|
AppliedAt: now,
|
|
}
|
|
require.NoError(t, limitStore.Create(context.Background(), limitRecord))
|
|
|
|
gotLimit, err := limitStore.GetByRecordID(context.Background(), limitRecord.RecordID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, limitRecord, gotLimit)
|
|
|
|
limits, err := limitStore.ListByUserID(context.Background(), limitRecord.UserID)
|
|
require.NoError(t, err)
|
|
require.Len(t, limits, 1)
|
|
|
|
limitRecord.Value = 5
|
|
require.NoError(t, limitStore.Update(context.Background(), limitRecord))
|
|
|
|
gotLimit, err = limitStore.GetByRecordID(context.Background(), limitRecord.RecordID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, limitRecord, gotLimit)
|
|
}
|
|
|
|
func TestPolicyLifecycleApplyAndRemoveSanction(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := newTestStore(t)
|
|
lifecycleStore := store.PolicyLifecycle()
|
|
sanctionStore := store.Sanctions()
|
|
snapshotStore := store.EntitlementSnapshots()
|
|
now := time.Unix(1_775_240_000, 0).UTC()
|
|
userID := common.UserID("user-123")
|
|
require.NoError(t, snapshotStore.Put(context.Background(), validEntitlementSnapshot(userID, now)))
|
|
|
|
record := policy.SanctionRecord{
|
|
RecordID: policy.SanctionRecordID("sanction-1"),
|
|
UserID: userID,
|
|
SanctionCode: policy.SanctionCodeLoginBlock,
|
|
Scope: common.Scope("auth"),
|
|
ReasonCode: common.ReasonCode("manual_block"),
|
|
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
|
AppliedAt: now,
|
|
}
|
|
require.NoError(t, lifecycleStore.ApplySanction(context.Background(), ports.ApplySanctionInput{
|
|
NewRecord: record,
|
|
}))
|
|
|
|
activeRecordID, err := store.loadActiveSanctionRecordID(
|
|
context.Background(),
|
|
store.client,
|
|
store.keyspace.ActiveSanction(userID, policy.SanctionCodeLoginBlock),
|
|
)
|
|
require.NoError(t, err)
|
|
require.Equal(t, record.RecordID, activeRecordID)
|
|
|
|
err = lifecycleStore.ApplySanction(context.Background(), ports.ApplySanctionInput{
|
|
NewRecord: policy.SanctionRecord{
|
|
RecordID: policy.SanctionRecordID("sanction-2"),
|
|
UserID: userID,
|
|
SanctionCode: policy.SanctionCodeLoginBlock,
|
|
Scope: common.Scope("auth"),
|
|
ReasonCode: common.ReasonCode("manual_block"),
|
|
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")},
|
|
AppliedAt: now.Add(time.Minute),
|
|
},
|
|
})
|
|
require.ErrorIs(t, err, ports.ErrConflict)
|
|
|
|
removed := record
|
|
removedAt := now.Add(30 * time.Minute)
|
|
removed.RemovedAt = &removedAt
|
|
removed.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")}
|
|
removed.RemovedReasonCode = common.ReasonCode("manual_remove")
|
|
require.NoError(t, lifecycleStore.RemoveSanction(context.Background(), ports.RemoveSanctionInput{
|
|
ExpectedActiveRecord: record,
|
|
UpdatedRecord: removed,
|
|
}))
|
|
|
|
stored, err := sanctionStore.GetByRecordID(context.Background(), record.RecordID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, removed, stored)
|
|
|
|
_, err = store.loadActiveSanctionRecordID(
|
|
context.Background(),
|
|
store.client,
|
|
store.keyspace.ActiveSanction(userID, policy.SanctionCodeLoginBlock),
|
|
)
|
|
require.ErrorIs(t, err, ports.ErrNotFound)
|
|
}
|
|
|
|
func TestPolicyLifecycleSetAndRemoveLimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := newTestStore(t)
|
|
lifecycleStore := store.PolicyLifecycle()
|
|
limitStore := store.Limits()
|
|
now := time.Unix(1_775_240_000, 0).UTC()
|
|
userID := common.UserID("user-123")
|
|
|
|
first := policy.LimitRecord{
|
|
RecordID: policy.LimitRecordID("limit-1"),
|
|
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,
|
|
}
|
|
require.NoError(t, lifecycleStore.SetLimit(context.Background(), ports.SetLimitInput{
|
|
NewRecord: first,
|
|
}))
|
|
|
|
activeRecordID, err := store.loadActiveLimitRecordID(
|
|
context.Background(),
|
|
store.client,
|
|
store.keyspace.ActiveLimit(userID, policy.LimitCodeMaxOwnedPrivateGames),
|
|
)
|
|
require.NoError(t, err)
|
|
require.Equal(t, first.RecordID, activeRecordID)
|
|
|
|
second := policy.LimitRecord{
|
|
RecordID: policy.LimitRecordID("limit-2"),
|
|
UserID: userID,
|
|
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
|
|
Value: 5,
|
|
ReasonCode: common.ReasonCode("manual_override"),
|
|
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")},
|
|
AppliedAt: now.Add(time.Hour),
|
|
}
|
|
updatedFirst := first
|
|
removedAt := second.AppliedAt
|
|
updatedFirst.RemovedAt = &removedAt
|
|
updatedFirst.RemovedBy = second.Actor
|
|
updatedFirst.RemovedReasonCode = second.ReasonCode
|
|
require.NoError(t, lifecycleStore.SetLimit(context.Background(), ports.SetLimitInput{
|
|
ExpectedActiveRecord: &first,
|
|
UpdatedActiveRecord: &updatedFirst,
|
|
NewRecord: second,
|
|
}))
|
|
|
|
storedFirst, err := limitStore.GetByRecordID(context.Background(), first.RecordID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, updatedFirst, storedFirst)
|
|
|
|
activeRecordID, err = store.loadActiveLimitRecordID(
|
|
context.Background(),
|
|
store.client,
|
|
store.keyspace.ActiveLimit(userID, policy.LimitCodeMaxOwnedPrivateGames),
|
|
)
|
|
require.NoError(t, err)
|
|
require.Equal(t, second.RecordID, activeRecordID)
|
|
|
|
removedSecond := second
|
|
removeAt := now.Add(90 * time.Minute)
|
|
removedSecond.RemovedAt = &removeAt
|
|
removedSecond.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-3")}
|
|
removedSecond.RemovedReasonCode = common.ReasonCode("manual_remove")
|
|
require.NoError(t, lifecycleStore.RemoveLimit(context.Background(), ports.RemoveLimitInput{
|
|
ExpectedActiveRecord: second,
|
|
UpdatedRecord: removedSecond,
|
|
}))
|
|
|
|
storedSecond, err := limitStore.GetByRecordID(context.Background(), second.RecordID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, removedSecond, storedSecond)
|
|
|
|
_, err = store.loadActiveLimitRecordID(
|
|
context.Background(),
|
|
store.client,
|
|
store.keyspace.ActiveLimit(userID, policy.LimitCodeMaxOwnedPrivateGames),
|
|
)
|
|
require.ErrorIs(t, err, ports.ErrNotFound)
|
|
}
|
|
|
|
func TestEntitlementLifecycleTransitions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := newTestStore(t)
|
|
historyStore := store.EntitlementHistory()
|
|
snapshotStore := store.EntitlementSnapshots()
|
|
lifecycleStore := store.EntitlementLifecycle()
|
|
userID := common.UserID("user-123")
|
|
startedFreeAt := time.Unix(1_775_240_000, 0).UTC()
|
|
|
|
freeRecord := validEntitlementRecord(userID, startedFreeAt)
|
|
freeSnapshot := validEntitlementSnapshot(userID, startedFreeAt)
|
|
require.NoError(t, historyStore.Create(context.Background(), freeRecord))
|
|
require.NoError(t, snapshotStore.Put(context.Background(), freeSnapshot))
|
|
|
|
grantStartsAt := startedFreeAt.Add(24 * time.Hour)
|
|
grantEndsAt := grantStartsAt.Add(30 * 24 * time.Hour)
|
|
grantedRecord := paidEntitlementRecord(
|
|
entitlement.EntitlementRecordID("entitlement-paid-1"),
|
|
userID,
|
|
entitlement.PlanCodePaidMonthly,
|
|
grantStartsAt,
|
|
grantEndsAt,
|
|
common.Source("admin"),
|
|
common.ReasonCode("manual_grant"),
|
|
)
|
|
grantedSnapshot := paidEntitlementSnapshot(
|
|
userID,
|
|
entitlement.PlanCodePaidMonthly,
|
|
grantStartsAt,
|
|
grantEndsAt,
|
|
common.Source("admin"),
|
|
common.ReasonCode("manual_grant"),
|
|
)
|
|
closedFreeRecord := freeRecord
|
|
closedFreeRecord.ClosedAt = timePointer(grantStartsAt)
|
|
closedFreeRecord.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
|
|
closedFreeRecord.ClosedReasonCode = common.ReasonCode("manual_grant")
|
|
|
|
require.NoError(t, lifecycleStore.Grant(context.Background(), ports.GrantEntitlementInput{
|
|
ExpectedCurrentSnapshot: freeSnapshot,
|
|
ExpectedCurrentRecord: freeRecord,
|
|
UpdatedCurrentRecord: closedFreeRecord,
|
|
NewRecord: grantedRecord,
|
|
NewSnapshot: grantedSnapshot,
|
|
}))
|
|
|
|
storedSnapshot, err := snapshotStore.GetByUserID(context.Background(), userID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, grantedSnapshot, storedSnapshot)
|
|
|
|
storedFreeRecord, err := historyStore.GetByRecordID(context.Background(), freeRecord.RecordID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, closedFreeRecord, storedFreeRecord)
|
|
|
|
extendedEndsAt := grantEndsAt.Add(30 * 24 * time.Hour)
|
|
extensionRecord := paidEntitlementRecord(
|
|
entitlement.EntitlementRecordID("entitlement-paid-2"),
|
|
userID,
|
|
entitlement.PlanCodePaidMonthly,
|
|
grantEndsAt,
|
|
extendedEndsAt,
|
|
common.Source("admin"),
|
|
common.ReasonCode("manual_extend"),
|
|
)
|
|
extendedSnapshot := paidEntitlementSnapshot(
|
|
userID,
|
|
entitlement.PlanCodePaidMonthly,
|
|
grantStartsAt,
|
|
extendedEndsAt,
|
|
common.Source("admin"),
|
|
common.ReasonCode("manual_extend"),
|
|
)
|
|
|
|
require.NoError(t, lifecycleStore.Extend(context.Background(), ports.ExtendEntitlementInput{
|
|
ExpectedCurrentSnapshot: grantedSnapshot,
|
|
NewRecord: extensionRecord,
|
|
NewSnapshot: extendedSnapshot,
|
|
}))
|
|
|
|
storedSnapshot, err = snapshotStore.GetByUserID(context.Background(), userID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, extendedSnapshot, storedSnapshot)
|
|
|
|
revokeAt := grantEndsAt.Add(12 * time.Hour)
|
|
revokedCurrentRecord := extensionRecord
|
|
revokedCurrentRecord.ClosedAt = timePointer(revokeAt)
|
|
revokedCurrentRecord.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
|
|
revokedCurrentRecord.ClosedReasonCode = common.ReasonCode("manual_revoke")
|
|
|
|
freeAfterRevokeRecord := entitlement.PeriodRecord{
|
|
RecordID: entitlement.EntitlementRecordID("entitlement-free-2"),
|
|
UserID: userID,
|
|
PlanCode: entitlement.PlanCodeFree,
|
|
Source: common.Source("admin"),
|
|
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
|
ReasonCode: common.ReasonCode("manual_revoke"),
|
|
StartsAt: revokeAt,
|
|
CreatedAt: revokeAt,
|
|
}
|
|
freeAfterRevokeSnapshot := entitlement.CurrentSnapshot{
|
|
UserID: userID,
|
|
PlanCode: entitlement.PlanCodeFree,
|
|
IsPaid: false,
|
|
StartsAt: revokeAt,
|
|
Source: common.Source("admin"),
|
|
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
|
ReasonCode: common.ReasonCode("manual_revoke"),
|
|
UpdatedAt: revokeAt,
|
|
}
|
|
|
|
require.NoError(t, lifecycleStore.Revoke(context.Background(), ports.RevokeEntitlementInput{
|
|
ExpectedCurrentSnapshot: extendedSnapshot,
|
|
ExpectedCurrentRecord: extensionRecord,
|
|
UpdatedCurrentRecord: revokedCurrentRecord,
|
|
NewRecord: freeAfterRevokeRecord,
|
|
NewSnapshot: freeAfterRevokeSnapshot,
|
|
}))
|
|
|
|
storedSnapshot, err = snapshotStore.GetByUserID(context.Background(), userID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, freeAfterRevokeSnapshot, storedSnapshot)
|
|
|
|
historyRecords, err := historyStore.ListByUserID(context.Background(), userID)
|
|
require.NoError(t, err)
|
|
require.Len(t, historyRecords, 4)
|
|
}
|
|
|
|
func TestRepairExpiredEntitlementMaterializesFreeSnapshot(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
store := newTestStore(t)
|
|
historyStore := store.EntitlementHistory()
|
|
snapshotStore := store.EntitlementSnapshots()
|
|
lifecycleStore := store.EntitlementLifecycle()
|
|
userID := common.UserID("user-123")
|
|
startsAt := time.Unix(1_775_240_000, 0).UTC()
|
|
endsAt := startsAt.Add(24 * time.Hour)
|
|
expiredSnapshot := paidEntitlementSnapshot(
|
|
userID,
|
|
entitlement.PlanCodePaidMonthly,
|
|
startsAt,
|
|
endsAt,
|
|
common.Source("admin"),
|
|
common.ReasonCode("manual_grant"),
|
|
)
|
|
expiredSnapshot.UpdatedAt = endsAt.Add(24 * time.Hour)
|
|
expiredRecord := paidEntitlementRecord(
|
|
entitlement.EntitlementRecordID("entitlement-paid-1"),
|
|
userID,
|
|
entitlement.PlanCodePaidMonthly,
|
|
startsAt,
|
|
endsAt,
|
|
common.Source("admin"),
|
|
common.ReasonCode("manual_grant"),
|
|
)
|
|
require.NoError(t, historyStore.Create(context.Background(), expiredRecord))
|
|
require.NoError(t, snapshotStore.Put(context.Background(), expiredSnapshot))
|
|
|
|
repairedAt := endsAt.Add(2 * time.Hour)
|
|
freeRecord := entitlement.PeriodRecord{
|
|
RecordID: entitlement.EntitlementRecordID("entitlement-free-after-expiry"),
|
|
UserID: userID,
|
|
PlanCode: entitlement.PlanCodeFree,
|
|
Source: common.Source("entitlement_expiry_repair"),
|
|
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
|
|
ReasonCode: common.ReasonCode("paid_entitlement_expired"),
|
|
StartsAt: endsAt,
|
|
CreatedAt: repairedAt,
|
|
}
|
|
freeSnapshot := entitlement.CurrentSnapshot{
|
|
UserID: userID,
|
|
PlanCode: entitlement.PlanCodeFree,
|
|
IsPaid: false,
|
|
StartsAt: endsAt,
|
|
Source: common.Source("entitlement_expiry_repair"),
|
|
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
|
|
ReasonCode: common.ReasonCode("paid_entitlement_expired"),
|
|
UpdatedAt: repairedAt,
|
|
}
|
|
|
|
require.NoError(t, lifecycleStore.RepairExpired(context.Background(), ports.RepairExpiredEntitlementInput{
|
|
ExpectedExpiredSnapshot: expiredSnapshot,
|
|
NewRecord: freeRecord,
|
|
NewSnapshot: freeSnapshot,
|
|
}))
|
|
|
|
storedSnapshot, err := snapshotStore.GetByUserID(context.Background(), userID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, freeSnapshot, storedSnapshot)
|
|
|
|
historyRecords, err := historyStore.ListByUserID(context.Background(), userID)
|
|
require.NoError(t, err)
|
|
require.Len(t, historyRecords, 2)
|
|
require.Equal(t, freeRecord, historyRecords[1])
|
|
}
|
|
|
|
func newTestStore(t *testing.T) *Store {
|
|
t.Helper()
|
|
|
|
server := miniredis.RunT(t)
|
|
store, err := New(Config{
|
|
Addr: server.Addr(),
|
|
DB: 0,
|
|
KeyspacePrefix: "user:test:",
|
|
OperationTimeout: 250 * time.Millisecond,
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
_ = store.Close()
|
|
})
|
|
|
|
return store
|
|
}
|
|
|
|
func validAccountRecord() account.UserAccount {
|
|
createdAt := time.Unix(1_775_240_000, 0).UTC()
|
|
return account.UserAccount{
|
|
UserID: common.UserID("user-123"),
|
|
Email: common.Email("pilot@example.com"),
|
|
RaceName: common.RaceName("Pilot Nova"),
|
|
PreferredLanguage: common.LanguageTag("en"),
|
|
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
|
|
CreatedAt: createdAt,
|
|
UpdatedAt: createdAt,
|
|
}
|
|
}
|
|
|
|
func validEntitlementSnapshot(userID common.UserID, now time.Time) entitlement.CurrentSnapshot {
|
|
return entitlement.CurrentSnapshot{
|
|
UserID: userID,
|
|
PlanCode: entitlement.PlanCodeFree,
|
|
IsPaid: false,
|
|
StartsAt: now,
|
|
Source: common.Source("auth_registration"),
|
|
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
|
|
ReasonCode: common.ReasonCode("initial_free_entitlement"),
|
|
UpdatedAt: now,
|
|
}
|
|
}
|
|
|
|
func validEntitlementRecord(userID common.UserID, now time.Time) entitlement.PeriodRecord {
|
|
return entitlement.PeriodRecord{
|
|
RecordID: entitlement.EntitlementRecordID("entitlement-" + userID.String()),
|
|
UserID: userID,
|
|
PlanCode: entitlement.PlanCodeFree,
|
|
Source: common.Source("auth_registration"),
|
|
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
|
|
ReasonCode: common.ReasonCode("initial_free_entitlement"),
|
|
StartsAt: now,
|
|
CreatedAt: now,
|
|
}
|
|
}
|
|
|
|
func paidEntitlementRecord(
|
|
recordID entitlement.EntitlementRecordID,
|
|
userID common.UserID,
|
|
planCode entitlement.PlanCode,
|
|
startsAt time.Time,
|
|
endsAt time.Time,
|
|
source common.Source,
|
|
reasonCode common.ReasonCode,
|
|
) entitlement.PeriodRecord {
|
|
return entitlement.PeriodRecord{
|
|
RecordID: recordID,
|
|
UserID: userID,
|
|
PlanCode: planCode,
|
|
Source: source,
|
|
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
|
ReasonCode: reasonCode,
|
|
StartsAt: startsAt,
|
|
EndsAt: timePointer(endsAt),
|
|
CreatedAt: startsAt,
|
|
}
|
|
}
|
|
|
|
func paidEntitlementSnapshot(
|
|
userID common.UserID,
|
|
planCode entitlement.PlanCode,
|
|
startsAt time.Time,
|
|
endsAt time.Time,
|
|
source common.Source,
|
|
reasonCode common.ReasonCode,
|
|
) entitlement.CurrentSnapshot {
|
|
return entitlement.CurrentSnapshot{
|
|
UserID: userID,
|
|
PlanCode: planCode,
|
|
IsPaid: true,
|
|
StartsAt: startsAt,
|
|
EndsAt: timePointer(endsAt),
|
|
Source: source,
|
|
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
|
ReasonCode: reasonCode,
|
|
UpdatedAt: startsAt,
|
|
}
|
|
}
|
|
|
|
func timePointer(value time.Time) *time.Time {
|
|
utcValue := value.UTC()
|
|
return &utcValue
|
|
}
|
|
|
|
func createAccountInput(record account.UserAccount) ports.CreateAccountInput {
|
|
return ports.CreateAccountInput{
|
|
Account: record,
|
|
Reservation: raceNameReservation(record.UserID, record.RaceName, record.UpdatedAt),
|
|
}
|
|
}
|
|
|
|
func renameRaceNameInput(
|
|
record account.UserAccount,
|
|
newRaceName common.RaceName,
|
|
updatedAt time.Time,
|
|
) ports.RenameRaceNameInput {
|
|
return ports.RenameRaceNameInput{
|
|
UserID: record.UserID,
|
|
CurrentCanonicalKey: canonicalKey(record.RaceName),
|
|
NewRaceName: newRaceName,
|
|
NewReservation: raceNameReservation(record.UserID, newRaceName, updatedAt),
|
|
UpdatedAt: updatedAt,
|
|
}
|
|
}
|
|
|
|
func raceNameReservation(
|
|
userID common.UserID,
|
|
raceName common.RaceName,
|
|
reservedAt time.Time,
|
|
) account.RaceNameReservation {
|
|
return account.RaceNameReservation{
|
|
CanonicalKey: canonicalKey(raceName),
|
|
UserID: userID,
|
|
RaceName: raceName,
|
|
ReservedAt: reservedAt.UTC(),
|
|
}
|
|
}
|
|
|
|
func canonicalKey(raceName common.RaceName) account.RaceNameCanonicalKey {
|
|
return account.RaceNameCanonicalKey(strings.NewReplacer(
|
|
"1", "i",
|
|
"0", "o",
|
|
"8", "b",
|
|
).Replace(strings.ToLower(raceName.String())))
|
|
}
|