feat: game lobby service

This commit is contained in:
Ilia Denisov
2026-04-25 23:20:55 +02:00
committed by GitHub
parent 32dc29359a
commit 48b0056b49
336 changed files with 57074 additions and 1418 deletions
+47 -10
View File
@@ -13,8 +13,16 @@ import (
var base32NoPadding = base32.StdEncoding.WithPadding(base32.NoPadding)
// userNameSuffixAlphabet is the Crockford lowercase Base32 alphabet with
// `i`, `l`, `o`, and `u` excluded to avoid visual confusables. The chosen
// 32 characters also keep each byte pair aligned with a 5-bit group so the
// 5-byte random source encodes into exactly eight suffix characters.
const userNameSuffixAlphabet = "0123456789abcdefghjkmnpqrstvwxyz"
const userNameSuffixLength = 8
// IDGenerator creates opaque stable user identifiers and generated initial
// race names.
// user names.
type IDGenerator struct{}
// NewUserID returns one newly generated opaque user identifier.
@@ -32,20 +40,21 @@ func (IDGenerator) NewUserID() (common.UserID, error) {
return userID, nil
}
// NewInitialRaceName returns one generated race name in the `player-<shortid>`
// form.
func (IDGenerator) NewInitialRaceName() (common.RaceName, error) {
token, err := randomToken(5)
// NewUserName returns one generated user name in the `player-<suffix>` form.
// The suffix is eight characters drawn from the Crockford lowercase Base32
// alphabet (confusable-free: `i`, `l`, `o`, `u` are excluded).
func (IDGenerator) NewUserName() (common.UserName, error) {
suffix, err := randomSuffix(userNameSuffixLength)
if err != nil {
return "", fmt.Errorf("generate initial race name: %w", err)
return "", fmt.Errorf("generate user name: %w", err)
}
raceName := common.RaceName("player-" + token)
if err := raceName.Validate(); err != nil {
return "", fmt.Errorf("generate initial race name: %w", err)
userName := common.UserName("player-" + suffix)
if err := userName.Validate(); err != nil {
return "", fmt.Errorf("generate user name: %w", err)
}
return raceName, nil
return userName, nil
}
// NewEntitlementRecordID returns one generated entitlement history record
@@ -103,3 +112,31 @@ func randomToken(size int) (string, error) {
return strings.ToLower(base32NoPadding.EncodeToString(buffer)), nil
}
// randomSuffix returns a length-character suffix encoded from crypto-random
// bytes through the userNameSuffixAlphabet. Each character consumes five
// random bits, so the caller receives `ceil(length * 5 / 8)` bytes of
// entropy in the underlying buffer.
func randomSuffix(length int) (string, error) {
byteCount := (length*5 + 7) / 8
buffer := make([]byte, byteCount)
if _, err := rand.Read(buffer); err != nil {
return "", err
}
encoded := make([]byte, length)
for index := range encoded {
bitOffset := index * 5
byteIndex := bitOffset / 8
shift := bitOffset % 8
value := uint16(buffer[byteIndex]) << 8
if byteIndex+1 < len(buffer) {
value |= uint16(buffer[byteIndex+1])
}
encoded[index] = userNameSuffixAlphabet[(value>>(16-5-shift))&0x1F]
}
return string(encoded), nil
}
@@ -1,65 +0,0 @@
package local
import (
"fmt"
"strings"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/ports"
confusables "github.com/disciplinedware/go-confusables"
"golang.org/x/text/cases"
)
type confusableSkeletoner interface {
Skeleton(string) string
}
type raceNamePolicy struct {
caseFolder cases.Caser
skeletoner confusableSkeletoner
}
var raceNameAntiFraudReplacer = strings.NewReplacer(
"1", "i",
"0", "o",
"8", "b",
)
// NewRaceNamePolicy returns the local Stage 06 race-name canonicalization
// policy backed by Unicode case folding, explicit ASCII anti-fraud mappings,
// and a TR39 confusable skeleton.
func NewRaceNamePolicy() (ports.RaceNamePolicy, error) {
policy := &raceNamePolicy{
caseFolder: cases.Fold(),
skeletoner: confusables.Default(),
}
if policy.skeletoner == nil {
return nil, fmt.Errorf("new race-name policy: nil confusable skeletoner")
}
return policy, nil
}
// CanonicalKey returns the stable uniqueness key for raceName.
func (policy *raceNamePolicy) CanonicalKey(raceName common.RaceName) (account.RaceNameCanonicalKey, error) {
switch {
case policy == nil:
return "", fmt.Errorf("canonicalize race name: nil policy")
case policy.skeletoner == nil:
return "", fmt.Errorf("canonicalize race name: nil confusable skeletoner")
}
if err := raceName.Validate(); err != nil {
return "", fmt.Errorf("canonicalize race name: %w", err)
}
folded := policy.caseFolder.String(raceName.String())
antiFraudMapped := raceNameAntiFraudReplacer.Replace(folded)
key := account.RaceNameCanonicalKey(policy.skeletoner.Skeleton(antiFraudMapped))
if err := key.Validate(); err != nil {
return "", fmt.Errorf("canonicalize race name: %w", err)
}
return key, nil
}
@@ -1,72 +0,0 @@
package local
import (
"testing"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/service/shared"
"github.com/stretchr/testify/require"
)
func TestRaceNamePolicyCanonicalKey(t *testing.T) {
t.Parallel()
policy, err := NewRaceNamePolicy()
require.NoError(t, err)
tests := []struct {
name string
left common.RaceName
right common.RaceName
}{
{
name: "case insensitive collision",
left: common.RaceName("Pilot Nova"),
right: common.RaceName("pilot nova"),
},
{
name: "ascii anti fraud collision",
left: common.RaceName("Pilot Nova"),
right: common.RaceName("P1lot N0va"),
},
{
name: "unicode confusable collision",
left: common.RaceName("paypal"),
right: common.RaceName("раураl"),
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
leftKey, err := policy.CanonicalKey(tt.left)
require.NoError(t, err)
rightKey, err := policy.CanonicalKey(tt.right)
require.NoError(t, err)
require.Equal(t, rightKey, leftKey)
})
}
}
func TestBuildRaceNameReservationPreservesOriginalDisplayValue(t *testing.T) {
t.Parallel()
policy, err := NewRaceNamePolicy()
require.NoError(t, err)
record, err := shared.BuildRaceNameReservation(
policy,
common.UserID("user-123"),
common.RaceName("P1lot Nova"),
time.Unix(1_775_240_000, 0).UTC(),
)
require.NoError(t, err)
require.Equal(t, common.RaceName("P1lot Nova"), record.RaceName)
require.NotEqual(t, account.RaceNameCanonicalKey(""), record.CanonicalKey)
}
@@ -121,7 +121,10 @@ func (publisher *Publisher) PublishProfileChanged(ctx context.Context, event por
values := buildEnvelope(ports.ProfileChangedEventType, event.UserID.String(), event.OccurredAt, event.Source.String(), traceIDFromContext(ctx, event.TraceID))
values["operation"] = string(event.Operation)
values["race_name"] = event.RaceName.String()
values["user_name"] = event.UserName.String()
if !event.DisplayName.IsZero() {
values["display_name"] = event.DisplayName.String()
}
return publisher.publish(ctx, "publish profile changed event", values)
}
@@ -27,12 +27,13 @@ func TestPublisherPublishesFlatRedisStreamEntry(t *testing.T) {
occurredAt := time.Unix(1_775_240_000, 0).UTC()
err = publisher.PublishProfileChanged(context.Background(), ports.ProfileChangedEvent{
UserID: common.UserID("user-123"),
OccurredAt: occurredAt,
Source: common.Source("gateway_self_service"),
TraceID: "4bf92f3577b34da6a3ce929d0e0e4736",
Operation: ports.ProfileChangedOperationUpdated,
RaceName: common.RaceName("Nova Prime"),
UserID: common.UserID("user-123"),
OccurredAt: occurredAt,
Source: common.Source("gateway_self_service"),
TraceID: "4bf92f3577b34da6a3ce929d0e0e4736",
Operation: ports.ProfileChangedOperationUpdated,
UserName: common.UserName("player-abcdefgh"),
DisplayName: common.DisplayName("NovaPrime"),
})
require.NoError(t, err)
@@ -45,7 +46,8 @@ func TestPublisherPublishesFlatRedisStreamEntry(t *testing.T) {
require.Equal(t, "gateway_self_service", entries[0].Values["source"])
require.Equal(t, "4bf92f3577b34da6a3ce929d0e0e4736", entries[0].Values["trace_id"])
require.Equal(t, string(ports.ProfileChangedOperationUpdated), entries[0].Values["operation"])
require.Equal(t, "Nova Prime", entries[0].Values["race_name"])
require.Equal(t, "player-abcdefgh", entries[0].Values["user_name"])
require.Equal(t, "NovaPrime", entries[0].Values["display_name"])
for index := 0; index < 20; index++ {
err = publisher.PublishSettingsChanged(context.Background(), ports.SettingsChangedEvent{
@@ -77,10 +79,11 @@ func TestPublisherRejectsInvalidEventBeforeXAdd(t *testing.T) {
require.NoError(t, err)
err = publisher.PublishProfileChanged(context.Background(), ports.ProfileChangedEvent{
UserID: common.UserID("user-123"),
OccurredAt: time.Unix(1_775_240_000, 0).UTC(),
Operation: ports.ProfileChangedOperationUpdated,
RaceName: common.RaceName("Nova Prime"),
UserID: common.UserID("user-123"),
OccurredAt: time.Unix(1_775_240_000, 0).UTC(),
Operation: ports.ProfileChangedOperationUpdated,
UserName: common.UserName("player-abcdefgh"),
DisplayName: common.DisplayName("NovaPrime"),
})
require.Error(t, err)
@@ -0,0 +1,192 @@
// Package lifecycleevents implements the Redis Streams-backed publisher for
// trusted user-lifecycle events consumed by `Game Lobby`.
package lifecycleevents
import (
"context"
"crypto/tls"
"errors"
"fmt"
"strconv"
"strings"
"time"
"galaxy/user/internal/ports"
"github.com/redis/go-redis/v9"
"go.opentelemetry.io/otel/trace"
)
// Config configures one Redis-backed user-lifecycle publisher.
type Config struct {
// Addr is the Redis network address in host:port form.
Addr string
// Username is the optional Redis ACL username.
Username string
// Password is the optional Redis ACL password.
Password string
// DB is the Redis logical database index.
DB int
// TLSEnabled enables TLS with a conservative minimum protocol version.
TLSEnabled bool
// Stream identifies the Redis Stream key used for lifecycle events. The
// default platform key is `user:lifecycle_events`.
Stream string
// StreamMaxLen bounds the stream with approximate trimming via
// `XADD MAXLEN ~`.
StreamMaxLen int64
// OperationTimeout bounds each Redis round trip performed by the adapter.
OperationTimeout time.Duration
}
// Publisher publishes trusted user-lifecycle events into the dedicated Redis
// Stream consumed by `Game Lobby` for Race Name Directory cascade release.
type Publisher struct {
client *redis.Client
stream string
streamMaxLen int64
operationTimeout time.Duration
}
// New constructs a Redis-backed lifecycle-event publisher from cfg.
func New(cfg Config) (*Publisher, error) {
switch {
case strings.TrimSpace(cfg.Addr) == "":
return nil, errors.New("new redis lifecycle-event publisher: redis addr must not be empty")
case cfg.DB < 0:
return nil, errors.New("new redis lifecycle-event publisher: redis db must not be negative")
case strings.TrimSpace(cfg.Stream) == "":
return nil, errors.New("new redis lifecycle-event publisher: stream must not be empty")
case cfg.StreamMaxLen <= 0:
return nil, errors.New("new redis lifecycle-event publisher: stream max len must be positive")
case cfg.OperationTimeout <= 0:
return nil, errors.New("new redis lifecycle-event publisher: operation timeout must be positive")
}
options := &redis.Options{
Addr: cfg.Addr,
Username: cfg.Username,
Password: cfg.Password,
DB: cfg.DB,
Protocol: 2,
DisableIdentity: true,
}
if cfg.TLSEnabled {
options.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12}
}
return &Publisher{
client: redis.NewClient(options),
stream: cfg.Stream,
streamMaxLen: cfg.StreamMaxLen,
operationTimeout: cfg.OperationTimeout,
}, nil
}
// Close releases the underlying Redis client resources.
func (publisher *Publisher) Close() error {
if publisher == nil || publisher.client == nil {
return nil
}
return publisher.client.Close()
}
// Ping verifies that the configured Redis backend is reachable within the
// adapter operation timeout budget.
func (publisher *Publisher) Ping(ctx context.Context) error {
operationCtx, cancel, err := publisher.operationContext(ctx, "ping redis lifecycle-event publisher")
if err != nil {
return err
}
defer cancel()
if err := publisher.client.Ping(operationCtx).Err(); err != nil {
return fmt.Errorf("ping redis lifecycle-event publisher: %w", err)
}
return nil
}
// PublishUserLifecycleEvent publishes one committed lifecycle event to the
// configured Redis Stream.
func (publisher *Publisher) PublishUserLifecycleEvent(ctx context.Context, event ports.UserLifecycleEvent) error {
if err := event.Validate(); err != nil {
return fmt.Errorf("publish user lifecycle event: %w", err)
}
traceID := traceIDFromContext(ctx, event.TraceID)
values := map[string]any{
"event_type": string(event.EventType),
"user_id": event.UserID.String(),
"occurred_at_ms": strconv.FormatInt(event.OccurredAt.UTC().UnixMilli(), 10),
"source": event.Source.String(),
"actor_type": event.Actor.Type.String(),
"reason_code": event.ReasonCode.String(),
}
if !event.Actor.ID.IsZero() {
values["actor_id"] = event.Actor.ID.String()
}
if traceID != "" {
values["trace_id"] = traceID
}
operationCtx, cancel, err := publisher.operationContext(ctx, "publish user lifecycle event")
if err != nil {
return err
}
defer cancel()
if err := publisher.client.XAdd(operationCtx, &redis.XAddArgs{
Stream: publisher.stream,
MaxLen: publisher.streamMaxLen,
Approx: true,
Values: values,
}).Err(); err != nil {
return fmt.Errorf("publish user lifecycle event: %w", err)
}
return nil
}
func (publisher *Publisher) operationContext(ctx context.Context, operation string) (context.Context, context.CancelFunc, error) {
if publisher == nil || publisher.client == nil {
return nil, nil, fmt.Errorf("%s: nil publisher", operation)
}
if ctx == nil {
return nil, nil, fmt.Errorf("%s: nil context", operation)
}
operationCtx, cancel := context.WithTimeout(ctx, publisher.operationTimeout)
return operationCtx, cancel, nil
}
func traceIDFromContext(ctx context.Context, fallback string) string {
if strings.TrimSpace(fallback) != "" {
return fallback
}
if ctx == nil {
return ""
}
spanContext := trace.SpanContextFromContext(ctx)
if !spanContext.IsValid() {
return ""
}
return spanContext.TraceID().String()
}
var (
_ interface{ Close() error } = (*Publisher)(nil)
_ interface{ Ping(context.Context) error } = (*Publisher)(nil)
_ ports.UserLifecyclePublisher = (*Publisher)(nil)
)
@@ -0,0 +1,154 @@
package lifecycleevents
import (
"context"
"strconv"
"testing"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/ports"
"github.com/alicebob/miniredis/v2"
"github.com/stretchr/testify/require"
)
func TestPublisherPublishesPermanentBlockedEnvelope(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
publisher, err := New(Config{
Addr: server.Addr(),
Stream: "user:lifecycle_events",
StreamMaxLen: 10,
OperationTimeout: time.Second,
})
require.NoError(t, err)
occurredAt := time.Unix(1_775_240_000, 0).UTC()
require.NoError(t, publisher.PublishUserLifecycleEvent(context.Background(), ports.UserLifecycleEvent{
EventType: ports.UserLifecyclePermanentBlockedEventType,
UserID: common.UserID("user-123"),
OccurredAt: occurredAt,
Source: common.Source("admin_internal_api"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("terminal_policy_violation"),
TraceID: "4bf92f3577b34da6a3ce929d0e0e4736",
}))
entries, err := publisher.client.XRange(context.Background(), publisher.stream, "-", "+").Result()
require.NoError(t, err)
require.Len(t, entries, 1)
values := entries[0].Values
require.Equal(t, string(ports.UserLifecyclePermanentBlockedEventType), values["event_type"])
require.Equal(t, "user-123", values["user_id"])
require.Equal(t, strconv.FormatInt(occurredAt.UnixMilli(), 10), values["occurred_at_ms"])
require.Equal(t, "admin_internal_api", values["source"])
require.Equal(t, "admin", values["actor_type"])
require.Equal(t, "admin-1", values["actor_id"])
require.Equal(t, "terminal_policy_violation", values["reason_code"])
require.Equal(t, "4bf92f3577b34da6a3ce929d0e0e4736", values["trace_id"])
}
func TestPublisherOmitsOptionalActorIDAndTraceID(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
publisher, err := New(Config{
Addr: server.Addr(),
Stream: "user:lifecycle_events",
StreamMaxLen: 10,
OperationTimeout: time.Second,
})
require.NoError(t, err)
require.NoError(t, publisher.PublishUserLifecycleEvent(context.Background(), ports.UserLifecycleEvent{
EventType: ports.UserLifecycleDeletedEventType,
UserID: common.UserID("user-123"),
OccurredAt: time.Unix(1_775_240_000, 0).UTC(),
Source: common.Source("admin_internal_api"),
Actor: common.ActorRef{Type: common.ActorType("admin")},
ReasonCode: common.ReasonCode("user_right_to_be_forgotten"),
}))
entries, err := publisher.client.XRange(context.Background(), publisher.stream, "-", "+").Result()
require.NoError(t, err)
require.Len(t, entries, 1)
values := entries[0].Values
_, hasActorID := values["actor_id"]
require.False(t, hasActorID)
_, hasTraceID := values["trace_id"]
require.False(t, hasTraceID)
require.Equal(t, string(ports.UserLifecycleDeletedEventType), values["event_type"])
}
func TestPublisherRejectsInvalidEventBeforeXAdd(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
publisher, err := New(Config{
Addr: server.Addr(),
Stream: "user:lifecycle_events",
StreamMaxLen: 10,
OperationTimeout: time.Second,
})
require.NoError(t, err)
err = publisher.PublishUserLifecycleEvent(context.Background(), ports.UserLifecycleEvent{
EventType: "user.lifecycle.unknown",
UserID: common.UserID("user-123"),
OccurredAt: time.Unix(1_775_240_000, 0).UTC(),
Source: common.Source("admin_internal_api"),
Actor: common.ActorRef{Type: common.ActorType("admin")},
ReasonCode: common.ReasonCode("manual_block"),
})
require.Error(t, err)
length, xLenErr := publisher.client.XLen(context.Background(), publisher.stream).Result()
require.NoError(t, xLenErr)
require.Zero(t, length)
}
func TestPublisherTrimsBeyondMaxLen(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
publisher, err := New(Config{
Addr: server.Addr(),
Stream: "user:lifecycle_events",
StreamMaxLen: 5,
OperationTimeout: time.Second,
})
require.NoError(t, err)
occurredAt := time.Unix(1_775_240_000, 0).UTC()
for index := 0; index < 20; index++ {
require.NoError(t, publisher.PublishUserLifecycleEvent(context.Background(), ports.UserLifecycleEvent{
EventType: ports.UserLifecyclePermanentBlockedEventType,
UserID: common.UserID("user-123"),
OccurredAt: occurredAt.Add(time.Duration(index+1) * time.Second),
Source: common.Source("admin_internal_api"),
Actor: common.ActorRef{Type: common.ActorType("admin")},
ReasonCode: common.ReasonCode("terminal_policy_violation"),
}))
}
length, err := publisher.client.XLen(context.Background(), publisher.stream).Result()
require.NoError(t, err)
require.LessOrEqual(t, length, int64(20))
}
func TestPublisherPingReportsReachability(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
publisher, err := New(Config{
Addr: server.Addr(),
Stream: "user:lifecycle_events",
StreamMaxLen: 10,
OperationTimeout: time.Second,
})
require.NoError(t, err)
require.NoError(t, publisher.Ping(context.Background()))
}
@@ -20,12 +20,14 @@ var knownSanctionCodes = []policy.SanctionCode{
policy.SanctionCodePrivateGameManageBlock,
policy.SanctionCodeGameJoinBlock,
policy.SanctionCodeProfileUpdateBlock,
policy.SanctionCodePermanentBlock,
}
var knownLimitCodes = []policy.LimitCode{
policy.LimitCodeMaxOwnedPrivateGames,
policy.LimitCodeMaxPendingPublicApplications,
policy.LimitCodeMaxActiveGameMemberships,
policy.LimitCodeMaxRegisteredRaceNames,
}
var knownEligibilityMarkers = []policy.EligibilityMarker{
@@ -189,6 +191,16 @@ func deriveEligibilityMarkerValues(
isPaid bool,
activeSanctionCodes map[policy.SanctionCode]struct{},
) map[policy.EligibilityMarker]bool {
if _, permanentBlocked := activeSanctionCodes[policy.SanctionCodePermanentBlock]; permanentBlocked {
return map[policy.EligibilityMarker]bool{
policy.EligibilityMarkerCanLogin: false,
policy.EligibilityMarkerCanCreatePrivateGame: false,
policy.EligibilityMarkerCanManagePrivateGame: false,
policy.EligibilityMarkerCanJoinGame: false,
policy.EligibilityMarkerCanUpdateProfile: false,
}
}
_, loginBlocked := activeSanctionCodes[policy.SanctionCodeLoginBlock]
_, createBlocked := activeSanctionCodes[policy.SanctionCodePrivateGameCreateBlock]
_, manageBlocked := activeSanctionCodes[policy.SanctionCodePrivateGameManageBlock]
@@ -0,0 +1,58 @@
package userstore
import (
"testing"
"galaxy/user/internal/domain/policy"
"github.com/stretchr/testify/require"
)
func TestDeriveEligibilityMarkerValuesCollapsesUnderPermanentBlock(t *testing.T) {
t.Parallel()
activeCodes := map[policy.SanctionCode]struct{}{
policy.SanctionCodePermanentBlock: {},
}
values := deriveEligibilityMarkerValues(true, activeCodes)
require.False(t, values[policy.EligibilityMarkerCanLogin])
require.False(t, values[policy.EligibilityMarkerCanCreatePrivateGame])
require.False(t, values[policy.EligibilityMarkerCanManagePrivateGame])
require.False(t, values[policy.EligibilityMarkerCanJoinGame])
require.False(t, values[policy.EligibilityMarkerCanUpdateProfile])
}
func TestDeriveEligibilityMarkerValuesPermanentBlockDominatesOtherSanctions(t *testing.T) {
t.Parallel()
activeCodes := map[policy.SanctionCode]struct{}{
policy.SanctionCodePermanentBlock: {},
policy.SanctionCodeLoginBlock: {},
policy.SanctionCodeGameJoinBlock: {},
}
values := deriveEligibilityMarkerValues(false, activeCodes)
for marker, value := range values {
require.Falsef(t, value, "marker %q must be false under permanent_block", marker)
}
}
func TestDeriveEligibilityMarkerValuesFreeUserWithoutPermanentBlock(t *testing.T) {
t.Parallel()
values := deriveEligibilityMarkerValues(false, map[policy.SanctionCode]struct{}{})
require.True(t, values[policy.EligibilityMarkerCanLogin])
require.False(t, values[policy.EligibilityMarkerCanCreatePrivateGame])
require.False(t, values[policy.EligibilityMarkerCanManagePrivateGame])
require.True(t, values[policy.EligibilityMarkerCanJoinGame])
require.True(t, values[policy.EligibilityMarkerCanUpdateProfile])
}
func TestKnownCatalogsIncludeStage22Codes(t *testing.T) {
t.Parallel()
require.Contains(t, knownSanctionCodes, policy.SanctionCodePermanentBlock)
require.Contains(t, knownLimitCodes, policy.LimitCodeMaxRegisteredRaceNames)
}
@@ -25,21 +25,21 @@ func TestListUserIDsCreatedAtPagination(t *testing.T) {
first := validAccountRecord()
first.UserID = common.UserID("user-100")
first.Email = common.Email("u100@example.com")
first.RaceName = common.RaceName("User 100")
first.UserName = common.UserName("player-user100aa")
first.CreatedAt = base.Add(-time.Hour)
first.UpdatedAt = first.CreatedAt
second := validAccountRecord()
second.UserID = common.UserID("user-200")
second.Email = common.Email("u200@example.com")
second.RaceName = common.RaceName("User 200")
second.UserName = common.UserName("player-user200aa")
second.CreatedAt = base
second.UpdatedAt = second.CreatedAt
third := validAccountRecord()
third.UserID = common.UserID("user-300")
third.Email = common.Email("u300@example.com")
third.RaceName = common.RaceName("User 300")
third.UserName = common.UserName("player-user300aa")
third.CreatedAt = base
third.UpdatedAt = third.CreatedAt
@@ -80,7 +80,6 @@ func TestEnsureByEmailInitialAdminIndexes(t *testing.T) {
Account: record,
Entitlement: validEntitlementSnapshot(record.UserID, now),
EntitlementRecord: validEntitlementRecord(record.UserID, now),
Reservation: raceNameReservation(record.UserID, record.RaceName, now),
})
require.NoError(t, err)
require.Equal(t, ports.EnsureByEmailOutcomeCreated, result.Outcome)
@@ -125,7 +124,6 @@ func TestEntitlementLifecycleSyncsAdminIndexes(t *testing.T) {
Account: record,
Entitlement: validEntitlementSnapshot(record.UserID, now),
EntitlementRecord: validEntitlementRecord(record.UserID, now),
Reservation: raceNameReservation(record.UserID, record.RaceName, now),
})
require.NoError(t, err)
@@ -248,7 +246,6 @@ func TestPolicyLifecycleSyncsAdminIndexes(t *testing.T) {
Account: record,
Entitlement: validEntitlementSnapshot(record.UserID, now),
EntitlementRecord: validEntitlementRecord(record.UserID, now),
Reservation: raceNameReservation(record.UserID, record.RaceName, now),
})
require.NoError(t, err)
@@ -325,7 +322,6 @@ func TestAdminListerReevaluatesExpiredPaidSnapshots(t *testing.T) {
Account: record,
Entitlement: validEntitlementSnapshot(userID, record.CreatedAt),
EntitlementRecord: validEntitlementRecord(userID, record.CreatedAt),
Reservation: raceNameReservation(userID, record.RaceName, record.CreatedAt),
})
require.NoError(t, err)
@@ -401,7 +397,7 @@ func (generator adminStoreIDGenerator) NewUserID() (common.UserID, error) {
return "", nil
}
func (generator adminStoreIDGenerator) NewInitialRaceName() (common.RaceName, error) {
func (generator adminStoreIDGenerator) NewUserName() (common.UserName, error) {
return "", nil
}
+101 -247
View File
@@ -61,19 +61,14 @@ type Store struct {
type accountRecord struct {
UserID string `json:"user_id"`
Email string `json:"email"`
RaceName string `json:"race_name"`
UserName string `json:"user_name"`
DisplayName string `json:"display_name,omitempty"`
PreferredLanguage string `json:"preferred_language"`
TimeZone string `json:"time_zone"`
DeclaredCountry *string `json:"declared_country,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type raceNameReservationRecord struct {
CanonicalKey string `json:"canonical_key"`
UserID string `json:"user_id"`
RaceName string `json:"race_name"`
ReservedAt string `json:"reserved_at"`
DeletedAt *string `json:"deleted_at,omitempty"`
}
type blockedEmailRecord struct {
@@ -190,8 +185,8 @@ func (store *Store) Ping(ctx context.Context) error {
return nil
}
// Create stores one new account record together with the exact and canonical
// race-name lookup state.
// Create stores one new account record together with the exact user-name
// lookup state.
func (store *Store) Create(ctx context.Context, input ports.CreateAccountInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("create account in redis: %w", err)
@@ -201,15 +196,10 @@ func (store *Store) Create(ctx context.Context, input ports.CreateAccountInput)
if err != nil {
return fmt.Errorf("create account in redis: %w", err)
}
reservationPayload, err := marshalRaceNameReservationRecord(input.Reservation)
if err != nil {
return fmt.Errorf("create account in redis: %w", err)
}
accountKey := store.keyspace.Account(input.Account.UserID)
emailLookupKey := store.keyspace.EmailLookup(input.Account.Email)
raceNameLookupKey := store.keyspace.RaceNameLookup(input.Account.RaceName)
reservationKey := store.keyspace.RaceNameReservation(input.Reservation.CanonicalKey)
userNameLookupKey := store.keyspace.UserNameLookup(input.Account.UserName)
operationCtx, cancel, err := store.operationContext(ctx, "create account in redis")
if err != nil {
@@ -224,18 +214,17 @@ func (store *Store) Create(ctx context.Context, input ports.CreateAccountInput)
if err := ensureKeyAbsent(operationCtx, tx, emailLookupKey); err != nil {
return fmt.Errorf("create account %q in redis: %w", input.Account.UserID, err)
}
if err := ensureKeyAbsent(operationCtx, tx, raceNameLookupKey); err != nil {
return fmt.Errorf("create account %q in redis: %w", input.Account.UserID, err)
}
if err := ensureKeyAbsent(operationCtx, tx, reservationKey); err != nil {
if err := ensureKeyAbsent(operationCtx, tx, userNameLookupKey); err != nil {
if errors.Is(err, ports.ErrConflict) {
return fmt.Errorf("create account %q in redis: %w", input.Account.UserID, ports.ErrUserNameConflict)
}
return fmt.Errorf("create account %q in redis: %w", input.Account.UserID, err)
}
_, err := tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
pipe.Set(operationCtx, accountKey, accountPayload, 0)
pipe.Set(operationCtx, emailLookupKey, input.Account.UserID.String(), 0)
pipe.Set(operationCtx, raceNameLookupKey, input.Account.UserID.String(), 0)
pipe.Set(operationCtx, reservationKey, reservationPayload, 0)
pipe.Set(operationCtx, userNameLookupKey, input.Account.UserID.String(), 0)
store.addCreatedAtIndex(pipe, operationCtx, input.Account)
store.syncDeclaredCountryIndex(pipe, operationCtx, account.UserAccount{}, input.Account)
return nil
@@ -245,7 +234,7 @@ func (store *Store) Create(ctx context.Context, input ports.CreateAccountInput)
}
return nil
}, accountKey, emailLookupKey, raceNameLookupKey, reservationKey)
}, accountKey, emailLookupKey, userNameLookupKey)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
@@ -317,26 +306,26 @@ func (store *Store) GetByEmail(ctx context.Context, email common.Email) (account
return record, nil
}
// GetByRaceName returns the stored account identified by the exact stored race
// name.
func (store *Store) GetByRaceName(ctx context.Context, raceName common.RaceName) (account.UserAccount, error) {
if err := raceName.Validate(); err != nil {
return account.UserAccount{}, fmt.Errorf("get account by race name from redis: %w", err)
// GetByUserName returns the stored account identified by the exact stored
// user name.
func (store *Store) GetByUserName(ctx context.Context, userName common.UserName) (account.UserAccount, error) {
if err := userName.Validate(); err != nil {
return account.UserAccount{}, fmt.Errorf("get account by user name from redis: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "get account by race name from redis")
operationCtx, cancel, err := store.operationContext(ctx, "get account by user name from redis")
if err != nil {
return account.UserAccount{}, err
}
defer cancel()
userID, err := store.loadLookupUserID(operationCtx, store.client, store.keyspace.RaceNameLookup(raceName))
userID, err := store.loadLookupUserID(operationCtx, store.client, store.keyspace.UserNameLookup(userName))
if err != nil {
switch {
case errors.Is(err, ports.ErrNotFound):
return account.UserAccount{}, fmt.Errorf("get account by race name %q from redis: %w", raceName, ports.ErrNotFound)
return account.UserAccount{}, fmt.Errorf("get account by user name %q from redis: %w", userName, ports.ErrNotFound)
default:
return account.UserAccount{}, fmt.Errorf("get account by race name %q from redis: %w", raceName, err)
return account.UserAccount{}, fmt.Errorf("get account by user name %q from redis: %w", userName, err)
}
}
@@ -344,16 +333,18 @@ func (store *Store) GetByRaceName(ctx context.Context, raceName common.RaceName)
if err != nil {
switch {
case errors.Is(err, ports.ErrNotFound):
return account.UserAccount{}, fmt.Errorf("get account by race name %q from redis: lookup references missing user %q", raceName, userID)
return account.UserAccount{}, fmt.Errorf("get account by user name %q from redis: lookup references missing user %q", userName, userID)
default:
return account.UserAccount{}, fmt.Errorf("get account by race name %q from redis: %w", raceName, err)
return account.UserAccount{}, fmt.Errorf("get account by user name %q from redis: %w", userName, err)
}
}
return record, nil
}
// ExistsByUserID reports whether userID identifies a stored account.
// ExistsByUserID reports whether userID currently identifies a stored account
// that is not soft-deleted. Soft-deleted accounts are treated as non-existing
// for external callers per Stage 22.
func (store *Store) ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error) {
if err := userID.Validate(); err != nil {
return false, fmt.Errorf("exists by user id from redis: %w", err)
@@ -365,114 +356,25 @@ func (store *Store) ExistsByUserID(ctx context.Context, userID common.UserID) (b
}
defer cancel()
exists, err := store.client.Exists(operationCtx, store.keyspace.Account(userID)).Result()
if err != nil {
record, err := store.loadAccount(operationCtx, store.client, userID)
switch {
case err == nil:
case errors.Is(err, ports.ErrNotFound):
return false, nil
default:
return false, fmt.Errorf("exists by user id %q from redis: %w", userID, err)
}
return exists == 1, nil
if record.IsDeleted() {
return false, nil
}
return true, nil
}
// RenameRaceName replaces the stored race name of userID and swaps the exact
// and canonical race-name lookup state atomically.
func (store *Store) RenameRaceName(ctx context.Context, input ports.RenameRaceNameInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("rename account race name in redis: %w", err)
}
accountKey := store.keyspace.Account(input.UserID)
newRaceNameLookupKey := store.keyspace.RaceNameLookup(input.NewRaceName)
newReservationKey := store.keyspace.RaceNameReservation(input.NewReservation.CanonicalKey)
newReservationPayload, err := marshalRaceNameReservationRecord(input.NewReservation)
if err != nil {
return fmt.Errorf("rename account race name in redis: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "rename account race name in redis")
if err != nil {
return err
}
defer cancel()
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
record, err := store.loadAccount(operationCtx, tx, input.UserID)
if err != nil {
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, err)
}
if record.RaceName == input.NewRaceName {
return nil
}
currentRaceNameLookupKey := store.keyspace.RaceNameLookup(record.RaceName)
currentLookupUserID, err := store.loadLookupUserID(operationCtx, tx, currentRaceNameLookupKey)
if err != nil {
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, err)
}
if currentLookupUserID != input.UserID {
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, ports.ErrConflict)
}
currentReservation, err := store.loadRaceNameReservation(operationCtx, tx, input.CurrentCanonicalKey)
if err != nil {
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, err)
}
if currentReservation.UserID != input.UserID || currentReservation.RaceName != record.RaceName {
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, ports.ErrConflict)
}
if err := ensureLookupAvailableOrOwned(operationCtx, tx, newRaceNameLookupKey, input.UserID); err != nil {
if errors.Is(err, ports.ErrConflict) {
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, ports.ErrRaceNameConflict)
}
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, err)
}
if input.CurrentCanonicalKey != input.NewReservation.CanonicalKey {
if err := store.ensureReservationAvailableOrOwned(operationCtx, tx, input.NewReservation.CanonicalKey, input.UserID); err != nil {
if errors.Is(err, ports.ErrConflict) {
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, ports.ErrRaceNameConflict)
}
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, err)
}
}
record.RaceName = input.NewRaceName
record.UpdatedAt = input.UpdatedAt.UTC()
payload, err := marshalAccountRecord(record)
if err != nil {
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, err)
}
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
pipe.Set(operationCtx, accountKey, payload, 0)
pipe.Set(operationCtx, newRaceNameLookupKey, input.UserID.String(), 0)
pipe.Set(operationCtx, newReservationKey, newReservationPayload, 0)
pipe.Del(operationCtx, currentRaceNameLookupKey)
if input.CurrentCanonicalKey != input.NewReservation.CanonicalKey {
pipe.Del(operationCtx, store.keyspace.RaceNameReservation(input.CurrentCanonicalKey))
}
return nil
})
if err != nil {
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, err)
}
return nil
}, accountKey, newRaceNameLookupKey, newReservationKey)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, ports.ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
// Update replaces the stored account state for record.UserID.
// Update replaces the stored account state for record.UserID. `email` and
// `user_name` are immutable; any attempt to mutate them returns
// ports.ErrConflict.
func (store *Store) Update(ctx context.Context, record account.UserAccount) error {
if err := record.Validate(); err != nil {
return fmt.Errorf("update account in redis: %w", err)
@@ -485,7 +387,7 @@ func (store *Store) Update(ctx context.Context, record account.UserAccount) erro
accountKey := store.keyspace.Account(record.UserID)
emailLookupKey := store.keyspace.EmailLookup(record.Email)
raceNameLookupKey := store.keyspace.RaceNameLookup(record.RaceName)
userNameLookupKey := store.keyspace.UserNameLookup(record.UserName)
operationCtx, cancel, err := store.operationContext(ctx, "update account in redis")
if err != nil {
@@ -498,7 +400,7 @@ func (store *Store) Update(ctx context.Context, record account.UserAccount) erro
if err != nil {
return fmt.Errorf("update account %q in redis: %w", record.UserID, err)
}
if current.Email != record.Email || current.RaceName != record.RaceName {
if current.Email != record.Email || current.UserName != record.UserName {
return fmt.Errorf("update account %q in redis: %w", record.UserID, ports.ErrConflict)
}
@@ -510,11 +412,11 @@ func (store *Store) Update(ctx context.Context, record account.UserAccount) erro
return fmt.Errorf("update account %q in redis: %w", record.UserID, ports.ErrConflict)
}
raceLookupUserID, err := store.loadLookupUserID(operationCtx, tx, raceNameLookupKey)
userNameLookupUserID, err := store.loadLookupUserID(operationCtx, tx, userNameLookupKey)
if err != nil {
return fmt.Errorf("update account %q in redis: %w", record.UserID, err)
}
if raceLookupUserID != record.UserID {
if userNameLookupUserID != record.UserID {
return fmt.Errorf("update account %q in redis: %w", record.UserID, ports.ErrConflict)
}
@@ -528,7 +430,7 @@ func (store *Store) Update(ctx context.Context, record account.UserAccount) erro
}
return nil
}, accountKey, emailLookupKey, raceNameLookupKey)
}, accountKey, emailLookupKey, userNameLookupKey)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
@@ -965,17 +867,31 @@ func (store *Store) ResolveByEmail(ctx context.Context, email common.Email) (por
accountRecord, err := store.GetByEmailAccount(operationCtx, email)
switch {
case err == nil:
return ports.ResolveByEmailResult{
Kind: ports.AuthResolutionKindExisting,
UserID: accountRecord.UserID,
}, nil
case errors.Is(err, ports.ErrNotFound):
return ports.ResolveByEmailResult{Kind: ports.AuthResolutionKindCreatable}, nil
default:
return ports.ResolveByEmailResult{}, fmt.Errorf("resolve by email %q in redis: %w", email, err)
}
if accountRecord.IsDeleted() {
return ports.ResolveByEmailResult{
Kind: ports.AuthResolutionKindBlocked,
BlockReasonCode: deletedAccountBlockReasonCode,
}, nil
}
return ports.ResolveByEmailResult{
Kind: ports.AuthResolutionKindExisting,
UserID: accountRecord.UserID,
}, nil
}
// deletedAccountBlockReasonCode is the reason_code returned when an auth-facing
// lookup resolves to a soft-deleted account. It is not a real sanction; the
// auth/session service treats it as a blocked outcome and refuses to issue a
// session for the subject.
const deletedAccountBlockReasonCode common.ReasonCode = "account_deleted"
// EnsureByEmail atomically returns an existing user, creates a new one, or
// reports a blocked outcome for one e-mail subject.
func (store *Store) EnsureByEmail(ctx context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
@@ -995,11 +911,6 @@ func (store *Store) EnsureByEmail(ctx context.Context, input ports.EnsureByEmail
if err != nil {
return ports.EnsureByEmailResult{}, fmt.Errorf("ensure by email in redis: %w", err)
}
reservationPayload, err := marshalRaceNameReservationRecord(input.Reservation)
if err != nil {
return ports.EnsureByEmailResult{}, fmt.Errorf("ensure by email in redis: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "ensure by email in redis")
if err != nil {
return ports.EnsureByEmailResult{}, err
@@ -1011,8 +922,7 @@ func (store *Store) EnsureByEmail(ctx context.Context, input ports.EnsureByEmail
accountKey := store.keyspace.Account(input.Account.UserID)
emailLookupKey := store.keyspace.EmailLookup(input.Email)
raceNameLookupKey := store.keyspace.RaceNameLookup(input.Account.RaceName)
reservationKey := store.keyspace.RaceNameReservation(input.Reservation.CanonicalKey)
userNameLookupKey := store.keyspace.UserNameLookup(input.Account.UserName)
blockedEmailKey := store.keyspace.BlockedEmailSubject(input.Email)
entitlementKey := store.keyspace.EntitlementSnapshot(input.Account.UserID)
entitlementRecordKey := store.keyspace.EntitlementRecord(input.EntitlementRecord.RecordID)
@@ -1039,6 +949,14 @@ func (store *Store) EnsureByEmail(ctx context.Context, input ports.EnsureByEmail
if err != nil {
return fmt.Errorf("ensure by email %q in redis: %w", input.Email, err)
}
if record.IsDeleted() {
result = ports.EnsureByEmailResult{
Outcome: ports.EnsureByEmailOutcomeBlocked,
BlockReasonCode: deletedAccountBlockReasonCode,
}
handled = true
return nil
}
result = ports.EnsureByEmailResult{
Outcome: ports.EnsureByEmailOutcomeExisting,
UserID: record.UserID,
@@ -1052,15 +970,9 @@ func (store *Store) EnsureByEmail(ctx context.Context, input ports.EnsureByEmail
if err := ensureKeyAbsent(operationCtx, tx, accountKey); err != nil {
return fmt.Errorf("ensure by email %q in redis: %w", input.Email, err)
}
if err := ensureKeyAbsent(operationCtx, tx, raceNameLookupKey); err != nil {
if err := ensureKeyAbsent(operationCtx, tx, userNameLookupKey); err != nil {
if errors.Is(err, ports.ErrConflict) {
return fmt.Errorf("ensure by email %q in redis: %w", input.Email, ports.ErrRaceNameConflict)
}
return fmt.Errorf("ensure by email %q in redis: %w", input.Email, err)
}
if err := ensureKeyAbsent(operationCtx, tx, reservationKey); err != nil {
if errors.Is(err, ports.ErrConflict) {
return fmt.Errorf("ensure by email %q in redis: %w", input.Email, ports.ErrRaceNameConflict)
return fmt.Errorf("ensure by email %q in redis: %w", input.Email, ports.ErrUserNameConflict)
}
return fmt.Errorf("ensure by email %q in redis: %w", input.Email, err)
}
@@ -1074,8 +986,7 @@ func (store *Store) EnsureByEmail(ctx context.Context, input ports.EnsureByEmail
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
pipe.Set(operationCtx, accountKey, accountPayload, 0)
pipe.Set(operationCtx, emailLookupKey, input.Account.UserID.String(), 0)
pipe.Set(operationCtx, raceNameLookupKey, input.Account.UserID.String(), 0)
pipe.Set(operationCtx, reservationKey, reservationPayload, 0)
pipe.Set(operationCtx, userNameLookupKey, input.Account.UserID.String(), 0)
pipe.Set(operationCtx, entitlementKey, entitlementPayload, 0)
pipe.Set(operationCtx, entitlementRecordKey, entitlementRecordPayload, 0)
pipe.ZAdd(operationCtx, entitlementHistoryKey, redis.Z{
@@ -1100,7 +1011,7 @@ func (store *Store) EnsureByEmail(ctx context.Context, input ports.EnsureByEmail
}
handled = true
return nil
}, blockedEmailKey, emailLookupKey, accountKey, raceNameLookupKey, reservationKey, entitlementKey, entitlementRecordKey, entitlementHistoryKey)
}, blockedEmailKey, emailLookupKey, accountKey, userNameLookupKey, entitlementKey, entitlementRecordKey, entitlementHistoryKey)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
@@ -1136,6 +1047,9 @@ func (store *Store) BlockByUserID(ctx context.Context, input ports.BlockByUserID
}
return ports.BlockResult{}, fmt.Errorf("block by user id %q in redis: %w", input.UserID, err)
}
if currentAccount.IsDeleted() {
return ports.BlockResult{}, fmt.Errorf("block by user id %q in redis: %w", input.UserID, ports.ErrNotFound)
}
accountKey := store.keyspace.Account(input.UserID)
blockedEmailKey := store.keyspace.BlockedEmailSubject(currentAccount.Email)
@@ -1145,6 +1059,9 @@ func (store *Store) BlockByUserID(ctx context.Context, input ports.BlockByUserID
if err != nil {
return fmt.Errorf("block by user id %q in redis: %w", input.UserID, err)
}
if accountRecord.IsDeleted() {
return fmt.Errorf("block by user id %q in redis: %w", input.UserID, ports.ErrNotFound)
}
blocked, err := store.loadBlockedEmail(operationCtx, tx, accountRecord.Email)
switch {
@@ -1327,22 +1244,6 @@ func (store *Store) loadLookupUserID(ctx context.Context, getter bytesGetter, ke
return userID, nil
}
func (store *Store) loadRaceNameReservation(
ctx context.Context,
getter bytesGetter,
key account.RaceNameCanonicalKey,
) (account.RaceNameReservation, error) {
payload, err := getter.Get(ctx, store.keyspace.RaceNameReservation(key)).Bytes()
switch {
case errors.Is(err, redis.Nil):
return account.RaceNameReservation{}, ports.ErrNotFound
case err != nil:
return account.RaceNameReservation{}, err
}
return decodeRaceNameReservationRecord(payload)
}
func (store *Store) loadBlockedEmail(ctx context.Context, getter bytesGetter, email common.Email) (authblock.BlockedEmailSubject, error) {
payload, err := getter.Get(ctx, store.keyspace.BlockedEmailSubject(email)).Bytes()
switch {
@@ -1436,32 +1337,12 @@ func ensureLookupAvailableOrOwned(
return nil
}
func (store *Store) ensureReservationAvailableOrOwned(
ctx context.Context,
getter bytesGetter,
key account.RaceNameCanonicalKey,
userID common.UserID,
) error {
record, err := store.loadRaceNameReservation(ctx, getter, key)
switch {
case errors.Is(err, ports.ErrNotFound):
return nil
case err != nil:
return err
}
if record.UserID != userID {
return ports.ErrConflict
}
return nil
}
func marshalAccountRecord(record account.UserAccount) ([]byte, error) {
encoded := accountRecord{
UserID: record.UserID.String(),
Email: record.Email.String(),
RaceName: record.RaceName.String(),
UserName: record.UserName.String(),
DisplayName: record.DisplayName.String(),
PreferredLanguage: record.PreferredLanguage.String(),
TimeZone: record.TimeZone.String(),
CreatedAt: record.CreatedAt.UTC().Format(time.RFC3339Nano),
@@ -1471,6 +1352,10 @@ func marshalAccountRecord(record account.UserAccount) ([]byte, error) {
value := record.DeclaredCountry.String()
encoded.DeclaredCountry = &value
}
if record.DeletedAt != nil {
value := record.DeletedAt.UTC().Format(time.RFC3339Nano)
encoded.DeletedAt = &value
}
return json.Marshal(encoded)
}
@@ -1493,7 +1378,8 @@ func decodeAccountRecord(payload []byte) (account.UserAccount, error) {
record := account.UserAccount{
UserID: common.UserID(encoded.UserID),
Email: common.Email(encoded.Email),
RaceName: common.RaceName(encoded.RaceName),
UserName: common.UserName(encoded.UserName),
DisplayName: common.DisplayName(encoded.DisplayName),
PreferredLanguage: common.LanguageTag(encoded.PreferredLanguage),
TimeZone: common.TimeZoneName(encoded.TimeZone),
CreatedAt: createdAt.UTC(),
@@ -1502,6 +1388,14 @@ func decodeAccountRecord(payload []byte) (account.UserAccount, error) {
if encoded.DeclaredCountry != nil {
record.DeclaredCountry = common.CountryCode(*encoded.DeclaredCountry)
}
if encoded.DeletedAt != nil {
deletedAt, err := time.Parse(time.RFC3339Nano, *encoded.DeletedAt)
if err != nil {
return account.UserAccount{}, fmt.Errorf("decode account record deleted_at: %w", err)
}
deletedAt = deletedAt.UTC()
record.DeletedAt = &deletedAt
}
if err := record.Validate(); err != nil {
return account.UserAccount{}, fmt.Errorf("decode account record: %w", err)
}
@@ -1509,41 +1403,6 @@ func decodeAccountRecord(payload []byte) (account.UserAccount, error) {
return record, nil
}
func marshalRaceNameReservationRecord(record account.RaceNameReservation) ([]byte, error) {
encoded := raceNameReservationRecord{
CanonicalKey: record.CanonicalKey.String(),
UserID: record.UserID.String(),
RaceName: record.RaceName.String(),
ReservedAt: record.ReservedAt.UTC().Format(time.RFC3339Nano),
}
return json.Marshal(encoded)
}
func decodeRaceNameReservationRecord(payload []byte) (account.RaceNameReservation, error) {
var encoded raceNameReservationRecord
if err := decodeJSONPayload(payload, &encoded); err != nil {
return account.RaceNameReservation{}, err
}
reservedAt, err := time.Parse(time.RFC3339Nano, encoded.ReservedAt)
if err != nil {
return account.RaceNameReservation{}, fmt.Errorf("decode race-name reservation reserved_at: %w", err)
}
record := account.RaceNameReservation{
CanonicalKey: account.RaceNameCanonicalKey(encoded.CanonicalKey),
UserID: common.UserID(encoded.UserID),
RaceName: common.RaceName(encoded.RaceName),
ReservedAt: reservedAt.UTC(),
}
if err := record.Validate(); err != nil {
return account.RaceNameReservation{}, fmt.Errorf("decode race-name reservation: %w", err)
}
return record, nil
}
func marshalBlockedEmailRecord(record authblock.BlockedEmailSubject) ([]byte, error) {
encoded := blockedEmailRecord{
Email: record.Email.String(),
@@ -1902,9 +1761,9 @@ func (adapter *AccountStore) GetByEmail(ctx context.Context, email common.Email)
return adapter.store.GetByEmail(ctx, email)
}
// GetByRaceName returns the stored account identified by raceName.
func (adapter *AccountStore) GetByRaceName(ctx context.Context, raceName common.RaceName) (account.UserAccount, error) {
return adapter.store.GetByRaceName(ctx, raceName)
// GetByUserName returns the stored account identified by userName.
func (adapter *AccountStore) GetByUserName(ctx context.Context, userName common.UserName) (account.UserAccount, error) {
return adapter.store.GetByUserName(ctx, userName)
}
// ExistsByUserID reports whether userID currently identifies a stored
@@ -1913,11 +1772,6 @@ func (adapter *AccountStore) ExistsByUserID(ctx context.Context, userID common.U
return adapter.store.ExistsByUserID(ctx, userID)
}
// RenameRaceName replaces the stored race name of userID atomically.
func (adapter *AccountStore) RenameRaceName(ctx context.Context, input ports.RenameRaceNameInput) error {
return adapter.store.RenameRaceName(ctx, input)
}
// Update replaces the stored account state for record.UserID.
func (adapter *AccountStore) Update(ctx context.Context, record account.UserAccount) error {
return adapter.store.Update(ctx, record)
@@ -2,7 +2,6 @@ package userstore
import (
"context"
"strings"
"testing"
"time"
@@ -34,18 +33,13 @@ func TestAccountStoreCreateAndLookups(t *testing.T) {
require.NoError(t, err)
require.Equal(t, record, byEmail)
byRaceName, err := accountStore.GetByRaceName(context.Background(), record.RaceName)
byUserName, err := accountStore.GetByUserName(context.Background(), record.UserName)
require.NoError(t, err)
require.Equal(t, record, byRaceName)
require.Equal(t, record, byUserName)
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) {
@@ -80,14 +74,13 @@ func TestEnsureResolveAndBlockFlows(t *testing.T) {
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))
byUserName, err := store.GetByUserName(context.Background(), accountRecord.UserName)
require.NoError(t, err)
require.Equal(t, accountRecord.UserID, reservation.UserID)
require.Equal(t, accountRecord.UserID, byUserName.UserID)
entitlementHistory, err := store.ListEntitlementRecordsByUserID(context.Background(), accountRecord.UserID)
require.NoError(t, err)
@@ -124,7 +117,6 @@ func TestEnsureResolveAndBlockFlows(t *testing.T) {
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)
@@ -156,7 +148,6 @@ func TestBlockedEmailWithoutUserPreventsEnsureCreate(t *testing.T) {
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)
@@ -174,7 +165,7 @@ func TestEnsureByEmailExistingDoesNotOverwriteStoredSettings(t *testing.T) {
existingAccount := account.UserAccount{
UserID: common.UserID("user-existing"),
Email: common.Email("pilot@example.com"),
RaceName: common.RaceName("Pilot Nova"),
UserName: common.UserName("player-abcdefgh"),
PreferredLanguage: common.LanguageTag("en"),
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
CreatedAt: createdAt,
@@ -187,7 +178,7 @@ func TestEnsureByEmailExistingDoesNotOverwriteStoredSettings(t *testing.T) {
Account: account.UserAccount{
UserID: common.UserID("user-created"),
Email: existingAccount.Email,
RaceName: common.RaceName("player-new123"),
UserName: common.UserName("player-newabcde"),
PreferredLanguage: common.LanguageTag("fr-FR"),
TimeZone: common.TimeZoneName("UTC"),
CreatedAt: createdAt.Add(time.Minute),
@@ -195,7 +186,6 @@ func TestEnsureByEmailExistingDoesNotOverwriteStoredSettings(t *testing.T) {
},
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)
@@ -206,76 +196,49 @@ func TestEnsureByEmailExistingDoesNotOverwriteStoredSettings(t *testing.T) {
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) {
func TestAccountStoreUpdateDisplayNamePreservesImmutableFields(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)))
updated := record
updated.DisplayName = common.DisplayName("NovaPrime")
updated.UpdatedAt = record.UpdatedAt.Add(time.Minute)
reservation, err := store.loadRaceNameReservation(context.Background(), store.client, canonicalKey(common.RaceName("P1lot Nova")))
require.NoError(t, accountStore.Update(context.Background(), updated))
byUserID, err := accountStore.GetByUserID(context.Background(), record.UserID)
require.NoError(t, err)
require.Equal(t, common.RaceName("P1lot Nova"), reservation.RaceName)
require.Equal(t, updated, byUserID)
byEmail, err := accountStore.GetByEmail(context.Background(), record.Email)
require.NoError(t, err)
require.Equal(t, updated, byEmail)
byUserName, err := accountStore.GetByUserName(context.Background(), record.UserName)
require.NoError(t, err)
require.Equal(t, updated, byUserName)
}
func TestAccountStoreRenameRaceNameReturnsConflictWhenTargetExists(t *testing.T) {
func TestAccountStoreUpdateRejectsUserNameMutation(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")
record := validAccountRecord()
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record)))
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(first)))
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(second)))
attempted := record
attempted.UserName = common.UserName("player-changed")
attempted.UpdatedAt = record.UpdatedAt.Add(time.Minute)
err := accountStore.RenameRaceName(context.Background(), renameRaceNameInput(first, second.RaceName, first.UpdatedAt.Add(time.Minute)))
err := accountStore.Update(context.Background(), attempted)
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) {
@@ -301,12 +264,35 @@ func TestAccountStoreUpdateDeclaredCountryPreservesLookups(t *testing.T) {
require.NoError(t, err)
require.Equal(t, updated, byEmail)
byRaceName, err := accountStore.GetByRaceName(context.Background(), record.RaceName)
byUserName, err := accountStore.GetByUserName(context.Background(), record.UserName)
require.NoError(t, err)
require.Equal(t, updated, byRaceName)
require.Equal(t, updated, byUserName)
}
func TestAccountStoreCreateReturnsConflictWhenCanonicalReservationExists(t *testing.T) {
func TestAccountStorePersistsSoftDeleteMarker(t *testing.T) {
t.Parallel()
store := newTestStore(t)
accountStore := store.Accounts()
record := validAccountRecord()
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record)))
deletedAt := record.UpdatedAt.Add(time.Hour)
updated := record
updated.UpdatedAt = deletedAt
updated.DeletedAt = &deletedAt
require.NoError(t, accountStore.Update(context.Background(), updated))
byUserID, err := accountStore.GetByUserID(context.Background(), record.UserID)
require.NoError(t, err)
require.NotNil(t, byUserID.DeletedAt)
require.True(t, byUserID.DeletedAt.Equal(deletedAt))
require.True(t, byUserID.IsDeleted())
}
func TestAccountStoreCreateReturnsUserNameConflict(t *testing.T) {
t.Parallel()
store := newTestStore(t)
@@ -316,12 +302,11 @@ func TestAccountStoreCreateReturnsConflictWhenCanonicalReservationExists(t *test
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)
require.ErrorIs(t, err, ports.ErrUserNameConflict)
}
func TestBlockByUserIDRepeatedCallsStayIdempotent(t *testing.T) {
@@ -805,7 +790,7 @@ func validAccountRecord() account.UserAccount {
return account.UserAccount{
UserID: common.UserID("user-123"),
Email: common.Email("pilot@example.com"),
RaceName: common.RaceName("Pilot Nova"),
UserName: common.UserName("player-abcdefgh"),
PreferredLanguage: common.LanguageTag("en"),
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
CreatedAt: createdAt,
@@ -889,42 +874,6 @@ func timePointer(value time.Time) *time.Time {
func createAccountInput(record account.UserAccount) ports.CreateAccountInput {
return ports.CreateAccountInput{
Account: record,
Reservation: raceNameReservation(record.UserID, record.RaceName, record.UpdatedAt),
Account: record,
}
}
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())))
}
+3 -10
View File
@@ -8,7 +8,6 @@ import (
"strings"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
@@ -35,15 +34,9 @@ func (k Keyspace) EmailLookup(email common.Email) string {
return k.prefix() + "lookup:email:" + encodeKeyComponent(email.String())
}
// RaceNameLookup returns the exact stored race-name lookup key.
func (k Keyspace) RaceNameLookup(raceName common.RaceName) string {
return k.prefix() + "lookup:race-name:" + encodeKeyComponent(raceName.String())
}
// RaceNameReservation returns the replaceable canonical race-name reservation
// key.
func (k Keyspace) RaceNameReservation(key account.RaceNameCanonicalKey) string {
return k.prefix() + "reservation:race-name:" + encodeKeyComponent(key.String())
// UserNameLookup returns the exact stored user-name lookup key.
func (k Keyspace) UserNameLookup(userName common.UserName) string {
return k.prefix() + "lookup:user-name:" + encodeKeyComponent(userName.String())
}
// BlockedEmailSubject returns the dedicated blocked-email-subject key.
@@ -4,7 +4,6 @@ import (
"testing"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
@@ -19,8 +18,7 @@ func TestKeyspaceBuildsStableKeys(t *testing.T) {
require.Equal(t, "custom:account:dXNlci0xMjM", keyspace.Account(common.UserID("user-123")))
require.Equal(t, "custom:lookup:email:cGlsb3RAZXhhbXBsZS5jb20", keyspace.EmailLookup(common.Email("pilot@example.com")))
require.Equal(t, "custom:lookup:race-name:UGlsb3QgTm92YQ", keyspace.RaceNameLookup(common.RaceName("Pilot Nova")))
require.Equal(t, "custom:reservation:race-name:cGlsb3Qtbm92YQ", keyspace.RaceNameReservation(account.RaceNameCanonicalKey("pilot-nova")))
require.Equal(t, "custom:lookup:user-name:cGxheWVyLWFiY2RlZmdo", keyspace.UserNameLookup(common.UserName("player-abcdefgh")))
require.Equal(t, "custom:blocked-email:cGlsb3RAZXhhbXBsZS5jb20", keyspace.BlockedEmailSubject(common.Email("pilot@example.com")))
require.Equal(t, "custom:entitlement:record:ZW50aXRsZW1lbnQtMTIz", keyspace.EntitlementRecord(entitlement.EntitlementRecordID("entitlement-123")))
require.Equal(t, "custom:sanction:record:c2FuY3Rpb24tMQ", keyspace.SanctionRecord(policy.SanctionRecordID("sanction-1")))