feat: user service

This commit is contained in:
Ilia Denisov
2026-04-10 19:05:02 +02:00
committed by GitHub
parent 710bad712e
commit 23ffcb7535
140 changed files with 33418 additions and 952 deletions
+13
View File
@@ -0,0 +1,13 @@
// Package local provides small in-process runtime adapters used by the user
// service process.
package local
import "time"
// Clock returns the current wall-clock time.
type Clock struct{}
// Now returns the current time.
func (Clock) Now() time.Time {
return time.Now()
}
@@ -0,0 +1,29 @@
package local
import (
"context"
"fmt"
"galaxy/user/internal/ports"
)
// NoopDeclaredCountryChangedPublisher validates and discards auxiliary
// declared-country change events.
type NoopDeclaredCountryChangedPublisher struct{}
// PublishDeclaredCountryChanged validates event and discards it.
func (NoopDeclaredCountryChangedPublisher) PublishDeclaredCountryChanged(
ctx context.Context,
event ports.DeclaredCountryChangedEvent,
) error {
if ctx == nil {
return fmt.Errorf("publish declared-country changed event: nil context")
}
if err := ctx.Err(); err != nil {
return err
}
return event.Validate()
}
var _ ports.DeclaredCountryChangedPublisher = NoopDeclaredCountryChangedPublisher{}
@@ -0,0 +1,62 @@
package local
import (
"context"
"fmt"
"galaxy/user/internal/ports"
)
// NoopDomainEventPublisher validates and discards auxiliary user-domain
// events.
type NoopDomainEventPublisher struct{}
// PublishProfileChanged validates event and discards it.
func (NoopDomainEventPublisher) PublishProfileChanged(ctx context.Context, event ports.ProfileChangedEvent) error {
return validateNoopPublish(ctx, "publish profile changed event", event.Validate)
}
// PublishSettingsChanged validates event and discards it.
func (NoopDomainEventPublisher) PublishSettingsChanged(ctx context.Context, event ports.SettingsChangedEvent) error {
return validateNoopPublish(ctx, "publish settings changed event", event.Validate)
}
// PublishEntitlementChanged validates event and discards it.
func (NoopDomainEventPublisher) PublishEntitlementChanged(ctx context.Context, event ports.EntitlementChangedEvent) error {
return validateNoopPublish(ctx, "publish entitlement changed event", event.Validate)
}
// PublishSanctionChanged validates event and discards it.
func (NoopDomainEventPublisher) PublishSanctionChanged(ctx context.Context, event ports.SanctionChangedEvent) error {
return validateNoopPublish(ctx, "publish sanction changed event", event.Validate)
}
// PublishLimitChanged validates event and discards it.
func (NoopDomainEventPublisher) PublishLimitChanged(ctx context.Context, event ports.LimitChangedEvent) error {
return validateNoopPublish(ctx, "publish limit changed event", event.Validate)
}
// PublishDeclaredCountryChanged validates event and discards it.
func (NoopDomainEventPublisher) PublishDeclaredCountryChanged(ctx context.Context, event ports.DeclaredCountryChangedEvent) error {
return validateNoopPublish(ctx, "publish declared-country changed event", event.Validate)
}
func validateNoopPublish(ctx context.Context, operation string, validate func() error) error {
if ctx == nil {
return fmt.Errorf("%s: nil context", operation)
}
if err := ctx.Err(); err != nil {
return err
}
return validate()
}
var (
_ ports.ProfileChangedPublisher = NoopDomainEventPublisher{}
_ ports.SettingsChangedPublisher = NoopDomainEventPublisher{}
_ ports.EntitlementChangedPublisher = NoopDomainEventPublisher{}
_ ports.SanctionChangedPublisher = NoopDomainEventPublisher{}
_ ports.LimitChangedPublisher = NoopDomainEventPublisher{}
_ ports.DeclaredCountryChangedPublisher = NoopDomainEventPublisher{}
)
@@ -0,0 +1,105 @@
package local
import (
"crypto/rand"
"encoding/base32"
"fmt"
"strings"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
)
var base32NoPadding = base32.StdEncoding.WithPadding(base32.NoPadding)
// IDGenerator creates opaque stable user identifiers and generated initial
// race names.
type IDGenerator struct{}
// NewUserID returns one newly generated opaque user identifier.
func (IDGenerator) NewUserID() (common.UserID, error) {
token, err := randomToken(10)
if err != nil {
return "", fmt.Errorf("generate user id: %w", err)
}
userID := common.UserID("user-" + token)
if err := userID.Validate(); err != nil {
return "", fmt.Errorf("generate user id: %w", err)
}
return userID, nil
}
// NewInitialRaceName returns one generated race name in the `player-<shortid>`
// form.
func (IDGenerator) NewInitialRaceName() (common.RaceName, error) {
token, err := randomToken(5)
if err != nil {
return "", fmt.Errorf("generate initial race name: %w", err)
}
raceName := common.RaceName("player-" + token)
if err := raceName.Validate(); err != nil {
return "", fmt.Errorf("generate initial race name: %w", err)
}
return raceName, nil
}
// NewEntitlementRecordID returns one generated entitlement history record
// identifier.
func (IDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
token, err := randomToken(10)
if err != nil {
return "", fmt.Errorf("generate entitlement record id: %w", err)
}
recordID := entitlement.EntitlementRecordID("entitlement-" + token)
if err := recordID.Validate(); err != nil {
return "", fmt.Errorf("generate entitlement record id: %w", err)
}
return recordID, nil
}
// NewSanctionRecordID returns one generated sanction history record
// identifier.
func (IDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
token, err := randomToken(10)
if err != nil {
return "", fmt.Errorf("generate sanction record id: %w", err)
}
recordID := policy.SanctionRecordID("sanction-" + token)
if err := recordID.Validate(); err != nil {
return "", fmt.Errorf("generate sanction record id: %w", err)
}
return recordID, nil
}
// NewLimitRecordID returns one generated limit history record identifier.
func (IDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
token, err := randomToken(10)
if err != nil {
return "", fmt.Errorf("generate limit record id: %w", err)
}
recordID := policy.LimitRecordID("limit-" + token)
if err := recordID.Validate(); err != nil {
return "", fmt.Errorf("generate limit record id: %w", err)
}
return recordID, nil
}
func randomToken(size int) (string, error) {
buffer := make([]byte, size)
if _, err := rand.Read(buffer); err != nil {
return "", err
}
return strings.ToLower(base32NoPadding.EncodeToString(buffer)), nil
}
@@ -0,0 +1,65 @@
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
}
@@ -0,0 +1,72 @@
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)
}
@@ -0,0 +1,311 @@
// Package domainevents implements Redis Stream-backed auxiliary user-domain
// event publishers.
package domainevents
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 domain-event 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 domain 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 auxiliary user-domain events into one Redis Stream.
type Publisher struct {
client *redis.Client
stream string
streamMaxLen int64
operationTimeout time.Duration
}
// New constructs a Redis-backed domain-event publisher from cfg.
func New(cfg Config) (*Publisher, error) {
switch {
case strings.TrimSpace(cfg.Addr) == "":
return nil, errors.New("new redis domain-event publisher: redis addr must not be empty")
case cfg.DB < 0:
return nil, errors.New("new redis domain-event publisher: redis db must not be negative")
case strings.TrimSpace(cfg.Stream) == "":
return nil, errors.New("new redis domain-event publisher: stream must not be empty")
case cfg.StreamMaxLen <= 0:
return nil, errors.New("new redis domain-event publisher: stream max len must be positive")
case cfg.OperationTimeout <= 0:
return nil, errors.New("new redis domain-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 domain-event publisher")
if err != nil {
return err
}
defer cancel()
if err := publisher.client.Ping(operationCtx).Err(); err != nil {
return fmt.Errorf("ping redis domain-event publisher: %w", err)
}
return nil
}
// PublishProfileChanged publishes one committed profile-change event.
func (publisher *Publisher) PublishProfileChanged(ctx context.Context, event ports.ProfileChangedEvent) error {
if err := event.Validate(); err != nil {
return fmt.Errorf("publish profile changed event: %w", err)
}
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()
return publisher.publish(ctx, "publish profile changed event", values)
}
// PublishSettingsChanged publishes one committed settings-change event.
func (publisher *Publisher) PublishSettingsChanged(ctx context.Context, event ports.SettingsChangedEvent) error {
if err := event.Validate(); err != nil {
return fmt.Errorf("publish settings changed event: %w", err)
}
values := buildEnvelope(ports.SettingsChangedEventType, event.UserID.String(), event.OccurredAt, event.Source.String(), traceIDFromContext(ctx, event.TraceID))
values["operation"] = string(event.Operation)
values["preferred_language"] = event.PreferredLanguage.String()
values["time_zone"] = event.TimeZone.String()
return publisher.publish(ctx, "publish settings changed event", values)
}
// PublishEntitlementChanged publishes one committed entitlement-change event.
func (publisher *Publisher) PublishEntitlementChanged(ctx context.Context, event ports.EntitlementChangedEvent) error {
if err := event.Validate(); err != nil {
return fmt.Errorf("publish entitlement changed event: %w", err)
}
values := buildEnvelope(ports.EntitlementChangedEventType, event.UserID.String(), event.OccurredAt, event.Source.String(), traceIDFromContext(ctx, event.TraceID))
values["operation"] = string(event.Operation)
values["plan_code"] = string(event.PlanCode)
values["is_paid"] = strconv.FormatBool(event.IsPaid)
values["starts_at_ms"] = strconv.FormatInt(event.StartsAt.UTC().UnixMilli(), 10)
values["reason_code"] = event.ReasonCode.String()
values["actor_type"] = event.Actor.Type.String()
values["updated_at_ms"] = strconv.FormatInt(event.UpdatedAt.UTC().UnixMilli(), 10)
if !event.Actor.ID.IsZero() {
values["actor_id"] = event.Actor.ID.String()
}
if event.EndsAt != nil {
values["ends_at_ms"] = strconv.FormatInt(event.EndsAt.UTC().UnixMilli(), 10)
}
return publisher.publish(ctx, "publish entitlement changed event", values)
}
// PublishSanctionChanged publishes one committed sanction-change event.
func (publisher *Publisher) PublishSanctionChanged(ctx context.Context, event ports.SanctionChangedEvent) error {
if err := event.Validate(); err != nil {
return fmt.Errorf("publish sanction changed event: %w", err)
}
values := buildEnvelope(ports.SanctionChangedEventType, event.UserID.String(), event.OccurredAt, event.Source.String(), traceIDFromContext(ctx, event.TraceID))
values["operation"] = string(event.Operation)
values["sanction_code"] = string(event.SanctionCode)
values["scope"] = event.Scope.String()
values["reason_code"] = event.ReasonCode.String()
values["actor_type"] = event.Actor.Type.String()
values["applied_at_ms"] = strconv.FormatInt(event.AppliedAt.UTC().UnixMilli(), 10)
if !event.Actor.ID.IsZero() {
values["actor_id"] = event.Actor.ID.String()
}
if event.ExpiresAt != nil {
values["expires_at_ms"] = strconv.FormatInt(event.ExpiresAt.UTC().UnixMilli(), 10)
}
if event.RemovedAt != nil {
values["removed_at_ms"] = strconv.FormatInt(event.RemovedAt.UTC().UnixMilli(), 10)
}
return publisher.publish(ctx, "publish sanction changed event", values)
}
// PublishLimitChanged publishes one committed limit-change event.
func (publisher *Publisher) PublishLimitChanged(ctx context.Context, event ports.LimitChangedEvent) error {
if err := event.Validate(); err != nil {
return fmt.Errorf("publish limit changed event: %w", err)
}
values := buildEnvelope(ports.LimitChangedEventType, event.UserID.String(), event.OccurredAt, event.Source.String(), traceIDFromContext(ctx, event.TraceID))
values["operation"] = string(event.Operation)
values["limit_code"] = string(event.LimitCode)
values["reason_code"] = event.ReasonCode.String()
values["actor_type"] = event.Actor.Type.String()
values["applied_at_ms"] = strconv.FormatInt(event.AppliedAt.UTC().UnixMilli(), 10)
if event.Value != nil {
values["value"] = strconv.Itoa(*event.Value)
}
if !event.Actor.ID.IsZero() {
values["actor_id"] = event.Actor.ID.String()
}
if event.ExpiresAt != nil {
values["expires_at_ms"] = strconv.FormatInt(event.ExpiresAt.UTC().UnixMilli(), 10)
}
if event.RemovedAt != nil {
values["removed_at_ms"] = strconv.FormatInt(event.RemovedAt.UTC().UnixMilli(), 10)
}
return publisher.publish(ctx, "publish limit changed event", values)
}
// PublishDeclaredCountryChanged publishes one committed declared-country change
// event.
func (publisher *Publisher) PublishDeclaredCountryChanged(ctx context.Context, event ports.DeclaredCountryChangedEvent) error {
if err := event.Validate(); err != nil {
return fmt.Errorf("publish declared-country changed event: %w", err)
}
values := buildEnvelope(
ports.DeclaredCountryChangedEventType,
event.UserID.String(),
event.UpdatedAt,
event.Source.String(),
traceIDFromContext(ctx, event.TraceID),
)
values["declared_country"] = event.DeclaredCountry.String()
values["updated_at_ms"] = strconv.FormatInt(event.UpdatedAt.UTC().UnixMilli(), 10)
return publisher.publish(ctx, "publish declared-country changed event", values)
}
func (publisher *Publisher) publish(ctx context.Context, operation string, values map[string]any) error {
operationCtx, cancel, err := publisher.operationContext(ctx, operation)
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("%s: %w", operation, 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 buildEnvelope(eventType string, userID string, occurredAt time.Time, source string, traceID string) map[string]any {
values := map[string]any{
"event_type": eventType,
"user_id": userID,
"occurred_at_ms": strconv.FormatInt(occurredAt.UTC().UnixMilli(), 10),
"source": source,
}
if traceID != "" {
values["trace_id"] = traceID
}
return values
}
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.ProfileChangedPublisher = (*Publisher)(nil)
_ ports.SettingsChangedPublisher = (*Publisher)(nil)
_ ports.EntitlementChangedPublisher = (*Publisher)(nil)
_ ports.SanctionChangedPublisher = (*Publisher)(nil)
_ ports.LimitChangedPublisher = (*Publisher)(nil)
_ ports.DeclaredCountryChangedPublisher = (*Publisher)(nil)
)
@@ -0,0 +1,90 @@
package domainevents
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 TestPublisherPublishesFlatRedisStreamEntry(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
publisher, err := New(Config{
Addr: server.Addr(),
Stream: "user:test_events",
StreamMaxLen: 5,
OperationTimeout: time.Second,
})
require.NoError(t, err)
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"),
})
require.NoError(t, err)
entries, err := publisher.client.XRange(context.Background(), publisher.stream, "-", "+").Result()
require.NoError(t, err)
require.Len(t, entries, 1)
require.Equal(t, ports.ProfileChangedEventType, entries[0].Values["event_type"])
require.Equal(t, "user-123", entries[0].Values["user_id"])
require.Equal(t, strconv.FormatInt(occurredAt.UnixMilli(), 10), entries[0].Values["occurred_at_ms"])
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"])
for index := 0; index < 20; index++ {
err = publisher.PublishSettingsChanged(context.Background(), ports.SettingsChangedEvent{
UserID: common.UserID("user-123"),
OccurredAt: occurredAt.Add(time.Duration(index+1) * time.Second),
Source: common.Source("gateway_self_service"),
Operation: ports.SettingsChangedOperationUpdated,
PreferredLanguage: common.LanguageTag("en-US"),
TimeZone: common.TimeZoneName("UTC"),
})
require.NoError(t, err)
}
length, err := publisher.client.XLen(context.Background(), publisher.stream).Result()
require.NoError(t, err)
require.LessOrEqual(t, length, int64(20))
}
func TestPublisherRejectsInvalidEventBeforeXAdd(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
publisher, err := New(Config{
Addr: server.Addr(),
Stream: "user:test_events",
StreamMaxLen: 5,
OperationTimeout: time.Second,
})
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"),
})
require.Error(t, err)
length, xLenErr := publisher.client.XLen(context.Background(), publisher.stream).Result()
require.NoError(t, xLenErr)
require.Zero(t, length)
}
@@ -0,0 +1,215 @@
package userstore
import (
"context"
"errors"
"galaxy/user/internal/adapters/redisstate"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"github.com/redis/go-redis/v9"
)
var knownSanctionCodes = []policy.SanctionCode{
policy.SanctionCodeLoginBlock,
policy.SanctionCodePrivateGameCreateBlock,
policy.SanctionCodePrivateGameManageBlock,
policy.SanctionCodeGameJoinBlock,
policy.SanctionCodeProfileUpdateBlock,
}
var knownLimitCodes = []policy.LimitCode{
policy.LimitCodeMaxOwnedPrivateGames,
policy.LimitCodeMaxPendingPublicApplications,
policy.LimitCodeMaxActiveGameMemberships,
}
var knownEligibilityMarkers = []policy.EligibilityMarker{
policy.EligibilityMarkerCanLogin,
policy.EligibilityMarkerCanCreatePrivateGame,
policy.EligibilityMarkerCanManagePrivateGame,
policy.EligibilityMarkerCanJoinGame,
policy.EligibilityMarkerCanUpdateProfile,
}
func (store *Store) addCreatedAtIndex(
pipe redis.Pipeliner,
ctx context.Context,
record account.UserAccount,
) {
pipe.ZAdd(ctx, store.keyspace.CreatedAtIndex(), redis.Z{
Score: redisstate.CreatedAtScore(record.CreatedAt),
Member: record.UserID.String(),
})
}
func (store *Store) syncDeclaredCountryIndex(
pipe redis.Pipeliner,
ctx context.Context,
previous account.UserAccount,
current account.UserAccount,
) {
if !previous.DeclaredCountry.IsZero() {
pipe.SRem(ctx, store.keyspace.DeclaredCountryIndex(previous.DeclaredCountry), current.UserID.String())
}
if !current.DeclaredCountry.IsZero() {
pipe.SAdd(ctx, store.keyspace.DeclaredCountryIndex(current.DeclaredCountry), current.UserID.String())
}
}
func (store *Store) syncEntitlementIndexes(
pipe redis.Pipeliner,
ctx context.Context,
snapshot entitlement.CurrentSnapshot,
) {
pipe.SRem(ctx, store.keyspace.PaidStateIndex(entitlement.PaidStateFree), snapshot.UserID.String())
pipe.SRem(ctx, store.keyspace.PaidStateIndex(entitlement.PaidStatePaid), snapshot.UserID.String())
pipe.SAdd(ctx, store.keyspace.PaidStateIndex(paidStateFromSnapshot(snapshot)), snapshot.UserID.String())
pipe.ZRem(ctx, store.keyspace.FinitePaidExpiryIndex(), snapshot.UserID.String())
if snapshot.HasFiniteExpiry() {
pipe.ZAdd(ctx, store.keyspace.FinitePaidExpiryIndex(), redis.Z{
Score: redisstate.ExpiryScore(*snapshot.EndsAt),
Member: snapshot.UserID.String(),
})
}
}
func (store *Store) syncActiveSanctionCodeIndexes(
pipe redis.Pipeliner,
ctx context.Context,
userID common.UserID,
activeCodes map[policy.SanctionCode]struct{},
) {
for _, code := range knownSanctionCodes {
pipe.SRem(ctx, store.keyspace.ActiveSanctionCodeIndex(code), userID.String())
if _, ok := activeCodes[code]; ok {
pipe.SAdd(ctx, store.keyspace.ActiveSanctionCodeIndex(code), userID.String())
}
}
}
func (store *Store) syncActiveLimitCodeIndexes(
pipe redis.Pipeliner,
ctx context.Context,
userID common.UserID,
activeCodes map[policy.LimitCode]struct{},
) {
for _, code := range knownLimitCodes {
pipe.SRem(ctx, store.keyspace.ActiveLimitCodeIndex(code), userID.String())
if _, ok := activeCodes[code]; ok {
pipe.SAdd(ctx, store.keyspace.ActiveLimitCodeIndex(code), userID.String())
}
}
}
func (store *Store) syncEligibilityMarkerIndexes(
pipe redis.Pipeliner,
ctx context.Context,
userID common.UserID,
isPaid bool,
activeSanctionCodes map[policy.SanctionCode]struct{},
) {
values := deriveEligibilityMarkerValues(isPaid, activeSanctionCodes)
for _, marker := range knownEligibilityMarkers {
pipe.SRem(ctx, store.keyspace.EligibilityMarkerIndex(marker, true), userID.String())
pipe.SRem(ctx, store.keyspace.EligibilityMarkerIndex(marker, false), userID.String())
pipe.SAdd(ctx, store.keyspace.EligibilityMarkerIndex(marker, values[marker]), userID.String())
}
}
func (store *Store) loadActiveSanctionCodeSet(
ctx context.Context,
getter bytesGetter,
userID common.UserID,
) (map[policy.SanctionCode]struct{}, error) {
activeCodes := make(map[policy.SanctionCode]struct{}, len(knownSanctionCodes))
for _, code := range knownSanctionCodes {
_, err := store.loadActiveSanctionRecordID(ctx, getter, store.keyspace.ActiveSanction(userID, code))
switch {
case err == nil:
activeCodes[code] = struct{}{}
case errors.Is(err, ports.ErrNotFound):
continue
default:
return nil, err
}
}
return activeCodes, nil
}
func (store *Store) loadActiveLimitCodeSet(
ctx context.Context,
getter bytesGetter,
userID common.UserID,
) (map[policy.LimitCode]struct{}, error) {
activeCodes := make(map[policy.LimitCode]struct{}, len(knownLimitCodes))
for _, code := range knownLimitCodes {
_, err := store.loadActiveLimitRecordID(ctx, getter, store.keyspace.ActiveLimit(userID, code))
switch {
case err == nil:
activeCodes[code] = struct{}{}
case errors.Is(err, ports.ErrNotFound):
continue
default:
return nil, err
}
}
return activeCodes, nil
}
func (store *Store) activeSanctionWatchKeys(userID common.UserID) []string {
keys := make([]string, 0, len(knownSanctionCodes))
for _, code := range knownSanctionCodes {
keys = append(keys, store.keyspace.ActiveSanction(userID, code))
}
return keys
}
func (store *Store) activeLimitWatchKeys(userID common.UserID) []string {
keys := make([]string, 0, len(knownLimitCodes))
for _, code := range knownLimitCodes {
keys = append(keys, store.keyspace.ActiveLimit(userID, code))
}
return keys
}
func deriveEligibilityMarkerValues(
isPaid bool,
activeSanctionCodes map[policy.SanctionCode]struct{},
) map[policy.EligibilityMarker]bool {
_, loginBlocked := activeSanctionCodes[policy.SanctionCodeLoginBlock]
_, createBlocked := activeSanctionCodes[policy.SanctionCodePrivateGameCreateBlock]
_, manageBlocked := activeSanctionCodes[policy.SanctionCodePrivateGameManageBlock]
_, joinBlocked := activeSanctionCodes[policy.SanctionCodeGameJoinBlock]
_, profileBlocked := activeSanctionCodes[policy.SanctionCodeProfileUpdateBlock]
canLogin := !loginBlocked
return map[policy.EligibilityMarker]bool{
policy.EligibilityMarkerCanLogin: canLogin,
policy.EligibilityMarkerCanCreatePrivateGame: canLogin && isPaid && !createBlocked,
policy.EligibilityMarkerCanManagePrivateGame: canLogin && isPaid && !manageBlocked,
policy.EligibilityMarkerCanJoinGame: canLogin && !joinBlocked,
policy.EligibilityMarkerCanUpdateProfile: canLogin && !profileBlocked,
}
}
func paidStateFromSnapshot(snapshot entitlement.CurrentSnapshot) entitlement.PaidState {
if snapshot.IsPaid {
return entitlement.PaidStatePaid
}
return entitlement.PaidStateFree
}
@@ -0,0 +1,449 @@
package userstore
import (
"context"
"testing"
"time"
"galaxy/user/internal/adapters/redisstate"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/adminusers"
"galaxy/user/internal/service/entitlementsvc"
"github.com/stretchr/testify/require"
)
func TestListUserIDsCreatedAtPagination(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := time.Unix(1_775_240_000, 0).UTC()
first := validAccountRecord()
first.UserID = common.UserID("user-100")
first.Email = common.Email("u100@example.com")
first.RaceName = common.RaceName("User 100")
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.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.CreatedAt = base
third.UpdatedAt = third.CreatedAt
require.NoError(t, store.Create(context.Background(), createAccountInput(first)))
require.NoError(t, store.Create(context.Background(), createAccountInput(second)))
require.NoError(t, store.Create(context.Background(), createAccountInput(third)))
firstPage, err := store.ListUserIDs(context.Background(), ports.ListUsersInput{
PageSize: 2,
Filters: ports.UserListFilters{},
})
require.NoError(t, err)
require.Equal(t, []common.UserID{third.UserID, second.UserID}, firstPage.UserIDs)
require.NotEmpty(t, firstPage.NextPageToken)
secondPage, err := store.ListUserIDs(context.Background(), ports.ListUsersInput{
PageSize: 2,
PageToken: firstPage.NextPageToken,
Filters: ports.UserListFilters{},
})
require.NoError(t, err)
require.Equal(t, []common.UserID{first.UserID}, secondPage.UserIDs)
require.Empty(t, secondPage.NextPageToken)
}
func TestEnsureByEmailInitialAdminIndexes(t *testing.T) {
t.Parallel()
store := newTestStore(t)
now := time.Unix(1_775_240_000, 0).UTC()
record := validAccountRecord()
record.DeclaredCountry = common.CountryCode("DE")
record.CreatedAt = now
record.UpdatedAt = now
result, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
Email: record.Email,
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)
requireSortedSetScore(t, store, store.keyspace.CreatedAtIndex(), record.UserID.String(), redisstate.CreatedAtScore(record.CreatedAt))
requireSetContains(t, store, store.keyspace.PaidStateIndex(entitlement.PaidStateFree), record.UserID.String())
requireSetNotContains(t, store, store.keyspace.PaidStateIndex(entitlement.PaidStatePaid), record.UserID.String())
requireSetContains(t, store, store.keyspace.DeclaredCountryIndex(record.DeclaredCountry), record.UserID.String())
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanLogin, true), record.UserID.String())
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanCreatePrivateGame, false), record.UserID.String())
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanJoinGame, true), record.UserID.String())
}
func TestAccountUpdateSyncsDeclaredCountryIndex(t *testing.T) {
t.Parallel()
store := newTestStore(t)
accountStore := store.Accounts()
record := validAccountRecord()
record.DeclaredCountry = common.CountryCode("DE")
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))
requireSetNotContains(t, store, store.keyspace.DeclaredCountryIndex(common.CountryCode("DE")), record.UserID.String())
requireSetContains(t, store, store.keyspace.DeclaredCountryIndex(common.CountryCode("FR")), record.UserID.String())
}
func TestEntitlementLifecycleSyncsAdminIndexes(t *testing.T) {
t.Parallel()
store := newTestStore(t)
now := time.Unix(1_775_240_000, 0).UTC()
record := validAccountRecord()
record.CreatedAt = now
record.UpdatedAt = now
_, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
Email: record.Email,
Account: record,
Entitlement: validEntitlementSnapshot(record.UserID, now),
EntitlementRecord: validEntitlementRecord(record.UserID, now),
Reservation: raceNameReservation(record.UserID, record.RaceName, now),
})
require.NoError(t, err)
lifecycleStore := store.EntitlementLifecycle()
freeRecord := validEntitlementRecord(record.UserID, now)
freeSnapshot := validEntitlementSnapshot(record.UserID, now)
grantStartsAt := now.Add(time.Hour)
grantEndsAt := grantStartsAt.Add(30 * 24 * time.Hour)
grantedRecord := paidEntitlementRecord(
entitlement.EntitlementRecordID("entitlement-paid-1"),
record.UserID,
entitlement.PlanCodePaidMonthly,
grantStartsAt,
grantEndsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
grantedSnapshot := paidEntitlementSnapshot(
record.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,
}))
requireSetContains(t, store, store.keyspace.PaidStateIndex(entitlement.PaidStatePaid), record.UserID.String())
requireSetNotContains(t, store, store.keyspace.PaidStateIndex(entitlement.PaidStateFree), record.UserID.String())
requireSortedSetScore(t, store, store.keyspace.FinitePaidExpiryIndex(), record.UserID.String(), redisstate.ExpiryScore(grantEndsAt))
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanCreatePrivateGame, true), record.UserID.String())
extendedEndsAt := grantEndsAt.Add(30 * 24 * time.Hour)
extensionRecord := paidEntitlementRecord(
entitlement.EntitlementRecordID("entitlement-paid-2"),
record.UserID,
entitlement.PlanCodePaidMonthly,
grantEndsAt,
extendedEndsAt,
common.Source("admin"),
common.ReasonCode("manual_extend"),
)
extendedSnapshot := paidEntitlementSnapshot(
record.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,
}))
requireSortedSetScore(t, store, store.keyspace.FinitePaidExpiryIndex(), record.UserID.String(), redisstate.ExpiryScore(extendedEndsAt))
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: record.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: record.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,
}))
requireSetContains(t, store, store.keyspace.PaidStateIndex(entitlement.PaidStateFree), record.UserID.String())
requireSetNotContains(t, store, store.keyspace.PaidStateIndex(entitlement.PaidStatePaid), record.UserID.String())
requireSortedSetMissing(t, store, store.keyspace.FinitePaidExpiryIndex(), record.UserID.String())
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanCreatePrivateGame, false), record.UserID.String())
}
func TestPolicyLifecycleSyncsAdminIndexes(t *testing.T) {
t.Parallel()
store := newTestStore(t)
now := time.Unix(1_775_240_000, 0).UTC()
record := validAccountRecord()
record.CreatedAt = now
record.UpdatedAt = now
_, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
Email: record.Email,
Account: record,
Entitlement: validEntitlementSnapshot(record.UserID, now),
EntitlementRecord: validEntitlementRecord(record.UserID, now),
Reservation: raceNameReservation(record.UserID, record.RaceName, now),
})
require.NoError(t, err)
lifecycleStore := store.PolicyLifecycle()
sanctionRecord := policy.SanctionRecord{
RecordID: policy.SanctionRecordID("sanction-1"),
UserID: record.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: sanctionRecord,
}))
requireSetContains(t, store, store.keyspace.ActiveSanctionCodeIndex(policy.SanctionCodeLoginBlock), record.UserID.String())
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanLogin, false), record.UserID.String())
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanJoinGame, false), record.UserID.String())
removedSanction := sanctionRecord
removedAt := now.Add(time.Minute)
removedSanction.RemovedAt = &removedAt
removedSanction.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")}
removedSanction.RemovedReasonCode = common.ReasonCode("manual_remove")
require.NoError(t, lifecycleStore.RemoveSanction(context.Background(), ports.RemoveSanctionInput{
ExpectedActiveRecord: sanctionRecord,
UpdatedRecord: removedSanction,
}))
requireSetNotContains(t, store, store.keyspace.ActiveSanctionCodeIndex(policy.SanctionCodeLoginBlock), record.UserID.String())
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanLogin, true), record.UserID.String())
limitRecord := policy.LimitRecord{
RecordID: policy.LimitRecordID("limit-1"),
UserID: record.UserID,
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
Value: 5,
ReasonCode: common.ReasonCode("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(2 * time.Minute),
}
require.NoError(t, lifecycleStore.SetLimit(context.Background(), ports.SetLimitInput{
NewRecord: limitRecord,
}))
requireSetContains(t, store, store.keyspace.ActiveLimitCodeIndex(policy.LimitCodeMaxOwnedPrivateGames), record.UserID.String())
removedLimit := limitRecord
limitRemovedAt := now.Add(3 * time.Minute)
removedLimit.RemovedAt = &limitRemovedAt
removedLimit.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")}
removedLimit.RemovedReasonCode = common.ReasonCode("manual_remove")
require.NoError(t, lifecycleStore.RemoveLimit(context.Background(), ports.RemoveLimitInput{
ExpectedActiveRecord: limitRecord,
UpdatedRecord: removedLimit,
}))
requireSetNotContains(t, store, store.keyspace.ActiveLimitCodeIndex(policy.LimitCodeMaxOwnedPrivateGames), record.UserID.String())
}
func TestAdminListerReevaluatesExpiredPaidSnapshots(t *testing.T) {
t.Parallel()
store := newTestStore(t)
userID := common.UserID("user-123")
now := time.Unix(1_775_240_000, 0).UTC()
record := validAccountRecord()
record.CreatedAt = now.Add(-2 * time.Hour)
record.UpdatedAt = record.CreatedAt
_, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
Email: record.Email,
Account: record,
Entitlement: validEntitlementSnapshot(userID, record.CreatedAt),
EntitlementRecord: validEntitlementRecord(userID, record.CreatedAt),
Reservation: raceNameReservation(userID, record.RaceName, record.CreatedAt),
})
require.NoError(t, err)
grantStartsAt := now.Add(-90 * time.Minute)
grantEndsAt := now.Add(-30 * time.Minute)
freeRecord := validEntitlementRecord(userID, record.CreatedAt)
freeSnapshot := validEntitlementSnapshot(userID, record.CreatedAt)
grantedRecord := paidEntitlementRecord(
entitlement.EntitlementRecordID("entitlement-paid-expired"),
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, store.EntitlementLifecycle().Grant(context.Background(), ports.GrantEntitlementInput{
ExpectedCurrentSnapshot: freeSnapshot,
ExpectedCurrentRecord: freeRecord,
UpdatedCurrentRecord: closedFreeRecord,
NewRecord: grantedRecord,
NewSnapshot: grantedSnapshot,
}))
reader, err := entitlementsvc.NewReader(
store.EntitlementSnapshots(),
store.EntitlementLifecycle(),
adminStoreClock{now: now},
adminStoreIDGenerator{entitlementRecordID: entitlement.EntitlementRecordID("entitlement-free-after-expiry")},
)
require.NoError(t, err)
lister, err := adminusers.NewLister(store.Accounts(), reader, store.Sanctions(), store.Limits(), adminStoreClock{now: now}, store)
require.NoError(t, err)
result, err := lister.Execute(context.Background(), adminusers.ListUsersInput{PaidState: "free"})
require.NoError(t, err)
require.Len(t, result.Items, 1)
require.Equal(t, "user-123", result.Items[0].UserID)
require.Equal(t, "free", result.Items[0].Entitlement.PlanCode)
require.False(t, result.Items[0].Entitlement.IsPaid)
storedSnapshot, err := store.EntitlementSnapshots().GetByUserID(context.Background(), userID)
require.NoError(t, err)
require.Equal(t, entitlement.PlanCodeFree, storedSnapshot.PlanCode)
require.False(t, storedSnapshot.IsPaid)
}
type adminStoreClock struct {
now time.Time
}
func (clock adminStoreClock) Now() time.Time {
return clock.now
}
type adminStoreIDGenerator struct {
entitlementRecordID entitlement.EntitlementRecordID
}
func (generator adminStoreIDGenerator) NewUserID() (common.UserID, error) {
return "", nil
}
func (generator adminStoreIDGenerator) NewInitialRaceName() (common.RaceName, error) {
return "", nil
}
func (generator adminStoreIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
return generator.entitlementRecordID, nil
}
func (generator adminStoreIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
return "", nil
}
func (generator adminStoreIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
return "", nil
}
func requireSetContains(t *testing.T, store *Store, key string, member string) {
t.Helper()
exists, err := store.client.SIsMember(context.Background(), key, member).Result()
require.NoError(t, err)
require.True(t, exists, "expected %q to contain %q", key, member)
}
func requireSetNotContains(t *testing.T, store *Store, key string, member string) {
t.Helper()
exists, err := store.client.SIsMember(context.Background(), key, member).Result()
require.NoError(t, err)
require.False(t, exists, "expected %q not to contain %q", key, member)
}
func requireSortedSetScore(t *testing.T, store *Store, key string, member string, want float64) {
t.Helper()
got, err := store.client.ZScore(context.Background(), key, member).Result()
require.NoError(t, err)
require.Equal(t, want, got)
}
func requireSortedSetMissing(t *testing.T, store *Store, key string, member string) {
t.Helper()
_, err := store.client.ZScore(context.Background(), key, member).Result()
require.Error(t, err)
}
@@ -0,0 +1,752 @@
package userstore
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/ports"
"github.com/redis/go-redis/v9"
)
type entitlementPeriodRecord struct {
RecordID string `json:"record_id"`
UserID string `json:"user_id"`
PlanCode string `json:"plan_code"`
Source string `json:"source"`
ActorType string `json:"actor_type"`
ActorID *string `json:"actor_id,omitempty"`
ReasonCode string `json:"reason_code"`
StartsAt string `json:"starts_at"`
EndsAt *string `json:"ends_at,omitempty"`
CreatedAt string `json:"created_at"`
ClosedAt *string `json:"closed_at,omitempty"`
ClosedByType *string `json:"closed_by_type,omitempty"`
ClosedByID *string `json:"closed_by_id,omitempty"`
ClosedReasonCode *string `json:"closed_reason_code,omitempty"`
}
// CreateEntitlementRecord stores one new entitlement history record.
func (store *Store) CreateEntitlementRecord(ctx context.Context, record entitlement.PeriodRecord) error {
if err := record.Validate(); err != nil {
return fmt.Errorf("create entitlement record in redis: %w", err)
}
payload, err := marshalEntitlementPeriodRecord(record)
if err != nil {
return fmt.Errorf("create entitlement record in redis: %w", err)
}
recordKey := store.keyspace.EntitlementRecord(record.RecordID)
historyKey := store.keyspace.EntitlementHistory(record.UserID)
operationCtx, cancel, err := store.operationContext(ctx, "create entitlement record in redis")
if err != nil {
return err
}
defer cancel()
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
if err := ensureKeyAbsent(operationCtx, tx, recordKey); err != nil {
return fmt.Errorf("create entitlement record %q in redis: %w", record.RecordID, err)
}
_, err := tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
pipe.Set(operationCtx, recordKey, payload, 0)
pipe.ZAdd(operationCtx, historyKey, redis.Z{
Score: float64(record.StartsAt.UTC().UnixMicro()),
Member: record.RecordID.String(),
})
return nil
})
if err != nil {
return fmt.Errorf("create entitlement record %q in redis: %w", record.RecordID, err)
}
return nil
}, recordKey, historyKey)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("create entitlement record %q in redis: %w", record.RecordID, ports.ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
// GetEntitlementRecordByRecordID returns the entitlement history record
// identified by recordID.
func (store *Store) GetEntitlementRecordByRecordID(
ctx context.Context,
recordID entitlement.EntitlementRecordID,
) (entitlement.PeriodRecord, error) {
if err := recordID.Validate(); err != nil {
return entitlement.PeriodRecord{}, fmt.Errorf("get entitlement record by record id from redis: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "get entitlement record by record id from redis")
if err != nil {
return entitlement.PeriodRecord{}, err
}
defer cancel()
record, err := store.loadEntitlementRecord(operationCtx, store.client, recordID)
if err != nil {
switch {
case errors.Is(err, ports.ErrNotFound):
return entitlement.PeriodRecord{}, fmt.Errorf("get entitlement record by record id %q from redis: %w", recordID, ports.ErrNotFound)
default:
return entitlement.PeriodRecord{}, fmt.Errorf("get entitlement record by record id %q from redis: %w", recordID, err)
}
}
return record, nil
}
// ListEntitlementRecordsByUserID returns every entitlement history record
// owned by userID.
func (store *Store) ListEntitlementRecordsByUserID(
ctx context.Context,
userID common.UserID,
) ([]entitlement.PeriodRecord, error) {
if err := userID.Validate(); err != nil {
return nil, fmt.Errorf("list entitlement records by user id from redis: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "list entitlement records by user id from redis")
if err != nil {
return nil, err
}
defer cancel()
recordIDs, err := store.client.ZRange(operationCtx, store.keyspace.EntitlementHistory(userID), 0, -1).Result()
if err != nil {
return nil, fmt.Errorf("list entitlement records by user id %q from redis: %w", userID, err)
}
records := make([]entitlement.PeriodRecord, 0, len(recordIDs))
for _, rawRecordID := range recordIDs {
record, err := store.loadEntitlementRecord(operationCtx, store.client, entitlement.EntitlementRecordID(rawRecordID))
if err != nil {
return nil, fmt.Errorf("list entitlement records by user id %q from redis: %w", userID, err)
}
records = append(records, record)
}
return records, nil
}
// UpdateEntitlementRecord replaces one stored entitlement history record.
func (store *Store) UpdateEntitlementRecord(ctx context.Context, record entitlement.PeriodRecord) error {
if err := record.Validate(); err != nil {
return fmt.Errorf("update entitlement record in redis: %w", err)
}
payload, err := marshalEntitlementPeriodRecord(record)
if err != nil {
return fmt.Errorf("update entitlement record in redis: %w", err)
}
recordKey := store.keyspace.EntitlementRecord(record.RecordID)
operationCtx, cancel, err := store.operationContext(ctx, "update entitlement record in redis")
if err != nil {
return err
}
defer cancel()
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
if _, err := store.loadEntitlementRecord(operationCtx, tx, record.RecordID); err != nil {
return fmt.Errorf("update entitlement record %q in redis: %w", record.RecordID, err)
}
_, err := tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
pipe.Set(operationCtx, recordKey, payload, 0)
return nil
})
if err != nil {
return fmt.Errorf("update entitlement record %q in redis: %w", record.RecordID, err)
}
return nil
}, recordKey)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("update entitlement record %q in redis: %w", record.RecordID, ports.ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
// GrantEntitlement atomically closes the current free history record, creates
// one paid history record, and replaces the current snapshot.
func (store *Store) GrantEntitlement(ctx context.Context, input ports.GrantEntitlementInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("grant entitlement in redis: %w", err)
}
updatedCurrentRecordPayload, err := marshalEntitlementPeriodRecord(input.UpdatedCurrentRecord)
if err != nil {
return fmt.Errorf("grant entitlement in redis: %w", err)
}
newRecordPayload, err := marshalEntitlementPeriodRecord(input.NewRecord)
if err != nil {
return fmt.Errorf("grant entitlement in redis: %w", err)
}
newSnapshotPayload, err := marshalEntitlementSnapshotRecord(input.NewSnapshot)
if err != nil {
return fmt.Errorf("grant entitlement in redis: %w", err)
}
currentRecordKey := store.keyspace.EntitlementRecord(input.ExpectedCurrentRecord.RecordID)
newRecordKey := store.keyspace.EntitlementRecord(input.NewRecord.RecordID)
historyKey := store.keyspace.EntitlementHistory(input.NewRecord.UserID)
snapshotKey := store.keyspace.EntitlementSnapshot(input.NewSnapshot.UserID)
watchedKeys := append(
[]string{currentRecordKey, newRecordKey, historyKey, snapshotKey},
store.activeSanctionWatchKeys(input.NewSnapshot.UserID)...,
)
operationCtx, cancel, err := store.operationContext(ctx, "grant entitlement in redis")
if err != nil {
return err
}
defer cancel()
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
storedSnapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedCurrentSnapshot.UserID)
if err != nil {
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
if !equalEntitlementSnapshots(storedSnapshot, input.ExpectedCurrentSnapshot) {
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
}
storedCurrentRecord, err := store.loadEntitlementRecord(operationCtx, tx, input.ExpectedCurrentRecord.RecordID)
if err != nil {
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
if !equalEntitlementPeriodRecords(storedCurrentRecord, input.ExpectedCurrentRecord) {
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
}
if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil {
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewSnapshot.UserID)
if err != nil {
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
pipe.Set(operationCtx, currentRecordKey, updatedCurrentRecordPayload, 0)
pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0)
pipe.ZAdd(operationCtx, historyKey, redis.Z{
Score: float64(input.NewRecord.StartsAt.UTC().UnixMicro()),
Member: input.NewRecord.RecordID.String(),
})
pipe.Set(operationCtx, snapshotKey, newSnapshotPayload, 0)
store.syncEntitlementIndexes(pipe, operationCtx, input.NewSnapshot)
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewSnapshot.UserID, input.NewSnapshot.IsPaid, activeSanctionCodes)
return nil
})
if err != nil {
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
return nil
}, watchedKeys...)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
// ExtendEntitlement atomically appends one paid history segment and replaces
// the current paid snapshot.
func (store *Store) ExtendEntitlement(ctx context.Context, input ports.ExtendEntitlementInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("extend entitlement in redis: %w", err)
}
newRecordPayload, err := marshalEntitlementPeriodRecord(input.NewRecord)
if err != nil {
return fmt.Errorf("extend entitlement in redis: %w", err)
}
newSnapshotPayload, err := marshalEntitlementSnapshotRecord(input.NewSnapshot)
if err != nil {
return fmt.Errorf("extend entitlement in redis: %w", err)
}
newRecordKey := store.keyspace.EntitlementRecord(input.NewRecord.RecordID)
historyKey := store.keyspace.EntitlementHistory(input.NewRecord.UserID)
snapshotKey := store.keyspace.EntitlementSnapshot(input.NewSnapshot.UserID)
watchedKeys := append(
[]string{newRecordKey, historyKey, snapshotKey},
store.activeSanctionWatchKeys(input.NewSnapshot.UserID)...,
)
operationCtx, cancel, err := store.operationContext(ctx, "extend entitlement in redis")
if err != nil {
return err
}
defer cancel()
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
storedSnapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedCurrentSnapshot.UserID)
if err != nil {
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
if !equalEntitlementSnapshots(storedSnapshot, input.ExpectedCurrentSnapshot) {
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
}
if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil {
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewSnapshot.UserID)
if err != nil {
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0)
pipe.ZAdd(operationCtx, historyKey, redis.Z{
Score: float64(input.NewRecord.StartsAt.UTC().UnixMicro()),
Member: input.NewRecord.RecordID.String(),
})
pipe.Set(operationCtx, snapshotKey, newSnapshotPayload, 0)
store.syncEntitlementIndexes(pipe, operationCtx, input.NewSnapshot)
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewSnapshot.UserID, input.NewSnapshot.IsPaid, activeSanctionCodes)
return nil
})
if err != nil {
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
return nil
}, watchedKeys...)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
// RevokeEntitlement atomically closes the current paid history record,
// creates one free history record, and replaces the current snapshot.
func (store *Store) RevokeEntitlement(ctx context.Context, input ports.RevokeEntitlementInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("revoke entitlement in redis: %w", err)
}
updatedCurrentRecordPayload, err := marshalEntitlementPeriodRecord(input.UpdatedCurrentRecord)
if err != nil {
return fmt.Errorf("revoke entitlement in redis: %w", err)
}
newRecordPayload, err := marshalEntitlementPeriodRecord(input.NewRecord)
if err != nil {
return fmt.Errorf("revoke entitlement in redis: %w", err)
}
newSnapshotPayload, err := marshalEntitlementSnapshotRecord(input.NewSnapshot)
if err != nil {
return fmt.Errorf("revoke entitlement in redis: %w", err)
}
currentRecordKey := store.keyspace.EntitlementRecord(input.ExpectedCurrentRecord.RecordID)
newRecordKey := store.keyspace.EntitlementRecord(input.NewRecord.RecordID)
historyKey := store.keyspace.EntitlementHistory(input.NewRecord.UserID)
snapshotKey := store.keyspace.EntitlementSnapshot(input.NewSnapshot.UserID)
watchedKeys := append(
[]string{currentRecordKey, newRecordKey, historyKey, snapshotKey},
store.activeSanctionWatchKeys(input.NewSnapshot.UserID)...,
)
operationCtx, cancel, err := store.operationContext(ctx, "revoke entitlement in redis")
if err != nil {
return err
}
defer cancel()
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
storedSnapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedCurrentSnapshot.UserID)
if err != nil {
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
if !equalEntitlementSnapshots(storedSnapshot, input.ExpectedCurrentSnapshot) {
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
}
storedCurrentRecord, err := store.loadEntitlementRecord(operationCtx, tx, input.ExpectedCurrentRecord.RecordID)
if err != nil {
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
if !equalEntitlementPeriodRecords(storedCurrentRecord, input.ExpectedCurrentRecord) {
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
}
if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil {
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewSnapshot.UserID)
if err != nil {
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
pipe.Set(operationCtx, currentRecordKey, updatedCurrentRecordPayload, 0)
pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0)
pipe.ZAdd(operationCtx, historyKey, redis.Z{
Score: float64(input.NewRecord.StartsAt.UTC().UnixMicro()),
Member: input.NewRecord.RecordID.String(),
})
pipe.Set(operationCtx, snapshotKey, newSnapshotPayload, 0)
store.syncEntitlementIndexes(pipe, operationCtx, input.NewSnapshot)
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewSnapshot.UserID, input.NewSnapshot.IsPaid, activeSanctionCodes)
return nil
})
if err != nil {
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
return nil
}, watchedKeys...)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
// RepairExpiredEntitlement atomically replaces one expired finite paid
// snapshot with a materialized free state.
func (store *Store) RepairExpiredEntitlement(ctx context.Context, input ports.RepairExpiredEntitlementInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("repair expired entitlement in redis: %w", err)
}
newRecordPayload, err := marshalEntitlementPeriodRecord(input.NewRecord)
if err != nil {
return fmt.Errorf("repair expired entitlement in redis: %w", err)
}
newSnapshotPayload, err := marshalEntitlementSnapshotRecord(input.NewSnapshot)
if err != nil {
return fmt.Errorf("repair expired entitlement in redis: %w", err)
}
newRecordKey := store.keyspace.EntitlementRecord(input.NewRecord.RecordID)
historyKey := store.keyspace.EntitlementHistory(input.NewRecord.UserID)
snapshotKey := store.keyspace.EntitlementSnapshot(input.NewSnapshot.UserID)
watchedKeys := append(
[]string{newRecordKey, historyKey, snapshotKey},
store.activeSanctionWatchKeys(input.NewSnapshot.UserID)...,
)
operationCtx, cancel, err := store.operationContext(ctx, "repair expired entitlement in redis")
if err != nil {
return err
}
defer cancel()
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
storedSnapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedExpiredSnapshot.UserID)
if err != nil {
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, err)
}
if !equalEntitlementSnapshots(storedSnapshot, input.ExpectedExpiredSnapshot) {
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, ports.ErrConflict)
}
if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil {
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, err)
}
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewSnapshot.UserID)
if err != nil {
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, err)
}
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0)
pipe.ZAdd(operationCtx, historyKey, redis.Z{
Score: float64(input.NewRecord.StartsAt.UTC().UnixMicro()),
Member: input.NewRecord.RecordID.String(),
})
pipe.Set(operationCtx, snapshotKey, newSnapshotPayload, 0)
store.syncEntitlementIndexes(pipe, operationCtx, input.NewSnapshot)
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewSnapshot.UserID, input.NewSnapshot.IsPaid, activeSanctionCodes)
return nil
})
if err != nil {
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, err)
}
return nil
}, watchedKeys...)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, ports.ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
func (store *Store) loadEntitlementRecord(
ctx context.Context,
getter bytesGetter,
recordID entitlement.EntitlementRecordID,
) (entitlement.PeriodRecord, error) {
payload, err := getter.Get(ctx, store.keyspace.EntitlementRecord(recordID)).Bytes()
switch {
case errors.Is(err, redis.Nil):
return entitlement.PeriodRecord{}, ports.ErrNotFound
case err != nil:
return entitlement.PeriodRecord{}, err
}
return decodeEntitlementPeriodRecord(payload)
}
func marshalEntitlementPeriodRecord(record entitlement.PeriodRecord) ([]byte, error) {
encoded := entitlementPeriodRecord{
RecordID: record.RecordID.String(),
UserID: record.UserID.String(),
PlanCode: string(record.PlanCode),
Source: record.Source.String(),
ActorType: record.Actor.Type.String(),
ReasonCode: record.ReasonCode.String(),
StartsAt: record.StartsAt.UTC().Format(time.RFC3339Nano),
CreatedAt: record.CreatedAt.UTC().Format(time.RFC3339Nano),
}
if !record.Actor.ID.IsZero() {
value := record.Actor.ID.String()
encoded.ActorID = &value
}
if record.EndsAt != nil {
value := record.EndsAt.UTC().Format(time.RFC3339Nano)
encoded.EndsAt = &value
}
if record.ClosedAt != nil {
value := record.ClosedAt.UTC().Format(time.RFC3339Nano)
encoded.ClosedAt = &value
}
if !record.ClosedBy.Type.IsZero() {
value := record.ClosedBy.Type.String()
encoded.ClosedByType = &value
}
if !record.ClosedBy.ID.IsZero() {
value := record.ClosedBy.ID.String()
encoded.ClosedByID = &value
}
if !record.ClosedReasonCode.IsZero() {
value := record.ClosedReasonCode.String()
encoded.ClosedReasonCode = &value
}
return json.Marshal(encoded)
}
func decodeEntitlementPeriodRecord(payload []byte) (entitlement.PeriodRecord, error) {
var encoded entitlementPeriodRecord
if err := decodeJSONPayload(payload, &encoded); err != nil {
return entitlement.PeriodRecord{}, err
}
startsAt, err := time.Parse(time.RFC3339Nano, encoded.StartsAt)
if err != nil {
return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record starts_at: %w", err)
}
createdAt, err := time.Parse(time.RFC3339Nano, encoded.CreatedAt)
if err != nil {
return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record created_at: %w", err)
}
record := entitlement.PeriodRecord{
RecordID: entitlement.EntitlementRecordID(encoded.RecordID),
UserID: common.UserID(encoded.UserID),
PlanCode: entitlement.PlanCode(encoded.PlanCode),
Source: common.Source(encoded.Source),
Actor: common.ActorRef{Type: common.ActorType(encoded.ActorType)},
ReasonCode: common.ReasonCode(encoded.ReasonCode),
StartsAt: startsAt.UTC(),
CreatedAt: createdAt.UTC(),
}
if encoded.ActorID != nil {
record.Actor.ID = common.ActorID(*encoded.ActorID)
}
if encoded.EndsAt != nil {
value, err := time.Parse(time.RFC3339Nano, *encoded.EndsAt)
if err != nil {
return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record ends_at: %w", err)
}
value = value.UTC()
record.EndsAt = &value
}
if encoded.ClosedAt != nil {
value, err := time.Parse(time.RFC3339Nano, *encoded.ClosedAt)
if err != nil {
return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record closed_at: %w", err)
}
value = value.UTC()
record.ClosedAt = &value
}
if encoded.ClosedByType != nil {
record.ClosedBy.Type = common.ActorType(*encoded.ClosedByType)
}
if encoded.ClosedByID != nil {
record.ClosedBy.ID = common.ActorID(*encoded.ClosedByID)
}
if encoded.ClosedReasonCode != nil {
record.ClosedReasonCode = common.ReasonCode(*encoded.ClosedReasonCode)
}
if err := record.Validate(); err != nil {
return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record: %w", err)
}
return record, nil
}
func equalEntitlementSnapshots(left entitlement.CurrentSnapshot, right entitlement.CurrentSnapshot) bool {
return left.UserID == right.UserID &&
left.PlanCode == right.PlanCode &&
left.IsPaid == right.IsPaid &&
left.StartsAt.Equal(right.StartsAt) &&
equalOptionalTime(left.EndsAt, right.EndsAt) &&
left.Source == right.Source &&
left.Actor == right.Actor &&
left.ReasonCode == right.ReasonCode &&
left.UpdatedAt.Equal(right.UpdatedAt)
}
func equalEntitlementPeriodRecords(left entitlement.PeriodRecord, right entitlement.PeriodRecord) bool {
return left.RecordID == right.RecordID &&
left.UserID == right.UserID &&
left.PlanCode == right.PlanCode &&
left.Source == right.Source &&
left.Actor == right.Actor &&
left.ReasonCode == right.ReasonCode &&
left.StartsAt.Equal(right.StartsAt) &&
equalOptionalTime(left.EndsAt, right.EndsAt) &&
left.CreatedAt.Equal(right.CreatedAt) &&
equalOptionalTime(left.ClosedAt, right.ClosedAt) &&
left.ClosedBy == right.ClosedBy &&
left.ClosedReasonCode == right.ClosedReasonCode
}
func equalOptionalTime(left *time.Time, right *time.Time) bool {
switch {
case left == nil && right == nil:
return true
case left == nil || right == nil:
return false
default:
return left.Equal(*right)
}
}
// EntitlementHistoryStore adapts Store to the existing
// EntitlementHistoryStore port.
type EntitlementHistoryStore struct {
store *Store
}
// EntitlementHistory returns one adapter that exposes the entitlement-history
// store port over Store.
func (store *Store) EntitlementHistory() *EntitlementHistoryStore {
if store == nil {
return nil
}
return &EntitlementHistoryStore{store: store}
}
// Create stores one new entitlement history record.
func (adapter *EntitlementHistoryStore) Create(ctx context.Context, record entitlement.PeriodRecord) error {
return adapter.store.CreateEntitlementRecord(ctx, record)
}
// GetByRecordID returns the entitlement history record identified by recordID.
func (adapter *EntitlementHistoryStore) GetByRecordID(
ctx context.Context,
recordID entitlement.EntitlementRecordID,
) (entitlement.PeriodRecord, error) {
return adapter.store.GetEntitlementRecordByRecordID(ctx, recordID)
}
// ListByUserID returns every entitlement history record owned by userID.
func (adapter *EntitlementHistoryStore) ListByUserID(
ctx context.Context,
userID common.UserID,
) ([]entitlement.PeriodRecord, error) {
return adapter.store.ListEntitlementRecordsByUserID(ctx, userID)
}
// Update replaces one stored entitlement history record.
func (adapter *EntitlementHistoryStore) Update(ctx context.Context, record entitlement.PeriodRecord) error {
return adapter.store.UpdateEntitlementRecord(ctx, record)
}
var _ ports.EntitlementHistoryStore = (*EntitlementHistoryStore)(nil)
// EntitlementLifecycleStore adapts Store to the existing
// EntitlementLifecycleStore port.
type EntitlementLifecycleStore struct {
store *Store
}
// EntitlementLifecycle returns one adapter that exposes the atomic
// entitlement-lifecycle store port over Store.
func (store *Store) EntitlementLifecycle() *EntitlementLifecycleStore {
if store == nil {
return nil
}
return &EntitlementLifecycleStore{store: store}
}
// Grant atomically applies one free-to-paid transition.
func (adapter *EntitlementLifecycleStore) Grant(ctx context.Context, input ports.GrantEntitlementInput) error {
return adapter.store.GrantEntitlement(ctx, input)
}
// Extend atomically appends one paid extension segment and updates the current
// snapshot.
func (adapter *EntitlementLifecycleStore) Extend(ctx context.Context, input ports.ExtendEntitlementInput) error {
return adapter.store.ExtendEntitlement(ctx, input)
}
// Revoke atomically applies one paid-to-free transition.
func (adapter *EntitlementLifecycleStore) Revoke(ctx context.Context, input ports.RevokeEntitlementInput) error {
return adapter.store.RevokeEntitlement(ctx, input)
}
// RepairExpired atomically repairs one expired finite paid snapshot.
func (adapter *EntitlementLifecycleStore) RepairExpired(
ctx context.Context,
input ports.RepairExpiredEntitlementInput,
) error {
return adapter.store.RepairExpiredEntitlement(ctx, input)
}
var _ ports.EntitlementLifecycleStore = (*EntitlementLifecycleStore)(nil)
@@ -0,0 +1,137 @@
package userstore
import (
"context"
"errors"
"fmt"
"time"
"galaxy/user/internal/adapters/redisstate"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/ports"
"github.com/redis/go-redis/v9"
)
// ListUserIDs returns one deterministic page of user identifiers ordered by
// `created_at desc`, then `user_id desc`.
func (store *Store) ListUserIDs(ctx context.Context, input ports.ListUsersInput) (ports.ListUsersResult, error) {
if err := input.Validate(); err != nil {
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "list users in redis")
if err != nil {
return ports.ListUsersResult{}, err
}
defer cancel()
startIndex := int64(0)
filters := userListFiltersFromPorts(input.Filters)
if input.PageToken != "" {
cursor, err := redisstate.DecodePageToken(input.PageToken, filters)
if err != nil {
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", ports.ErrInvalidPageToken)
}
score, err := store.client.ZScore(operationCtx, store.keyspace.CreatedAtIndex(), cursor.UserID.String()).Result()
switch {
case errors.Is(err, redis.Nil):
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", ports.ErrInvalidPageToken)
case err != nil:
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
}
if !time.UnixMicro(int64(score)).UTC().Equal(cursor.CreatedAt.UTC()) {
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", ports.ErrInvalidPageToken)
}
rank, err := store.client.ZRevRank(operationCtx, store.keyspace.CreatedAtIndex(), cursor.UserID.String()).Result()
switch {
case errors.Is(err, redis.Nil):
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", ports.ErrInvalidPageToken)
case err != nil:
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
}
startIndex = rank + 1
}
rawPage, err := store.client.ZRevRangeWithScores(
operationCtx,
store.keyspace.CreatedAtIndex(),
startIndex,
startIndex+int64(input.PageSize),
).Result()
if err != nil {
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
}
result := ports.ListUsersResult{
UserIDs: make([]common.UserID, 0, min(len(rawPage), input.PageSize)),
}
visibleCount := min(len(rawPage), input.PageSize)
for index := 0; index < visibleCount; index++ {
userID, err := memberUserID(rawPage[index].Member)
if err != nil {
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
}
result.UserIDs = append(result.UserIDs, userID)
}
if len(rawPage) > input.PageSize {
lastVisible := rawPage[input.PageSize-1]
lastUserID, err := memberUserID(lastVisible.Member)
if err != nil {
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
}
token, err := redisstate.EncodePageToken(redisstate.PageCursor{
CreatedAt: time.UnixMicro(int64(lastVisible.Score)).UTC(),
UserID: lastUserID,
}, filters)
if err != nil {
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
}
result.NextPageToken = token
}
return result, nil
}
func userListFiltersFromPorts(filters ports.UserListFilters) redisstate.UserListFilters {
return redisstate.UserListFilters{
PaidState: filters.PaidState,
PaidExpiresBefore: filters.PaidExpiresBefore,
PaidExpiresAfter: filters.PaidExpiresAfter,
DeclaredCountry: filters.DeclaredCountry,
SanctionCode: filters.SanctionCode,
LimitCode: filters.LimitCode,
CanLogin: filters.CanLogin,
CanCreatePrivateGame: filters.CanCreatePrivateGame,
CanJoinGame: filters.CanJoinGame,
}
}
func memberUserID(member any) (common.UserID, error) {
value, ok := member.(string)
if !ok {
return "", fmt.Errorf("unexpected created-at index member type %T", member)
}
userID := common.UserID(value)
if err := userID.Validate(); err != nil {
return "", fmt.Errorf("created-at index member user id: %w", err)
}
return userID, nil
}
func min(left int, right int) int {
if left < right {
return left
}
return right
}
var _ ports.UserListStore = (*Store)(nil)
@@ -0,0 +1,445 @@
package userstore
import (
"context"
"errors"
"fmt"
"time"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"github.com/redis/go-redis/v9"
)
// ApplySanction atomically creates one new active sanction record.
func (store *Store) ApplySanction(ctx context.Context, input ports.ApplySanctionInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("apply sanction in redis: %w", err)
}
recordPayload, err := marshalSanctionRecord(input.NewRecord)
if err != nil {
return fmt.Errorf("apply sanction in redis: %w", err)
}
recordKey := store.keyspace.SanctionRecord(input.NewRecord.RecordID)
historyKey := store.keyspace.SanctionHistory(input.NewRecord.UserID)
activeKey := store.keyspace.ActiveSanction(input.NewRecord.UserID, input.NewRecord.SanctionCode)
snapshotKey := store.keyspace.EntitlementSnapshot(input.NewRecord.UserID)
watchedKeys := append(
[]string{recordKey, historyKey, activeKey, snapshotKey},
store.activeSanctionWatchKeys(input.NewRecord.UserID)...,
)
operationCtx, cancel, err := store.operationContext(ctx, "apply sanction in redis")
if err != nil {
return err
}
defer cancel()
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
if err := ensureKeyAbsent(operationCtx, tx, recordKey); err != nil {
return fmt.Errorf("apply sanction for user %q in redis: %w", input.NewRecord.UserID, err)
}
if err := ensureKeyAbsent(operationCtx, tx, activeKey); err != nil {
return fmt.Errorf("apply sanction for user %q in redis: %w", input.NewRecord.UserID, err)
}
snapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.NewRecord.UserID)
if err != nil {
return fmt.Errorf("apply sanction for user %q in redis: %w", input.NewRecord.UserID, err)
}
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewRecord.UserID)
if err != nil {
return fmt.Errorf("apply sanction for user %q in redis: %w", input.NewRecord.UserID, err)
}
activeSanctionCodes[input.NewRecord.SanctionCode] = struct{}{}
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
pipe.Set(operationCtx, recordKey, recordPayload, 0)
pipe.ZAdd(operationCtx, historyKey, redis.Z{
Score: float64(input.NewRecord.AppliedAt.UTC().UnixMicro()),
Member: input.NewRecord.RecordID.String(),
})
setActiveSlot(pipe, operationCtx, activeKey, input.NewRecord.RecordID.String(), input.NewRecord.ExpiresAt)
store.syncActiveSanctionCodeIndexes(pipe, operationCtx, input.NewRecord.UserID, activeSanctionCodes)
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewRecord.UserID, snapshot.IsPaid, activeSanctionCodes)
return nil
})
if err != nil {
return fmt.Errorf("apply sanction for user %q in redis: %w", input.NewRecord.UserID, err)
}
return nil
}, watchedKeys...)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("apply sanction for user %q in redis: %w", input.NewRecord.UserID, ports.ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
// RemoveSanction atomically removes one active sanction record.
func (store *Store) RemoveSanction(ctx context.Context, input ports.RemoveSanctionInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("remove sanction in redis: %w", err)
}
updatedPayload, err := marshalSanctionRecord(input.UpdatedRecord)
if err != nil {
return fmt.Errorf("remove sanction in redis: %w", err)
}
recordKey := store.keyspace.SanctionRecord(input.ExpectedActiveRecord.RecordID)
activeKey := store.keyspace.ActiveSanction(input.ExpectedActiveRecord.UserID, input.ExpectedActiveRecord.SanctionCode)
snapshotKey := store.keyspace.EntitlementSnapshot(input.ExpectedActiveRecord.UserID)
watchedKeys := append(
[]string{recordKey, activeKey, snapshotKey},
store.activeSanctionWatchKeys(input.ExpectedActiveRecord.UserID)...,
)
operationCtx, cancel, err := store.operationContext(ctx, "remove sanction in redis")
if err != nil {
return err
}
defer cancel()
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
activeRecordID, err := store.loadActiveSanctionRecordID(operationCtx, tx, activeKey)
if err != nil {
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
}
if activeRecordID != input.ExpectedActiveRecord.RecordID {
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, ports.ErrConflict)
}
storedRecord, err := store.loadSanctionRecord(operationCtx, tx, input.ExpectedActiveRecord.RecordID)
if err != nil {
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
}
if !equalSanctionRecords(storedRecord, input.ExpectedActiveRecord) {
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, ports.ErrConflict)
}
snapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedActiveRecord.UserID)
if err != nil {
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
}
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.ExpectedActiveRecord.UserID)
if err != nil {
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
}
delete(activeSanctionCodes, input.ExpectedActiveRecord.SanctionCode)
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
pipe.Set(operationCtx, recordKey, updatedPayload, 0)
pipe.Del(operationCtx, activeKey)
store.syncActiveSanctionCodeIndexes(pipe, operationCtx, input.ExpectedActiveRecord.UserID, activeSanctionCodes)
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.ExpectedActiveRecord.UserID, snapshot.IsPaid, activeSanctionCodes)
return nil
})
if err != nil {
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
}
return nil
}, watchedKeys...)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, ports.ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
// SetLimit atomically creates or replaces one active limit record.
func (store *Store) SetLimit(ctx context.Context, input ports.SetLimitInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("set limit in redis: %w", err)
}
newRecordPayload, err := marshalLimitRecord(input.NewRecord)
if err != nil {
return fmt.Errorf("set limit in redis: %w", err)
}
newRecordKey := store.keyspace.LimitRecord(input.NewRecord.RecordID)
historyKey := store.keyspace.LimitHistory(input.NewRecord.UserID)
activeKey := store.keyspace.ActiveLimit(input.NewRecord.UserID, input.NewRecord.LimitCode)
watchedKeys := append(
[]string{newRecordKey, historyKey, activeKey},
store.activeLimitWatchKeys(input.NewRecord.UserID)...,
)
operationCtx, cancel, err := store.operationContext(ctx, "set limit in redis")
if err != nil {
return err
}
defer cancel()
if input.ExpectedActiveRecord != nil {
watchedKeys = append(watchedKeys, store.keyspace.LimitRecord(input.ExpectedActiveRecord.RecordID))
}
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil {
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
}
var updatedPayload []byte
if input.ExpectedActiveRecord == nil {
if err := ensureKeyAbsent(operationCtx, tx, activeKey); err != nil {
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
}
} else {
activeRecordID, err := store.loadActiveLimitRecordID(operationCtx, tx, activeKey)
if err != nil {
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
}
if activeRecordID != input.ExpectedActiveRecord.RecordID {
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, ports.ErrConflict)
}
storedRecord, err := store.loadLimitRecord(operationCtx, tx, input.ExpectedActiveRecord.RecordID)
if err != nil {
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
}
if !equalLimitRecords(storedRecord, *input.ExpectedActiveRecord) {
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, ports.ErrConflict)
}
updatedPayload, err = marshalLimitRecord(*input.UpdatedActiveRecord)
if err != nil {
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
}
}
activeLimitCodes, err := store.loadActiveLimitCodeSet(operationCtx, tx, input.NewRecord.UserID)
if err != nil {
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
}
activeLimitCodes[input.NewRecord.LimitCode] = struct{}{}
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
if input.ExpectedActiveRecord != nil {
pipe.Set(operationCtx, store.keyspace.LimitRecord(input.ExpectedActiveRecord.RecordID), updatedPayload, 0)
}
pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0)
pipe.ZAdd(operationCtx, historyKey, redis.Z{
Score: float64(input.NewRecord.AppliedAt.UTC().UnixMicro()),
Member: input.NewRecord.RecordID.String(),
})
setActiveSlot(pipe, operationCtx, activeKey, input.NewRecord.RecordID.String(), input.NewRecord.ExpiresAt)
store.syncActiveLimitCodeIndexes(pipe, operationCtx, input.NewRecord.UserID, activeLimitCodes)
return nil
})
if err != nil {
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
}
return nil
}, watchedKeys...)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, ports.ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
// RemoveLimit atomically removes one active limit record.
func (store *Store) RemoveLimit(ctx context.Context, input ports.RemoveLimitInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("remove limit in redis: %w", err)
}
updatedPayload, err := marshalLimitRecord(input.UpdatedRecord)
if err != nil {
return fmt.Errorf("remove limit in redis: %w", err)
}
recordKey := store.keyspace.LimitRecord(input.ExpectedActiveRecord.RecordID)
activeKey := store.keyspace.ActiveLimit(input.ExpectedActiveRecord.UserID, input.ExpectedActiveRecord.LimitCode)
watchedKeys := append(
[]string{recordKey, activeKey},
store.activeLimitWatchKeys(input.ExpectedActiveRecord.UserID)...,
)
operationCtx, cancel, err := store.operationContext(ctx, "remove limit in redis")
if err != nil {
return err
}
defer cancel()
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
activeRecordID, err := store.loadActiveLimitRecordID(operationCtx, tx, activeKey)
if err != nil {
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
}
if activeRecordID != input.ExpectedActiveRecord.RecordID {
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, ports.ErrConflict)
}
storedRecord, err := store.loadLimitRecord(operationCtx, tx, input.ExpectedActiveRecord.RecordID)
if err != nil {
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
}
if !equalLimitRecords(storedRecord, input.ExpectedActiveRecord) {
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, ports.ErrConflict)
}
activeLimitCodes, err := store.loadActiveLimitCodeSet(operationCtx, tx, input.ExpectedActiveRecord.UserID)
if err != nil {
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
}
delete(activeLimitCodes, input.ExpectedActiveRecord.LimitCode)
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
pipe.Set(operationCtx, recordKey, updatedPayload, 0)
pipe.Del(operationCtx, activeKey)
store.syncActiveLimitCodeIndexes(pipe, operationCtx, input.ExpectedActiveRecord.UserID, activeLimitCodes)
return nil
})
if err != nil {
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
}
return nil
}, watchedKeys...)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, ports.ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
func (store *Store) loadActiveSanctionRecordID(
ctx context.Context,
getter bytesGetter,
key string,
) (policy.SanctionRecordID, error) {
value, err := getter.Get(ctx, key).Result()
switch {
case errors.Is(err, redis.Nil):
return "", ports.ErrNotFound
case err != nil:
return "", err
}
recordID := policy.SanctionRecordID(value)
if err := recordID.Validate(); err != nil {
return "", fmt.Errorf("active sanction record id: %w", err)
}
return recordID, nil
}
func (store *Store) loadActiveLimitRecordID(
ctx context.Context,
getter bytesGetter,
key string,
) (policy.LimitRecordID, error) {
value, err := getter.Get(ctx, key).Result()
switch {
case errors.Is(err, redis.Nil):
return "", ports.ErrNotFound
case err != nil:
return "", err
}
recordID := policy.LimitRecordID(value)
if err := recordID.Validate(); err != nil {
return "", fmt.Errorf("active limit record id: %w", err)
}
return recordID, nil
}
func setActiveSlot(
pipe redis.Pipeliner,
ctx context.Context,
key string,
recordID string,
expiresAt *time.Time,
) {
pipe.Set(ctx, key, recordID, 0)
if expiresAt != nil {
pipe.PExpireAt(ctx, key, expiresAt.UTC())
}
}
func equalSanctionRecords(left policy.SanctionRecord, right policy.SanctionRecord) bool {
return left.RecordID == right.RecordID &&
left.UserID == right.UserID &&
left.SanctionCode == right.SanctionCode &&
left.Scope == right.Scope &&
left.ReasonCode == right.ReasonCode &&
left.Actor == right.Actor &&
left.AppliedAt.Equal(right.AppliedAt) &&
equalOptionalTime(left.ExpiresAt, right.ExpiresAt) &&
equalOptionalTime(left.RemovedAt, right.RemovedAt) &&
left.RemovedBy == right.RemovedBy &&
left.RemovedReasonCode == right.RemovedReasonCode
}
func equalLimitRecords(left policy.LimitRecord, right policy.LimitRecord) bool {
return left.RecordID == right.RecordID &&
left.UserID == right.UserID &&
left.LimitCode == right.LimitCode &&
left.Value == right.Value &&
left.ReasonCode == right.ReasonCode &&
left.Actor == right.Actor &&
left.AppliedAt.Equal(right.AppliedAt) &&
equalOptionalTime(left.ExpiresAt, right.ExpiresAt) &&
equalOptionalTime(left.RemovedAt, right.RemovedAt) &&
left.RemovedBy == right.RemovedBy &&
left.RemovedReasonCode == right.RemovedReasonCode
}
// PolicyLifecycleStore adapts Store to the existing PolicyLifecycleStore
// port.
type PolicyLifecycleStore struct {
store *Store
}
// PolicyLifecycle returns one adapter that exposes the atomic policy-lifecycle
// store port over Store.
func (store *Store) PolicyLifecycle() *PolicyLifecycleStore {
if store == nil {
return nil
}
return &PolicyLifecycleStore{store: store}
}
// ApplySanction atomically creates one new active sanction record.
func (adapter *PolicyLifecycleStore) ApplySanction(ctx context.Context, input ports.ApplySanctionInput) error {
return adapter.store.ApplySanction(ctx, input)
}
// RemoveSanction atomically removes one active sanction record.
func (adapter *PolicyLifecycleStore) RemoveSanction(ctx context.Context, input ports.RemoveSanctionInput) error {
return adapter.store.RemoveSanction(ctx, input)
}
// SetLimit atomically creates or replaces one active limit record.
func (adapter *PolicyLifecycleStore) SetLimit(ctx context.Context, input ports.SetLimitInput) error {
return adapter.store.SetLimit(ctx, input)
}
// RemoveLimit atomically removes one active limit record.
func (adapter *PolicyLifecycleStore) RemoveLimit(ctx context.Context, input ports.RemoveLimitInput) error {
return adapter.store.RemoveLimit(ctx, input)
}
var _ ports.PolicyLifecycleStore = (*PolicyLifecycleStore)(nil)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,930 @@
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())))
}
@@ -0,0 +1,200 @@
// Package redisstate defines the frozen Redis logical keyspace and pagination
// helpers used by future User Service storage adapters.
package redisstate
import (
"encoding/base64"
"fmt"
"strings"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
)
const defaultPrefix = "user:"
// Keyspace builds the frozen Redis logical keys used by future storage
// adapters. The package intentionally exposes key construction only and does
// not depend on any Redis client.
type Keyspace struct {
// Prefix stores the namespace prefix applied to every key. The zero value
// uses `user:`.
Prefix string
}
// Account returns the primary user-account key for userID.
func (k Keyspace) Account(userID common.UserID) string {
return k.prefix() + "account:" + encodeKeyComponent(userID.String())
}
// EmailLookup returns the exact normalized e-mail lookup key.
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())
}
// BlockedEmailSubject returns the dedicated blocked-email-subject key.
func (k Keyspace) BlockedEmailSubject(email common.Email) string {
return k.prefix() + "blocked-email:" + encodeKeyComponent(email.String())
}
// EntitlementRecord returns the primary entitlement history-record key.
func (k Keyspace) EntitlementRecord(recordID entitlement.EntitlementRecordID) string {
return k.prefix() + "entitlement:record:" + encodeKeyComponent(recordID.String())
}
// EntitlementHistory returns the per-user entitlement-history index key.
func (k Keyspace) EntitlementHistory(userID common.UserID) string {
return k.prefix() + "entitlement:history:" + encodeKeyComponent(userID.String())
}
// EntitlementSnapshot returns the current entitlement-snapshot key.
func (k Keyspace) EntitlementSnapshot(userID common.UserID) string {
return k.prefix() + "entitlement:snapshot:" + encodeKeyComponent(userID.String())
}
// SanctionRecord returns the primary sanction history-record key.
func (k Keyspace) SanctionRecord(recordID policy.SanctionRecordID) string {
return k.prefix() + "sanction:record:" + encodeKeyComponent(recordID.String())
}
// SanctionHistory returns the per-user sanction-history index key.
func (k Keyspace) SanctionHistory(userID common.UserID) string {
return k.prefix() + "sanction:history:" + encodeKeyComponent(userID.String())
}
// ActiveSanction returns the per-user active-sanction slot for one sanction
// code. The slot guarantees at most one active sanction per `user_id +
// sanction_code`.
func (k Keyspace) ActiveSanction(userID common.UserID, code policy.SanctionCode) string {
return k.prefix() + "sanction:active:" + encodeKeyComponent(userID.String()) + ":" + encodeKeyComponent(string(code))
}
// LimitRecord returns the primary limit history-record key.
func (k Keyspace) LimitRecord(recordID policy.LimitRecordID) string {
return k.prefix() + "limit:record:" + encodeKeyComponent(recordID.String())
}
// LimitHistory returns the per-user limit-history index key.
func (k Keyspace) LimitHistory(userID common.UserID) string {
return k.prefix() + "limit:history:" + encodeKeyComponent(userID.String())
}
// ActiveLimit returns the per-user active-limit slot for one limit code. The
// slot guarantees at most one active limit per `user_id + limit_code`.
func (k Keyspace) ActiveLimit(userID common.UserID, code policy.LimitCode) string {
return k.prefix() + "limit:active:" + encodeKeyComponent(userID.String()) + ":" + encodeKeyComponent(string(code))
}
// CreatedAtIndex returns the deterministic newest-first user-ordering index.
func (k Keyspace) CreatedAtIndex() string {
return k.prefix() + "index:created-at"
}
// PaidStateIndex returns the coarse free-versus-paid index key.
func (k Keyspace) PaidStateIndex(state entitlement.PaidState) string {
return k.prefix() + "index:paid-state:" + encodeKeyComponent(string(state))
}
// FinitePaidExpiryIndex returns the finite paid-expiry index key. Lifetime
// plans intentionally do not participate in this index.
func (k Keyspace) FinitePaidExpiryIndex() string {
return k.prefix() + "index:paid-expiry:finite"
}
// DeclaredCountryIndex returns the current declared-country reverse-lookup
// index key.
func (k Keyspace) DeclaredCountryIndex(code common.CountryCode) string {
return k.prefix() + "index:declared-country:" + encodeKeyComponent(code.String())
}
// ActiveSanctionCodeIndex returns the reverse-lookup index key for users with
// an active sanction code.
func (k Keyspace) ActiveSanctionCodeIndex(code policy.SanctionCode) string {
return k.prefix() + "index:active-sanction:" + encodeKeyComponent(string(code))
}
// ActiveLimitCodeIndex returns the reverse-lookup index key for users with an
// active limit code.
func (k Keyspace) ActiveLimitCodeIndex(code policy.LimitCode) string {
return k.prefix() + "index:active-limit:" + encodeKeyComponent(string(code))
}
// EligibilityMarkerIndex returns the reverse-lookup index key for one derived
// eligibility marker boolean.
func (k Keyspace) EligibilityMarkerIndex(marker policy.EligibilityMarker, value bool) string {
return fmt.Sprintf("%sindex:eligibility:%s:%t", k.prefix(), encodeKeyComponent(string(marker)), value)
}
// CreatedAtScore returns the frozen ZSET score representation for created-at
// ordering and deterministic pagination.
func CreatedAtScore(createdAt time.Time) float64 {
return float64(createdAt.UTC().UnixMicro())
}
// ExpiryScore returns the frozen ZSET score representation for finite paid
// expiry ordering.
func ExpiryScore(expiresAt time.Time) float64 {
return float64(expiresAt.UTC().UnixMicro())
}
// PageCursor identifies the last seen `(created_at, user_id)` tuple used by
// deterministic newest-first pagination.
type PageCursor struct {
// CreatedAt stores the created-at component of the last seen row.
CreatedAt time.Time
// UserID stores the user-id tiebreaker component of the last seen row.
UserID common.UserID
}
// Validate reports whether PageCursor contains a complete cursor tuple.
func (cursor PageCursor) Validate() error {
if err := common.ValidateTimestamp("page cursor created at", cursor.CreatedAt); err != nil {
return err
}
if err := cursor.UserID.Validate(); err != nil {
return fmt.Errorf("page cursor user id: %w", err)
}
return nil
}
// ComparePageOrder compares two listing positions using the frozen ordering:
// `created_at desc`, then `user_id desc`.
func ComparePageOrder(left PageCursor, right PageCursor) int {
switch {
case left.CreatedAt.After(right.CreatedAt):
return -1
case left.CreatedAt.Before(right.CreatedAt):
return 1
default:
return -strings.Compare(left.UserID.String(), right.UserID.String())
}
}
func (k Keyspace) prefix() string {
prefix := strings.TrimSpace(k.Prefix)
if prefix == "" {
return defaultPrefix
}
return prefix
}
func encodeKeyComponent(value string) string {
return base64.RawURLEncoding.EncodeToString([]byte(value))
}
@@ -0,0 +1,59 @@
package redisstate
import (
"testing"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"github.com/stretchr/testify/require"
)
func TestKeyspaceBuildsStableKeys(t *testing.T) {
t.Parallel()
keyspace := Keyspace{Prefix: "custom:"}
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: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")))
require.Equal(t, "custom:limit:record:bGltaXQtMQ", keyspace.LimitRecord(policy.LimitRecordID("limit-1")))
require.Equal(t, "custom:sanction:active:dXNlci0xMjM:bG9naW5fYmxvY2s", keyspace.ActiveSanction(common.UserID("user-123"), policy.SanctionCodeLoginBlock))
require.Equal(t, "custom:limit:active:dXNlci0xMjM:bWF4X293bmVkX3ByaXZhdGVfZ2FtZXM", keyspace.ActiveLimit(common.UserID("user-123"), policy.LimitCodeMaxOwnedPrivateGames))
require.Equal(t, "custom:index:created-at", keyspace.CreatedAtIndex())
require.Equal(t, "custom:index:paid-state:cGFpZA", keyspace.PaidStateIndex(entitlement.PaidStatePaid))
require.Equal(t, "custom:index:paid-expiry:finite", keyspace.FinitePaidExpiryIndex())
require.Equal(t, "custom:index:declared-country:REU", keyspace.DeclaredCountryIndex(common.CountryCode("DE")))
require.Equal(t, "custom:index:active-sanction:bG9naW5fYmxvY2s", keyspace.ActiveSanctionCodeIndex(policy.SanctionCodeLoginBlock))
require.Equal(t, "custom:index:active-limit:bWF4X293bmVkX3ByaXZhdGVfZ2FtZXM", keyspace.ActiveLimitCodeIndex(policy.LimitCodeMaxOwnedPrivateGames))
require.Equal(t, "custom:index:eligibility:Y2FuX2xvZ2lu:true", keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanLogin, true))
}
func TestComparePageOrder(t *testing.T) {
t.Parallel()
newer := PageCursor{CreatedAt: time.Unix(20, 0).UTC(), UserID: common.UserID("user-200")}
older := PageCursor{CreatedAt: time.Unix(10, 0).UTC(), UserID: common.UserID("user-100")}
sameTimeHigherUserID := PageCursor{CreatedAt: time.Unix(20, 0).UTC(), UserID: common.UserID("user-300")}
require.Negative(t, ComparePageOrder(newer, older))
require.Positive(t, ComparePageOrder(older, newer))
require.Negative(t, ComparePageOrder(sameTimeHigherUserID, newer))
}
func TestScoresUseUnixMicro(t *testing.T) {
t.Parallel()
value := time.Unix(1_775_240_000, 123_000).UTC()
want := float64(value.UnixMicro())
require.Equal(t, want, CreatedAtScore(value))
require.Equal(t, want, ExpiryScore(value))
}
@@ -0,0 +1,191 @@
package redisstate
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
)
var (
// ErrPageTokenFiltersMismatch reports that a supplied page token was created
// for a different normalized filter set.
ErrPageTokenFiltersMismatch = errors.New("page token filters do not match current filters")
)
// UserListFilters stores the frozen admin-listing filter set that becomes part
// of the opaque page token fingerprint.
type UserListFilters struct {
// PaidState stores the coarse free-versus-paid filter.
PaidState entitlement.PaidState
// PaidExpiresBefore stores the optional finite-paid expiry upper bound.
PaidExpiresBefore *time.Time
// PaidExpiresAfter stores the optional finite-paid expiry lower bound.
PaidExpiresAfter *time.Time
// DeclaredCountry stores the optional declared-country filter.
DeclaredCountry common.CountryCode
// SanctionCode stores the optional active-sanction filter.
SanctionCode policy.SanctionCode
// LimitCode stores the optional active-limit filter.
LimitCode policy.LimitCode
// CanLogin stores the optional login-eligibility filter.
CanLogin *bool
// CanCreatePrivateGame stores the optional private-game-create eligibility
// filter.
CanCreatePrivateGame *bool
// CanJoinGame stores the optional join-game eligibility filter.
CanJoinGame *bool
}
// Validate reports whether UserListFilters is structurally valid.
func (filters UserListFilters) Validate() error {
if !filters.PaidState.IsKnown() {
return fmt.Errorf("paid state %q is unsupported", filters.PaidState)
}
if filters.PaidExpiresBefore != nil && filters.PaidExpiresBefore.IsZero() {
return fmt.Errorf("paid expires before must not be zero")
}
if filters.PaidExpiresAfter != nil && filters.PaidExpiresAfter.IsZero() {
return fmt.Errorf("paid expires after must not be zero")
}
if !filters.DeclaredCountry.IsZero() {
if err := filters.DeclaredCountry.Validate(); err != nil {
return fmt.Errorf("declared country: %w", err)
}
}
if filters.SanctionCode != "" && !filters.SanctionCode.IsKnown() {
return fmt.Errorf("sanction code %q is unsupported", filters.SanctionCode)
}
if filters.LimitCode != "" && !filters.LimitCode.IsKnown() {
return fmt.Errorf("limit code %q is unsupported", filters.LimitCode)
}
return nil
}
// EncodePageToken encodes cursor and filters into the frozen opaque page token
// format.
func EncodePageToken(cursor PageCursor, filters UserListFilters) (string, error) {
if err := cursor.Validate(); err != nil {
return "", fmt.Errorf("encode page token: %w", err)
}
fingerprint, err := normalizeFilters(filters)
if err != nil {
return "", fmt.Errorf("encode page token: %w", err)
}
payload, err := json.Marshal(pageTokenPayload{
CreatedAt: cursor.CreatedAt.UTC().Format(time.RFC3339Nano),
UserID: cursor.UserID.String(),
Filters: fingerprint,
})
if err != nil {
return "", fmt.Errorf("encode page token: %w", err)
}
return base64.RawURLEncoding.EncodeToString(payload), nil
}
// DecodePageToken decodes raw into the frozen page cursor and verifies that
// the embedded normalized filter set matches expectedFilters.
func DecodePageToken(raw string, expectedFilters UserListFilters) (PageCursor, error) {
fingerprint, err := normalizeFilters(expectedFilters)
if err != nil {
return PageCursor{}, fmt.Errorf("decode page token: %w", err)
}
payload, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return PageCursor{}, fmt.Errorf("decode page token: %w", err)
}
var token pageTokenPayload
if err := json.Unmarshal(payload, &token); err != nil {
return PageCursor{}, fmt.Errorf("decode page token: %w", err)
}
if token.Filters != fingerprint {
return PageCursor{}, ErrPageTokenFiltersMismatch
}
createdAt, err := time.Parse(time.RFC3339Nano, token.CreatedAt)
if err != nil {
return PageCursor{}, fmt.Errorf("decode page token: parse created_at: %w", err)
}
cursor := PageCursor{
CreatedAt: createdAt.UTC(),
UserID: common.UserID(token.UserID),
}
if err := cursor.Validate(); err != nil {
return PageCursor{}, fmt.Errorf("decode page token: %w", err)
}
return cursor, nil
}
type pageTokenPayload struct {
CreatedAt string `json:"created_at"`
UserID string `json:"user_id"`
Filters normalizedFilterPayload `json:"filters"`
}
type normalizedFilterPayload struct {
PaidState string `json:"paid_state,omitempty"`
PaidExpiresBeforeUTC string `json:"paid_expires_before_utc,omitempty"`
PaidExpiresAfterUTC string `json:"paid_expires_after_utc,omitempty"`
DeclaredCountry string `json:"declared_country,omitempty"`
SanctionCode string `json:"sanction_code,omitempty"`
LimitCode string `json:"limit_code,omitempty"`
CanLogin string `json:"can_login,omitempty"`
CanCreatePrivateGame string `json:"can_create_private_game,omitempty"`
CanJoinGame string `json:"can_join_game,omitempty"`
}
func normalizeFilters(filters UserListFilters) (normalizedFilterPayload, error) {
if err := filters.Validate(); err != nil {
return normalizedFilterPayload{}, err
}
return normalizedFilterPayload{
PaidState: string(filters.PaidState),
PaidExpiresBeforeUTC: formatOptionalTime(filters.PaidExpiresBefore),
PaidExpiresAfterUTC: formatOptionalTime(filters.PaidExpiresAfter),
DeclaredCountry: filters.DeclaredCountry.String(),
SanctionCode: string(filters.SanctionCode),
LimitCode: string(filters.LimitCode),
CanLogin: formatOptionalBool(filters.CanLogin),
CanCreatePrivateGame: formatOptionalBool(filters.CanCreatePrivateGame),
CanJoinGame: formatOptionalBool(filters.CanJoinGame),
}, nil
}
func formatOptionalTime(value *time.Time) string {
if value == nil {
return ""
}
return value.UTC().Format(time.RFC3339Nano)
}
func formatOptionalBool(value *bool) string {
if value == nil {
return ""
}
if *value {
return "true"
}
return "false"
}
@@ -0,0 +1,70 @@
package redisstate
import (
"testing"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"github.com/stretchr/testify/require"
)
func TestEncodeDecodePageToken(t *testing.T) {
t.Parallel()
before := time.Unix(1_775_250_000, 0).UTC()
after := time.Unix(1_775_240_000, 0).UTC()
canLogin := true
canCreate := false
canJoin := true
filters := UserListFilters{
PaidState: entitlement.PaidStatePaid,
PaidExpiresBefore: &before,
PaidExpiresAfter: &after,
DeclaredCountry: common.CountryCode("DE"),
SanctionCode: policy.SanctionCodeLoginBlock,
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
CanLogin: &canLogin,
CanCreatePrivateGame: &canCreate,
CanJoinGame: &canJoin,
}
cursor := PageCursor{
CreatedAt: time.Unix(1_775_240_100, 987_000_000).UTC(),
UserID: common.UserID("user-123"),
}
token, err := EncodePageToken(cursor, filters)
require.NoError(t, err)
decoded, err := DecodePageToken(token, filters)
require.NoError(t, err)
require.Equal(t, cursor, decoded)
}
func TestDecodePageTokenFilterMismatch(t *testing.T) {
t.Parallel()
cursor := PageCursor{
CreatedAt: time.Unix(1_775_240_100, 0).UTC(),
UserID: common.UserID("user-123"),
}
filters := UserListFilters{
PaidState: entitlement.PaidStatePaid,
}
token, err := EncodePageToken(cursor, filters)
require.NoError(t, err)
_, err = DecodePageToken(token, UserListFilters{PaidState: entitlement.PaidStateFree})
require.ErrorIs(t, err, ErrPageTokenFiltersMismatch)
}
func TestDecodePageTokenRejectsInvalidInput(t *testing.T) {
t.Parallel()
_, err := DecodePageToken("%%%not-base64%%%", UserListFilters{})
require.Error(t, err)
}
+133
View File
@@ -0,0 +1,133 @@
// Package adminapi exposes the optional private admin HTTP listener used for
// operational endpoints such as Prometheus metrics.
package adminapi
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"sync"
"galaxy/user/internal/config"
)
// Server owns the optional admin HTTP listener exposed by the user service.
type Server struct {
cfg config.AdminHTTPConfig
handler http.Handler
logger *slog.Logger
stateMu sync.RWMutex
server *http.Server
listener net.Listener
}
// NewServer constructs an admin HTTP server for cfg and handler.
func NewServer(cfg config.AdminHTTPConfig, handler http.Handler, logger *slog.Logger) *Server {
if handler == nil {
handler = http.NotFoundHandler()
}
if logger == nil {
logger = slog.Default()
}
mux := http.NewServeMux()
mux.Handle("GET /metrics", handler)
return &Server{
cfg: cfg,
handler: mux,
logger: logger.With("component", "admin_http"),
}
}
// Enabled reports whether the admin listener should run.
func (server *Server) Enabled() bool {
return server != nil && server.cfg.Addr != ""
}
// Run binds the configured listener and serves the admin HTTP surface until
// Shutdown closes the server. A disabled admin server returns when ctx is
// canceled.
func (server *Server) Run(ctx context.Context) error {
if ctx == nil {
return errors.New("run admin HTTP server: nil context")
}
if err := ctx.Err(); err != nil {
return err
}
if !server.Enabled() {
<-ctx.Done()
return nil
}
listener, err := net.Listen("tcp", server.cfg.Addr)
if err != nil {
return fmt.Errorf("run admin HTTP server: listen on %q: %w", server.cfg.Addr, err)
}
httpServer := &http.Server{
Handler: server.handler,
ReadHeaderTimeout: server.cfg.ReadHeaderTimeout,
ReadTimeout: server.cfg.ReadTimeout,
IdleTimeout: server.cfg.IdleTimeout,
}
server.stateMu.Lock()
server.server = httpServer
server.listener = listener
server.stateMu.Unlock()
server.logger.Info("admin HTTP server started", "addr", listener.Addr().String())
shutdownDone := make(chan struct{})
go func() {
defer close(shutdownDone)
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), server.cfg.ReadTimeout)
defer cancel()
_ = server.Shutdown(shutdownCtx)
}()
defer func() {
server.stateMu.Lock()
server.server = nil
server.listener = nil
server.stateMu.Unlock()
<-shutdownDone
}()
err = httpServer.Serve(listener)
switch {
case err == nil:
return nil
case errors.Is(err, http.ErrServerClosed):
server.logger.Info("admin HTTP server stopped")
return nil
default:
return fmt.Errorf("run admin HTTP server: serve on %q: %w", server.cfg.Addr, err)
}
}
// Shutdown gracefully stops the admin HTTP server within ctx.
func (server *Server) Shutdown(ctx context.Context) error {
if ctx == nil {
return errors.New("shutdown admin HTTP server: nil context")
}
server.stateMu.RLock()
httpServer := server.server
server.stateMu.RUnlock()
if httpServer == nil {
return nil
}
if err := httpServer.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("shutdown admin HTTP server: %w", err)
}
return nil
}
+98
View File
@@ -0,0 +1,98 @@
package adminapi
import (
"context"
"net/http"
"testing"
"time"
"galaxy/user/internal/config"
"github.com/stretchr/testify/require"
)
func TestServerRunDisabledWaitsForContext(t *testing.T) {
t.Parallel()
server := NewServer(config.AdminHTTPConfig{}, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("disabled admin server must not serve requests")
}), nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- server.Run(ctx)
}()
cancel()
select {
case err := <-errCh:
require.ErrorIs(t, err, context.Canceled)
case <-time.After(2 * time.Second):
t.Fatal("disabled admin server did not stop after context cancellation")
}
}
func TestServerRunServesMetricsOnly(t *testing.T) {
t.Parallel()
server := NewServer(config.AdminHTTPConfig{
Addr: "127.0.0.1:0",
ReadHeaderTimeout: 2 * time.Second,
ReadTimeout: 10 * time.Second,
IdleTimeout: time.Minute,
}, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("sample_metric 1\n"))
}), nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- server.Run(ctx)
}()
addr := waitForListener(t, server)
metricsResponse, err := http.Get("http://" + addr + "/metrics")
require.NoError(t, err)
t.Cleanup(func() { _ = metricsResponse.Body.Close() })
require.Equal(t, http.StatusOK, metricsResponse.StatusCode)
rootResponse, err := http.Get("http://" + addr + "/")
require.NoError(t, err)
t.Cleanup(func() { _ = rootResponse.Body.Close() })
require.Equal(t, http.StatusNotFound, rootResponse.StatusCode)
cancel()
select {
case err := <-errCh:
require.NoError(t, err)
case <-time.After(2 * time.Second):
t.Fatal("admin server did not stop after context cancellation")
}
}
func waitForListener(t *testing.T, server *Server) string {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
server.stateMu.RLock()
listener := server.listener
server.stateMu.RUnlock()
if listener != nil {
return listener.Addr().String()
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("admin server listener did not start")
return ""
}
@@ -0,0 +1,205 @@
package internalhttp
import (
"context"
"net/http"
"strconv"
"strings"
"time"
"galaxy/user/internal/service/adminusers"
"galaxy/user/internal/service/shared"
"github.com/gin-gonic/gin"
)
type getUserByEmailRequest struct {
Email string `json:"email"`
}
type getUserByRaceNameRequest struct {
RaceName string `json:"race_name"`
}
func handleGetUserByID(useCase GetUserByIDUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, adminusers.GetUserByIDInput{
UserID: c.Param("user_id"),
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, result)
}
}
func handleGetUserByEmail(useCase GetUserByEmailUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request getUserByEmailRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, adminusers.GetUserByEmailInput{
Email: request.Email,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, result)
}
}
func handleGetUserByRaceName(useCase GetUserByRaceNameUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request getUserByRaceNameRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, adminusers.GetUserByRaceNameInput{
RaceName: request.RaceName,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, result)
}
}
func handleListUsers(useCase ListUsersUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
input, err := buildListUsersInput(c)
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, input)
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, result)
}
}
func buildListUsersInput(c *gin.Context) (adminusers.ListUsersInput, error) {
pageSize, err := parseOptionalPageSize(c, "page_size")
if err != nil {
return adminusers.ListUsersInput{}, err
}
pageToken, err := parseOptionalPageToken(c, "page_token")
if err != nil {
return adminusers.ListUsersInput{}, err
}
paidExpiresBefore, err := parseOptionalRFC3339Query(c, "paid_expires_before")
if err != nil {
return adminusers.ListUsersInput{}, err
}
paidExpiresAfter, err := parseOptionalRFC3339Query(c, "paid_expires_after")
if err != nil {
return adminusers.ListUsersInput{}, err
}
canLogin, err := parseOptionalBoolQuery(c, "can_login")
if err != nil {
return adminusers.ListUsersInput{}, err
}
canCreatePrivateGame, err := parseOptionalBoolQuery(c, "can_create_private_game")
if err != nil {
return adminusers.ListUsersInput{}, err
}
canJoinGame, err := parseOptionalBoolQuery(c, "can_join_game")
if err != nil {
return adminusers.ListUsersInput{}, err
}
return adminusers.ListUsersInput{
PageSize: pageSize,
PageToken: pageToken,
PaidState: c.Query("paid_state"),
PaidExpiresBefore: paidExpiresBefore,
PaidExpiresAfter: paidExpiresAfter,
DeclaredCountry: c.Query("declared_country"),
SanctionCode: c.Query("sanction_code"),
LimitCode: c.Query("limit_code"),
CanLogin: canLogin,
CanCreatePrivateGame: canCreatePrivateGame,
CanJoinGame: canJoinGame,
}, nil
}
func parseOptionalPageSize(c *gin.Context, name string) (int, error) {
raw, present := c.GetQuery(name)
if !present {
return 0, nil
}
value, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || value < 1 || value > 200 {
return 0, shared.InvalidRequest("page_size must be between 1 and 200")
}
return value, nil
}
func parseOptionalPageToken(c *gin.Context, name string) (string, error) {
raw, present := c.GetQuery(name)
if !present {
return "", nil
}
if strings.TrimSpace(raw) != raw {
return "", shared.InvalidRequest("page_token must not contain surrounding whitespace")
}
return raw, nil
}
func parseOptionalRFC3339Query(c *gin.Context, name string) (*time.Time, error) {
raw, present := c.GetQuery(name)
if !present {
return nil, nil
}
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(raw))
if err != nil {
return nil, shared.InvalidRequest(name + " must be a valid RFC 3339 timestamp")
}
return &parsed, nil
}
func parseOptionalBoolQuery(c *gin.Context, name string) (*bool, error) {
raw, present := c.GetQuery(name)
if !present {
return nil, nil
}
parsed, err := strconv.ParseBool(strings.TrimSpace(raw))
if err != nil {
return nil, shared.InvalidRequest(name + " must be a valid boolean")
}
return &parsed, nil
}
@@ -0,0 +1,233 @@
package internalhttp
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"galaxy/user/internal/service/accountview"
"galaxy/user/internal/service/adminusers"
"galaxy/user/internal/service/shared"
"github.com/stretchr/testify/require"
)
func TestAdminReadHandlersSuccessCases(t *testing.T) {
t.Parallel()
handler := mustNewHandler(t, Dependencies{
GetUserByID: getUserByIDFunc(func(_ context.Context, input adminusers.GetUserByIDInput) (adminusers.LookupResult, error) {
require.Equal(t, "user-123", input.UserID)
return adminusers.LookupResult{User: sampleAccountView()}, nil
}),
GetUserByEmail: getUserByEmailFunc(func(_ context.Context, input adminusers.GetUserByEmailInput) (adminusers.LookupResult, error) {
require.Equal(t, "pilot@example.com", input.Email)
return adminusers.LookupResult{User: sampleAccountView()}, nil
}),
GetUserByRaceName: getUserByRaceNameFunc(func(_ context.Context, input adminusers.GetUserByRaceNameInput) (adminusers.LookupResult, error) {
require.Equal(t, "Pilot Nova", input.RaceName)
return adminusers.LookupResult{User: sampleAccountView()}, nil
}),
ListUsers: listUsersFunc(func(_ context.Context, input adminusers.ListUsersInput) (adminusers.ListUsersResult, error) {
require.Equal(t, 2, input.PageSize)
require.Equal(t, "cursor-1", input.PageToken)
require.Equal(t, "paid", input.PaidState)
require.Equal(t, "DE", input.DeclaredCountry)
require.Equal(t, "login_block", input.SanctionCode)
require.Equal(t, "max_owned_private_games", input.LimitCode)
require.NotNil(t, input.PaidExpiresBefore)
require.NotNil(t, input.PaidExpiresAfter)
require.NotNil(t, input.CanLogin)
require.NotNil(t, input.CanCreatePrivateGame)
require.NotNil(t, input.CanJoinGame)
require.False(t, *input.CanLogin)
require.True(t, *input.CanCreatePrivateGame)
require.True(t, *input.CanJoinGame)
require.Equal(t, time.Date(2026, time.April, 10, 12, 0, 0, 0, time.UTC), input.PaidExpiresBefore.UTC())
require.Equal(t, time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC), input.PaidExpiresAfter.UTC())
other := sampleAccountView()
other.UserID = "user-234"
other.Email = "second@example.com"
other.RaceName = "Second Pilot"
return adminusers.ListUsersResult{
Items: []accountview.AccountView{sampleAccountView(), other},
NextPageToken: "cursor-2",
}, nil
}),
})
tests := []struct {
name string
method string
path string
body string
wantStatus int
wantBody string
}{
{
name: "get user by id",
method: http.MethodGet,
path: "/api/v1/internal/users/user-123",
wantStatus: http.StatusOK,
wantBody: `{"user":{"user_id":"user-123","email":"pilot@example.com","race_name":"Pilot Nova","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}}`,
},
{
name: "get user by email",
method: http.MethodPost,
path: "/api/v1/internal/user-lookups/by-email",
body: `{"email":"pilot@example.com"}`,
wantStatus: http.StatusOK,
wantBody: `{"user":{"user_id":"user-123","email":"pilot@example.com","race_name":"Pilot Nova","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}}`,
},
{
name: "get user by race name",
method: http.MethodPost,
path: "/api/v1/internal/user-lookups/by-race-name",
body: `{"race_name":"Pilot Nova"}`,
wantStatus: http.StatusOK,
wantBody: `{"user":{"user_id":"user-123","email":"pilot@example.com","race_name":"Pilot Nova","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}}`,
},
{
name: "list users",
method: http.MethodGet,
path: "/api/v1/internal/users?page_size=2&page_token=cursor-1&paid_state=paid&paid_expires_before=2026-04-10T12:00:00Z&paid_expires_after=2026-04-01T12:00:00Z&declared_country=DE&sanction_code=login_block&limit_code=max_owned_private_games&can_login=false&can_create_private_game=true&can_join_game=true",
wantStatus: http.StatusOK,
wantBody: `{"items":[{"user_id":"user-123","email":"pilot@example.com","race_name":"Pilot Nova","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},{"user_id":"user-234","email":"second@example.com","race_name":"Second Pilot","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}],"next_page_token":"cursor-2"}`,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var body *bytes.Buffer
if tt.body != "" {
body = bytes.NewBufferString(tt.body)
} else {
body = &bytes.Buffer{}
}
request := httptest.NewRequest(tt.method, tt.path, body)
if tt.body != "" {
request.Header.Set("Content-Type", "application/json")
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
require.Equal(t, tt.wantStatus, recorder.Code)
assertJSONEq(t, recorder.Body.String(), tt.wantBody)
})
}
}
func TestAdminReadHandlersErrorCases(t *testing.T) {
t.Parallel()
handler := mustNewHandler(t, Dependencies{
GetUserByID: getUserByIDFunc(func(context.Context, adminusers.GetUserByIDInput) (adminusers.LookupResult, error) {
return adminusers.LookupResult{}, shared.SubjectNotFound()
}),
GetUserByEmail: getUserByEmailFunc(func(context.Context, adminusers.GetUserByEmailInput) (adminusers.LookupResult, error) {
return adminusers.LookupResult{}, shared.SubjectNotFound()
}),
GetUserByRaceName: getUserByRaceNameFunc(func(context.Context, adminusers.GetUserByRaceNameInput) (adminusers.LookupResult, error) {
return adminusers.LookupResult{}, shared.SubjectNotFound()
}),
ListUsers: listUsersFunc(func(context.Context, adminusers.ListUsersInput) (adminusers.ListUsersResult, error) {
return adminusers.ListUsersResult{}, shared.InvalidRequest("page_token is invalid or does not match current filters")
}),
})
tests := []struct {
name string
method string
path string
body string
wantStatus int
wantBody string
}{
{
name: "get user by id not found",
method: http.MethodGet,
path: "/api/v1/internal/users/user-missing",
wantStatus: http.StatusNotFound,
wantBody: `{"error":{"code":"subject_not_found","message":"subject not found"}}`,
},
{
name: "get user by email malformed json",
method: http.MethodPost,
path: "/api/v1/internal/user-lookups/by-email",
body: `{"email":"pilot@example.com","extra":true}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"request body contains unknown field \"extra\""}}`,
},
{
name: "get user by race name not found",
method: http.MethodPost,
path: "/api/v1/internal/user-lookups/by-race-name",
body: `{"race_name":"Missing Pilot"}`,
wantStatus: http.StatusNotFound,
wantBody: `{"error":{"code":"subject_not_found","message":"subject not found"}}`,
},
{
name: "list users invalid page size",
method: http.MethodGet,
path: "/api/v1/internal/users?page_size=201",
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"page_size must be between 1 and 200"}}`,
},
{
name: "list users invalid timestamp",
method: http.MethodGet,
path: "/api/v1/internal/users?paid_expires_before=not-a-time",
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"paid_expires_before must be a valid RFC 3339 timestamp"}}`,
},
{
name: "list users invalid boolean",
method: http.MethodGet,
path: "/api/v1/internal/users?can_login=maybe",
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"can_login must be a valid boolean"}}`,
},
{
name: "list users invalid page token",
method: http.MethodGet,
path: "/api/v1/internal/users?page_token=cursor-1",
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"page_token is invalid or does not match current filters"}}`,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var body *bytes.Buffer
if tt.body != "" {
body = bytes.NewBufferString(tt.body)
} else {
body = &bytes.Buffer{}
}
request := httptest.NewRequest(tt.method, tt.path, body)
if tt.body != "" {
request.Header.Set("Content-Type", "application/json")
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
require.Equal(t, tt.wantStatus, recorder.Code)
assertJSONEq(t, recorder.Body.String(), tt.wantBody)
})
}
}
+841
View File
@@ -0,0 +1,841 @@
package internalhttp
import (
"context"
"fmt"
"log/slog"
"net/http"
"time"
"galaxy/user/internal/logging"
"galaxy/user/internal/service/authdirectory"
"galaxy/user/internal/service/entitlementsvc"
"galaxy/user/internal/service/geosync"
"galaxy/user/internal/service/lobbyeligibility"
"galaxy/user/internal/service/policysvc"
"galaxy/user/internal/service/selfservice"
"galaxy/user/internal/service/shared"
"galaxy/user/internal/telemetry"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
"go.opentelemetry.io/otel/attribute"
)
const internalHTTPServiceName = "galaxy-user-internal"
type errorResponse struct {
Error errorBody `json:"error"`
}
type errorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
type resolveByEmailRequest struct {
Email string `json:"email"`
}
type resolveByEmailResponse struct {
Kind string `json:"kind"`
UserID string `json:"user_id,omitempty"`
BlockReasonCode string `json:"block_reason_code,omitempty"`
}
type existsByUserIDResponse struct {
Exists bool `json:"exists"`
}
type ensureByEmailRequest struct {
Email string `json:"email"`
RegistrationContext *ensureRegistrationContextDTO `json:"registration_context"`
}
type ensureRegistrationContextDTO struct {
PreferredLanguage string `json:"preferred_language"`
TimeZone string `json:"time_zone"`
}
type ensureByEmailResponse struct {
Outcome string `json:"outcome"`
UserID string `json:"user_id,omitempty"`
BlockReasonCode string `json:"block_reason_code,omitempty"`
}
type blockByUserIDRequest struct {
ReasonCode string `json:"reason_code"`
}
type blockByEmailRequest struct {
Email string `json:"email"`
ReasonCode string `json:"reason_code"`
}
type blockResponse struct {
Outcome string `json:"outcome"`
UserID string `json:"user_id,omitempty"`
}
type getMyAccountResponse struct {
Account selfservice.AccountView `json:"account"`
}
type updateMyProfileRequest struct {
RaceName string `json:"race_name"`
}
type updateMySettingsRequest struct {
PreferredLanguage string `json:"preferred_language"`
TimeZone string `json:"time_zone"`
}
type syncDeclaredCountryRequest struct {
DeclaredCountry string `json:"declared_country"`
}
type syncDeclaredCountryResponse struct {
UserID string `json:"user_id"`
DeclaredCountry string `json:"declared_country"`
UpdatedAt time.Time `json:"updated_at"`
}
type actorDTO struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
}
type grantEntitlementRequest struct {
PlanCode string `json:"plan_code"`
Source string `json:"source"`
ReasonCode string `json:"reason_code"`
Actor actorDTO `json:"actor"`
StartsAt string `json:"starts_at"`
EndsAt string `json:"ends_at,omitempty"`
}
type extendEntitlementRequest struct {
Source string `json:"source"`
ReasonCode string `json:"reason_code"`
Actor actorDTO `json:"actor"`
EndsAt string `json:"ends_at"`
}
type revokeEntitlementRequest struct {
Source string `json:"source"`
ReasonCode string `json:"reason_code"`
Actor actorDTO `json:"actor"`
}
type applySanctionRequest struct {
SanctionCode string `json:"sanction_code"`
Scope string `json:"scope"`
ReasonCode string `json:"reason_code"`
Actor actorDTO `json:"actor"`
AppliedAt string `json:"applied_at"`
ExpiresAt string `json:"expires_at,omitempty"`
}
type removeSanctionRequest struct {
SanctionCode string `json:"sanction_code"`
ReasonCode string `json:"reason_code"`
Actor actorDTO `json:"actor"`
}
type setLimitRequest struct {
LimitCode string `json:"limit_code"`
Value int `json:"value"`
ReasonCode string `json:"reason_code"`
Actor actorDTO `json:"actor"`
AppliedAt string `json:"applied_at"`
ExpiresAt string `json:"expires_at,omitempty"`
}
type removeLimitRequest struct {
LimitCode string `json:"limit_code"`
ReasonCode string `json:"reason_code"`
Actor actorDTO `json:"actor"`
}
type entitlementSnapshotResponse struct {
PlanCode string `json:"plan_code"`
IsPaid bool `json:"is_paid"`
Source string `json:"source"`
Actor actorDTO `json:"actor"`
ReasonCode string `json:"reason_code"`
StartsAt time.Time `json:"starts_at"`
EndsAt *time.Time `json:"ends_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
type entitlementCommandResponse struct {
UserID string `json:"user_id"`
Entitlement entitlementSnapshotResponse `json:"entitlement"`
}
func newHandlerWithConfig(cfg Config, deps Dependencies) (http.Handler, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
normalizedDeps, err := normalizeDependencies(deps)
if err != nil {
return nil, err
}
configureGinModeOnce.Do(func() {
gin.SetMode(gin.ReleaseMode)
})
engine := gin.New()
engine.Use(newOTelMiddleware(normalizedDeps.Telemetry))
engine.Use(withObservability(normalizedDeps.Logger, normalizedDeps.Telemetry))
engine.POST("/api/v1/internal/user-resolutions/by-email", handleResolveByEmail(normalizedDeps.ResolveByEmail, cfg.RequestTimeout))
engine.GET("/api/v1/internal/users/:user_id/exists", handleExistsByUserID(normalizedDeps.ExistsByUserID, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/ensure-by-email", handleEnsureByEmail(normalizedDeps.EnsureByEmail, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/block", handleBlockByUserID(normalizedDeps.BlockByUserID, cfg.RequestTimeout))
engine.POST("/api/v1/internal/user-blocks/by-email", handleBlockByEmail(normalizedDeps.BlockByEmail, cfg.RequestTimeout))
engine.GET("/api/v1/internal/users/:user_id/account", handleGetMyAccount(normalizedDeps.GetMyAccount, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/profile", handleUpdateMyProfile(normalizedDeps.UpdateMyProfile, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/settings", handleUpdateMySettings(normalizedDeps.UpdateMySettings, cfg.RequestTimeout))
engine.GET("/api/v1/internal/users/:user_id", handleGetUserByID(normalizedDeps.GetUserByID, cfg.RequestTimeout))
engine.POST("/api/v1/internal/user-lookups/by-email", handleGetUserByEmail(normalizedDeps.GetUserByEmail, cfg.RequestTimeout))
engine.POST("/api/v1/internal/user-lookups/by-race-name", handleGetUserByRaceName(normalizedDeps.GetUserByRaceName, cfg.RequestTimeout))
engine.GET("/api/v1/internal/users", handleListUsers(normalizedDeps.ListUsers, cfg.RequestTimeout))
engine.GET("/api/v1/internal/users/:user_id/eligibility", handleGetUserEligibility(normalizedDeps.GetUserEligibility, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/declared-country/sync", handleSyncDeclaredCountry(normalizedDeps.SyncDeclaredCountry, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/entitlements/grant", handleGrantEntitlement(normalizedDeps.GrantEntitlement, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/entitlements/extend", handleExtendEntitlement(normalizedDeps.ExtendEntitlement, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/entitlements/revoke", handleRevokeEntitlement(normalizedDeps.RevokeEntitlement, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/sanctions/apply", handleApplySanction(normalizedDeps.ApplySanction, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/sanctions/remove", handleRemoveSanction(normalizedDeps.RemoveSanction, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/limits/set", handleSetLimit(normalizedDeps.SetLimit, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/limits/remove", handleRemoveLimit(normalizedDeps.RemoveLimit, cfg.RequestTimeout))
return engine, nil
}
func handleResolveByEmail(useCase ResolveByEmailUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request resolveByEmailRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, authdirectory.ResolveByEmailInput{
Email: request.Email,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, resolveByEmailResponse{
Kind: result.Kind,
UserID: result.UserID,
BlockReasonCode: result.BlockReasonCode,
})
}
}
func handleExistsByUserID(useCase ExistsByUserIDUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, authdirectory.ExistsByUserIDInput{
UserID: c.Param("user_id"),
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, existsByUserIDResponse{Exists: result.Exists})
}
}
func handleEnsureByEmail(useCase EnsureByEmailUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request ensureByEmailRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
if request.RegistrationContext == nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest("registration_context must be present")))
return
}
var registrationContext *authdirectory.RegistrationContext
registrationContext = &authdirectory.RegistrationContext{
PreferredLanguage: request.RegistrationContext.PreferredLanguage,
TimeZone: request.RegistrationContext.TimeZone,
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, authdirectory.EnsureByEmailInput{
Email: request.Email,
RegistrationContext: registrationContext,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, ensureByEmailResponse{
Outcome: result.Outcome,
UserID: result.UserID,
BlockReasonCode: result.BlockReasonCode,
})
}
}
func handleBlockByUserID(useCase BlockByUserIDUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request blockByUserIDRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, authdirectory.BlockByUserIDInput{
UserID: c.Param("user_id"),
ReasonCode: request.ReasonCode,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, blockResponse{
Outcome: result.Outcome,
UserID: result.UserID,
})
}
}
func handleBlockByEmail(useCase BlockByEmailUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request blockByEmailRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, authdirectory.BlockByEmailInput{
Email: request.Email,
ReasonCode: request.ReasonCode,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, blockResponse{
Outcome: result.Outcome,
UserID: result.UserID,
})
}
}
func handleGetMyAccount(useCase GetMyAccountUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, selfservice.GetMyAccountInput{
UserID: c.Param("user_id"),
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, getMyAccountResponse{
Account: result.Account,
})
}
}
func handleUpdateMyProfile(useCase UpdateMyProfileUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request updateMyProfileRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, selfservice.UpdateMyProfileInput{
UserID: c.Param("user_id"),
RaceName: request.RaceName,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, getMyAccountResponse{
Account: result.Account,
})
}
}
func handleUpdateMySettings(useCase UpdateMySettingsUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request updateMySettingsRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, selfservice.UpdateMySettingsInput{
UserID: c.Param("user_id"),
PreferredLanguage: request.PreferredLanguage,
TimeZone: request.TimeZone,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, getMyAccountResponse{
Account: result.Account,
})
}
}
func handleGetUserEligibility(useCase GetUserEligibilityUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, lobbyeligibility.GetUserEligibilityInput{
UserID: c.Param("user_id"),
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, result)
}
}
func handleSyncDeclaredCountry(useCase SyncDeclaredCountryUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request syncDeclaredCountryRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, geosync.SyncDeclaredCountryInput{
UserID: c.Param("user_id"),
DeclaredCountry: request.DeclaredCountry,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, syncDeclaredCountryResponse{
UserID: result.UserID,
DeclaredCountry: result.DeclaredCountry,
UpdatedAt: result.UpdatedAt.UTC(),
})
}
}
func handleGrantEntitlement(useCase GrantEntitlementUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request grantEntitlementRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, entitlementsvc.GrantInput{
UserID: c.Param("user_id"),
PlanCode: request.PlanCode,
Source: request.Source,
ReasonCode: request.ReasonCode,
Actor: entitlementsvc.ActorInput{
Type: request.Actor.Type,
ID: request.Actor.ID,
},
StartsAt: request.StartsAt,
EndsAt: request.EndsAt,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, entitlementCommandResponseFromResult(result))
}
}
func handleExtendEntitlement(useCase ExtendEntitlementUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request extendEntitlementRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, entitlementsvc.ExtendInput{
UserID: c.Param("user_id"),
Source: request.Source,
ReasonCode: request.ReasonCode,
Actor: entitlementsvc.ActorInput{
Type: request.Actor.Type,
ID: request.Actor.ID,
},
EndsAt: request.EndsAt,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, entitlementCommandResponseFromResult(result))
}
}
func handleRevokeEntitlement(useCase RevokeEntitlementUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request revokeEntitlementRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, entitlementsvc.RevokeInput{
UserID: c.Param("user_id"),
Source: request.Source,
ReasonCode: request.ReasonCode,
Actor: entitlementsvc.ActorInput{
Type: request.Actor.Type,
ID: request.Actor.ID,
},
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, entitlementCommandResponseFromResult(result))
}
}
func handleApplySanction(useCase ApplySanctionUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request applySanctionRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, policysvc.ApplySanctionInput{
UserID: c.Param("user_id"),
SanctionCode: request.SanctionCode,
Scope: request.Scope,
ReasonCode: request.ReasonCode,
Actor: policysvc.ActorInput{
Type: request.Actor.Type,
ID: request.Actor.ID,
},
AppliedAt: request.AppliedAt,
ExpiresAt: request.ExpiresAt,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, result)
}
}
func handleRemoveSanction(useCase RemoveSanctionUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request removeSanctionRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, policysvc.RemoveSanctionInput{
UserID: c.Param("user_id"),
SanctionCode: request.SanctionCode,
ReasonCode: request.ReasonCode,
Actor: policysvc.ActorInput{
Type: request.Actor.Type,
ID: request.Actor.ID,
},
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, result)
}
}
func handleSetLimit(useCase SetLimitUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request setLimitRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, policysvc.SetLimitInput{
UserID: c.Param("user_id"),
LimitCode: request.LimitCode,
Value: request.Value,
ReasonCode: request.ReasonCode,
Actor: policysvc.ActorInput{
Type: request.Actor.Type,
ID: request.Actor.ID,
},
AppliedAt: request.AppliedAt,
ExpiresAt: request.ExpiresAt,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, result)
}
}
func handleRemoveLimit(useCase RemoveLimitUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request removeLimitRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, policysvc.RemoveLimitInput{
UserID: c.Param("user_id"),
LimitCode: request.LimitCode,
ReasonCode: request.ReasonCode,
Actor: policysvc.ActorInput{
Type: request.Actor.Type,
ID: request.Actor.ID,
},
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, result)
}
}
func normalizeDependencies(deps Dependencies) (Dependencies, error) {
switch {
case deps.ResolveByEmail == nil:
return Dependencies{}, fmt.Errorf("resolve-by-email use case must not be nil")
case deps.EnsureByEmail == nil:
return Dependencies{}, fmt.Errorf("ensure-by-email use case must not be nil")
case deps.ExistsByUserID == nil:
return Dependencies{}, fmt.Errorf("exists-by-user-id use case must not be nil")
case deps.BlockByUserID == nil:
return Dependencies{}, fmt.Errorf("block-by-user-id use case must not be nil")
case deps.BlockByEmail == nil:
return Dependencies{}, fmt.Errorf("block-by-email use case must not be nil")
case deps.GetMyAccount == nil:
return Dependencies{}, fmt.Errorf("get-my-account use case must not be nil")
case deps.UpdateMyProfile == nil:
return Dependencies{}, fmt.Errorf("update-my-profile use case must not be nil")
case deps.UpdateMySettings == nil:
return Dependencies{}, fmt.Errorf("update-my-settings use case must not be nil")
case deps.GetUserByID == nil:
return Dependencies{}, fmt.Errorf("get-user-by-id use case must not be nil")
case deps.GetUserByEmail == nil:
return Dependencies{}, fmt.Errorf("get-user-by-email use case must not be nil")
case deps.GetUserByRaceName == nil:
return Dependencies{}, fmt.Errorf("get-user-by-race-name use case must not be nil")
case deps.ListUsers == nil:
return Dependencies{}, fmt.Errorf("list-users use case must not be nil")
case deps.GetUserEligibility == nil:
return Dependencies{}, fmt.Errorf("get-user-eligibility use case must not be nil")
case deps.SyncDeclaredCountry == nil:
return Dependencies{}, fmt.Errorf("sync-declared-country use case must not be nil")
case deps.GrantEntitlement == nil:
return Dependencies{}, fmt.Errorf("grant-entitlement use case must not be nil")
case deps.ExtendEntitlement == nil:
return Dependencies{}, fmt.Errorf("extend-entitlement use case must not be nil")
case deps.RevokeEntitlement == nil:
return Dependencies{}, fmt.Errorf("revoke-entitlement use case must not be nil")
case deps.ApplySanction == nil:
return Dependencies{}, fmt.Errorf("apply-sanction use case must not be nil")
case deps.RemoveSanction == nil:
return Dependencies{}, fmt.Errorf("remove-sanction use case must not be nil")
case deps.SetLimit == nil:
return Dependencies{}, fmt.Errorf("set-limit use case must not be nil")
case deps.RemoveLimit == nil:
return Dependencies{}, fmt.Errorf("remove-limit use case must not be nil")
default:
if deps.Logger == nil {
deps.Logger = slog.Default()
}
return deps, nil
}
}
func entitlementCommandResponseFromResult(result entitlementsvc.CommandResult) entitlementCommandResponse {
response := entitlementCommandResponse{
UserID: result.UserID,
Entitlement: entitlementSnapshotResponse{
PlanCode: string(result.Entitlement.PlanCode),
IsPaid: result.Entitlement.IsPaid,
Source: result.Entitlement.Source.String(),
Actor: actorDTO{Type: result.Entitlement.Actor.Type.String(), ID: result.Entitlement.Actor.ID.String()},
ReasonCode: result.Entitlement.ReasonCode.String(),
StartsAt: result.Entitlement.StartsAt.UTC(),
UpdatedAt: result.Entitlement.UpdatedAt.UTC(),
},
}
if result.Entitlement.EndsAt != nil {
value := result.Entitlement.EndsAt.UTC()
response.Entitlement.EndsAt = &value
}
return response
}
func newOTelMiddleware(runtime *telemetry.Runtime) gin.HandlerFunc {
options := []otelgin.Option{}
if runtime != nil {
options = append(
options,
otelgin.WithTracerProvider(runtime.TracerProvider()),
otelgin.WithMeterProvider(runtime.MeterProvider()),
)
}
return otelgin.Middleware(internalHTTPServiceName, options...)
}
func withObservability(logger *slog.Logger, metrics *telemetry.Runtime) gin.HandlerFunc {
if logger == nil {
logger = slog.Default()
}
return func(c *gin.Context) {
startedAt := time.Now()
c.Next()
statusCode := c.Writer.Status()
route := c.FullPath()
if route == "" {
route = "unmatched"
}
errorCode, _ := c.Get(internalErrorCodeContextKey)
errorCodeValue, _ := errorCode.(string)
outcome := outcomeFromStatusCode(statusCode)
duration := time.Since(startedAt)
attrs := []any{
"transport", "http",
"route", route,
"method", c.Request.Method,
"status_code", statusCode,
"duration_ms", float64(duration.Microseconds()) / 1000,
"edge_outcome", string(outcome),
}
if errorCodeValue != "" {
attrs = append(attrs, "error_code", errorCodeValue)
}
attrs = append(attrs, logging.TraceAttrsFromContext(c.Request.Context())...)
metricAttrs := []attribute.KeyValue{
attribute.String("route", route),
attribute.String("method", c.Request.Method),
attribute.String("edge_outcome", string(outcome)),
}
if errorCodeValue != "" {
metricAttrs = append(metricAttrs, attribute.String("error_code", errorCodeValue))
}
metrics.RecordInternalHTTPRequest(c.Request.Context(), metricAttrs, duration)
switch outcome {
case edgeOutcomeSuccess:
logger.InfoContext(c.Request.Context(), "internal request completed", attrs...)
case edgeOutcomeFailed:
logger.ErrorContext(c.Request.Context(), "internal request failed", attrs...)
default:
logger.WarnContext(c.Request.Context(), "internal request rejected", attrs...)
}
}
}
type edgeOutcome string
const (
edgeOutcomeSuccess edgeOutcome = "success"
edgeOutcomeRejected edgeOutcome = "rejected"
edgeOutcomeFailed edgeOutcome = "failed"
)
func outcomeFromStatusCode(statusCode int) edgeOutcome {
switch {
case statusCode >= 500:
return edgeOutcomeFailed
case statusCode >= 400:
return edgeOutcomeRejected
default:
return edgeOutcomeSuccess
}
}
File diff suppressed because it is too large Load Diff
+88
View File
@@ -0,0 +1,88 @@
package internalhttp
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"galaxy/user/internal/service/shared"
"github.com/gin-gonic/gin"
)
const internalErrorCodeContextKey = "internal_error_code"
type malformedJSONRequestError struct {
message string
}
func (err *malformedJSONRequestError) Error() string {
if err == nil {
return ""
}
return err.message
}
func decodeJSONRequest(request *http.Request, target any) error {
if request == nil || request.Body == nil {
return &malformedJSONRequestError{message: "request body must not be empty"}
}
decoder := json.NewDecoder(request.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return describeJSONDecodeError(err)
}
if err := decoder.Decode(&struct{}{}); err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return &malformedJSONRequestError{message: "request body must contain a single JSON object"}
}
return &malformedJSONRequestError{message: "request body must contain a single JSON object"}
}
func describeJSONDecodeError(err error) error {
var syntaxErr *json.SyntaxError
var typeErr *json.UnmarshalTypeError
switch {
case errors.Is(err, io.EOF):
return &malformedJSONRequestError{message: "request body must not be empty"}
case errors.As(err, &syntaxErr):
return &malformedJSONRequestError{message: "request body contains malformed JSON"}
case errors.Is(err, io.ErrUnexpectedEOF):
return &malformedJSONRequestError{message: "request body contains malformed JSON"}
case errors.As(err, &typeErr):
if strings.TrimSpace(typeErr.Field) != "" {
return &malformedJSONRequestError{
message: fmt.Sprintf("request body contains an invalid value for %q", typeErr.Field),
}
}
return &malformedJSONRequestError{message: "request body contains an invalid JSON value"}
case strings.HasPrefix(err.Error(), "json: unknown field "):
return &malformedJSONRequestError{
message: fmt.Sprintf("request body contains unknown field %s", strings.TrimPrefix(err.Error(), "json: unknown field ")),
}
default:
return &malformedJSONRequestError{message: "request body contains invalid JSON"}
}
}
func abortWithProjection(c *gin.Context, projection shared.InternalErrorProjection) {
c.Set(internalErrorCodeContextKey, projection.Code)
c.AbortWithStatusJSON(projection.StatusCode, errorResponse{
Error: errorBody{
Code: projection.Code,
Message: projection.Message,
},
})
}
@@ -0,0 +1,112 @@
package internalhttp
import (
"bytes"
"context"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"galaxy/user/internal/service/authdirectory"
usertelemetry "galaxy/user/internal/telemetry"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/attribute"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
)
func TestInternalHandlerEmitsTraceFieldsAndMetrics(t *testing.T) {
t.Parallel()
logger, buffer := newObservedLogger()
telemetryRuntime, reader, recorder := newObservedInternalTelemetryRuntime(t)
handler := mustNewHandler(t, Dependencies{
Logger: logger,
Telemetry: telemetryRuntime,
ExistsByUserID: existsByUserIDFunc(func(context.Context, authdirectory.ExistsByUserIDInput) (authdirectory.ExistsByUserIDResult, error) {
return authdirectory.ExistsByUserIDResult{Exists: true}, nil
}),
})
recorderHTTP := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/v1/internal/users/user-123/exists", nil)
request.Header.Set("traceparent", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")
handler.ServeHTTP(recorderHTTP, request)
require.Equal(t, http.StatusOK, recorderHTTP.Code)
require.NotEmpty(t, recorder.Ended())
assert.Contains(t, buffer.String(), "otel_trace_id")
assert.Contains(t, buffer.String(), "otel_span_id")
assertMetricCount(t, reader, "user.internal_http.requests", map[string]string{
"route": "/api/v1/internal/users/:user_id/exists",
"method": http.MethodGet,
"edge_outcome": "success",
}, 1)
}
func newObservedInternalTelemetryRuntime(t *testing.T) (*usertelemetry.Runtime, *sdkmetric.ManualReader, *tracetest.SpanRecorder) {
t.Helper()
reader := sdkmetric.NewManualReader()
meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
recorder := tracetest.NewSpanRecorder()
tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
runtime, err := usertelemetry.NewWithProviders(meterProvider, tracerProvider)
require.NoError(t, err)
return runtime, reader, recorder
}
func newObservedLogger() (*slog.Logger, *bytes.Buffer) {
buffer := &bytes.Buffer{}
return slog.New(slog.NewJSONHandler(buffer, &slog.HandlerOptions{Level: slog.LevelDebug})), buffer
}
func assertMetricCount(t *testing.T, reader *sdkmetric.ManualReader, metricName string, wantAttrs map[string]string, wantValue int64) {
t.Helper()
var resourceMetrics metricdata.ResourceMetrics
require.NoError(t, reader.Collect(context.Background(), &resourceMetrics))
for _, scopeMetrics := range resourceMetrics.ScopeMetrics {
for _, metric := range scopeMetrics.Metrics {
if metric.Name != metricName {
continue
}
sum, ok := metric.Data.(metricdata.Sum[int64])
require.True(t, ok)
for _, point := range sum.DataPoints {
if hasMetricAttributes(point.Attributes.ToSlice(), wantAttrs) {
assert.Equal(t, wantValue, point.Value)
return
}
}
}
}
require.Failf(t, "test failed", "metric %q with attrs %v not found", metricName, wantAttrs)
}
func hasMetricAttributes(values []attribute.KeyValue, want map[string]string) bool {
if len(values) != len(want) {
return false
}
for _, value := range values {
if want[string(value.Key)] != value.Value.AsString() {
return false
}
}
return true
}
+411
View File
@@ -0,0 +1,411 @@
// Package internalhttp exposes the trusted internal HTTP API used by auth,
// gateway self-service, and internal administrative workflows.
package internalhttp
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"sync"
"time"
"galaxy/user/internal/service/adminusers"
"galaxy/user/internal/service/authdirectory"
"galaxy/user/internal/service/entitlementsvc"
"galaxy/user/internal/service/geosync"
"galaxy/user/internal/service/lobbyeligibility"
"galaxy/user/internal/service/policysvc"
"galaxy/user/internal/service/selfservice"
"galaxy/user/internal/telemetry"
)
const jsonContentType = "application/json; charset=utf-8"
var configureGinModeOnce sync.Once
// ResolveByEmailUseCase describes the auth-facing resolve-by-email service
// consumed by the HTTP transport layer.
type ResolveByEmailUseCase interface {
// Execute resolves one e-mail subject without creating any account.
Execute(ctx context.Context, input authdirectory.ResolveByEmailInput) (authdirectory.ResolveByEmailResult, error)
}
// EnsureByEmailUseCase describes the auth-facing ensure-by-email service
// consumed by the HTTP transport layer.
type EnsureByEmailUseCase interface {
// Execute returns an existing user, creates a new one, or reports a blocked
// outcome for one e-mail subject.
Execute(ctx context.Context, input authdirectory.EnsureByEmailInput) (authdirectory.EnsureByEmailResult, error)
}
// ExistsByUserIDUseCase describes the auth-facing exists-by-user-id service
// consumed by the HTTP transport layer.
type ExistsByUserIDUseCase interface {
// Execute reports whether one stable user identifier exists.
Execute(ctx context.Context, input authdirectory.ExistsByUserIDInput) (authdirectory.ExistsByUserIDResult, error)
}
// BlockByUserIDUseCase describes the auth-facing block-by-user-id service
// consumed by the HTTP transport layer.
type BlockByUserIDUseCase interface {
// Execute blocks one account addressed by stable user identifier.
Execute(ctx context.Context, input authdirectory.BlockByUserIDInput) (authdirectory.BlockResult, error)
}
// BlockByEmailUseCase describes the auth-facing block-by-email service
// consumed by the HTTP transport layer.
type BlockByEmailUseCase interface {
// Execute blocks one exact normalized e-mail subject.
Execute(ctx context.Context, input authdirectory.BlockByEmailInput) (authdirectory.BlockResult, error)
}
// GetMyAccountUseCase describes the self-service account-read use case
// consumed by the HTTP transport layer.
type GetMyAccountUseCase interface {
// Execute returns the authenticated account aggregate for one user.
Execute(ctx context.Context, input selfservice.GetMyAccountInput) (selfservice.GetMyAccountResult, error)
}
// UpdateMyProfileUseCase describes the self-service profile-mutation use case
// consumed by the HTTP transport layer.
type UpdateMyProfileUseCase interface {
// Execute updates the allowed self-service profile fields for one user.
Execute(ctx context.Context, input selfservice.UpdateMyProfileInput) (selfservice.UpdateMyProfileResult, error)
}
// UpdateMySettingsUseCase describes the self-service settings-mutation use
// case consumed by the HTTP transport layer.
type UpdateMySettingsUseCase interface {
// Execute updates the allowed self-service settings fields for one user.
Execute(ctx context.Context, input selfservice.UpdateMySettingsInput) (selfservice.UpdateMySettingsResult, error)
}
// GetUserByIDUseCase describes the trusted admin exact-read by stable user id
// consumed by the HTTP transport layer.
type GetUserByIDUseCase interface {
// Execute returns the full current account aggregate for one user id.
Execute(ctx context.Context, input adminusers.GetUserByIDInput) (adminusers.LookupResult, error)
}
// GetUserByEmailUseCase describes the trusted admin exact-read by normalized
// e-mail consumed by the HTTP transport layer.
type GetUserByEmailUseCase interface {
// Execute returns the full current account aggregate for one normalized
// e-mail address.
Execute(ctx context.Context, input adminusers.GetUserByEmailInput) (adminusers.LookupResult, error)
}
// GetUserByRaceNameUseCase describes the trusted admin exact-read by exact
// stored race name consumed by the HTTP transport layer.
type GetUserByRaceNameUseCase interface {
// Execute returns the full current account aggregate for one exact race
// name.
Execute(ctx context.Context, input adminusers.GetUserByRaceNameInput) (adminusers.LookupResult, error)
}
// ListUsersUseCase describes the trusted admin paginated listing use case
// consumed by the HTTP transport layer.
type ListUsersUseCase interface {
// Execute returns one deterministic filtered page of full account
// aggregates.
Execute(ctx context.Context, input adminusers.ListUsersInput) (adminusers.ListUsersResult, error)
}
// GetUserEligibilityUseCase describes the trusted lobby-facing eligibility
// snapshot use case consumed by the HTTP transport layer.
type GetUserEligibilityUseCase interface {
// Execute returns one read-optimized lobby eligibility snapshot for one
// user.
Execute(ctx context.Context, input lobbyeligibility.GetUserEligibilityInput) (lobbyeligibility.GetUserEligibilityResult, error)
}
// SyncDeclaredCountryUseCase describes the trusted geo-facing declared-country
// sync use case consumed by the HTTP transport layer.
type SyncDeclaredCountryUseCase interface {
// Execute synchronizes the current effective declared country for one user.
Execute(ctx context.Context, input geosync.SyncDeclaredCountryInput) (geosync.SyncDeclaredCountryResult, error)
}
// GrantEntitlementUseCase describes the trusted entitlement-grant use case
// consumed by the HTTP transport layer.
type GrantEntitlementUseCase interface {
// Execute grants a new current paid entitlement for one user.
Execute(ctx context.Context, input entitlementsvc.GrantInput) (entitlementsvc.CommandResult, error)
}
// ExtendEntitlementUseCase describes the trusted entitlement-extend use case
// consumed by the HTTP transport layer.
type ExtendEntitlementUseCase interface {
// Execute extends the current finite paid entitlement for one user.
Execute(ctx context.Context, input entitlementsvc.ExtendInput) (entitlementsvc.CommandResult, error)
}
// RevokeEntitlementUseCase describes the trusted entitlement-revoke use case
// consumed by the HTTP transport layer.
type RevokeEntitlementUseCase interface {
// Execute revokes the current paid entitlement for one user.
Execute(ctx context.Context, input entitlementsvc.RevokeInput) (entitlementsvc.CommandResult, error)
}
// ApplySanctionUseCase describes the trusted sanction-apply use case consumed
// by the HTTP transport layer.
type ApplySanctionUseCase interface {
// Execute applies one new active sanction record.
Execute(ctx context.Context, input policysvc.ApplySanctionInput) (policysvc.SanctionCommandResult, error)
}
// RemoveSanctionUseCase describes the trusted sanction-remove use case
// consumed by the HTTP transport layer.
type RemoveSanctionUseCase interface {
// Execute removes one current active sanction record by code.
Execute(ctx context.Context, input policysvc.RemoveSanctionInput) (policysvc.SanctionCommandResult, error)
}
// SetLimitUseCase describes the trusted limit-set use case consumed by the
// HTTP transport layer.
type SetLimitUseCase interface {
// Execute creates or replaces one current active limit record.
Execute(ctx context.Context, input policysvc.SetLimitInput) (policysvc.LimitCommandResult, error)
}
// RemoveLimitUseCase describes the trusted limit-remove use case consumed by
// the HTTP transport layer.
type RemoveLimitUseCase interface {
// Execute removes one current active limit record by code.
Execute(ctx context.Context, input policysvc.RemoveLimitInput) (policysvc.LimitCommandResult, error)
}
// Config describes the trusted internal HTTP listener owned by the user
// service.
type Config struct {
// Addr stores the TCP listen address.
Addr string
// ReadHeaderTimeout bounds how long the listener may spend reading request
// headers before rejecting the connection.
ReadHeaderTimeout time.Duration
// ReadTimeout bounds how long the listener may spend reading one request.
ReadTimeout time.Duration
// IdleTimeout bounds how long keep-alive connections stay open.
IdleTimeout time.Duration
// RequestTimeout bounds one application-layer request execution.
RequestTimeout time.Duration
}
// Validate reports whether cfg contains a usable internal HTTP listener
// configuration.
func (cfg Config) Validate() error {
switch {
case cfg.Addr == "":
return errors.New("internal HTTP addr must not be empty")
case cfg.ReadHeaderTimeout <= 0:
return errors.New("internal HTTP read header timeout must be positive")
case cfg.ReadTimeout <= 0:
return errors.New("internal HTTP read timeout must be positive")
case cfg.IdleTimeout <= 0:
return errors.New("internal HTTP idle timeout must be positive")
case cfg.RequestTimeout <= 0:
return errors.New("internal HTTP request timeout must be positive")
default:
return nil
}
}
// Dependencies describes the collaborators used by the trusted internal HTTP
// transport layer.
type Dependencies struct {
// ResolveByEmail executes the auth-facing resolve-by-email use case.
ResolveByEmail ResolveByEmailUseCase
// EnsureByEmail executes the auth-facing ensure-by-email use case.
EnsureByEmail EnsureByEmailUseCase
// ExistsByUserID executes the auth-facing exists-by-user-id use case.
ExistsByUserID ExistsByUserIDUseCase
// BlockByUserID executes the auth-facing block-by-user-id use case.
BlockByUserID BlockByUserIDUseCase
// BlockByEmail executes the auth-facing block-by-email use case.
BlockByEmail BlockByEmailUseCase
// GetMyAccount executes the self-service authenticated account-read use
// case.
GetMyAccount GetMyAccountUseCase
// UpdateMyProfile executes the self-service profile-mutation use case.
UpdateMyProfile UpdateMyProfileUseCase
// UpdateMySettings executes the self-service settings-mutation use case.
UpdateMySettings UpdateMySettingsUseCase
// GetUserByID executes the trusted admin exact-read by stable user id.
GetUserByID GetUserByIDUseCase
// GetUserByEmail executes the trusted admin exact-read by normalized
// e-mail.
GetUserByEmail GetUserByEmailUseCase
// GetUserByRaceName executes the trusted admin exact-read by exact stored
// race name.
GetUserByRaceName GetUserByRaceNameUseCase
// ListUsers executes the trusted admin paginated filtered listing use case.
ListUsers ListUsersUseCase
// GetUserEligibility executes the trusted lobby-facing eligibility snapshot
// read.
GetUserEligibility GetUserEligibilityUseCase
// SyncDeclaredCountry executes the trusted geo-facing declared-country sync
// command.
SyncDeclaredCountry SyncDeclaredCountryUseCase
// GrantEntitlement executes the trusted entitlement-grant use case.
GrantEntitlement GrantEntitlementUseCase
// ExtendEntitlement executes the trusted entitlement-extend use case.
ExtendEntitlement ExtendEntitlementUseCase
// RevokeEntitlement executes the trusted entitlement-revoke use case.
RevokeEntitlement RevokeEntitlementUseCase
// ApplySanction executes the trusted sanction-apply use case.
ApplySanction ApplySanctionUseCase
// RemoveSanction executes the trusted sanction-remove use case.
RemoveSanction RemoveSanctionUseCase
// SetLimit executes the trusted limit-set use case.
SetLimit SetLimitUseCase
// RemoveLimit executes the trusted limit-remove use case.
RemoveLimit RemoveLimitUseCase
// Logger writes structured transport logs. When nil, the default logger is
// used.
Logger *slog.Logger
// Telemetry records OpenTelemetry spans and low-cardinality HTTP metrics.
Telemetry *telemetry.Runtime
}
// Server owns the trusted internal HTTP listener exposed by the user service.
type Server struct {
cfg Config
handler http.Handler
logger *slog.Logger
stateMu sync.RWMutex
server *http.Server
listener net.Listener
}
// NewServer constructs one trusted internal HTTP server for cfg and deps.
func NewServer(cfg Config, deps Dependencies) (*Server, error) {
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("new internal HTTP server: %w", err)
}
handler, err := newHandlerWithConfig(cfg, deps)
if err != nil {
return nil, fmt.Errorf("new internal HTTP server: %w", err)
}
logger := deps.Logger
if logger == nil {
logger = slog.Default()
}
return &Server{
cfg: cfg,
handler: handler,
logger: logger,
}, nil
}
// Run binds the configured listener and serves the trusted internal HTTP
// surface until ctx is cancelled or Shutdown closes the server.
func (server *Server) Run(ctx context.Context) error {
if ctx == nil {
return errors.New("run internal HTTP server: nil context")
}
if err := ctx.Err(); err != nil {
return err
}
listener, err := net.Listen("tcp", server.cfg.Addr)
if err != nil {
return fmt.Errorf("run internal HTTP server: listen on %q: %w", server.cfg.Addr, err)
}
httpServer := &http.Server{
Handler: server.handler,
ReadHeaderTimeout: server.cfg.ReadHeaderTimeout,
ReadTimeout: server.cfg.ReadTimeout,
IdleTimeout: server.cfg.IdleTimeout,
}
server.stateMu.Lock()
server.server = httpServer
server.listener = listener
server.stateMu.Unlock()
server.logger.Info("internal HTTP server started", "addr", listener.Addr().String())
shutdownDone := make(chan struct{})
go func() {
defer close(shutdownDone)
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), server.cfg.RequestTimeout)
defer cancel()
_ = server.Shutdown(shutdownCtx)
}()
defer func() {
server.stateMu.Lock()
server.server = nil
server.listener = nil
server.stateMu.Unlock()
<-shutdownDone
}()
err = httpServer.Serve(listener)
switch {
case err == nil:
return nil
case errors.Is(err, http.ErrServerClosed):
server.logger.Info("internal HTTP server stopped")
return nil
default:
return fmt.Errorf("run internal HTTP server: serve on %q: %w", server.cfg.Addr, err)
}
}
// Shutdown gracefully stops the internal HTTP server within ctx.
func (server *Server) Shutdown(ctx context.Context) error {
if ctx == nil {
return errors.New("shutdown internal HTTP server: nil context")
}
server.stateMu.RLock()
httpServer := server.server
server.stateMu.RUnlock()
if httpServer == nil {
return nil
}
if err := httpServer.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("shutdown internal HTTP server: %w", err)
}
return nil
}
+493
View File
@@ -0,0 +1,493 @@
// Package app wires the runnable user-service process.
package app
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"galaxy/user/internal/adapters/local"
"galaxy/user/internal/adapters/redis/domainevents"
"galaxy/user/internal/adapters/redis/userstore"
"galaxy/user/internal/adminapi"
"galaxy/user/internal/api/internalhttp"
"galaxy/user/internal/config"
"galaxy/user/internal/service/adminusers"
"galaxy/user/internal/service/authdirectory"
"galaxy/user/internal/service/entitlementsvc"
"galaxy/user/internal/service/geosync"
"galaxy/user/internal/service/lobbyeligibility"
"galaxy/user/internal/service/policysvc"
"galaxy/user/internal/service/selfservice"
"galaxy/user/internal/telemetry"
)
type pinger interface {
Ping(context.Context) error
}
type closer interface {
Close() error
}
// Runtime owns the runnable user-service process plus the cleanup functions
// that release runtime resources after shutdown.
type Runtime struct {
cfg config.Config
logger *slog.Logger
// Server owns the internal HTTP listener exposed by the user service.
Server *internalhttp.Server
// AdminServer owns the optional private admin HTTP listener.
AdminServer *adminapi.Server
// Telemetry owns the process-wide OpenTelemetry providers and Prometheus
// handler.
Telemetry *telemetry.Runtime
cleanupFns []func() error
}
// NewRuntime constructs the runnable user-service process from cfg.
func NewRuntime(ctx context.Context, cfg config.Config, logger *slog.Logger) (*Runtime, error) {
if ctx == nil {
return nil, fmt.Errorf("new user-service runtime: nil context")
}
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("new user-service runtime: %w", err)
}
if logger == nil {
logger = slog.Default()
}
runtime := &Runtime{
cfg: cfg,
logger: logger,
}
cleanupOnError := func(err error) (*Runtime, error) {
return nil, fmt.Errorf("%w; cleanup: %w", err, runtime.Close())
}
telemetryRuntime, err := telemetry.NewProcess(ctx, telemetry.ProcessConfig{
ServiceName: cfg.Telemetry.ServiceName,
TracesExporter: cfg.Telemetry.TracesExporter,
MetricsExporter: cfg.Telemetry.MetricsExporter,
TracesProtocol: cfg.Telemetry.TracesProtocol,
MetricsProtocol: cfg.Telemetry.MetricsProtocol,
StdoutTracesEnabled: cfg.Telemetry.StdoutTracesEnabled,
StdoutMetricsEnabled: cfg.Telemetry.StdoutMetricsEnabled,
}, logger.With("component", "telemetry"))
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: telemetry runtime: %w", err))
}
runtime.Telemetry = telemetryRuntime
runtime.cleanupFns = append(runtime.cleanupFns, func() error {
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
defer cancel()
return telemetryRuntime.Shutdown(shutdownCtx)
})
store, err := userstore.New(userstore.Config{
Addr: cfg.Redis.Addr,
Username: cfg.Redis.Username,
Password: cfg.Redis.Password,
DB: cfg.Redis.DB,
TLSEnabled: cfg.Redis.TLSEnabled,
KeyspacePrefix: cfg.Redis.KeyspacePrefix,
OperationTimeout: cfg.Redis.OperationTimeout,
})
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: redis user store: %w", err))
}
runtime.cleanupFns = append(runtime.cleanupFns, store.Close)
if err := pingDependency(ctx, "redis user store", store); err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: %w", err))
}
domainEventPublisher, err := domainevents.New(domainevents.Config{
Addr: cfg.Redis.Addr,
Username: cfg.Redis.Username,
Password: cfg.Redis.Password,
DB: cfg.Redis.DB,
TLSEnabled: cfg.Redis.TLSEnabled,
Stream: cfg.Redis.DomainEventsStream,
StreamMaxLen: cfg.Redis.DomainEventsStreamMaxLen,
OperationTimeout: cfg.Redis.OperationTimeout,
})
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: redis domain-event publisher: %w", err))
}
runtime.cleanupFns = append(runtime.cleanupFns, domainEventPublisher.Close)
if err := pingDependency(ctx, "redis domain-event publisher", domainEventPublisher); err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: %w", err))
}
clock := local.Clock{}
idGenerator := local.IDGenerator{}
raceNamePolicy, err := local.NewRaceNamePolicy()
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: race-name policy: %w", err))
}
componentLogger := func(component string) *slog.Logger {
return logger.With("component", component)
}
resolver, err := authdirectory.NewResolverWithObservability(store, componentLogger("authdirectory"), telemetryRuntime)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: resolver: %w", err))
}
ensurer, err := authdirectory.NewEnsurerWithObservability(
store,
clock,
idGenerator,
raceNamePolicy,
componentLogger("authdirectory"),
telemetryRuntime,
domainEventPublisher,
domainEventPublisher,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: ensurer: %w", err))
}
existenceChecker, err := authdirectory.NewExistenceChecker(store)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: existence checker: %w", err))
}
blockByUserID, err := authdirectory.NewBlockByUserIDService(store, clock)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: block-by-user-id service: %w", err))
}
blockByEmail, err := authdirectory.NewBlockByEmailService(store, clock)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: block-by-email service: %w", err))
}
entitlementReader, err := entitlementsvc.NewReaderWithObservability(
store.EntitlementSnapshots(),
store.EntitlementLifecycle(),
clock,
idGenerator,
componentLogger("entitlementsvc"),
telemetryRuntime,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: entitlement reader: %w", err))
}
grantEntitlement, err := entitlementsvc.NewGrantServiceWithObservability(
store.Accounts(),
store.EntitlementHistory(),
entitlementReader,
store.EntitlementLifecycle(),
clock,
idGenerator,
componentLogger("entitlementsvc"),
telemetryRuntime,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: grant entitlement service: %w", err))
}
extendEntitlement, err := entitlementsvc.NewExtendServiceWithObservability(
store.Accounts(),
store.EntitlementHistory(),
entitlementReader,
store.EntitlementLifecycle(),
clock,
idGenerator,
componentLogger("entitlementsvc"),
telemetryRuntime,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: extend entitlement service: %w", err))
}
revokeEntitlement, err := entitlementsvc.NewRevokeServiceWithObservability(
store.Accounts(),
store.EntitlementHistory(),
entitlementReader,
store.EntitlementLifecycle(),
clock,
idGenerator,
componentLogger("entitlementsvc"),
telemetryRuntime,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: revoke entitlement service: %w", err))
}
accountGetter, err := selfservice.NewAccountGetter(store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), clock)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: account getter: %w", err))
}
profileUpdater, err := selfservice.NewProfileUpdaterWithObservability(
store.Accounts(),
entitlementReader,
store.Sanctions(),
store.Limits(),
clock,
raceNamePolicy,
componentLogger("selfservice"),
telemetryRuntime,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: profile updater: %w", err))
}
settingsUpdater, err := selfservice.NewSettingsUpdaterWithObservability(
store.Accounts(),
entitlementReader,
store.Sanctions(),
store.Limits(),
clock,
componentLogger("selfservice"),
telemetryRuntime,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: settings updater: %w", err))
}
getUserByID, err := adminusers.NewByIDGetter(store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), clock)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: admin get-user-by-id: %w", err))
}
getUserByEmail, err := adminusers.NewByEmailGetter(store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), clock)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: admin get-user-by-email: %w", err))
}
getUserByRaceName, err := adminusers.NewByRaceNameGetter(store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), clock)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: admin get-user-by-race-name: %w", err))
}
listUsers, err := adminusers.NewLister(store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), clock, store)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: admin list-users: %w", err))
}
userEligibility, err := lobbyeligibility.NewSnapshotReader(store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), clock)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: lobby eligibility snapshot reader: %w", err))
}
syncDeclaredCountry, err := geosync.NewSyncServiceWithObservability(
store.Accounts(),
clock,
domainEventPublisher,
componentLogger("geosync"),
telemetryRuntime,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: geo declared-country sync service: %w", err))
}
applySanction, err := policysvc.NewApplySanctionServiceWithObservability(
store.Accounts(),
store.Sanctions(),
store.Limits(),
store.PolicyLifecycle(),
clock,
idGenerator,
componentLogger("policysvc"),
telemetryRuntime,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: apply sanction service: %w", err))
}
removeSanction, err := policysvc.NewRemoveSanctionServiceWithObservability(
store.Accounts(),
store.Sanctions(),
store.Limits(),
store.PolicyLifecycle(),
clock,
idGenerator,
componentLogger("policysvc"),
telemetryRuntime,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: remove sanction service: %w", err))
}
setLimit, err := policysvc.NewSetLimitServiceWithObservability(
store.Accounts(),
store.Sanctions(),
store.Limits(),
store.PolicyLifecycle(),
clock,
idGenerator,
componentLogger("policysvc"),
telemetryRuntime,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: set limit service: %w", err))
}
removeLimit, err := policysvc.NewRemoveLimitServiceWithObservability(
store.Accounts(),
store.Sanctions(),
store.Limits(),
store.PolicyLifecycle(),
clock,
idGenerator,
componentLogger("policysvc"),
telemetryRuntime,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: remove limit service: %w", err))
}
server, err := internalhttp.NewServer(internalhttp.Config{
Addr: cfg.InternalHTTP.Addr,
ReadHeaderTimeout: cfg.InternalHTTP.ReadHeaderTimeout,
ReadTimeout: cfg.InternalHTTP.ReadTimeout,
IdleTimeout: cfg.InternalHTTP.IdleTimeout,
RequestTimeout: cfg.InternalHTTP.RequestTimeout,
}, internalhttp.Dependencies{
ResolveByEmail: resolver,
EnsureByEmail: ensurer,
ExistsByUserID: existenceChecker,
BlockByUserID: blockByUserID,
BlockByEmail: blockByEmail,
GetMyAccount: accountGetter,
UpdateMyProfile: profileUpdater,
UpdateMySettings: settingsUpdater,
GetUserByID: getUserByID,
GetUserByEmail: getUserByEmail,
GetUserByRaceName: getUserByRaceName,
ListUsers: listUsers,
GetUserEligibility: userEligibility,
SyncDeclaredCountry: syncDeclaredCountry,
GrantEntitlement: grantEntitlement,
ExtendEntitlement: extendEntitlement,
RevokeEntitlement: revokeEntitlement,
ApplySanction: applySanction,
RemoveSanction: removeSanction,
SetLimit: setLimit,
RemoveLimit: removeLimit,
Logger: logger.With("component", "internal_http"),
Telemetry: telemetryRuntime,
})
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: internal HTTP server: %w", err))
}
adminServer := adminapi.NewServer(cfg.AdminHTTP, telemetryRuntime.Handler(), logger)
runtime.Server = server
runtime.AdminServer = adminServer
return runtime, nil
}
// Run serves the internal and admin HTTP listeners until ctx is canceled or a
// listener fails.
func (runtime *Runtime) Run(ctx context.Context) error {
if ctx == nil {
return errors.New("run user-service runtime: nil context")
}
if runtime == nil {
return errors.New("run user-service runtime: nil runtime")
}
if runtime.Server == nil {
return errors.New("run user-service runtime: nil internal HTTP server")
}
if runtime.AdminServer == nil {
return errors.New("run user-service runtime: nil admin HTTP server")
}
runCtx, cancel := context.WithCancel(ctx)
defer cancel()
var (
wg sync.WaitGroup
shutdownMu sync.Mutex
shutdownDone bool
shutdownErr error
)
shutdownServers := func() {
shutdownMu.Lock()
defer shutdownMu.Unlock()
if shutdownDone {
return
}
shutdownDone = true
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), runtime.cfg.ShutdownTimeout)
defer shutdownCancel()
shutdownErr = errors.Join(
runtime.Server.Shutdown(shutdownCtx),
runtime.AdminServer.Shutdown(shutdownCtx),
)
}
errCh := make(chan error, 2)
runServer := func(name string, serve func(context.Context) error) {
wg.Add(1)
go func() {
defer wg.Done()
if err := serve(runCtx); err != nil {
select {
case errCh <- fmt.Errorf("%s: %w", name, err):
default:
}
cancel()
}
}()
}
runServer("internal HTTP server", runtime.Server.Run)
runServer("admin HTTP server", runtime.AdminServer.Run)
done := make(chan struct{})
go func() {
defer close(done)
<-runCtx.Done()
shutdownServers()
wg.Wait()
}()
var runErr error
select {
case runErr = <-errCh:
cancel()
case <-ctx.Done():
cancel()
case <-done:
}
<-done
return errors.Join(runErr, shutdownErr)
}
// Close releases every runtime dependency in reverse construction order.
func (runtime *Runtime) Close() error {
if runtime == nil {
return nil
}
var messages []string
for index := len(runtime.cleanupFns) - 1; index >= 0; index-- {
if err := runtime.cleanupFns[index](); err != nil {
messages = append(messages, err.Error())
}
}
if len(messages) == 0 {
return nil
}
return errors.New(strings.Join(messages, "; "))
}
func pingDependency(ctx context.Context, name string, dependency pinger) error {
if err := dependency.Ping(ctx); err != nil {
return fmt.Errorf("ping %s: %w", name, err)
}
return nil
}
var _ closer = (*userstore.Store)(nil)
+551
View File
@@ -0,0 +1,551 @@
// Package config loads the user-service process configuration from environment
// variables.
package config
import (
"crypto/tls"
"fmt"
"net"
"os"
"strconv"
"strings"
"time"
)
const (
shutdownTimeoutEnvVar = "USERSERVICE_SHUTDOWN_TIMEOUT"
logLevelEnvVar = "USERSERVICE_LOG_LEVEL"
internalHTTPAddrEnvVar = "USERSERVICE_INTERNAL_HTTP_ADDR"
internalHTTPReadHeaderTimeoutEnvVar = "USERSERVICE_INTERNAL_HTTP_READ_HEADER_TIMEOUT"
internalHTTPReadTimeoutEnvVar = "USERSERVICE_INTERNAL_HTTP_READ_TIMEOUT"
internalHTTPIdleTimeoutEnvVar = "USERSERVICE_INTERNAL_HTTP_IDLE_TIMEOUT"
internalHTTPRequestTimeoutEnvVar = "USERSERVICE_INTERNAL_HTTP_REQUEST_TIMEOUT"
adminHTTPAddrEnvVar = "USERSERVICE_ADMIN_HTTP_ADDR"
adminHTTPReadHeaderTimeoutEnvVar = "USERSERVICE_ADMIN_HTTP_READ_HEADER_TIMEOUT"
adminHTTPReadTimeoutEnvVar = "USERSERVICE_ADMIN_HTTP_READ_TIMEOUT"
adminHTTPIdleTimeoutEnvVar = "USERSERVICE_ADMIN_HTTP_IDLE_TIMEOUT"
redisAddrEnvVar = "USERSERVICE_REDIS_ADDR"
redisUsernameEnvVar = "USERSERVICE_REDIS_USERNAME"
redisPasswordEnvVar = "USERSERVICE_REDIS_PASSWORD"
redisDBEnvVar = "USERSERVICE_REDIS_DB"
redisTLSEnabledEnvVar = "USERSERVICE_REDIS_TLS_ENABLED"
redisOperationTimeoutEnvVar = "USERSERVICE_REDIS_OPERATION_TIMEOUT"
redisKeyspacePrefixEnvVar = "USERSERVICE_REDIS_KEYSPACE_PREFIX"
redisDomainEventsStreamEnvVar = "USERSERVICE_REDIS_DOMAIN_EVENTS_STREAM"
redisDomainEventsStreamMaxLenEnvVar = "USERSERVICE_REDIS_DOMAIN_EVENTS_STREAM_MAX_LEN"
otelServiceNameEnvVar = "OTEL_SERVICE_NAME"
otelTracesExporterEnvVar = "OTEL_TRACES_EXPORTER"
otelMetricsExporterEnvVar = "OTEL_METRICS_EXPORTER"
otelExporterOTLPProtocolEnvVar = "OTEL_EXPORTER_OTLP_PROTOCOL"
otelExporterOTLPTracesProtocolEnvVar = "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL"
otelExporterOTLPMetricsProtocolEnvVar = "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL"
otelStdoutTracesEnabledEnvVar = "USERSERVICE_OTEL_STDOUT_TRACES_ENABLED"
otelStdoutMetricsEnabledEnvVar = "USERSERVICE_OTEL_STDOUT_METRICS_ENABLED"
defaultShutdownTimeout = 5 * time.Second
defaultLogLevel = "info"
defaultInternalHTTPAddr = ":8091"
defaultAdminHTTPAddr = ""
defaultReadHeaderTimeout = 2 * time.Second
defaultReadTimeout = 10 * time.Second
defaultIdleTimeout = time.Minute
defaultRequestTimeout = 3 * time.Second
defaultRedisDB = 0
defaultRedisOperationTimeout = 250 * time.Millisecond
defaultRedisKeyspacePrefix = "user:"
defaultDomainEventsStream = "user:domain_events"
defaultDomainEventsStreamMaxLen = 1024
defaultOTelServiceName = "galaxy-user"
otelExporterNone = "none"
otelExporterOTLP = "otlp"
otelProtocolHTTPProtobuf = "http/protobuf"
otelProtocolGRPC = "grpc"
)
// Config stores the full user-service process configuration.
type Config struct {
// ShutdownTimeout bounds graceful shutdown of the long-lived listeners and
// runtime resources.
ShutdownTimeout time.Duration
// Logging configures the process-wide logger.
Logging LoggingConfig
// InternalHTTP configures the trusted internal HTTP listener.
InternalHTTP InternalHTTPConfig
// AdminHTTP configures the optional private admin HTTP listener.
AdminHTTP AdminHTTPConfig
// Redis configures the Redis-backed user store and domain-event publisher.
Redis RedisConfig
// Telemetry configures the process-wide OpenTelemetry runtime.
Telemetry TelemetryConfig
}
// LoggingConfig configures the process-wide logger.
type LoggingConfig struct {
// Level stores the process log level.
Level string
}
// InternalHTTPConfig configures the internal HTTP listener.
type InternalHTTPConfig struct {
// Addr stores the TCP listen address.
Addr string
// ReadHeaderTimeout bounds request-header reading.
ReadHeaderTimeout time.Duration
// ReadTimeout bounds reading one request.
ReadTimeout time.Duration
// IdleTimeout bounds how long keep-alive connections stay open.
IdleTimeout time.Duration
// RequestTimeout bounds one application-layer request execution.
RequestTimeout time.Duration
}
// Validate reports whether cfg stores a usable internal HTTP listener
// configuration.
func (cfg InternalHTTPConfig) Validate() error {
switch {
case strings.TrimSpace(cfg.Addr) == "":
return fmt.Errorf("internal HTTP addr must not be empty")
case cfg.ReadHeaderTimeout <= 0:
return fmt.Errorf("internal HTTP read header timeout must be positive")
case cfg.ReadTimeout <= 0:
return fmt.Errorf("internal HTTP read timeout must be positive")
case cfg.IdleTimeout <= 0:
return fmt.Errorf("internal HTTP idle timeout must be positive")
case cfg.RequestTimeout <= 0:
return fmt.Errorf("internal HTTP request timeout must be positive")
default:
return nil
}
}
// AdminHTTPConfig describes the private operational HTTP listener used for
// Prometheus metrics exposure. The listener remains disabled when Addr is
// empty.
type AdminHTTPConfig struct {
// Addr stores the TCP listen address used by the admin HTTP server.
Addr string
// ReadHeaderTimeout bounds request-header reading.
ReadHeaderTimeout time.Duration
// ReadTimeout bounds reading one request.
ReadTimeout time.Duration
// IdleTimeout bounds how long keep-alive connections stay open.
IdleTimeout time.Duration
}
// Validate reports whether cfg stores a usable optional admin HTTP listener
// configuration.
func (cfg AdminHTTPConfig) Validate() error {
if strings.TrimSpace(cfg.Addr) == "" {
return nil
}
switch {
case cfg.ReadHeaderTimeout <= 0:
return fmt.Errorf("admin HTTP read header timeout must be positive")
case cfg.ReadTimeout <= 0:
return fmt.Errorf("admin HTTP read timeout must be positive")
case cfg.IdleTimeout <= 0:
return fmt.Errorf("admin HTTP idle timeout must be positive")
default:
return nil
}
}
// RedisConfig configures the Redis-backed store and domain-event publisher.
type RedisConfig struct {
// Addr stores the Redis network address.
Addr string
// Username stores the optional Redis ACL username.
Username string
// Password stores the optional Redis ACL password.
Password string
// DB stores the Redis logical database index.
DB int
// TLSEnabled reports whether TLS must be used for Redis connections.
TLSEnabled bool
// OperationTimeout bounds one Redis round trip.
OperationTimeout time.Duration
// KeyspacePrefix stores the root prefix of the service-owned Redis keyspace.
KeyspacePrefix string
// DomainEventsStream stores the Redis Stream key used for auxiliary
// post-commit domain events.
DomainEventsStream string
// DomainEventsStreamMaxLen bounds the domain-events Redis Stream with
// approximate trimming.
DomainEventsStreamMaxLen int64
}
// TLSConfig returns the conservative TLS configuration used by Redis adapters
// when TLSEnabled is true.
func (cfg RedisConfig) TLSConfig() *tls.Config {
if !cfg.TLSEnabled {
return nil
}
return &tls.Config{MinVersion: tls.VersionTLS12}
}
// Validate reports whether cfg stores a usable Redis configuration.
func (cfg RedisConfig) Validate() error {
switch {
case strings.TrimSpace(cfg.Addr) == "":
return fmt.Errorf("redis addr must not be empty")
case cfg.DB < 0:
return fmt.Errorf("redis db must not be negative")
case cfg.OperationTimeout <= 0:
return fmt.Errorf("redis operation timeout must be positive")
case strings.TrimSpace(cfg.KeyspacePrefix) == "":
return fmt.Errorf("redis keyspace prefix must not be empty")
case strings.TrimSpace(cfg.DomainEventsStream) == "":
return fmt.Errorf("redis domain events stream must not be empty")
case cfg.DomainEventsStreamMaxLen <= 0:
return fmt.Errorf("redis domain events stream max len must be positive")
default:
return nil
}
}
// TelemetryConfig configures the user-service OpenTelemetry runtime.
type TelemetryConfig struct {
// ServiceName overrides the default OpenTelemetry service name.
ServiceName string
// TracesExporter selects the external traces exporter. Supported values are
// `none` and `otlp`.
TracesExporter string
// MetricsExporter selects the external metrics exporter. Supported values
// are `none` and `otlp`.
MetricsExporter string
// TracesProtocol selects the OTLP traces protocol when TracesExporter is
// `otlp`.
TracesProtocol string
// MetricsProtocol selects the OTLP metrics protocol when MetricsExporter is
// `otlp`.
MetricsProtocol string
// StdoutTracesEnabled enables the additional stdout trace exporter used for
// local development and debugging.
StdoutTracesEnabled bool
// StdoutMetricsEnabled enables the additional stdout metric exporter used
// for local development and debugging.
StdoutMetricsEnabled bool
}
// Validate reports whether cfg contains a supported OpenTelemetry exporter
// configuration.
func (cfg TelemetryConfig) Validate() error {
switch cfg.TracesExporter {
case otelExporterNone, otelExporterOTLP:
default:
return fmt.Errorf("%s %q is unsupported", otelTracesExporterEnvVar, cfg.TracesExporter)
}
switch cfg.MetricsExporter {
case otelExporterNone, otelExporterOTLP:
default:
return fmt.Errorf("%s %q is unsupported", otelMetricsExporterEnvVar, cfg.MetricsExporter)
}
if cfg.TracesProtocol != "" && cfg.TracesProtocol != otelProtocolHTTPProtobuf && cfg.TracesProtocol != otelProtocolGRPC {
return fmt.Errorf("%s %q is unsupported", otelExporterOTLPTracesProtocolEnvVar, cfg.TracesProtocol)
}
if cfg.MetricsProtocol != "" && cfg.MetricsProtocol != otelProtocolHTTPProtobuf && cfg.MetricsProtocol != otelProtocolGRPC {
return fmt.Errorf("%s %q is unsupported", otelExporterOTLPMetricsProtocolEnvVar, cfg.MetricsProtocol)
}
return nil
}
// DefaultAdminHTTPConfig returns the default settings for the optional private
// admin HTTP listener.
func DefaultAdminHTTPConfig() AdminHTTPConfig {
return AdminHTTPConfig{
Addr: defaultAdminHTTPAddr,
ReadHeaderTimeout: defaultReadHeaderTimeout,
ReadTimeout: defaultReadTimeout,
IdleTimeout: defaultIdleTimeout,
}
}
// DefaultConfig returns the default process configuration with all optional
// values filled.
func DefaultConfig() Config {
return Config{
ShutdownTimeout: defaultShutdownTimeout,
Logging: LoggingConfig{
Level: defaultLogLevel,
},
InternalHTTP: InternalHTTPConfig{
Addr: defaultInternalHTTPAddr,
ReadHeaderTimeout: defaultReadHeaderTimeout,
ReadTimeout: defaultReadTimeout,
IdleTimeout: defaultIdleTimeout,
RequestTimeout: defaultRequestTimeout,
},
AdminHTTP: DefaultAdminHTTPConfig(),
Redis: RedisConfig{
DB: defaultRedisDB,
OperationTimeout: defaultRedisOperationTimeout,
KeyspacePrefix: defaultRedisKeyspacePrefix,
DomainEventsStream: defaultDomainEventsStream,
DomainEventsStreamMaxLen: defaultDomainEventsStreamMaxLen,
},
Telemetry: TelemetryConfig{
ServiceName: defaultOTelServiceName,
TracesExporter: otelExporterNone,
MetricsExporter: otelExporterNone,
},
}
}
// Validate reports whether cfg is process-ready.
func (cfg Config) Validate() error {
switch {
case cfg.ShutdownTimeout <= 0:
return fmt.Errorf("shutdown timeout must be positive")
}
if err := cfg.InternalHTTP.Validate(); err != nil {
return fmt.Errorf("internal HTTP config: %w", err)
}
if err := cfg.AdminHTTP.Validate(); err != nil {
return fmt.Errorf("admin HTTP config: %w", err)
}
if err := cfg.Redis.Validate(); err != nil {
return fmt.Errorf("redis config: %w", err)
}
if _, err := parseLogLevel(cfg.Logging.Level); err != nil {
return fmt.Errorf("logging config: %w", err)
}
if err := cfg.Telemetry.Validate(); err != nil {
return fmt.Errorf("telemetry config: %w", err)
}
return nil
}
// LoadFromEnv loads Config from the process environment.
func LoadFromEnv() (Config, error) {
cfg := DefaultConfig()
var err error
cfg.ShutdownTimeout, err = loadDuration(shutdownTimeoutEnvVar, cfg.ShutdownTimeout)
if err != nil {
return Config{}, err
}
cfg.Logging.Level = loadString(logLevelEnvVar, cfg.Logging.Level)
cfg.InternalHTTP.Addr = loadString(internalHTTPAddrEnvVar, cfg.InternalHTTP.Addr)
cfg.InternalHTTP.ReadHeaderTimeout, err = loadDuration(internalHTTPReadHeaderTimeoutEnvVar, cfg.InternalHTTP.ReadHeaderTimeout)
if err != nil {
return Config{}, err
}
cfg.InternalHTTP.ReadTimeout, err = loadDuration(internalHTTPReadTimeoutEnvVar, cfg.InternalHTTP.ReadTimeout)
if err != nil {
return Config{}, err
}
cfg.InternalHTTP.IdleTimeout, err = loadDuration(internalHTTPIdleTimeoutEnvVar, cfg.InternalHTTP.IdleTimeout)
if err != nil {
return Config{}, err
}
cfg.InternalHTTP.RequestTimeout, err = loadDuration(internalHTTPRequestTimeoutEnvVar, cfg.InternalHTTP.RequestTimeout)
if err != nil {
return Config{}, err
}
cfg.AdminHTTP.Addr = loadString(adminHTTPAddrEnvVar, cfg.AdminHTTP.Addr)
cfg.AdminHTTP.ReadHeaderTimeout, err = loadDuration(adminHTTPReadHeaderTimeoutEnvVar, cfg.AdminHTTP.ReadHeaderTimeout)
if err != nil {
return Config{}, err
}
cfg.AdminHTTP.ReadTimeout, err = loadDuration(adminHTTPReadTimeoutEnvVar, cfg.AdminHTTP.ReadTimeout)
if err != nil {
return Config{}, err
}
cfg.AdminHTTP.IdleTimeout, err = loadDuration(adminHTTPIdleTimeoutEnvVar, cfg.AdminHTTP.IdleTimeout)
if err != nil {
return Config{}, err
}
cfg.Redis.Addr = loadString(redisAddrEnvVar, cfg.Redis.Addr)
cfg.Redis.Username = loadString(redisUsernameEnvVar, cfg.Redis.Username)
cfg.Redis.Password = loadString(redisPasswordEnvVar, cfg.Redis.Password)
cfg.Redis.DB, err = loadInt(redisDBEnvVar, cfg.Redis.DB)
if err != nil {
return Config{}, err
}
cfg.Redis.TLSEnabled, err = loadBool(redisTLSEnabledEnvVar, cfg.Redis.TLSEnabled)
if err != nil {
return Config{}, err
}
cfg.Redis.OperationTimeout, err = loadDuration(redisOperationTimeoutEnvVar, cfg.Redis.OperationTimeout)
if err != nil {
return Config{}, err
}
cfg.Redis.KeyspacePrefix = loadString(redisKeyspacePrefixEnvVar, cfg.Redis.KeyspacePrefix)
cfg.Redis.DomainEventsStream = loadString(redisDomainEventsStreamEnvVar, cfg.Redis.DomainEventsStream)
cfg.Redis.DomainEventsStreamMaxLen, err = loadInt64(redisDomainEventsStreamMaxLenEnvVar, cfg.Redis.DomainEventsStreamMaxLen)
if err != nil {
return Config{}, err
}
cfg.Telemetry.ServiceName = loadString(otelServiceNameEnvVar, cfg.Telemetry.ServiceName)
cfg.Telemetry.TracesExporter = normalizeExporterValue(loadString(otelTracesExporterEnvVar, cfg.Telemetry.TracesExporter))
cfg.Telemetry.MetricsExporter = normalizeExporterValue(loadString(otelMetricsExporterEnvVar, cfg.Telemetry.MetricsExporter))
cfg.Telemetry.TracesProtocol = loadOTLPProtocol(
os.Getenv(otelExporterOTLPTracesProtocolEnvVar),
os.Getenv(otelExporterOTLPProtocolEnvVar),
cfg.Telemetry.TracesExporter,
)
cfg.Telemetry.MetricsProtocol = loadOTLPProtocol(
os.Getenv(otelExporterOTLPMetricsProtocolEnvVar),
os.Getenv(otelExporterOTLPProtocolEnvVar),
cfg.Telemetry.MetricsExporter,
)
cfg.Telemetry.StdoutTracesEnabled, err = loadBool(otelStdoutTracesEnabledEnvVar, cfg.Telemetry.StdoutTracesEnabled)
if err != nil {
return Config{}, err
}
cfg.Telemetry.StdoutMetricsEnabled, err = loadBool(otelStdoutMetricsEnabledEnvVar, cfg.Telemetry.StdoutMetricsEnabled)
if err != nil {
return Config{}, err
}
if err := cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
func loadString(envName string, defaultValue string) string {
value, ok := os.LookupEnv(envName)
if !ok {
return defaultValue
}
return strings.TrimSpace(value)
}
func loadDuration(envName string, defaultValue time.Duration) (time.Duration, error) {
value, ok := os.LookupEnv(envName)
if !ok {
return defaultValue, nil
}
duration, err := time.ParseDuration(strings.TrimSpace(value))
if err != nil {
return 0, fmt.Errorf("%s: parse duration: %w", envName, err)
}
return duration, nil
}
func loadInt(envName string, defaultValue int) (int, error) {
value, ok := os.LookupEnv(envName)
if !ok {
return defaultValue, nil
}
parsedValue, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
return 0, fmt.Errorf("%s: parse int: %w", envName, err)
}
return parsedValue, nil
}
func loadInt64(envName string, defaultValue int64) (int64, error) {
value, ok := os.LookupEnv(envName)
if !ok {
return defaultValue, nil
}
parsedValue, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
if err != nil {
return 0, fmt.Errorf("%s: parse int64: %w", envName, err)
}
return parsedValue, nil
}
func loadBool(envName string, defaultValue bool) (bool, error) {
value, ok := os.LookupEnv(envName)
if !ok {
return defaultValue, nil
}
parsedValue, err := strconv.ParseBool(strings.TrimSpace(value))
if err != nil {
return false, fmt.Errorf("%s: parse bool: %w", envName, err)
}
return parsedValue, nil
}
func parseLogLevel(value string) (string, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "debug", "info", "warn", "error":
return value, nil
default:
return "", fmt.Errorf("unsupported log level %q", value)
}
}
func normalizeExporterValue(value string) string {
switch strings.TrimSpace(value) {
case "", otelExporterNone:
return otelExporterNone
default:
return strings.TrimSpace(value)
}
}
func loadOTLPProtocol(primary string, fallback string, exporter string) string {
protocol := strings.TrimSpace(primary)
if protocol == "" {
protocol = strings.TrimSpace(fallback)
}
if protocol == "" && exporter == otelExporterOTLP {
return otelProtocolHTTPProtobuf
}
return protocol
}
// ListenAddress returns the resolved listen address used by tests and process
// startup.
func (cfg InternalHTTPConfig) ListenAddress() string {
if strings.HasPrefix(cfg.Addr, ":") {
return net.JoinHostPort("", strings.TrimPrefix(cfg.Addr, ":"))
}
return cfg.Addr
}
+106
View File
@@ -0,0 +1,106 @@
package config
import (
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestLoadFromEnvUsesDefaults(t *testing.T) {
t.Setenv(redisAddrEnvVar, "127.0.0.1:6379")
cfg, err := LoadFromEnv()
require.NoError(t, err)
defaults := DefaultConfig()
require.Equal(t, defaults.ShutdownTimeout, cfg.ShutdownTimeout)
require.Equal(t, defaults.Logging.Level, cfg.Logging.Level)
require.Equal(t, defaults.InternalHTTP, cfg.InternalHTTP)
require.Equal(t, defaults.AdminHTTP, cfg.AdminHTTP)
require.Equal(t, "127.0.0.1:6379", cfg.Redis.Addr)
require.Equal(t, defaults.Redis.DB, cfg.Redis.DB)
require.Equal(t, defaults.Redis.DomainEventsStream, cfg.Redis.DomainEventsStream)
require.Equal(t, defaults.Redis.DomainEventsStreamMaxLen, cfg.Redis.DomainEventsStreamMaxLen)
require.Equal(t, defaults.Telemetry, cfg.Telemetry)
}
func TestLoadFromEnvAppliesOverrides(t *testing.T) {
t.Setenv(shutdownTimeoutEnvVar, "9s")
t.Setenv(logLevelEnvVar, "debug")
t.Setenv(internalHTTPAddrEnvVar, "127.0.0.1:18091")
t.Setenv(internalHTTPReadHeaderTimeoutEnvVar, "3s")
t.Setenv(internalHTTPRequestTimeoutEnvVar, "750ms")
t.Setenv(adminHTTPAddrEnvVar, "127.0.0.1:19091")
t.Setenv(adminHTTPIdleTimeoutEnvVar, "90s")
t.Setenv(redisAddrEnvVar, "127.0.0.1:6380")
t.Setenv(redisUsernameEnvVar, "alice")
t.Setenv(redisPasswordEnvVar, "secret")
t.Setenv(redisDBEnvVar, "3")
t.Setenv(redisTLSEnabledEnvVar, "true")
t.Setenv(redisOperationTimeoutEnvVar, "900ms")
t.Setenv(redisKeyspacePrefixEnvVar, "user:custom:")
t.Setenv(redisDomainEventsStreamEnvVar, "user:test_events")
t.Setenv(redisDomainEventsStreamMaxLenEnvVar, "2048")
t.Setenv(otelServiceNameEnvVar, "galaxy-user-stage12")
t.Setenv(otelTracesExporterEnvVar, "otlp")
t.Setenv(otelMetricsExporterEnvVar, "otlp")
t.Setenv(otelExporterOTLPTracesProtocolEnvVar, "grpc")
t.Setenv(otelExporterOTLPMetricsProtocolEnvVar, "http/protobuf")
t.Setenv(otelStdoutTracesEnabledEnvVar, "true")
t.Setenv(otelStdoutMetricsEnabledEnvVar, "true")
cfg, err := LoadFromEnv()
require.NoError(t, err)
require.Equal(t, 9*time.Second, cfg.ShutdownTimeout)
require.Equal(t, "debug", cfg.Logging.Level)
require.Equal(t, "127.0.0.1:18091", cfg.InternalHTTP.Addr)
require.Equal(t, 3*time.Second, cfg.InternalHTTP.ReadHeaderTimeout)
require.Equal(t, 750*time.Millisecond, cfg.InternalHTTP.RequestTimeout)
require.Equal(t, "127.0.0.1:19091", cfg.AdminHTTP.Addr)
require.Equal(t, 90*time.Second, cfg.AdminHTTP.IdleTimeout)
require.Equal(t, "127.0.0.1:6380", cfg.Redis.Addr)
require.Equal(t, "alice", cfg.Redis.Username)
require.Equal(t, "secret", cfg.Redis.Password)
require.Equal(t, 3, cfg.Redis.DB)
require.True(t, cfg.Redis.TLSEnabled)
require.Equal(t, 900*time.Millisecond, cfg.Redis.OperationTimeout)
require.Equal(t, "user:custom:", cfg.Redis.KeyspacePrefix)
require.Equal(t, "user:test_events", cfg.Redis.DomainEventsStream)
require.Equal(t, int64(2048), cfg.Redis.DomainEventsStreamMaxLen)
require.Equal(t, "galaxy-user-stage12", cfg.Telemetry.ServiceName)
require.Equal(t, "otlp", cfg.Telemetry.TracesExporter)
require.Equal(t, "otlp", cfg.Telemetry.MetricsExporter)
require.Equal(t, "grpc", cfg.Telemetry.TracesProtocol)
require.Equal(t, "http/protobuf", cfg.Telemetry.MetricsProtocol)
require.True(t, cfg.Telemetry.StdoutTracesEnabled)
require.True(t, cfg.Telemetry.StdoutMetricsEnabled)
}
func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
tests := []struct {
name string
envName string
envVal string
}{
{name: "invalid duration", envName: shutdownTimeoutEnvVar, envVal: "later"},
{name: "invalid bool", envName: redisTLSEnabledEnvVar, envVal: "sometimes"},
{name: "invalid log level", envName: logLevelEnvVar, envVal: "verbose"},
{name: "invalid int", envName: redisDBEnvVar, envVal: "db-three"},
{name: "invalid stream max len", envName: redisDomainEventsStreamMaxLenEnvVar, envVal: "many"},
{name: "invalid traces exporter", envName: otelTracesExporterEnvVar, envVal: "zipkin"},
{name: "invalid metrics protocol", envName: otelExporterOTLPMetricsProtocolEnvVar, envVal: "udp"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Setenv(redisAddrEnvVar, "127.0.0.1:6379")
t.Setenv(tt.envName, tt.envVal)
_, err := LoadFromEnv()
require.Error(t, err)
})
}
}
+136
View File
@@ -0,0 +1,136 @@
// Package account defines the logical user-account entities owned directly by
// User Service.
package account
import (
"fmt"
"strings"
"time"
"galaxy/user/internal/domain/common"
)
// RaceNameCanonicalKey stores the policy-produced reservation key used to
// enforce replaceable race-name uniqueness.
type RaceNameCanonicalKey string
// String returns RaceNameCanonicalKey as its stored canonical string.
func (key RaceNameCanonicalKey) String() string {
return string(key)
}
// IsZero reports whether RaceNameCanonicalKey does not contain a usable value.
func (key RaceNameCanonicalKey) IsZero() bool {
return strings.TrimSpace(string(key)) == ""
}
// Validate reports whether RaceNameCanonicalKey is non-empty and trimmed.
func (key RaceNameCanonicalKey) Validate() error {
switch {
case key.IsZero():
return fmt.Errorf("race name canonical key must not be empty")
case strings.TrimSpace(string(key)) != string(key):
return fmt.Errorf("race name canonical key must not contain surrounding whitespace")
default:
return nil
}
}
// UserAccount stores the current editable account state of one regular user.
type UserAccount struct {
// UserID identifies the durable regular-user account.
UserID common.UserID
// Email stores the normalized login/contact address of the account.
Email common.Email
// RaceName stores the original-casing user-facing race name.
RaceName common.RaceName
// PreferredLanguage stores the current declared language tag.
PreferredLanguage common.LanguageTag
// TimeZone stores the current declared time-zone name.
TimeZone common.TimeZoneName
// DeclaredCountry stores the latest effective declared-country value. The
// zero value means the geo workflow has not synchronized any country yet.
DeclaredCountry common.CountryCode
// CreatedAt stores the account creation timestamp.
CreatedAt time.Time
// UpdatedAt stores the last account mutation timestamp.
UpdatedAt time.Time
}
// Validate reports whether UserAccount satisfies the frozen Stage 02
// structural invariants.
func (record UserAccount) Validate() error {
if err := record.UserID.Validate(); err != nil {
return fmt.Errorf("user account user id: %w", err)
}
if err := record.Email.Validate(); err != nil {
return fmt.Errorf("user account email: %w", err)
}
if err := record.RaceName.Validate(); err != nil {
return fmt.Errorf("user account race name: %w", err)
}
if err := record.PreferredLanguage.Validate(); err != nil {
return fmt.Errorf("user account preferred language: %w", err)
}
if err := record.TimeZone.Validate(); err != nil {
return fmt.Errorf("user account time zone: %w", err)
}
if !record.DeclaredCountry.IsZero() {
if err := record.DeclaredCountry.Validate(); err != nil {
return fmt.Errorf("user account declared country: %w", err)
}
}
if err := common.ValidateTimestamp("user account created at", record.CreatedAt); err != nil {
return err
}
if err := common.ValidateTimestamp("user account updated at", record.UpdatedAt); err != nil {
return err
}
if record.UpdatedAt.Before(record.CreatedAt) {
return fmt.Errorf("user account updated at must not be before created at")
}
return nil
}
// RaceNameReservation stores the current uniqueness reservation for one
// canonicalized race-name key.
type RaceNameReservation struct {
// CanonicalKey stores the policy-produced uniqueness key.
CanonicalKey RaceNameCanonicalKey
// UserID identifies the account that owns the reservation.
UserID common.UserID
// RaceName stores the original-casing name linked to the reservation.
RaceName common.RaceName
// ReservedAt stores when the reservation was acquired.
ReservedAt time.Time
}
// Validate reports whether RaceNameReservation satisfies the frozen Stage 02
// structural invariants.
func (record RaceNameReservation) Validate() error {
if err := record.CanonicalKey.Validate(); err != nil {
return fmt.Errorf("race name reservation canonical key: %w", err)
}
if err := record.UserID.Validate(); err != nil {
return fmt.Errorf("race name reservation user id: %w", err)
}
if err := record.RaceName.Validate(); err != nil {
return fmt.Errorf("race name reservation race name: %w", err)
}
if err := common.ValidateTimestamp("race name reservation reserved at", record.ReservedAt); err != nil {
return err
}
return nil
}
+119
View File
@@ -0,0 +1,119 @@
package account
import (
"testing"
"time"
"galaxy/user/internal/domain/common"
"github.com/stretchr/testify/require"
)
func TestUserAccountValidate(t *testing.T) {
t.Parallel()
createdAt := time.Unix(1_775_240_000, 0).UTC()
updatedAt := createdAt.Add(2 * time.Hour)
tests := []struct {
name string
record UserAccount
wantErr bool
}{
{
name: "valid without declared country",
record: 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/Berlin"),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
},
},
{
name: "valid with declared country",
record: 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/Berlin"),
DeclaredCountry: common.CountryCode("DE"),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
},
},
{
name: "updated before created",
record: 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/Berlin"),
CreatedAt: createdAt,
UpdatedAt: createdAt.Add(-time.Second),
},
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.record.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestRaceNameReservationValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
record RaceNameReservation
wantErr bool
}{
{
name: "valid",
record: RaceNameReservation{
CanonicalKey: RaceNameCanonicalKey("pilot-nova"),
UserID: common.UserID("user-123"),
RaceName: common.RaceName("Pilot Nova"),
ReservedAt: time.Unix(1_775_240_100, 0).UTC(),
},
},
{
name: "empty canonical key",
record: RaceNameReservation{
UserID: common.UserID("user-123"),
RaceName: common.RaceName("Pilot Nova"),
ReservedAt: time.Unix(1_775_240_100, 0).UTC(),
},
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.record.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
+56
View File
@@ -0,0 +1,56 @@
// Package authblock defines the dedicated pre-user auth-block entity stored by
// User Service.
package authblock
import (
"fmt"
"time"
"galaxy/user/internal/domain/common"
)
// BlockedEmailSubject stores a blocked e-mail subject that may exist before
// any user account exists.
type BlockedEmailSubject struct {
// Email stores the normalized blocked e-mail subject.
Email common.Email
// ReasonCode stores the machine-readable reason for the block.
ReasonCode common.ReasonCode
// BlockedAt stores when the block became effective.
BlockedAt time.Time
// Actor stores optional audit metadata for the block initiator.
Actor common.ActorRef
// ResolvedUserID stores the linked user when the blocked e-mail already
// belongs to an existing account.
ResolvedUserID common.UserID
}
// Validate reports whether BlockedEmailSubject satisfies the frozen Stage 02
// structural invariants.
func (record BlockedEmailSubject) Validate() error {
if err := record.Email.Validate(); err != nil {
return fmt.Errorf("blocked email subject email: %w", err)
}
if err := record.ReasonCode.Validate(); err != nil {
return fmt.Errorf("blocked email subject reason code: %w", err)
}
if err := common.ValidateTimestamp("blocked email subject blocked at", record.BlockedAt); err != nil {
return err
}
if !record.Actor.IsZero() {
if err := record.Actor.Validate(); err != nil {
return fmt.Errorf("blocked email subject actor: %w", err)
}
}
if !record.ResolvedUserID.IsZero() {
if err := record.ResolvedUserID.Validate(); err != nil {
return fmt.Errorf("blocked email subject resolved user id: %w", err)
}
}
return nil
}
@@ -0,0 +1,61 @@
package authblock
import (
"testing"
"time"
"galaxy/user/internal/domain/common"
"github.com/stretchr/testify/require"
)
func TestBlockedEmailSubjectValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
record BlockedEmailSubject
wantErr bool
}{
{
name: "valid without actor or user",
record: BlockedEmailSubject{
Email: common.Email("pilot@example.com"),
ReasonCode: common.ReasonCode("policy_blocked"),
BlockedAt: time.Unix(1_775_240_000, 0).UTC(),
},
},
{
name: "valid with actor and user",
record: BlockedEmailSubject{
Email: common.Email("pilot@example.com"),
ReasonCode: common.ReasonCode("policy_blocked"),
BlockedAt: time.Unix(1_775_240_000, 0).UTC(),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ResolvedUserID: common.UserID("user-123"),
},
},
{
name: "missing blocked at",
record: BlockedEmailSubject{
Email: common.Email("pilot@example.com"),
ReasonCode: common.ReasonCode("policy_blocked"),
},
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.record.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
+338
View File
@@ -0,0 +1,338 @@
// Package common defines shared value objects used across the user-service
// domain model.
package common
import (
"errors"
"fmt"
"net/mail"
"strings"
"time"
)
const (
maxRaceNameLength = 64
maxLanguageTagLength = 32
maxTimeZoneNameLength = 128
)
// UserID identifies one regular-platform user owned by User Service.
type UserID string
// String returns UserID as its stored identifier string.
func (id UserID) String() string {
return string(id)
}
// IsZero reports whether UserID does not contain a usable identifier.
func (id UserID) IsZero() bool {
return strings.TrimSpace(string(id)) == ""
}
// Validate reports whether UserID is non-empty, normalized, and uses the
// frozen Stage 02 prefix.
func (id UserID) Validate() error {
return validatePrefixedToken("user id", string(id), "user-")
}
// Email stores one normalized user-login e-mail address.
type Email string
// String returns Email as its stored canonical string.
func (email Email) String() string {
return string(email)
}
// IsZero reports whether Email does not contain a usable address.
func (email Email) IsZero() bool {
return strings.TrimSpace(string(email)) == ""
}
// Validate reports whether Email is non-empty, trimmed, and matches the same
// single-address syntax expected by internal REST contracts.
func (email Email) Validate() error {
raw := string(email)
if err := validateToken("email", raw); err != nil {
return err
}
parsedAddress, err := mail.ParseAddress(raw)
if err != nil || parsedAddress.Name != "" || parsedAddress.Address != raw {
return fmt.Errorf("email %q must be a single valid email address", raw)
}
return nil
}
// RaceName stores one original-casing race name selected for the user
// account.
type RaceName string
// String returns RaceName as its stored value.
func (name RaceName) String() string {
return string(name)
}
// IsZero reports whether RaceName does not contain a usable value.
func (name RaceName) IsZero() bool {
return strings.TrimSpace(string(name)) == ""
}
// Validate reports whether RaceName is non-empty, trimmed, and within the
// frozen OpenAPI length bound.
func (name RaceName) Validate() error {
raw := string(name)
if err := validateToken("race name", raw); err != nil {
return err
}
if len(raw) > maxRaceNameLength {
return fmt.Errorf("race name must be at most %d bytes", maxRaceNameLength)
}
return nil
}
// LanguageTag stores one declared BCP 47 language-tag string.
type LanguageTag string
// String returns LanguageTag as its stored value.
func (tag LanguageTag) String() string {
return string(tag)
}
// IsZero reports whether LanguageTag does not contain a usable value.
func (tag LanguageTag) IsZero() bool {
return strings.TrimSpace(string(tag)) == ""
}
// Validate reports whether LanguageTag is non-empty, trimmed, and within the
// frozen OpenAPI length bound. Stage 02 intentionally freezes the storage
// shape and not the later boundary-level BCP 47 parser choice.
func (tag LanguageTag) Validate() error {
raw := string(tag)
if err := validateToken("language tag", raw); err != nil {
return err
}
if len(raw) > maxLanguageTagLength {
return fmt.Errorf("language tag must be at most %d bytes", maxLanguageTagLength)
}
return nil
}
// TimeZoneName stores one declared IANA time-zone name.
type TimeZoneName string
// String returns TimeZoneName as its stored value.
func (name TimeZoneName) String() string {
return string(name)
}
// IsZero reports whether TimeZoneName does not contain a usable value.
func (name TimeZoneName) IsZero() bool {
return strings.TrimSpace(string(name)) == ""
}
// Validate reports whether TimeZoneName is non-empty, trimmed, and within the
// frozen OpenAPI length bound. Later application stages may tighten
// boundary-level validation further.
func (name TimeZoneName) Validate() error {
raw := string(name)
if err := validateToken("time zone name", raw); err != nil {
return err
}
if len(raw) > maxTimeZoneNameLength {
return fmt.Errorf("time zone name must be at most %d bytes", maxTimeZoneNameLength)
}
return nil
}
// CountryCode stores one ISO 3166-1 alpha-2 code.
type CountryCode string
// String returns CountryCode as its stored value.
func (code CountryCode) String() string {
return string(code)
}
// IsZero reports whether CountryCode does not contain a usable value.
func (code CountryCode) IsZero() bool {
return strings.TrimSpace(string(code)) == ""
}
// Validate reports whether CountryCode is an uppercase ISO 3166-1 alpha-2
// code.
func (code CountryCode) Validate() error {
raw := string(code)
if len(raw) != 2 {
return fmt.Errorf("country code %q must contain exactly two letters", raw)
}
for idx := 0; idx < len(raw); idx++ {
if raw[idx] < 'A' || raw[idx] > 'Z' {
return fmt.Errorf("country code %q must contain only uppercase ASCII letters", raw)
}
}
return nil
}
// ActorType stores one machine-readable actor type for audit metadata.
type ActorType string
// String returns ActorType as its stored value.
func (actorType ActorType) String() string {
return string(actorType)
}
// IsZero reports whether ActorType does not contain a usable value.
func (actorType ActorType) IsZero() bool {
return strings.TrimSpace(string(actorType)) == ""
}
// Validate reports whether ActorType is non-empty and trimmed.
func (actorType ActorType) Validate() error {
return validateToken("actor type", string(actorType))
}
// ActorID stores one optional stable actor identifier.
type ActorID string
// String returns ActorID as its stored value.
func (actorID ActorID) String() string {
return string(actorID)
}
// IsZero reports whether ActorID does not contain a usable value.
func (actorID ActorID) IsZero() bool {
return strings.TrimSpace(string(actorID)) == ""
}
// Validate reports whether ActorID is trimmed when present.
func (actorID ActorID) Validate() error {
if actorID.IsZero() {
return nil
}
return validateToken("actor id", string(actorID))
}
// ActorRef stores actor metadata captured on trusted mutations.
type ActorRef struct {
// Type identifies the machine-readable actor class such as `admin`,
// `service`, or `billing`.
Type ActorType
// ID stores the optional stable actor identifier.
ID ActorID
}
// IsZero reports whether ActorRef does not contain any audit actor metadata.
func (ref ActorRef) IsZero() bool {
return ref.Type.IsZero() && ref.ID.IsZero()
}
// Validate reports whether ActorRef contains a required type and an optional
// trimmed identifier.
func (ref ActorRef) Validate() error {
if err := ref.Type.Validate(); err != nil {
return fmt.Errorf("actor ref type: %w", err)
}
if err := ref.ID.Validate(); err != nil {
return fmt.Errorf("actor ref id: %w", err)
}
return nil
}
// ReasonCode stores one machine-readable reason code.
type ReasonCode string
// String returns ReasonCode as its stored value.
func (code ReasonCode) String() string {
return string(code)
}
// IsZero reports whether ReasonCode does not contain a usable value.
func (code ReasonCode) IsZero() bool {
return strings.TrimSpace(string(code)) == ""
}
// Validate reports whether ReasonCode is non-empty and trimmed.
func (code ReasonCode) Validate() error {
return validateToken("reason code", string(code))
}
// Source stores one machine-readable mutation source.
type Source string
// String returns Source as its stored value.
func (source Source) String() string {
return string(source)
}
// IsZero reports whether Source does not contain a usable value.
func (source Source) IsZero() bool {
return strings.TrimSpace(string(source)) == ""
}
// Validate reports whether Source is non-empty and trimmed.
func (source Source) Validate() error {
return validateToken("source", string(source))
}
// Scope stores one machine-readable sanction scope.
type Scope string
// String returns Scope as its stored value.
func (scope Scope) String() string {
return string(scope)
}
// IsZero reports whether Scope does not contain a usable value.
func (scope Scope) IsZero() bool {
return strings.TrimSpace(string(scope)) == ""
}
// Validate reports whether Scope is non-empty and trimmed.
func (scope Scope) Validate() error {
return validateToken("scope", string(scope))
}
// ValidateTimestamp reports whether value is set.
func ValidateTimestamp(name string, value time.Time) error {
if value.IsZero() {
return fmt.Errorf("%s must not be zero", name)
}
return nil
}
func validateToken(name string, value string) error {
switch {
case strings.TrimSpace(value) == "":
return fmt.Errorf("%s must not be empty", name)
case strings.TrimSpace(value) != value:
return fmt.Errorf("%s must not contain surrounding whitespace", name)
default:
return nil
}
}
func validatePrefixedToken(name string, value string, prefix string) error {
if err := validateToken(name, value); err != nil {
return err
}
if !strings.HasPrefix(value, prefix) {
return fmt.Errorf("%s must start with %q", name, prefix)
}
if len(value) == len(prefix) {
return fmt.Errorf("%s must contain opaque data after %q", name, prefix)
}
return nil
}
// ErrInvertedTimeRange reports that the logical end of a range is not after
// its start.
var ErrInvertedTimeRange = errors.New("time range end must be after start")
+207
View File
@@ -0,0 +1,207 @@
package common
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestUserIDValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value UserID
wantErr bool
}{
{name: "valid", value: UserID("user-abc123")},
{name: "empty", value: UserID(""), wantErr: true},
{name: "surrounding whitespace", value: UserID(" user-abc123 "), wantErr: true},
{name: "wrong prefix", value: UserID("account-abc123"), wantErr: true},
{name: "prefix only", value: UserID("user-"), wantErr: true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.value.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestEmailValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value Email
wantErr bool
}{
{name: "valid", value: Email("pilot@example.com")},
{name: "empty", value: Email(""), wantErr: true},
{name: "display name", value: Email("Pilot <pilot@example.com>"), wantErr: true},
{name: "invalid", value: Email("not-an-email"), wantErr: true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.value.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestRaceNameValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value RaceName
wantErr bool
}{
{name: "valid", value: RaceName("Admiral Nova")},
{name: "empty", value: RaceName(""), wantErr: true},
{name: "too long", value: RaceName("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmn"), wantErr: true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.value.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestLanguageTagValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value LanguageTag
wantErr bool
}{
{name: "valid", value: LanguageTag("en-US")},
{name: "empty", value: LanguageTag(""), wantErr: true},
{name: "surrounding whitespace", value: LanguageTag(" en "), wantErr: true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.value.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestTimeZoneNameValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value TimeZoneName
wantErr bool
}{
{name: "valid", value: TimeZoneName("Europe/Berlin")},
{name: "empty", value: TimeZoneName(""), wantErr: true},
{name: "surrounding whitespace", value: TimeZoneName(" UTC "), wantErr: true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.value.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestCountryCodeValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value CountryCode
wantErr bool
}{
{name: "valid", value: CountryCode("DE")},
{name: "lowercase", value: CountryCode("de"), wantErr: true},
{name: "wrong length", value: CountryCode("DEU"), wantErr: true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.value.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestActorRefValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value ActorRef
wantErr bool
}{
{name: "valid without id", value: ActorRef{Type: ActorType("service")}},
{name: "valid with id", value: ActorRef{Type: ActorType("admin"), ID: ActorID("admin-1")}},
{name: "missing type", value: ActorRef{ID: ActorID("admin-1")}, wantErr: true},
{name: "invalid id whitespace", value: ActorRef{Type: ActorType("admin"), ID: ActorID(" admin-1 ")}, wantErr: true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.value.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
+325
View File
@@ -0,0 +1,325 @@
// Package entitlement defines the logical entitlement entities owned by User
// Service.
package entitlement
import (
"fmt"
"strings"
"time"
"galaxy/user/internal/domain/common"
)
// PlanCode identifies one supported entitlement plan.
type PlanCode string
const (
// PlanCodeFree reports the free default entitlement.
PlanCodeFree PlanCode = "free"
// PlanCodePaidMonthly reports a finite monthly paid entitlement.
PlanCodePaidMonthly PlanCode = "paid_monthly"
// PlanCodePaidYearly reports a finite yearly paid entitlement.
PlanCodePaidYearly PlanCode = "paid_yearly"
// PlanCodePaidLifetime reports a non-expiring paid entitlement.
PlanCodePaidLifetime PlanCode = "paid_lifetime"
)
// IsKnown reports whether PlanCode belongs to the frozen v1 catalog.
func (code PlanCode) IsKnown() bool {
switch code {
case PlanCodeFree, PlanCodePaidMonthly, PlanCodePaidYearly, PlanCodePaidLifetime:
return true
default:
return false
}
}
// IsPaid reports whether PlanCode represents a paid entitlement state.
func (code PlanCode) IsPaid() bool {
switch code {
case PlanCodePaidMonthly, PlanCodePaidYearly, PlanCodePaidLifetime:
return true
default:
return false
}
}
// HasFiniteExpiry reports whether PlanCode requires a bounded `ends_at`
// value in the Stage 07 entitlement timeline model.
func (code PlanCode) HasFiniteExpiry() bool {
switch code {
case PlanCodePaidMonthly, PlanCodePaidYearly:
return true
default:
return false
}
}
// EntitlementRecordID identifies one immutable entitlement history record.
type EntitlementRecordID string
// String returns EntitlementRecordID as its stored identifier string.
func (id EntitlementRecordID) String() string {
return string(id)
}
// IsZero reports whether EntitlementRecordID does not contain a usable value.
func (id EntitlementRecordID) IsZero() bool {
return strings.TrimSpace(string(id)) == ""
}
// Validate reports whether EntitlementRecordID is non-empty, normalized, and
// uses the frozen Stage 02 prefix.
func (id EntitlementRecordID) Validate() error {
switch {
case id.IsZero():
return fmt.Errorf("entitlement record id must not be empty")
case strings.TrimSpace(string(id)) != string(id):
return fmt.Errorf("entitlement record id must not contain surrounding whitespace")
case !strings.HasPrefix(string(id), "entitlement-"):
return fmt.Errorf("entitlement record id must start with %q", "entitlement-")
case len(string(id)) == len("entitlement-"):
return fmt.Errorf("entitlement record id must contain opaque data after %q", "entitlement-")
default:
return nil
}
}
// PeriodRecord stores one entitlement-period history record.
type PeriodRecord struct {
// RecordID identifies the immutable history record.
RecordID EntitlementRecordID
// UserID identifies the account that owns the entitlement record.
UserID common.UserID
// PlanCode stores the effective plan for the recorded period.
PlanCode PlanCode
// Source stores the machine-readable mutation source.
Source common.Source
// Actor stores the audit actor metadata captured for the mutation.
Actor common.ActorRef
// ReasonCode stores the machine-readable reason for the mutation.
ReasonCode common.ReasonCode
// StartsAt stores when the period becomes effective.
StartsAt time.Time
// EndsAt stores the optional planned end of the period.
EndsAt *time.Time
// CreatedAt stores when the history record was created.
CreatedAt time.Time
// ClosedAt stores when the period was later closed early by another trusted
// mutation.
ClosedAt *time.Time
// ClosedBy stores optional audit actor metadata for the close mutation.
ClosedBy common.ActorRef
// ClosedReasonCode stores the reason for closing the period early.
ClosedReasonCode common.ReasonCode
}
// Validate reports whether PeriodRecord satisfies the frozen Stage 02
// structural invariants.
func (record PeriodRecord) Validate() error {
if err := record.RecordID.Validate(); err != nil {
return fmt.Errorf("entitlement period record id: %w", err)
}
if err := record.UserID.Validate(); err != nil {
return fmt.Errorf("entitlement period user id: %w", err)
}
if !record.PlanCode.IsKnown() {
return fmt.Errorf("entitlement period plan code %q is unsupported", record.PlanCode)
}
if err := record.Source.Validate(); err != nil {
return fmt.Errorf("entitlement period source: %w", err)
}
if err := record.Actor.Validate(); err != nil {
return fmt.Errorf("entitlement period actor: %w", err)
}
if err := record.ReasonCode.Validate(); err != nil {
return fmt.Errorf("entitlement period reason code: %w", err)
}
if err := common.ValidateTimestamp("entitlement period starts at", record.StartsAt); err != nil {
return err
}
if err := validatePlanBounds("entitlement period", record.PlanCode, record.StartsAt, record.EndsAt); err != nil {
return err
}
if err := common.ValidateTimestamp("entitlement period created at", record.CreatedAt); err != nil {
return err
}
if record.ClosedAt == nil {
if !record.ClosedBy.IsZero() {
return fmt.Errorf("entitlement period closed by must be empty when closed at is absent")
}
if !record.ClosedReasonCode.IsZero() {
return fmt.Errorf("entitlement period closed reason code must be empty when closed at is absent")
}
return nil
}
if record.ClosedAt.Before(record.StartsAt) {
return fmt.Errorf("entitlement period closed at must not be before starts at")
}
if record.EndsAt != nil && record.ClosedAt.After(*record.EndsAt) {
return fmt.Errorf("entitlement period closed at must not be after ends at")
}
if record.ClosedAt.Before(record.CreatedAt) {
return fmt.Errorf("entitlement period closed at must not be before created at")
}
if err := record.ClosedBy.Validate(); err != nil {
return fmt.Errorf("entitlement period closed by: %w", err)
}
if err := record.ClosedReasonCode.Validate(); err != nil {
return fmt.Errorf("entitlement period closed reason code: %w", err)
}
return nil
}
// IsEffectiveAt reports whether PeriodRecord is the currently effective
// segment at the supplied timestamp.
func (record PeriodRecord) IsEffectiveAt(now time.Time) bool {
if record.ClosedAt != nil {
return false
}
if record.StartsAt.After(now) {
return false
}
if record.EndsAt != nil && !record.EndsAt.After(now) {
return false
}
return true
}
// CurrentSnapshot stores the read-optimized current entitlement state of one
// user account.
type CurrentSnapshot struct {
// UserID identifies the account that owns the current entitlement.
UserID common.UserID
// PlanCode stores the current effective plan code.
PlanCode PlanCode
// IsPaid stores the materialized paid/free state used on hot read paths.
IsPaid bool
// StartsAt stores when the current effective state started.
StartsAt time.Time
// EndsAt stores the optional end of the current finite entitlement.
EndsAt *time.Time
// Source stores the machine-readable source of the current state.
Source common.Source
// Actor stores the actor metadata attached to the last successful mutation.
Actor common.ActorRef
// ReasonCode stores the machine-readable reason attached to the last
// successful mutation.
ReasonCode common.ReasonCode
// UpdatedAt stores when the snapshot was last recomputed.
UpdatedAt time.Time
}
// Validate reports whether CurrentSnapshot satisfies the frozen Stage 02
// structural invariants.
func (record CurrentSnapshot) Validate() error {
if err := record.UserID.Validate(); err != nil {
return fmt.Errorf("entitlement snapshot user id: %w", err)
}
if !record.PlanCode.IsKnown() {
return fmt.Errorf("entitlement snapshot plan code %q is unsupported", record.PlanCode)
}
if record.IsPaid != record.PlanCode.IsPaid() {
return fmt.Errorf("entitlement snapshot paid flag must match plan code %q", record.PlanCode)
}
if err := common.ValidateTimestamp("entitlement snapshot starts at", record.StartsAt); err != nil {
return err
}
if err := validatePlanBounds("entitlement snapshot", record.PlanCode, record.StartsAt, record.EndsAt); err != nil {
return err
}
if err := record.Source.Validate(); err != nil {
return fmt.Errorf("entitlement snapshot source: %w", err)
}
if err := record.Actor.Validate(); err != nil {
return fmt.Errorf("entitlement snapshot actor: %w", err)
}
if err := record.ReasonCode.Validate(); err != nil {
return fmt.Errorf("entitlement snapshot reason code: %w", err)
}
if err := common.ValidateTimestamp("entitlement snapshot updated at", record.UpdatedAt); err != nil {
return err
}
return nil
}
// HasFiniteExpiry reports whether CurrentSnapshot participates in the finite
// paid-expiry index.
func (record CurrentSnapshot) HasFiniteExpiry() bool {
return record.IsPaid && record.EndsAt != nil
}
// IsExpiredAt reports whether CurrentSnapshot represents a finite paid state
// that has already reached its stored expiry.
func (record CurrentSnapshot) IsExpiredAt(now time.Time) bool {
return record.HasFiniteExpiry() && !record.EndsAt.After(now)
}
// PaidState identifies the coarse free-versus-paid filter used by admin
// listing.
type PaidState string
const (
// PaidStateFree filters accounts whose current entitlement is free.
PaidStateFree PaidState = "free"
// PaidStatePaid filters accounts whose current entitlement is paid.
PaidStatePaid PaidState = "paid"
)
// IsKnown reports whether PaidState belongs to the frozen Stage 02 filter
// vocabulary.
func (state PaidState) IsKnown() bool {
switch state {
case "", PaidStateFree, PaidStatePaid:
return true
default:
return false
}
}
func validatePlanBounds(
name string,
planCode PlanCode,
startsAt time.Time,
endsAt *time.Time,
) error {
switch {
case planCode.HasFiniteExpiry():
if endsAt == nil {
return fmt.Errorf("%s ends at must be present for plan code %q", name, planCode)
}
if !endsAt.After(startsAt) {
return common.ErrInvertedTimeRange
}
case endsAt != nil:
return fmt.Errorf("%s ends at must be empty for plan code %q", name, planCode)
}
return nil
}
@@ -0,0 +1,159 @@
package entitlement
import (
"testing"
"time"
"galaxy/user/internal/domain/common"
"github.com/stretchr/testify/require"
)
func TestPeriodRecordValidate(t *testing.T) {
t.Parallel()
startsAt := time.Unix(1_775_240_000, 0).UTC()
endsAt := startsAt.Add(30 * 24 * time.Hour)
createdAt := startsAt.Add(-time.Hour)
closedAt := startsAt.Add(12 * time.Hour)
tests := []struct {
name string
record PeriodRecord
wantErr bool
}{
{
name: "valid open record",
record: PeriodRecord{
RecordID: EntitlementRecordID("entitlement-123"),
UserID: common.UserID("user-123"),
PlanCode: PlanCodePaidMonthly,
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_grant"),
StartsAt: startsAt,
EndsAt: &endsAt,
CreatedAt: createdAt,
},
},
{
name: "valid closed record",
record: PeriodRecord{
RecordID: EntitlementRecordID("entitlement-123"),
UserID: common.UserID("user-123"),
PlanCode: PlanCodePaidMonthly,
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_grant"),
StartsAt: startsAt,
EndsAt: &endsAt,
CreatedAt: createdAt,
ClosedAt: &closedAt,
ClosedBy: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")},
ClosedReasonCode: common.ReasonCode("manual_revoke"),
},
},
{
name: "close metadata without closed at",
record: PeriodRecord{
RecordID: EntitlementRecordID("entitlement-123"),
UserID: common.UserID("user-123"),
PlanCode: PlanCodePaidMonthly,
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_grant"),
StartsAt: startsAt,
CreatedAt: createdAt,
ClosedReasonCode: common.ReasonCode("manual_revoke"),
},
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.record.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestCurrentSnapshotValidate(t *testing.T) {
t.Parallel()
startsAt := time.Unix(1_775_240_000, 0).UTC()
endsAt := startsAt.Add(30 * 24 * time.Hour)
updatedAt := startsAt.Add(2 * time.Hour)
tests := []struct {
name string
record CurrentSnapshot
wantErr bool
wantFinite bool
}{
{
name: "valid finite paid snapshot",
record: CurrentSnapshot{
UserID: common.UserID("user-123"),
PlanCode: PlanCodePaidMonthly,
IsPaid: true,
StartsAt: startsAt,
EndsAt: &endsAt,
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_grant"),
UpdatedAt: updatedAt,
},
wantFinite: true,
},
{
name: "valid free snapshot",
record: CurrentSnapshot{
UserID: common.UserID("user-123"),
PlanCode: PlanCodeFree,
IsPaid: false,
StartsAt: startsAt,
Source: common.Source("system"),
Actor: common.ActorRef{Type: common.ActorType("service")},
ReasonCode: common.ReasonCode("default_free_plan"),
UpdatedAt: updatedAt,
},
},
{
name: "paid flag mismatch",
record: CurrentSnapshot{
UserID: common.UserID("user-123"),
PlanCode: PlanCodeFree,
IsPaid: true,
StartsAt: startsAt,
Source: common.Source("system"),
Actor: common.ActorRef{Type: common.ActorType("service")},
ReasonCode: common.ReasonCode("default_free_plan"),
UpdatedAt: updatedAt,
},
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.record.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tt.wantFinite, tt.record.HasFiniteExpiry())
})
}
}
+511
View File
@@ -0,0 +1,511 @@
// Package policy defines sanction, limit, and eligibility-domain entities used
// by User Service.
package policy
import (
"fmt"
"slices"
"strings"
"time"
"galaxy/user/internal/domain/common"
)
// SanctionCode identifies one supported sanction in the v1 policy catalog.
type SanctionCode string
const (
// SanctionCodeLoginBlock denies login.
SanctionCodeLoginBlock SanctionCode = "login_block"
// SanctionCodePrivateGameCreateBlock denies private-game creation.
SanctionCodePrivateGameCreateBlock SanctionCode = "private_game_create_block"
// SanctionCodePrivateGameManageBlock denies private-game management.
SanctionCodePrivateGameManageBlock SanctionCode = "private_game_manage_block"
// SanctionCodeGameJoinBlock denies game joining.
SanctionCodeGameJoinBlock SanctionCode = "game_join_block"
// SanctionCodeProfileUpdateBlock denies self-service profile/settings
// mutations.
SanctionCodeProfileUpdateBlock SanctionCode = "profile_update_block"
)
// IsKnown reports whether SanctionCode belongs to the frozen v1 catalog.
func (code SanctionCode) IsKnown() bool {
switch code {
case SanctionCodeLoginBlock,
SanctionCodePrivateGameCreateBlock,
SanctionCodePrivateGameManageBlock,
SanctionCodeGameJoinBlock,
SanctionCodeProfileUpdateBlock:
return true
default:
return false
}
}
// LimitCode identifies one user-specific limit code recognized by User
// Service.
type LimitCode string
const (
// LimitCodeMaxOwnedPrivateGames limits how many private games the user may
// own while the current entitlement is paid.
LimitCodeMaxOwnedPrivateGames LimitCode = "max_owned_private_games"
// LimitCodeMaxPendingPublicApplications stores the total public-games budget
// consumed together with current active public memberships when Game Lobby
// derives remaining pending application headroom.
LimitCodeMaxPendingPublicApplications LimitCode = "max_pending_public_applications"
// LimitCodeMaxActiveGameMemberships limits how many active public-game
// memberships the user may hold at once.
LimitCodeMaxActiveGameMemberships LimitCode = "max_active_game_memberships"
)
const (
// LimitCodeMaxActivePrivateGames is a retired legacy code recognized only
// so old stored records do not break current reads.
LimitCodeMaxActivePrivateGames LimitCode = "max_active_private_games"
// LimitCodeMaxPendingPrivateJoinRequests is a retired legacy code
// recognized only so old stored records do not break current reads.
LimitCodeMaxPendingPrivateJoinRequests LimitCode = "max_pending_private_join_requests"
// LimitCodeMaxPendingPrivateInvitesSent is a retired legacy code
// recognized only so old stored records do not break current reads.
LimitCodeMaxPendingPrivateInvitesSent LimitCode = "max_pending_private_invites_sent"
)
// IsKnown reports whether LimitCode belongs to the current supported write/API
// catalog.
func (code LimitCode) IsKnown() bool {
return code.IsSupported()
}
// IsSupported reports whether LimitCode belongs to the current supported
// write/API catalog.
func (code LimitCode) IsSupported() bool {
switch code {
case LimitCodeMaxOwnedPrivateGames,
LimitCodeMaxPendingPublicApplications,
LimitCodeMaxActiveGameMemberships:
return true
default:
return false
}
}
// IsRetired reports whether LimitCode is a retired legacy code recognized
// only for read compatibility with already stored history records.
func (code LimitCode) IsRetired() bool {
switch code {
case LimitCodeMaxActivePrivateGames,
LimitCodeMaxPendingPrivateJoinRequests,
LimitCodeMaxPendingPrivateInvitesSent:
return true
default:
return false
}
}
// IsRecognized reports whether LimitCode is either currently supported or
// retired-but-recognized for read compatibility.
func (code LimitCode) IsRecognized() bool {
return code.IsSupported() || code.IsRetired()
}
// EligibilityMarker identifies one derived eligibility boolean that may be
// indexed for admin listing.
type EligibilityMarker string
const (
// EligibilityMarkerCanLogin tracks whether the user may currently log in.
EligibilityMarkerCanLogin EligibilityMarker = "can_login"
// EligibilityMarkerCanCreatePrivateGame tracks whether the user may create
// a private game.
EligibilityMarkerCanCreatePrivateGame EligibilityMarker = "can_create_private_game"
// EligibilityMarkerCanManagePrivateGame tracks whether the user may manage
// a private game.
EligibilityMarkerCanManagePrivateGame EligibilityMarker = "can_manage_private_game"
// EligibilityMarkerCanJoinGame tracks whether the user may join a game.
EligibilityMarkerCanJoinGame EligibilityMarker = "can_join_game"
// EligibilityMarkerCanUpdateProfile tracks whether the user may update
// self-service profile/settings fields.
EligibilityMarkerCanUpdateProfile EligibilityMarker = "can_update_profile"
)
// IsKnown reports whether EligibilityMarker belongs to the frozen v1 set.
func (marker EligibilityMarker) IsKnown() bool {
switch marker {
case EligibilityMarkerCanLogin,
EligibilityMarkerCanCreatePrivateGame,
EligibilityMarkerCanManagePrivateGame,
EligibilityMarkerCanJoinGame,
EligibilityMarkerCanUpdateProfile:
return true
default:
return false
}
}
// SanctionRecordID identifies one sanction history record.
type SanctionRecordID string
// String returns SanctionRecordID as its stored identifier string.
func (id SanctionRecordID) String() string {
return string(id)
}
// IsZero reports whether SanctionRecordID does not contain a usable value.
func (id SanctionRecordID) IsZero() bool {
return strings.TrimSpace(string(id)) == ""
}
// Validate reports whether SanctionRecordID is non-empty, normalized, and
// uses the frozen Stage 02 prefix.
func (id SanctionRecordID) Validate() error {
return validatePrefixedRecordID("sanction record id", string(id), "sanction-")
}
// LimitRecordID identifies one limit history record.
type LimitRecordID string
// String returns LimitRecordID as its stored identifier string.
func (id LimitRecordID) String() string {
return string(id)
}
// IsZero reports whether LimitRecordID does not contain a usable value.
func (id LimitRecordID) IsZero() bool {
return strings.TrimSpace(string(id)) == ""
}
// Validate reports whether LimitRecordID is non-empty, normalized, and uses
// the frozen Stage 02 prefix.
func (id LimitRecordID) Validate() error {
return validatePrefixedRecordID("limit record id", string(id), "limit-")
}
// SanctionRecord stores one sanction history record.
type SanctionRecord struct {
// RecordID identifies the sanction history record.
RecordID SanctionRecordID
// UserID identifies the account that owns the sanction.
UserID common.UserID
// SanctionCode stores the sanction applied to the account.
SanctionCode SanctionCode
// Scope stores the machine-readable scope attached to the sanction.
Scope common.Scope
// ReasonCode stores the reason for the sanction mutation.
ReasonCode common.ReasonCode
// Actor stores the audit actor metadata for the apply mutation.
Actor common.ActorRef
// AppliedAt stores when the sanction becomes effective.
AppliedAt time.Time
// ExpiresAt stores the optional planned expiry of the sanction.
ExpiresAt *time.Time
// RemovedAt stores when the sanction was later removed explicitly.
RemovedAt *time.Time
// RemovedBy stores the audit actor metadata for the remove mutation.
RemovedBy common.ActorRef
// RemovedReasonCode stores the reason for the remove mutation.
RemovedReasonCode common.ReasonCode
}
// Validate reports whether SanctionRecord satisfies the frozen structural
// invariants that do not depend on a caller-supplied clock.
func (record SanctionRecord) Validate() error {
if err := record.RecordID.Validate(); err != nil {
return fmt.Errorf("sanction record id: %w", err)
}
if err := record.UserID.Validate(); err != nil {
return fmt.Errorf("sanction user id: %w", err)
}
if !record.SanctionCode.IsKnown() {
return fmt.Errorf("sanction code %q is unsupported", record.SanctionCode)
}
if err := record.Scope.Validate(); err != nil {
return fmt.Errorf("sanction scope: %w", err)
}
if err := record.ReasonCode.Validate(); err != nil {
return fmt.Errorf("sanction reason code: %w", err)
}
if err := record.Actor.Validate(); err != nil {
return fmt.Errorf("sanction actor: %w", err)
}
if err := common.ValidateTimestamp("sanction applied at", record.AppliedAt); err != nil {
return err
}
if record.ExpiresAt != nil && !record.ExpiresAt.After(record.AppliedAt) {
return common.ErrInvertedTimeRange
}
if record.RemovedAt == nil {
if !record.RemovedBy.IsZero() {
return fmt.Errorf("sanction removed by must be empty when removed at is absent")
}
if !record.RemovedReasonCode.IsZero() {
return fmt.Errorf("sanction removed reason code must be empty when removed at is absent")
}
return nil
}
if record.RemovedAt.Before(record.AppliedAt) {
return fmt.Errorf("sanction removed at must not be before applied at")
}
if err := record.RemovedBy.Validate(); err != nil {
return fmt.Errorf("sanction removed by: %w", err)
}
if err := record.RemovedReasonCode.Validate(); err != nil {
return fmt.Errorf("sanction removed reason code: %w", err)
}
return nil
}
// ValidateAt reports whether SanctionRecord also satisfies the current-time
// Stage 02 invariant that `applied_at` must not be in the future.
func (record SanctionRecord) ValidateAt(now time.Time) error {
if err := record.Validate(); err != nil {
return err
}
if now.IsZero() {
return fmt.Errorf("sanction validation time must not be zero")
}
if record.AppliedAt.After(now.UTC()) {
return fmt.Errorf("sanction applied at must not be in the future")
}
return nil
}
// IsActiveAt reports whether SanctionRecord is active at now according to the
// frozen Stage 02 rules.
func (record SanctionRecord) IsActiveAt(now time.Time) bool {
now = now.UTC()
switch {
case now.IsZero():
return false
case record.AppliedAt.After(now):
return false
case record.RemovedAt != nil:
return false
case record.ExpiresAt != nil && !record.ExpiresAt.After(now):
return false
default:
return true
}
}
// LimitRecord stores one user-specific limit history record.
type LimitRecord struct {
// RecordID identifies the limit history record.
RecordID LimitRecordID
// UserID identifies the account that owns the limit.
UserID common.UserID
// LimitCode stores which count-based limit is overridden.
LimitCode LimitCode
// Value stores the override value.
Value int
// ReasonCode stores the reason for the limit mutation.
ReasonCode common.ReasonCode
// Actor stores the audit actor metadata for the set mutation.
Actor common.ActorRef
// AppliedAt stores when the limit becomes effective.
AppliedAt time.Time
// ExpiresAt stores the optional planned expiry of the limit.
ExpiresAt *time.Time
// RemovedAt stores when the limit was later removed explicitly.
RemovedAt *time.Time
// RemovedBy stores the audit actor metadata for the remove mutation.
RemovedBy common.ActorRef
// RemovedReasonCode stores the reason for the remove mutation.
RemovedReasonCode common.ReasonCode
}
// Validate reports whether LimitRecord satisfies the structural invariants
// that do not depend on a caller-supplied clock. Retired legacy limit codes
// remain recognized here so already stored records still decode safely.
func (record LimitRecord) Validate() error {
if err := record.RecordID.Validate(); err != nil {
return fmt.Errorf("limit record id: %w", err)
}
if err := record.UserID.Validate(); err != nil {
return fmt.Errorf("limit user id: %w", err)
}
if !record.LimitCode.IsRecognized() {
return fmt.Errorf("limit code %q is unsupported", record.LimitCode)
}
if record.Value < 0 {
return fmt.Errorf("limit value must not be negative")
}
if err := record.ReasonCode.Validate(); err != nil {
return fmt.Errorf("limit reason code: %w", err)
}
if err := record.Actor.Validate(); err != nil {
return fmt.Errorf("limit actor: %w", err)
}
if err := common.ValidateTimestamp("limit applied at", record.AppliedAt); err != nil {
return err
}
if record.ExpiresAt != nil && !record.ExpiresAt.After(record.AppliedAt) {
return common.ErrInvertedTimeRange
}
if record.RemovedAt == nil {
if !record.RemovedBy.IsZero() {
return fmt.Errorf("limit removed by must be empty when removed at is absent")
}
if !record.RemovedReasonCode.IsZero() {
return fmt.Errorf("limit removed reason code must be empty when removed at is absent")
}
return nil
}
if record.RemovedAt.Before(record.AppliedAt) {
return fmt.Errorf("limit removed at must not be before applied at")
}
if err := record.RemovedBy.Validate(); err != nil {
return fmt.Errorf("limit removed by: %w", err)
}
if err := record.RemovedReasonCode.Validate(); err != nil {
return fmt.Errorf("limit removed reason code: %w", err)
}
return nil
}
// ValidateAt reports whether LimitRecord also satisfies the current-time Stage
// 02 invariant that `applied_at` must not be in the future.
func (record LimitRecord) ValidateAt(now time.Time) error {
if err := record.Validate(); err != nil {
return err
}
if now.IsZero() {
return fmt.Errorf("limit validation time must not be zero")
}
if record.AppliedAt.After(now.UTC()) {
return fmt.Errorf("limit applied at must not be in the future")
}
return nil
}
// IsActiveAt reports whether LimitRecord is active at now according to the
// frozen Stage 02 rules.
func (record LimitRecord) IsActiveAt(now time.Time) bool {
now = now.UTC()
switch {
case now.IsZero():
return false
case record.AppliedAt.After(now):
return false
case record.RemovedAt != nil:
return false
case record.ExpiresAt != nil && !record.ExpiresAt.After(now):
return false
default:
return true
}
}
// ActiveSanctionsAt returns the active sanctions at now, sorted
// deterministically by `sanction_code`. The function returns an error when the
// input contains structurally invalid records or more than one active sanction
// for the same `user_id + sanction_code`.
func ActiveSanctionsAt(records []SanctionRecord, now time.Time) ([]SanctionRecord, error) {
active := make([]SanctionRecord, 0, len(records))
seen := make(map[SanctionCode]struct{}, len(records))
for _, record := range records {
if err := record.ValidateAt(now); err != nil {
return nil, err
}
if !record.IsActiveAt(now) {
continue
}
if _, ok := seen[record.SanctionCode]; ok {
return nil, fmt.Errorf("multiple active sanctions for code %q", record.SanctionCode)
}
seen[record.SanctionCode] = struct{}{}
active = append(active, record)
}
slices.SortFunc(active, func(left SanctionRecord, right SanctionRecord) int {
return strings.Compare(string(left.SanctionCode), string(right.SanctionCode))
})
return active, nil
}
// ActiveLimitsAt returns the active limits at now, sorted deterministically by
// `limit_code`. Retired legacy limit codes are ignored so historical records
// stored under the old catalog do not affect current effective reads. The
// function returns an error when the input contains structurally invalid
// records or more than one active current limit for the same
// `user_id + limit_code`.
func ActiveLimitsAt(records []LimitRecord, now time.Time) ([]LimitRecord, error) {
active := make([]LimitRecord, 0, len(records))
seen := make(map[LimitCode]struct{}, len(records))
for _, record := range records {
if err := record.ValidateAt(now); err != nil {
return nil, err
}
if !record.IsActiveAt(now) {
continue
}
if !record.LimitCode.IsSupported() {
continue
}
if _, ok := seen[record.LimitCode]; ok {
return nil, fmt.Errorf("multiple active limits for code %q", record.LimitCode)
}
seen[record.LimitCode] = struct{}{}
active = append(active, record)
}
slices.SortFunc(active, func(left LimitRecord, right LimitRecord) int {
return strings.Compare(string(left.LimitCode), string(right.LimitCode))
})
return active, nil
}
func validatePrefixedRecordID(name string, value string, prefix string) error {
switch {
case strings.TrimSpace(value) == "":
return fmt.Errorf("%s must not be empty", name)
case strings.TrimSpace(value) != value:
return fmt.Errorf("%s must not contain surrounding whitespace", name)
case !strings.HasPrefix(value, prefix):
return fmt.Errorf("%s must start with %q", name, prefix)
case len(value) == len(prefix):
return fmt.Errorf("%s must contain opaque data after %q", name, prefix)
default:
return nil
}
}
+236
View File
@@ -0,0 +1,236 @@
package policy
import (
"testing"
"time"
"galaxy/user/internal/domain/common"
"github.com/stretchr/testify/require"
)
func TestSanctionRecordValidateAt(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
expiresAt := now.Add(time.Hour)
removedAt := now.Add(30 * time.Minute)
tests := []struct {
name string
record SanctionRecord
wantErr bool
wantActive bool
}{
{
name: "active",
record: SanctionRecord{
RecordID: SanctionRecordID("sanction-1"),
UserID: common.UserID("user-123"),
SanctionCode: SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("policy_blocked"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(-time.Minute),
ExpiresAt: &expiresAt,
},
wantActive: true,
},
{
name: "expired",
record: SanctionRecord{
RecordID: SanctionRecordID("sanction-1"),
UserID: common.UserID("user-123"),
SanctionCode: SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("policy_blocked"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(-2 * time.Hour),
ExpiresAt: ptrTime(now.Add(-time.Minute)),
},
},
{
name: "removed",
record: SanctionRecord{
RecordID: SanctionRecordID("sanction-1"),
UserID: common.UserID("user-123"),
SanctionCode: SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("policy_blocked"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(-time.Hour),
RemovedAt: &removedAt,
RemovedBy: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")},
RemovedReasonCode: common.ReasonCode("manual_remove"),
},
},
{
name: "future applied at",
record: SanctionRecord{
RecordID: SanctionRecordID("sanction-1"),
UserID: common.UserID("user-123"),
SanctionCode: SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("policy_blocked"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(time.Minute),
},
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.record.ValidateAt(now)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tt.wantActive, tt.record.IsActiveAt(now))
})
}
}
func TestActiveSanctionsAt(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
records := []SanctionRecord{
{
RecordID: SanctionRecordID("sanction-1"),
UserID: common.UserID("user-123"),
SanctionCode: SanctionCodeProfileUpdateBlock,
Scope: common.Scope("profile"),
ReasonCode: common.ReasonCode("moderation"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(-time.Hour),
},
{
RecordID: SanctionRecordID("sanction-2"),
UserID: common.UserID("user-123"),
SanctionCode: SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("policy"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")},
AppliedAt: now.Add(-2 * time.Hour),
ExpiresAt: ptrTime(now.Add(-time.Minute)),
},
}
active, err := ActiveSanctionsAt(records, now)
require.NoError(t, err)
require.Len(t, active, 1)
require.Equal(t, SanctionCodeProfileUpdateBlock, active[0].SanctionCode)
}
func TestActiveSanctionsAtDuplicateActiveCode(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
_, err := ActiveSanctionsAt([]SanctionRecord{
{
RecordID: SanctionRecordID("sanction-1"),
UserID: common.UserID("user-123"),
SanctionCode: SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("policy"),
Actor: common.ActorRef{Type: common.ActorType("admin")},
AppliedAt: now.Add(-time.Hour),
},
{
RecordID: SanctionRecordID("sanction-2"),
UserID: common.UserID("user-123"),
SanctionCode: SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("policy"),
Actor: common.ActorRef{Type: common.ActorType("admin")},
AppliedAt: now.Add(-2 * time.Hour),
},
}, now)
require.Error(t, err)
}
func TestLimitRecordValidateAtAndActiveLimits(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
record := LimitRecord{
RecordID: LimitRecordID("limit-1"),
UserID: common.UserID("user-123"),
LimitCode: LimitCodeMaxOwnedPrivateGames,
Value: 3,
ReasonCode: common.ReasonCode("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(-time.Minute),
}
require.NoError(t, record.ValidateAt(now))
require.True(t, record.IsActiveAt(now))
active, err := ActiveLimitsAt([]LimitRecord{
record,
{
RecordID: LimitRecordID("limit-2"),
UserID: common.UserID("user-123"),
LimitCode: LimitCodeMaxActivePrivateGames,
Value: 7,
ReasonCode: common.ReasonCode("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin")},
AppliedAt: now.Add(-time.Hour),
},
}, now)
require.NoError(t, err)
require.Len(t, active, 1)
require.Equal(t, LimitCodeMaxOwnedPrivateGames, active[0].LimitCode)
}
func TestLimitCodeSupportAndRetiredRecognition(t *testing.T) {
t.Parallel()
require.True(t, LimitCodeMaxOwnedPrivateGames.IsSupported())
require.True(t, LimitCodeMaxPendingPublicApplications.IsSupported())
require.True(t, LimitCodeMaxActiveGameMemberships.IsSupported())
require.True(t, LimitCodeMaxActivePrivateGames.IsRetired())
require.True(t, LimitCodeMaxPendingPrivateJoinRequests.IsRetired())
require.True(t, LimitCodeMaxPendingPrivateInvitesSent.IsRetired())
require.True(t, LimitCodeMaxActivePrivateGames.IsRecognized())
require.False(t, LimitCode("unknown_limit").IsRecognized())
require.False(t, LimitCodeMaxActivePrivateGames.IsKnown())
}
func TestActiveLimitsAtDuplicateActiveCode(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
_, err := ActiveLimitsAt([]LimitRecord{
{
RecordID: LimitRecordID("limit-1"),
UserID: common.UserID("user-123"),
LimitCode: LimitCodeMaxOwnedPrivateGames,
Value: 2,
ReasonCode: common.ReasonCode("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin")},
AppliedAt: now.Add(-time.Hour),
},
{
RecordID: LimitRecordID("limit-2"),
UserID: common.UserID("user-123"),
LimitCode: LimitCodeMaxOwnedPrivateGames,
Value: 5,
ReasonCode: common.ReasonCode("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin")},
AppliedAt: now.Add(-2 * time.Hour),
},
}, now)
require.Error(t, err)
}
func ptrTime(value time.Time) *time.Time {
return &value
}
+43
View File
@@ -0,0 +1,43 @@
// Package logging configures the user-service process logger and provides
// context-aware helpers for attaching OpenTelemetry trace identifiers.
package logging
import (
"context"
"fmt"
"log/slog"
"os"
"strings"
"go.opentelemetry.io/otel/trace"
)
// New constructs the process-wide JSON logger from level.
func New(level string) (*slog.Logger, error) {
var slogLevel slog.Level
if err := slogLevel.UnmarshalText([]byte(strings.TrimSpace(level))); err != nil {
return nil, fmt.Errorf("build logger: %w", err)
}
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slogLevel,
})), nil
}
// TraceAttrsFromContext returns slog key-value pairs for the active
// OpenTelemetry span when ctx carries a valid span context.
func TraceAttrsFromContext(ctx context.Context) []any {
if ctx == nil {
return nil
}
spanContext := trace.SpanContextFromContext(ctx)
if !spanContext.IsValid() {
return nil
}
return []any{
"otel_trace_id", spanContext.TraceID().String(),
"otel_span_id", spanContext.SpanID().String(),
}
}
+130
View File
@@ -0,0 +1,130 @@
package ports
import (
"context"
"fmt"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
)
// CreateAccountInput stores the atomic account-create state that must commit
// together.
type CreateAccountInput struct {
// Account stores the durable user-account state.
Account account.UserAccount
// Reservation stores the canonical race-name reservation linked to Account.
Reservation account.RaceNameReservation
}
// Validate reports whether CreateAccountInput is structurally complete.
func (input CreateAccountInput) Validate() error {
if err := input.Account.Validate(); err != nil {
return fmt.Errorf("create account input account: %w", err)
}
if err := input.Reservation.Validate(); err != nil {
return fmt.Errorf("create account input reservation: %w", err)
}
if input.Account.UserID != input.Reservation.UserID {
return fmt.Errorf("create account input reservation user id must match account user id")
}
if input.Account.RaceName != input.Reservation.RaceName {
return fmt.Errorf("create account input reservation race name must match account race name")
}
return nil
}
// RenameRaceNameInput stores the atomic state required to replace one stored
// race name and its canonical reservation.
type RenameRaceNameInput struct {
// UserID identifies the account that must be updated.
UserID common.UserID
// CurrentCanonicalKey stores the currently owned canonical reservation key.
CurrentCanonicalKey account.RaceNameCanonicalKey
// NewRaceName stores the replacement exact stored race name.
NewRaceName common.RaceName
// NewReservation stores the replacement canonical reservation.
NewReservation account.RaceNameReservation
// UpdatedAt stores the account mutation timestamp.
UpdatedAt time.Time
}
// Validate reports whether RenameRaceNameInput is structurally complete.
func (input RenameRaceNameInput) Validate() error {
if err := input.UserID.Validate(); err != nil {
return fmt.Errorf("rename race name input user id: %w", err)
}
if err := input.CurrentCanonicalKey.Validate(); err != nil {
return fmt.Errorf("rename race name input current canonical key: %w", err)
}
if err := input.NewRaceName.Validate(); err != nil {
return fmt.Errorf("rename race name input race name: %w", err)
}
if err := input.NewReservation.Validate(); err != nil {
return fmt.Errorf("rename race name input reservation: %w", err)
}
if err := common.ValidateTimestamp("rename race name input updated at", input.UpdatedAt); err != nil {
return err
}
if input.NewReservation.UserID != input.UserID {
return fmt.Errorf("rename race name input reservation user id must match user id")
}
if input.NewReservation.RaceName != input.NewRaceName {
return fmt.Errorf("rename race name input reservation race name must match new race name")
}
return nil
}
// UserAccountStore persists source-of-truth user-account records and their
// exact lookup mappings.
type UserAccountStore interface {
// Create stores one new account record. Implementations must wrap
// ErrConflict when the user id, e-mail, or exact race-name lookup already
// exists.
Create(ctx context.Context, input CreateAccountInput) error
// GetByUserID returns the stored account identified by userID.
GetByUserID(ctx context.Context, userID common.UserID) (account.UserAccount, error)
// GetByEmail returns the stored account identified by the normalized e-mail
// address.
GetByEmail(ctx context.Context, email common.Email) (account.UserAccount, error)
// GetByRaceName returns the stored account identified by the exact stored
// race name.
GetByRaceName(ctx context.Context, raceName common.RaceName) (account.UserAccount, error)
// ExistsByUserID reports whether userID currently identifies a stored
// account.
ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error)
// RenameRaceName replaces the stored race name of userID and swaps the
// exact race-name lookup atomically. Implementations must wrap ErrConflict
// when newRaceName is already owned by another account.
RenameRaceName(ctx context.Context, input RenameRaceNameInput) error
// Update replaces the stored account state for record.UserID.
Update(ctx context.Context, record account.UserAccount) error
}
// RaceNameReservationStore persists source-of-truth race-name reservations.
type RaceNameReservationStore interface {
// Create stores one new race-name reservation keyed by its canonical
// uniqueness key. Implementations must wrap ErrConflict when the canonical
// key is already reserved.
Create(ctx context.Context, record account.RaceNameReservation) error
// GetByCanonicalKey returns the stored reservation identified by key.
GetByCanonicalKey(ctx context.Context, key account.RaceNameCanonicalKey) (account.RaceNameReservation, error)
// DeleteByCanonicalKey removes the reservation identified by key.
DeleteByCanonicalKey(ctx context.Context, key account.RaceNameCanonicalKey) error
}
+369
View File
@@ -0,0 +1,369 @@
package ports
import (
"context"
"fmt"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
)
// AuthResolutionKind identifies the coarse auth-facing resolution state of one
// e-mail subject.
type AuthResolutionKind string
const (
// AuthResolutionKindExisting reports that the e-mail belongs to an existing
// account.
AuthResolutionKindExisting AuthResolutionKind = "existing"
// AuthResolutionKindCreatable reports that the e-mail is not blocked and no
// account exists yet.
AuthResolutionKindCreatable AuthResolutionKind = "creatable"
// AuthResolutionKindBlocked reports that the e-mail subject is blocked.
AuthResolutionKindBlocked AuthResolutionKind = "blocked"
)
// IsKnown reports whether AuthResolutionKind belongs to the supported
// auth-facing vocabulary.
func (kind AuthResolutionKind) IsKnown() bool {
switch kind {
case AuthResolutionKindExisting, AuthResolutionKindCreatable, AuthResolutionKindBlocked:
return true
default:
return false
}
}
// ResolveByEmailResult stores the coarse auth-facing state of one e-mail
// subject.
type ResolveByEmailResult struct {
// Kind stores the coarse resolution state.
Kind AuthResolutionKind
// UserID is present only when Kind is AuthResolutionKindExisting.
UserID common.UserID
// BlockReasonCode is present only when Kind is AuthResolutionKindBlocked.
BlockReasonCode common.ReasonCode
}
// Validate reports whether ResolveByEmailResult satisfies the auth-facing
// invariant set.
func (result ResolveByEmailResult) Validate() error {
if !result.Kind.IsKnown() {
return fmt.Errorf("resolve-by-email result kind %q is unsupported", result.Kind)
}
switch result.Kind {
case AuthResolutionKindExisting:
if err := result.UserID.Validate(); err != nil {
return fmt.Errorf("resolve-by-email result user id: %w", err)
}
if !result.BlockReasonCode.IsZero() {
return fmt.Errorf("resolve-by-email result block reason code must be empty for existing outcome")
}
case AuthResolutionKindCreatable:
if !result.UserID.IsZero() {
return fmt.Errorf("resolve-by-email result user id must be empty for creatable outcome")
}
if !result.BlockReasonCode.IsZero() {
return fmt.Errorf("resolve-by-email result block reason code must be empty for creatable outcome")
}
case AuthResolutionKindBlocked:
if !result.UserID.IsZero() {
return fmt.Errorf("resolve-by-email result user id must be empty for blocked outcome")
}
if err := result.BlockReasonCode.Validate(); err != nil {
return fmt.Errorf("resolve-by-email result block reason code: %w", err)
}
}
return nil
}
// EnsureByEmailOutcome identifies the coarse auth-facing ensure result.
type EnsureByEmailOutcome string
const (
// EnsureByEmailOutcomeExisting reports that the e-mail already belongs to an
// existing account.
EnsureByEmailOutcomeExisting EnsureByEmailOutcome = "existing"
// EnsureByEmailOutcomeCreated reports that a new account was created.
EnsureByEmailOutcomeCreated EnsureByEmailOutcome = "created"
// EnsureByEmailOutcomeBlocked reports that creation or reuse is blocked by
// policy.
EnsureByEmailOutcomeBlocked EnsureByEmailOutcome = "blocked"
)
// IsKnown reports whether EnsureByEmailOutcome belongs to the supported
// auth-facing vocabulary.
func (outcome EnsureByEmailOutcome) IsKnown() bool {
switch outcome {
case EnsureByEmailOutcomeExisting, EnsureByEmailOutcomeCreated, EnsureByEmailOutcomeBlocked:
return true
default:
return false
}
}
// EnsureByEmailInput stores the complete create payload required for atomic
// ensure-by-email behavior.
type EnsureByEmailInput struct {
// Email stores the exact normalized e-mail subject addressed by the ensure
// call.
Email common.Email
// Account stores the fully initialized account that should be persisted when
// the e-mail does not yet exist and is not blocked.
Account account.UserAccount
// Entitlement stores the initial current entitlement snapshot for the new
// account.
Entitlement entitlement.CurrentSnapshot
// EntitlementRecord stores the initial entitlement history record that must
// be created atomically with Entitlement.
EntitlementRecord entitlement.PeriodRecord
// Reservation stores the canonical race-name reservation for Account.
Reservation account.RaceNameReservation
}
// Validate reports whether EnsureByEmailInput is structurally complete.
func (input EnsureByEmailInput) Validate() error {
if err := input.Email.Validate(); err != nil {
return fmt.Errorf("ensure-by-email input email: %w", err)
}
if err := input.Account.Validate(); err != nil {
return fmt.Errorf("ensure-by-email input account: %w", err)
}
if err := input.Entitlement.Validate(); err != nil {
return fmt.Errorf("ensure-by-email input entitlement snapshot: %w", err)
}
if err := input.EntitlementRecord.Validate(); err != nil {
return fmt.Errorf("ensure-by-email input entitlement record: %w", err)
}
if err := input.Reservation.Validate(); err != nil {
return fmt.Errorf("ensure-by-email input reservation: %w", err)
}
if input.Account.Email != input.Email {
return fmt.Errorf("ensure-by-email input account email must match request email")
}
if input.Account.UserID != input.Entitlement.UserID {
return fmt.Errorf("ensure-by-email input account user id must match entitlement user id")
}
if input.Account.UserID != input.EntitlementRecord.UserID {
return fmt.Errorf("ensure-by-email input account user id must match entitlement record user id")
}
if input.Account.UserID != input.Reservation.UserID {
return fmt.Errorf("ensure-by-email input account user id must match reservation user id")
}
if input.Account.RaceName != input.Reservation.RaceName {
return fmt.Errorf("ensure-by-email input account race name must match reservation race name")
}
if input.EntitlementRecord.PlanCode != input.Entitlement.PlanCode {
return fmt.Errorf("ensure-by-email input entitlement record plan code must match entitlement snapshot plan code")
}
if input.EntitlementRecord.Source != input.Entitlement.Source {
return fmt.Errorf("ensure-by-email input entitlement record source must match entitlement snapshot source")
}
if input.EntitlementRecord.Actor != input.Entitlement.Actor {
return fmt.Errorf("ensure-by-email input entitlement record actor must match entitlement snapshot actor")
}
if input.EntitlementRecord.ReasonCode != input.Entitlement.ReasonCode {
return fmt.Errorf("ensure-by-email input entitlement record reason code must match entitlement snapshot reason code")
}
if !input.EntitlementRecord.StartsAt.Equal(input.Entitlement.StartsAt) {
return fmt.Errorf("ensure-by-email input entitlement record starts at must match entitlement snapshot starts at")
}
if !equalOptionalTimes(input.EntitlementRecord.EndsAt, input.Entitlement.EndsAt) {
return fmt.Errorf("ensure-by-email input entitlement record ends at must match entitlement snapshot ends at")
}
return nil
}
// EnsureByEmailResult stores the coarse auth-facing outcome of an atomic
// ensure-by-email call.
type EnsureByEmailResult struct {
// Outcome stores the coarse ensure result.
Outcome EnsureByEmailOutcome
// UserID is present only for existing or created outcomes.
UserID common.UserID
// BlockReasonCode is present only for the blocked outcome.
BlockReasonCode common.ReasonCode
}
// Validate reports whether EnsureByEmailResult satisfies the auth-facing
// invariant set.
func (result EnsureByEmailResult) Validate() error {
if !result.Outcome.IsKnown() {
return fmt.Errorf("ensure-by-email result outcome %q is unsupported", result.Outcome)
}
switch result.Outcome {
case EnsureByEmailOutcomeExisting, EnsureByEmailOutcomeCreated:
if err := result.UserID.Validate(); err != nil {
return fmt.Errorf("ensure-by-email result user id: %w", err)
}
if !result.BlockReasonCode.IsZero() {
return fmt.Errorf("ensure-by-email result block reason code must be empty for existing or created outcome")
}
case EnsureByEmailOutcomeBlocked:
if !result.UserID.IsZero() {
return fmt.Errorf("ensure-by-email result user id must be empty for blocked outcome")
}
if err := result.BlockReasonCode.Validate(); err != nil {
return fmt.Errorf("ensure-by-email result block reason code: %w", err)
}
}
return nil
}
// AuthBlockOutcome identifies the coarse result of blocking one auth subject.
type AuthBlockOutcome string
const (
// AuthBlockOutcomeBlocked reports that the current mutation created a new
// block record.
AuthBlockOutcomeBlocked AuthBlockOutcome = "blocked"
// AuthBlockOutcomeAlreadyBlocked reports that the block already existed.
AuthBlockOutcomeAlreadyBlocked AuthBlockOutcome = "already_blocked"
)
// IsKnown reports whether AuthBlockOutcome belongs to the supported
// auth-facing vocabulary.
func (outcome AuthBlockOutcome) IsKnown() bool {
switch outcome {
case AuthBlockOutcomeBlocked, AuthBlockOutcomeAlreadyBlocked:
return true
default:
return false
}
}
// BlockByUserIDInput stores one auth-facing block request addressed by stable
// user identifier.
type BlockByUserIDInput struct {
// UserID identifies the account that must be blocked.
UserID common.UserID
// ReasonCode stores the machine-readable block reason.
ReasonCode common.ReasonCode
// BlockedAt stores the timestamp applied to the blocked e-mail subject
// record when a new block is created.
BlockedAt time.Time
}
// Validate reports whether BlockByUserIDInput is structurally complete.
func (input BlockByUserIDInput) Validate() error {
if err := input.UserID.Validate(); err != nil {
return fmt.Errorf("block-by-user-id input user id: %w", err)
}
if err := input.ReasonCode.Validate(); err != nil {
return fmt.Errorf("block-by-user-id input reason code: %w", err)
}
if err := common.ValidateTimestamp("block-by-user-id input blocked at", input.BlockedAt); err != nil {
return err
}
return nil
}
// BlockByEmailInput stores one auth-facing block request addressed by exact
// normalized e-mail subject.
type BlockByEmailInput struct {
// Email identifies the e-mail subject that must be blocked.
Email common.Email
// ReasonCode stores the machine-readable block reason.
ReasonCode common.ReasonCode
// BlockedAt stores the timestamp applied to the blocked e-mail subject
// record when a new block is created.
BlockedAt time.Time
}
// Validate reports whether BlockByEmailInput is structurally complete.
func (input BlockByEmailInput) Validate() error {
if err := input.Email.Validate(); err != nil {
return fmt.Errorf("block-by-email input email: %w", err)
}
if err := input.ReasonCode.Validate(); err != nil {
return fmt.Errorf("block-by-email input reason code: %w", err)
}
if err := common.ValidateTimestamp("block-by-email input blocked at", input.BlockedAt); err != nil {
return err
}
return nil
}
// BlockResult stores the coarse auth-facing result of a block mutation.
type BlockResult struct {
// Outcome reports whether a new block was applied or already existed.
Outcome AuthBlockOutcome
// UserID stores the resolved account when the blocked subject belongs to one
// existing user.
UserID common.UserID
}
// Validate reports whether BlockResult satisfies the auth-facing invariant
// set.
func (result BlockResult) Validate() error {
if !result.Outcome.IsKnown() {
return fmt.Errorf("block result outcome %q is unsupported", result.Outcome)
}
if !result.UserID.IsZero() {
if err := result.UserID.Validate(); err != nil {
return fmt.Errorf("block result user id: %w", err)
}
}
return nil
}
// AuthDirectoryStore performs the narrow set of atomic auth-facing reads and
// mutations that must not observe inconsistent cross-key Redis state.
type AuthDirectoryStore interface {
// ResolveByEmail returns the current coarse auth-facing resolution state for
// email.
ResolveByEmail(ctx context.Context, email common.Email) (ResolveByEmailResult, error)
// ExistsByUserID reports whether userID currently identifies a stored
// account.
ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error)
// EnsureByEmail returns an existing user, creates a new one, or reports a
// blocked outcome atomically for one e-mail subject.
EnsureByEmail(ctx context.Context, input EnsureByEmailInput) (EnsureByEmailResult, error)
// BlockByUserID applies a block to the account identified by userID.
BlockByUserID(ctx context.Context, input BlockByUserIDInput) (BlockResult, error)
// BlockByEmail applies a block to email even when no account exists yet.
BlockByEmail(ctx context.Context, input BlockByEmailInput) (BlockResult, error)
}
func equalOptionalTimes(left *time.Time, right *time.Time) bool {
switch {
case left == nil && right == nil:
return true
case left == nil || right == nil:
return false
default:
return left.Equal(*right)
}
}
+18
View File
@@ -0,0 +1,18 @@
package ports
import (
"context"
"galaxy/user/internal/domain/authblock"
"galaxy/user/internal/domain/common"
)
// BlockedEmailStore persists the dedicated blocked-email-subject model used by
// auth-facing flows.
type BlockedEmailStore interface {
// GetByEmail returns the blocked-email subject for email.
GetByEmail(ctx context.Context, email common.Email) (authblock.BlockedEmailSubject, error)
// Upsert stores or replaces the blocked-email subject for record.Email.
Upsert(ctx context.Context, record authblock.BlockedEmailSubject) error
}
+9
View File
@@ -0,0 +1,9 @@
package ports
import "time"
// Clock returns the current wall-clock time used by timestamped mutations.
type Clock interface {
// Now returns the current time.
Now() time.Time
}
@@ -0,0 +1,55 @@
package ports
import (
"context"
"fmt"
"time"
"galaxy/user/internal/domain/common"
)
const (
// DeclaredCountryChangedEventType identifies declared-country change events
// in the shared auxiliary event stream.
DeclaredCountryChangedEventType = "user.declared_country.changed"
)
// DeclaredCountryChangedEvent stores one auxiliary declared-country change
// notification emitted after a successful source-of-truth update.
type DeclaredCountryChangedEvent struct {
// UserID identifies the user whose current declared country changed.
UserID common.UserID
// TraceID stores the optional OpenTelemetry trace identifier propagated
// from the current request context.
TraceID string
// DeclaredCountry stores the latest effective declared country.
DeclaredCountry common.CountryCode
// UpdatedAt stores the persisted account mutation timestamp.
UpdatedAt time.Time
// Source stores the machine-readable upstream mutation source.
Source common.Source
}
// Validate reports whether event is structurally complete.
func (event DeclaredCountryChangedEvent) Validate() error {
if err := validateEventEnvelope("declared-country changed event", event.UserID, event.UpdatedAt, event.Source, event.TraceID); err != nil {
return err
}
if err := event.DeclaredCountry.Validate(); err != nil {
return fmt.Errorf("declared-country changed event declared country: %w", err)
}
return nil
}
// DeclaredCountryChangedPublisher publishes auxiliary declared-country change
// notifications after source-of-truth account updates.
type DeclaredCountryChangedPublisher interface {
// PublishDeclaredCountryChanged propagates one committed declared-country
// change event.
PublishDeclaredCountryChanged(ctx context.Context, event DeclaredCountryChangedEvent) error
}
@@ -0,0 +1,537 @@
package ports
import (
"context"
"fmt"
"strings"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
)
const (
// ProfileChangedEventType identifies profile-change events in the shared
// auxiliary event stream.
ProfileChangedEventType = "user.profile.changed"
// SettingsChangedEventType identifies settings-change events in the shared
// auxiliary event stream.
SettingsChangedEventType = "user.settings.changed"
// EntitlementChangedEventType identifies entitlement-change events in the
// shared auxiliary event stream.
EntitlementChangedEventType = "user.entitlement.changed"
// SanctionChangedEventType identifies sanction-change events in the shared
// auxiliary event stream.
SanctionChangedEventType = "user.sanction.changed"
// LimitChangedEventType identifies limit-change events in the shared
// auxiliary event stream.
LimitChangedEventType = "user.limit.changed"
)
// ProfileChangedOperation identifies one profile-change event kind.
type ProfileChangedOperation string
const (
// ProfileChangedOperationInitialized reports the initial account
// materialization performed during auth-driven user creation.
ProfileChangedOperationInitialized ProfileChangedOperation = "initialized"
// ProfileChangedOperationUpdated reports a later self-service profile
// update.
ProfileChangedOperationUpdated ProfileChangedOperation = "updated"
)
// IsKnown reports whether operation belongs to the frozen profile-change
// event vocabulary.
func (operation ProfileChangedOperation) IsKnown() bool {
switch operation {
case ProfileChangedOperationInitialized, ProfileChangedOperationUpdated:
return true
default:
return false
}
}
// SettingsChangedOperation identifies one settings-change event kind.
type SettingsChangedOperation string
const (
// SettingsChangedOperationInitialized reports the initial account settings
// materialization performed during auth-driven user creation.
SettingsChangedOperationInitialized SettingsChangedOperation = "initialized"
// SettingsChangedOperationUpdated reports a later self-service settings
// update.
SettingsChangedOperationUpdated SettingsChangedOperation = "updated"
)
// IsKnown reports whether operation belongs to the frozen settings-change
// event vocabulary.
func (operation SettingsChangedOperation) IsKnown() bool {
switch operation {
case SettingsChangedOperationInitialized, SettingsChangedOperationUpdated:
return true
default:
return false
}
}
// EntitlementChangedOperation identifies one entitlement-change event kind.
type EntitlementChangedOperation string
const (
// EntitlementChangedOperationInitialized reports the initial free snapshot
// created for a new user.
EntitlementChangedOperationInitialized EntitlementChangedOperation = "initialized"
// EntitlementChangedOperationGranted reports an explicit paid grant.
EntitlementChangedOperationGranted EntitlementChangedOperation = "granted"
// EntitlementChangedOperationExtended reports an explicit paid extension.
EntitlementChangedOperationExtended EntitlementChangedOperation = "extended"
// EntitlementChangedOperationRevoked reports an explicit paid revoke.
EntitlementChangedOperationRevoked EntitlementChangedOperation = "revoked"
// EntitlementChangedOperationExpiredRepaired reports lazy repair of a
// naturally expired finite paid snapshot.
EntitlementChangedOperationExpiredRepaired EntitlementChangedOperation = "expired_repaired"
)
// IsKnown reports whether operation belongs to the frozen entitlement-change
// event vocabulary.
func (operation EntitlementChangedOperation) IsKnown() bool {
switch operation {
case EntitlementChangedOperationInitialized,
EntitlementChangedOperationGranted,
EntitlementChangedOperationExtended,
EntitlementChangedOperationRevoked,
EntitlementChangedOperationExpiredRepaired:
return true
default:
return false
}
}
// SanctionChangedOperation identifies one sanction-change event kind.
type SanctionChangedOperation string
const (
// SanctionChangedOperationApplied reports a new active sanction.
SanctionChangedOperationApplied SanctionChangedOperation = "applied"
// SanctionChangedOperationRemoved reports explicit removal of an active
// sanction.
SanctionChangedOperationRemoved SanctionChangedOperation = "removed"
)
// IsKnown reports whether operation belongs to the frozen sanction-change
// event vocabulary.
func (operation SanctionChangedOperation) IsKnown() bool {
switch operation {
case SanctionChangedOperationApplied, SanctionChangedOperationRemoved:
return true
default:
return false
}
}
// LimitChangedOperation identifies one limit-change event kind.
type LimitChangedOperation string
const (
// LimitChangedOperationSet reports a new or replacement active limit.
LimitChangedOperationSet LimitChangedOperation = "set"
// LimitChangedOperationRemoved reports explicit removal of an active limit.
LimitChangedOperationRemoved LimitChangedOperation = "removed"
)
// IsKnown reports whether operation belongs to the frozen limit-change event
// vocabulary.
func (operation LimitChangedOperation) IsKnown() bool {
switch operation {
case LimitChangedOperationSet, LimitChangedOperationRemoved:
return true
default:
return false
}
}
// ProfileChangedEvent stores one post-commit auxiliary profile-change event.
type ProfileChangedEvent struct {
// UserID identifies the changed user.
UserID common.UserID
// OccurredAt stores the mutation timestamp emitted into the shared event
// envelope.
OccurredAt time.Time
// Source stores the machine-readable mutation source.
Source common.Source
// TraceID stores the optional OpenTelemetry trace identifier propagated
// from the current request context.
TraceID string
// Operation stores the profile-change event kind.
Operation ProfileChangedOperation
// RaceName stores the latest exact race name after the commit.
RaceName common.RaceName
}
// Validate reports whether event is structurally complete.
func (event ProfileChangedEvent) Validate() error {
if err := validateEventEnvelope("profile changed event", event.UserID, event.OccurredAt, event.Source, event.TraceID); err != nil {
return err
}
if !event.Operation.IsKnown() {
return fmt.Errorf("profile changed event operation %q is unsupported", event.Operation)
}
if err := event.RaceName.Validate(); err != nil {
return fmt.Errorf("profile changed event race name: %w", err)
}
return nil
}
// SettingsChangedEvent stores one post-commit auxiliary settings-change event.
type SettingsChangedEvent struct {
// UserID identifies the changed user.
UserID common.UserID
// OccurredAt stores the mutation timestamp emitted into the shared event
// envelope.
OccurredAt time.Time
// Source stores the machine-readable mutation source.
Source common.Source
// TraceID stores the optional OpenTelemetry trace identifier propagated
// from the current request context.
TraceID string
// Operation stores the settings-change event kind.
Operation SettingsChangedOperation
// PreferredLanguage stores the latest preferred language after the commit.
PreferredLanguage common.LanguageTag
// TimeZone stores the latest time-zone name after the commit.
TimeZone common.TimeZoneName
}
// Validate reports whether event is structurally complete.
func (event SettingsChangedEvent) Validate() error {
if err := validateEventEnvelope("settings changed event", event.UserID, event.OccurredAt, event.Source, event.TraceID); err != nil {
return err
}
if !event.Operation.IsKnown() {
return fmt.Errorf("settings changed event operation %q is unsupported", event.Operation)
}
if err := event.PreferredLanguage.Validate(); err != nil {
return fmt.Errorf("settings changed event preferred language: %w", err)
}
if err := event.TimeZone.Validate(); err != nil {
return fmt.Errorf("settings changed event time zone: %w", err)
}
return nil
}
// EntitlementChangedEvent stores one post-commit auxiliary entitlement-change
// event.
type EntitlementChangedEvent struct {
// UserID identifies the changed user.
UserID common.UserID
// OccurredAt stores the mutation timestamp emitted into the shared event
// envelope.
OccurredAt time.Time
// Source stores the machine-readable mutation source.
Source common.Source
// TraceID stores the optional OpenTelemetry trace identifier propagated
// from the current request context.
TraceID string
// Operation stores the entitlement-change event kind.
Operation EntitlementChangedOperation
// PlanCode stores the effective plan after the commit.
PlanCode entitlement.PlanCode
// IsPaid stores the effective paid/free flag after the commit.
IsPaid bool
// StartsAt stores when the effective entitlement state started.
StartsAt time.Time
// EndsAt stores the optional finite paid expiry.
EndsAt *time.Time
// ReasonCode stores the mutation reason.
ReasonCode common.ReasonCode
// Actor stores the audit actor metadata attached to the mutation.
Actor common.ActorRef
// UpdatedAt stores when the current entitlement snapshot was recomputed.
UpdatedAt time.Time
}
// Validate reports whether event is structurally complete.
func (event EntitlementChangedEvent) Validate() error {
if err := validateEventEnvelope("entitlement changed event", event.UserID, event.OccurredAt, event.Source, event.TraceID); err != nil {
return err
}
if !event.Operation.IsKnown() {
return fmt.Errorf("entitlement changed event operation %q is unsupported", event.Operation)
}
if !event.PlanCode.IsKnown() {
return fmt.Errorf("entitlement changed event plan code %q is unsupported", event.PlanCode)
}
if event.IsPaid != event.PlanCode.IsPaid() {
return fmt.Errorf("entitlement changed event paid flag must match plan code %q", event.PlanCode)
}
if err := common.ValidateTimestamp("entitlement changed event starts at", event.StartsAt); err != nil {
return err
}
if event.PlanCode.HasFiniteExpiry() {
if event.EndsAt == nil {
return fmt.Errorf("entitlement changed event ends at must be present for plan code %q", event.PlanCode)
}
if !event.EndsAt.After(event.StartsAt) {
return common.ErrInvertedTimeRange
}
} else if event.EndsAt != nil {
return fmt.Errorf("entitlement changed event ends at must be empty for plan code %q", event.PlanCode)
}
if err := event.ReasonCode.Validate(); err != nil {
return fmt.Errorf("entitlement changed event reason code: %w", err)
}
if err := event.Actor.Validate(); err != nil {
return fmt.Errorf("entitlement changed event actor: %w", err)
}
if err := common.ValidateTimestamp("entitlement changed event updated at", event.UpdatedAt); err != nil {
return err
}
return nil
}
// SanctionChangedEvent stores one post-commit auxiliary sanction-change event.
type SanctionChangedEvent struct {
// UserID identifies the changed user.
UserID common.UserID
// OccurredAt stores the mutation timestamp emitted into the shared event
// envelope.
OccurredAt time.Time
// Source stores the machine-readable mutation source.
Source common.Source
// TraceID stores the optional OpenTelemetry trace identifier propagated
// from the current request context.
TraceID string
// Operation stores the sanction-change event kind.
Operation SanctionChangedOperation
// SanctionCode stores the affected sanction code.
SanctionCode policy.SanctionCode
// Scope stores the machine-readable sanction scope.
Scope common.Scope
// ReasonCode stores the mutation reason.
ReasonCode common.ReasonCode
// Actor stores the audit actor metadata attached to the mutation.
Actor common.ActorRef
// AppliedAt stores when the sanction became effective.
AppliedAt time.Time
// ExpiresAt stores the optional planned sanction expiry.
ExpiresAt *time.Time
// RemovedAt stores the optional sanction removal timestamp.
RemovedAt *time.Time
}
// Validate reports whether event is structurally complete.
func (event SanctionChangedEvent) Validate() error {
if err := validateEventEnvelope("sanction changed event", event.UserID, event.OccurredAt, event.Source, event.TraceID); err != nil {
return err
}
if !event.Operation.IsKnown() {
return fmt.Errorf("sanction changed event operation %q is unsupported", event.Operation)
}
if !event.SanctionCode.IsKnown() {
return fmt.Errorf("sanction changed event sanction code %q is unsupported", event.SanctionCode)
}
if err := event.Scope.Validate(); err != nil {
return fmt.Errorf("sanction changed event scope: %w", err)
}
if err := event.ReasonCode.Validate(); err != nil {
return fmt.Errorf("sanction changed event reason code: %w", err)
}
if err := event.Actor.Validate(); err != nil {
return fmt.Errorf("sanction changed event actor: %w", err)
}
if err := common.ValidateTimestamp("sanction changed event applied at", event.AppliedAt); err != nil {
return err
}
if event.ExpiresAt != nil && !event.ExpiresAt.After(event.AppliedAt) {
return common.ErrInvertedTimeRange
}
if event.RemovedAt != nil && event.RemovedAt.Before(event.AppliedAt) {
return fmt.Errorf("sanction changed event removed at must not be before applied at")
}
return nil
}
// LimitChangedEvent stores one post-commit auxiliary limit-change event.
type LimitChangedEvent struct {
// UserID identifies the changed user.
UserID common.UserID
// OccurredAt stores the mutation timestamp emitted into the shared event
// envelope.
OccurredAt time.Time
// Source stores the machine-readable mutation source.
Source common.Source
// TraceID stores the optional OpenTelemetry trace identifier propagated
// from the current request context.
TraceID string
// Operation stores the limit-change event kind.
Operation LimitChangedOperation
// LimitCode stores the affected limit code.
LimitCode policy.LimitCode
// Value stores the active limit value when the operation is `set`.
Value *int
// ReasonCode stores the mutation reason.
ReasonCode common.ReasonCode
// Actor stores the audit actor metadata attached to the mutation.
Actor common.ActorRef
// AppliedAt stores when the limit became effective.
AppliedAt time.Time
// ExpiresAt stores the optional planned limit expiry.
ExpiresAt *time.Time
// RemovedAt stores the optional explicit limit removal timestamp.
RemovedAt *time.Time
}
// Validate reports whether event is structurally complete.
func (event LimitChangedEvent) Validate() error {
if err := validateEventEnvelope("limit changed event", event.UserID, event.OccurredAt, event.Source, event.TraceID); err != nil {
return err
}
if !event.Operation.IsKnown() {
return fmt.Errorf("limit changed event operation %q is unsupported", event.Operation)
}
if !event.LimitCode.IsSupported() {
return fmt.Errorf("limit changed event limit code %q is unsupported", event.LimitCode)
}
switch event.Operation {
case LimitChangedOperationSet:
if event.Value == nil {
return fmt.Errorf("limit changed event value must be present for operation %q", event.Operation)
}
if *event.Value < 0 {
return fmt.Errorf("limit changed event value must not be negative")
}
case LimitChangedOperationRemoved:
if event.Value != nil && *event.Value < 0 {
return fmt.Errorf("limit changed event value must not be negative")
}
}
if err := event.ReasonCode.Validate(); err != nil {
return fmt.Errorf("limit changed event reason code: %w", err)
}
if err := event.Actor.Validate(); err != nil {
return fmt.Errorf("limit changed event actor: %w", err)
}
if err := common.ValidateTimestamp("limit changed event applied at", event.AppliedAt); err != nil {
return err
}
if event.ExpiresAt != nil && !event.ExpiresAt.After(event.AppliedAt) {
return common.ErrInvertedTimeRange
}
if event.RemovedAt != nil && event.RemovedAt.Before(event.AppliedAt) {
return fmt.Errorf("limit changed event removed at must not be before applied at")
}
return nil
}
// ProfileChangedPublisher publishes auxiliary profile-change notifications.
type ProfileChangedPublisher interface {
// PublishProfileChanged propagates one committed profile-change event.
PublishProfileChanged(ctx context.Context, event ProfileChangedEvent) error
}
// SettingsChangedPublisher publishes auxiliary settings-change notifications.
type SettingsChangedPublisher interface {
// PublishSettingsChanged propagates one committed settings-change event.
PublishSettingsChanged(ctx context.Context, event SettingsChangedEvent) error
}
// EntitlementChangedPublisher publishes auxiliary entitlement-change
// notifications.
type EntitlementChangedPublisher interface {
// PublishEntitlementChanged propagates one committed entitlement-change
// event.
PublishEntitlementChanged(ctx context.Context, event EntitlementChangedEvent) error
}
// SanctionChangedPublisher publishes auxiliary sanction-change notifications.
type SanctionChangedPublisher interface {
// PublishSanctionChanged propagates one committed sanction-change event.
PublishSanctionChanged(ctx context.Context, event SanctionChangedEvent) error
}
// LimitChangedPublisher publishes auxiliary limit-change notifications.
type LimitChangedPublisher interface {
// PublishLimitChanged propagates one committed limit-change event.
PublishLimitChanged(ctx context.Context, event LimitChangedEvent) error
}
func validateEventEnvelope(name string, userID common.UserID, occurredAt time.Time, source common.Source, traceID string) error {
if err := userID.Validate(); err != nil {
return fmt.Errorf("%s user id: %w", name, err)
}
if err := common.ValidateTimestamp(name+" occurred at", occurredAt); err != nil {
return err
}
if err := source.Validate(); err != nil {
return fmt.Errorf("%s source: %w", name, err)
}
if traceID != "" {
if strings.TrimSpace(traceID) != traceID {
return fmt.Errorf("%s trace id must not contain surrounding whitespace", name)
}
}
return nil
}
+230
View File
@@ -0,0 +1,230 @@
package ports
import (
"context"
"fmt"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
)
// EntitlementHistoryStore persists immutable entitlement period records and
// later close-state updates.
type EntitlementHistoryStore interface {
// Create stores one new entitlement period history record. Implementations
// must wrap ErrConflict when record.RecordID already exists.
Create(ctx context.Context, record entitlement.PeriodRecord) error
// GetByRecordID returns the entitlement period history record identified by
// recordID.
GetByRecordID(ctx context.Context, recordID entitlement.EntitlementRecordID) (entitlement.PeriodRecord, error)
// ListByUserID returns every entitlement period history record owned by
// userID.
ListByUserID(ctx context.Context, userID common.UserID) ([]entitlement.PeriodRecord, error)
// Update replaces one stored entitlement period history record.
Update(ctx context.Context, record entitlement.PeriodRecord) error
}
// EntitlementSnapshotStore persists the read-optimized current entitlement
// snapshot.
type EntitlementSnapshotStore interface {
// GetByUserID returns the current entitlement snapshot for userID.
GetByUserID(ctx context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error)
// Put stores the current entitlement snapshot for record.UserID.
Put(ctx context.Context, record entitlement.CurrentSnapshot) error
}
// GrantEntitlementInput stores one atomic transition from a current free
// entitlement state to a current paid state.
type GrantEntitlementInput struct {
// ExpectedCurrentSnapshot stores the exact snapshot that must still be
// current before the mutation commits.
ExpectedCurrentSnapshot entitlement.CurrentSnapshot
// ExpectedCurrentRecord stores the current effective free period that must
// still be current before the mutation commits.
ExpectedCurrentRecord entitlement.PeriodRecord
// UpdatedCurrentRecord stores ExpectedCurrentRecord after the close metadata
// is applied.
UpdatedCurrentRecord entitlement.PeriodRecord
// NewRecord stores the new paid entitlement history segment.
NewRecord entitlement.PeriodRecord
// NewSnapshot stores the new current effective entitlement snapshot.
NewSnapshot entitlement.CurrentSnapshot
}
// Validate reports whether GrantEntitlementInput is structurally complete.
func (input GrantEntitlementInput) Validate() error {
if err := input.ExpectedCurrentSnapshot.Validate(); err != nil {
return fmt.Errorf("grant entitlement input expected current snapshot: %w", err)
}
if err := input.ExpectedCurrentRecord.Validate(); err != nil {
return fmt.Errorf("grant entitlement input expected current record: %w", err)
}
if err := input.UpdatedCurrentRecord.Validate(); err != nil {
return fmt.Errorf("grant entitlement input updated current record: %w", err)
}
if err := input.NewRecord.Validate(); err != nil {
return fmt.Errorf("grant entitlement input new record: %w", err)
}
if err := input.NewSnapshot.Validate(); err != nil {
return fmt.Errorf("grant entitlement input new snapshot: %w", err)
}
if input.ExpectedCurrentSnapshot.UserID != input.ExpectedCurrentRecord.UserID ||
input.ExpectedCurrentSnapshot.UserID != input.UpdatedCurrentRecord.UserID ||
input.ExpectedCurrentSnapshot.UserID != input.NewRecord.UserID ||
input.ExpectedCurrentSnapshot.UserID != input.NewSnapshot.UserID {
return fmt.Errorf("grant entitlement input all records must belong to the same user id")
}
if input.ExpectedCurrentRecord.RecordID != input.UpdatedCurrentRecord.RecordID {
return fmt.Errorf("grant entitlement input updated current record must preserve record id")
}
return nil
}
// ExtendEntitlementInput stores one atomic extension of a current finite paid
// entitlement state.
type ExtendEntitlementInput struct {
// ExpectedCurrentSnapshot stores the exact snapshot that must still be
// current before the mutation commits.
ExpectedCurrentSnapshot entitlement.CurrentSnapshot
// NewRecord stores the appended entitlement history segment that extends the
// current paid state.
NewRecord entitlement.PeriodRecord
// NewSnapshot stores the replacement current effective entitlement snapshot.
NewSnapshot entitlement.CurrentSnapshot
}
// Validate reports whether ExtendEntitlementInput is structurally complete.
func (input ExtendEntitlementInput) Validate() error {
if err := input.ExpectedCurrentSnapshot.Validate(); err != nil {
return fmt.Errorf("extend entitlement input expected current snapshot: %w", err)
}
if err := input.NewRecord.Validate(); err != nil {
return fmt.Errorf("extend entitlement input new record: %w", err)
}
if err := input.NewSnapshot.Validate(); err != nil {
return fmt.Errorf("extend entitlement input new snapshot: %w", err)
}
if input.ExpectedCurrentSnapshot.UserID != input.NewRecord.UserID ||
input.ExpectedCurrentSnapshot.UserID != input.NewSnapshot.UserID {
return fmt.Errorf("extend entitlement input all records must belong to the same user id")
}
return nil
}
// RevokeEntitlementInput stores one atomic transition from a current paid
// entitlement state to a new free state.
type RevokeEntitlementInput struct {
// ExpectedCurrentSnapshot stores the exact snapshot that must still be
// current before the mutation commits.
ExpectedCurrentSnapshot entitlement.CurrentSnapshot
// ExpectedCurrentRecord stores the current effective paid period that must
// still be current before the mutation commits.
ExpectedCurrentRecord entitlement.PeriodRecord
// UpdatedCurrentRecord stores ExpectedCurrentRecord after the close metadata
// is applied.
UpdatedCurrentRecord entitlement.PeriodRecord
// NewRecord stores the newly created free entitlement period.
NewRecord entitlement.PeriodRecord
// NewSnapshot stores the replacement current effective free snapshot.
NewSnapshot entitlement.CurrentSnapshot
}
// Validate reports whether RevokeEntitlementInput is structurally complete.
func (input RevokeEntitlementInput) Validate() error {
if err := input.ExpectedCurrentSnapshot.Validate(); err != nil {
return fmt.Errorf("revoke entitlement input expected current snapshot: %w", err)
}
if err := input.ExpectedCurrentRecord.Validate(); err != nil {
return fmt.Errorf("revoke entitlement input expected current record: %w", err)
}
if err := input.UpdatedCurrentRecord.Validate(); err != nil {
return fmt.Errorf("revoke entitlement input updated current record: %w", err)
}
if err := input.NewRecord.Validate(); err != nil {
return fmt.Errorf("revoke entitlement input new record: %w", err)
}
if err := input.NewSnapshot.Validate(); err != nil {
return fmt.Errorf("revoke entitlement input new snapshot: %w", err)
}
if input.ExpectedCurrentSnapshot.UserID != input.ExpectedCurrentRecord.UserID ||
input.ExpectedCurrentSnapshot.UserID != input.UpdatedCurrentRecord.UserID ||
input.ExpectedCurrentSnapshot.UserID != input.NewRecord.UserID ||
input.ExpectedCurrentSnapshot.UserID != input.NewSnapshot.UserID {
return fmt.Errorf("revoke entitlement input all records must belong to the same user id")
}
if input.ExpectedCurrentRecord.RecordID != input.UpdatedCurrentRecord.RecordID {
return fmt.Errorf("revoke entitlement input updated current record must preserve record id")
}
return nil
}
// RepairExpiredEntitlementInput stores one atomic lazy-repair transition from
// an expired finite paid snapshot to a materialized free state.
type RepairExpiredEntitlementInput struct {
// ExpectedExpiredSnapshot stores the exact expired snapshot that must still
// be current before the repair commits.
ExpectedExpiredSnapshot entitlement.CurrentSnapshot
// NewRecord stores the newly created free entitlement period.
NewRecord entitlement.PeriodRecord
// NewSnapshot stores the replacement current effective free snapshot.
NewSnapshot entitlement.CurrentSnapshot
}
// Validate reports whether RepairExpiredEntitlementInput is structurally
// complete.
func (input RepairExpiredEntitlementInput) Validate() error {
if err := input.ExpectedExpiredSnapshot.Validate(); err != nil {
return fmt.Errorf("repair expired entitlement input expected expired snapshot: %w", err)
}
if err := input.NewRecord.Validate(); err != nil {
return fmt.Errorf("repair expired entitlement input new record: %w", err)
}
if err := input.NewSnapshot.Validate(); err != nil {
return fmt.Errorf("repair expired entitlement input new snapshot: %w", err)
}
if input.ExpectedExpiredSnapshot.UserID != input.NewRecord.UserID ||
input.ExpectedExpiredSnapshot.UserID != input.NewSnapshot.UserID {
return fmt.Errorf("repair expired entitlement input all records must belong to the same user id")
}
return nil
}
// EntitlementLifecycleStore persists atomic entitlement timeline transitions
// that must keep history and current snapshot consistent.
type EntitlementLifecycleStore interface {
// Grant atomically closes the current free period, creates a new paid
// period, and replaces the current snapshot.
Grant(ctx context.Context, input GrantEntitlementInput) error
// Extend atomically appends one paid-history segment and replaces the
// current snapshot.
Extend(ctx context.Context, input ExtendEntitlementInput) error
// Revoke atomically closes the current paid period, creates a new free
// period, and replaces the current snapshot.
Revoke(ctx context.Context, input RevokeEntitlementInput) error
// RepairExpired atomically replaces one expired finite paid snapshot with a
// materialized free state.
RepairExpired(ctx context.Context, input RepairExpiredEntitlementInput) error
}
+31
View File
@@ -0,0 +1,31 @@
// Package ports defines the storage-agnostic boundaries used by the user
// service.
package ports
import (
"errors"
"fmt"
)
var (
// ErrNotFound reports that a requested source-of-truth record does not
// exist in the dependency behind the port.
ErrNotFound = errors.New("ports: record not found")
// ErrConflict reports that a create or update cannot be applied because the
// dependency state conflicts with the requested mutation.
ErrConflict = errors.New("ports: conflict")
// ErrInvalidPageToken reports that a supplied pagination token cannot be
// decoded or does not match the expected filter set.
ErrInvalidPageToken = errors.New("ports: invalid page token")
)
var (
// ErrRaceNameConflict reports that a mutation specifically failed because a
// race-name lookup or canonical reservation is already owned by another
// user. The sentinel still matches ErrConflict via errors.Is so callers can
// preserve the stable public conflict semantics while collecting more
// precise observability.
ErrRaceNameConflict = fmt.Errorf("%w: race name conflict", ErrConflict)
)
+29
View File
@@ -0,0 +1,29 @@
package ports
import (
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
)
// IDGenerator creates new user identifiers and generated initial race names.
type IDGenerator interface {
// NewUserID returns one newly generated stable user identifier.
NewUserID() (common.UserID, error)
// NewInitialRaceName returns one generated initial race name in the
// `player-<shortid>` form.
NewInitialRaceName() (common.RaceName, error)
// NewEntitlementRecordID returns one newly generated entitlement history
// record identifier.
NewEntitlementRecordID() (entitlement.EntitlementRecordID, error)
// NewSanctionRecordID returns one newly generated sanction history record
// identifier.
NewSanctionRecordID() (policy.SanctionRecordID, error)
// NewLimitRecordID returns one newly generated limit history record
// identifier.
NewLimitRecordID() (policy.LimitRecordID, error)
}
+188
View File
@@ -0,0 +1,188 @@
package ports
import (
"context"
"fmt"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/policy"
)
// SanctionStore persists sanction history records and later remove-state
// updates.
type SanctionStore interface {
// Create stores one new sanction history record. Implementations must wrap
// ErrConflict when record.RecordID already exists.
Create(ctx context.Context, record policy.SanctionRecord) error
// GetByRecordID returns the sanction history record identified by recordID.
GetByRecordID(ctx context.Context, recordID policy.SanctionRecordID) (policy.SanctionRecord, error)
// ListByUserID returns every sanction history record owned by userID.
ListByUserID(ctx context.Context, userID common.UserID) ([]policy.SanctionRecord, error)
// Update replaces one stored sanction history record.
Update(ctx context.Context, record policy.SanctionRecord) error
}
// LimitStore persists user-specific limit history records and later
// remove-state updates.
type LimitStore interface {
// Create stores one new limit history record. Implementations must wrap
// ErrConflict when record.RecordID already exists.
Create(ctx context.Context, record policy.LimitRecord) error
// GetByRecordID returns the limit history record identified by recordID.
GetByRecordID(ctx context.Context, recordID policy.LimitRecordID) (policy.LimitRecord, error)
// ListByUserID returns every limit history record owned by userID.
ListByUserID(ctx context.Context, userID common.UserID) ([]policy.LimitRecord, error)
// Update replaces one stored limit history record.
Update(ctx context.Context, record policy.LimitRecord) error
}
// ApplySanctionInput stores one atomic creation of a new active sanction.
type ApplySanctionInput struct {
// NewRecord stores the sanction history record that must become active.
NewRecord policy.SanctionRecord
}
// Validate reports whether ApplySanctionInput is structurally complete.
func (input ApplySanctionInput) Validate() error {
if err := input.NewRecord.Validate(); err != nil {
return fmt.Errorf("apply sanction input new record: %w", err)
}
return nil
}
// RemoveSanctionInput stores one atomic removal of the current active
// sanction for one `user_id + sanction_code`.
type RemoveSanctionInput struct {
// ExpectedActiveRecord stores the exact sanction record that must still be
// active before the mutation commits.
ExpectedActiveRecord policy.SanctionRecord
// UpdatedRecord stores ExpectedActiveRecord after remove metadata is
// applied.
UpdatedRecord policy.SanctionRecord
}
// Validate reports whether RemoveSanctionInput is structurally complete.
func (input RemoveSanctionInput) Validate() error {
if err := input.ExpectedActiveRecord.Validate(); err != nil {
return fmt.Errorf("remove sanction input expected active record: %w", err)
}
if err := input.UpdatedRecord.Validate(); err != nil {
return fmt.Errorf("remove sanction input updated record: %w", err)
}
if input.ExpectedActiveRecord.RecordID != input.UpdatedRecord.RecordID {
return fmt.Errorf("remove sanction input updated record must preserve record id")
}
if input.ExpectedActiveRecord.UserID != input.UpdatedRecord.UserID {
return fmt.Errorf("remove sanction input records must belong to the same user id")
}
if input.ExpectedActiveRecord.SanctionCode != input.UpdatedRecord.SanctionCode {
return fmt.Errorf("remove sanction input records must preserve sanction code")
}
return nil
}
// SetLimitInput stores one atomic creation or replacement of the current
// active limit for one `user_id + limit_code`.
type SetLimitInput struct {
// ExpectedActiveRecord stores the currently active limit that must still be
// active before replacement commits. It stays nil when no active limit
// exists yet.
ExpectedActiveRecord *policy.LimitRecord
// UpdatedActiveRecord stores ExpectedActiveRecord after remove metadata is
// applied. It stays nil when no active limit exists yet.
UpdatedActiveRecord *policy.LimitRecord
// NewRecord stores the limit history record that must become active.
NewRecord policy.LimitRecord
}
// Validate reports whether SetLimitInput is structurally complete.
func (input SetLimitInput) Validate() error {
if err := input.NewRecord.Validate(); err != nil {
return fmt.Errorf("set limit input new record: %w", err)
}
switch {
case input.ExpectedActiveRecord == nil && input.UpdatedActiveRecord == nil:
return nil
case input.ExpectedActiveRecord == nil || input.UpdatedActiveRecord == nil:
return fmt.Errorf("set limit input active replacement records must both be present or absent")
}
if err := input.ExpectedActiveRecord.Validate(); err != nil {
return fmt.Errorf("set limit input expected active record: %w", err)
}
if err := input.UpdatedActiveRecord.Validate(); err != nil {
return fmt.Errorf("set limit input updated active record: %w", err)
}
if input.ExpectedActiveRecord.RecordID != input.UpdatedActiveRecord.RecordID {
return fmt.Errorf("set limit input updated active record must preserve record id")
}
if input.ExpectedActiveRecord.UserID != input.UpdatedActiveRecord.UserID ||
input.ExpectedActiveRecord.UserID != input.NewRecord.UserID {
return fmt.Errorf("set limit input records must belong to the same user id")
}
if input.ExpectedActiveRecord.LimitCode != input.UpdatedActiveRecord.LimitCode ||
input.ExpectedActiveRecord.LimitCode != input.NewRecord.LimitCode {
return fmt.Errorf("set limit input records must preserve limit code")
}
return nil
}
// RemoveLimitInput stores one atomic removal of the current active limit for
// one `user_id + limit_code`.
type RemoveLimitInput struct {
// ExpectedActiveRecord stores the exact limit record that must still be
// active before the mutation commits.
ExpectedActiveRecord policy.LimitRecord
// UpdatedRecord stores ExpectedActiveRecord after remove metadata is
// applied.
UpdatedRecord policy.LimitRecord
}
// Validate reports whether RemoveLimitInput is structurally complete.
func (input RemoveLimitInput) Validate() error {
if err := input.ExpectedActiveRecord.Validate(); err != nil {
return fmt.Errorf("remove limit input expected active record: %w", err)
}
if err := input.UpdatedRecord.Validate(); err != nil {
return fmt.Errorf("remove limit input updated record: %w", err)
}
if input.ExpectedActiveRecord.RecordID != input.UpdatedRecord.RecordID {
return fmt.Errorf("remove limit input updated record must preserve record id")
}
if input.ExpectedActiveRecord.UserID != input.UpdatedRecord.UserID {
return fmt.Errorf("remove limit input records must belong to the same user id")
}
if input.ExpectedActiveRecord.LimitCode != input.UpdatedRecord.LimitCode {
return fmt.Errorf("remove limit input records must preserve limit code")
}
return nil
}
// PolicyLifecycleStore persists atomic sanction and limit transitions that
// must keep history and active-slot state consistent.
type PolicyLifecycleStore interface {
// ApplySanction atomically creates one new active sanction record.
ApplySanction(ctx context.Context, input ApplySanctionInput) error
// RemoveSanction atomically removes one active sanction record.
RemoveSanction(ctx context.Context, input RemoveSanctionInput) error
// SetLimit atomically creates or replaces one active limit record.
SetLimit(ctx context.Context, input SetLimitInput) error
// RemoveLimit atomically removes one active limit record.
RemoveLimit(ctx context.Context, input RemoveLimitInput) error
}
+14
View File
@@ -0,0 +1,14 @@
package ports
import (
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
)
// RaceNamePolicy produces the canonical uniqueness key used to reserve one
// replaceable race-name slot.
type RaceNamePolicy interface {
// CanonicalKey returns the stable reservation key for raceName. Callers are
// expected to pass a validated raceName value.
CanonicalKey(raceName common.RaceName) (account.RaceNameCanonicalKey, error)
}
+129
View File
@@ -0,0 +1,129 @@
package ports
import (
"context"
"fmt"
"strings"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
)
const (
// DefaultUserListPageSize stores the frozen default page size used by the
// trusted admin listing surface when the caller omits `page_size`.
DefaultUserListPageSize = 50
// MaxUserListPageSize stores the frozen maximum page size accepted by the
// trusted admin listing surface.
MaxUserListPageSize = 200
)
// UserListFilters stores the frozen admin-listing filter set.
type UserListFilters struct {
// PaidState stores the optional coarse free-versus-paid filter.
PaidState entitlement.PaidState
// PaidExpiresBefore stores the optional strict upper bound for finite paid
// expiry.
PaidExpiresBefore *time.Time
// PaidExpiresAfter stores the optional strict lower bound for finite paid
// expiry.
PaidExpiresAfter *time.Time
// DeclaredCountry stores the optional current declared-country filter.
DeclaredCountry common.CountryCode
// SanctionCode stores the optional active-sanction filter.
SanctionCode policy.SanctionCode
// LimitCode stores the optional active user-specific limit filter.
LimitCode policy.LimitCode
// CanLogin stores the optional derived login-eligibility filter.
CanLogin *bool
// CanCreatePrivateGame stores the optional derived private-game-create
// eligibility filter.
CanCreatePrivateGame *bool
// CanJoinGame stores the optional derived game-join eligibility filter.
CanJoinGame *bool
}
// Validate reports whether filters is structurally valid.
func (filters UserListFilters) Validate() error {
if !filters.PaidState.IsKnown() {
return fmt.Errorf("paid state %q is unsupported", filters.PaidState)
}
if filters.PaidExpiresBefore != nil && filters.PaidExpiresBefore.IsZero() {
return fmt.Errorf("paid expires before must not be zero")
}
if filters.PaidExpiresAfter != nil && filters.PaidExpiresAfter.IsZero() {
return fmt.Errorf("paid expires after must not be zero")
}
if !filters.DeclaredCountry.IsZero() {
if err := filters.DeclaredCountry.Validate(); err != nil {
return fmt.Errorf("declared country: %w", err)
}
}
if filters.SanctionCode != "" && !filters.SanctionCode.IsKnown() {
return fmt.Errorf("sanction code %q is unsupported", filters.SanctionCode)
}
if filters.LimitCode != "" && !filters.LimitCode.IsKnown() {
return fmt.Errorf("limit code %q is unsupported", filters.LimitCode)
}
return nil
}
// ListUsersInput stores one trusted admin-listing read request.
type ListUsersInput struct {
// PageSize stores the maximum number of ordered user identifiers returned
// in one storage page.
PageSize int
// PageToken stores the optional opaque continuation cursor.
PageToken string
// Filters stores the normalized filter set bound into PageToken.
Filters UserListFilters
}
// Validate reports whether input is structurally complete.
func (input ListUsersInput) Validate() error {
switch {
case input.PageSize < 1:
return fmt.Errorf("page size must be at least 1")
case input.PageSize > MaxUserListPageSize:
return fmt.Errorf("page size must be at most %d", MaxUserListPageSize)
case strings.TrimSpace(input.PageToken) != input.PageToken:
return fmt.Errorf("page token must not contain surrounding whitespace")
}
if err := input.Filters.Validate(); err != nil {
return fmt.Errorf("filters: %w", err)
}
return nil
}
// ListUsersResult stores one deterministic ordered storage page of user ids.
type ListUsersResult struct {
// UserIDs stores the ordered user identifiers returned for the requested
// page.
UserIDs []common.UserID
// NextPageToken stores the optional opaque continuation cursor for the next
// page.
NextPageToken string
}
// UserListStore provides deterministic ordered admin-listing pagination over
// stored user identifiers.
type UserListStore interface {
// ListUserIDs returns one deterministic storage page of user identifiers.
ListUserIDs(ctx context.Context, input ListUsersInput) (ListUsersResult, error)
}
@@ -0,0 +1,336 @@
// Package accountview materializes the shared account aggregate view used by
// self-service and trusted administrative reads.
package accountview
import (
"context"
"errors"
"fmt"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/shared"
)
// ActorRefView stores transport-ready audit actor metadata.
type ActorRefView struct {
// Type stores the machine-readable actor type.
Type string `json:"type"`
// ID stores the optional stable actor identifier.
ID string `json:"id,omitempty"`
}
// EntitlementSnapshotView stores the transport-ready current entitlement
// snapshot of one account.
type EntitlementSnapshotView struct {
// PlanCode stores the effective entitlement plan code.
PlanCode string `json:"plan_code"`
// IsPaid reports whether the effective plan is paid.
IsPaid bool `json:"is_paid"`
// Source stores the machine-readable mutation source.
Source string `json:"source"`
// Actor stores the audit actor metadata attached to the snapshot.
Actor ActorRefView `json:"actor"`
// ReasonCode stores the machine-readable reason attached to the snapshot.
ReasonCode string `json:"reason_code"`
// StartsAt stores when the effective state started.
StartsAt time.Time `json:"starts_at"`
// EndsAt stores the optional finite effective expiry.
EndsAt *time.Time `json:"ends_at,omitempty"`
// UpdatedAt stores when the snapshot was last recomputed.
UpdatedAt time.Time `json:"updated_at"`
}
// ActiveSanctionView stores one transport-ready active sanction.
type ActiveSanctionView struct {
// SanctionCode stores the active sanction code.
SanctionCode string `json:"sanction_code"`
// Scope stores the machine-readable sanction scope.
Scope string `json:"scope"`
// ReasonCode stores the machine-readable sanction reason.
ReasonCode string `json:"reason_code"`
// Actor stores the audit actor metadata attached to the sanction.
Actor ActorRefView `json:"actor"`
// AppliedAt stores when the sanction became active.
AppliedAt time.Time `json:"applied_at"`
// ExpiresAt stores the optional planned sanction expiry.
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// ActiveLimitView stores one transport-ready active user-specific limit.
type ActiveLimitView struct {
// LimitCode stores the active limit code.
LimitCode string `json:"limit_code"`
// Value stores the current override value.
Value int `json:"value"`
// ReasonCode stores the machine-readable limit reason.
ReasonCode string `json:"reason_code"`
// Actor stores the audit actor metadata attached to the limit.
Actor ActorRefView `json:"actor"`
// AppliedAt stores when the limit became active.
AppliedAt time.Time `json:"applied_at"`
// ExpiresAt stores the optional planned limit expiry.
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// AccountView stores the transport-ready account aggregate shared by
// self-service and admin reads.
type AccountView struct {
// UserID stores the durable regular-user identifier.
UserID string `json:"user_id"`
// Email stores the exact normalized login e-mail address.
Email string `json:"email"`
// RaceName stores the current user-facing race name.
RaceName string `json:"race_name"`
// PreferredLanguage stores the current BCP 47 preferred language.
PreferredLanguage string `json:"preferred_language"`
// TimeZone stores the current IANA time-zone name.
TimeZone string `json:"time_zone"`
// DeclaredCountry stores the optional latest effective declared country.
DeclaredCountry string `json:"declared_country,omitempty"`
// Entitlement stores the current entitlement snapshot.
Entitlement EntitlementSnapshotView `json:"entitlement"`
// ActiveSanctions stores the current active sanctions sorted by code.
ActiveSanctions []ActiveSanctionView `json:"active_sanctions"`
// ActiveLimits stores the current active user-specific limits sorted by
// code.
ActiveLimits []ActiveLimitView `json:"active_limits"`
// CreatedAt stores when the account was created.
CreatedAt time.Time `json:"created_at"`
// UpdatedAt stores when the account was last mutated.
UpdatedAt time.Time `json:"updated_at"`
}
// Aggregate stores the raw domain state that backs one shared account view.
type Aggregate struct {
// AccountRecord stores the current editable account record.
AccountRecord account.UserAccount
// EntitlementSnapshot stores the current effective entitlement snapshot.
EntitlementSnapshot entitlement.CurrentSnapshot
// ActiveSanctions stores the active sanctions sorted by code.
ActiveSanctions []policy.SanctionRecord
// ActiveLimits stores the active user-specific limits sorted by code.
ActiveLimits []policy.LimitRecord
}
// HasActiveSanction reports whether aggregate currently contains code in its
// active sanction set.
func (aggregate Aggregate) HasActiveSanction(code policy.SanctionCode) bool {
for _, record := range aggregate.ActiveSanctions {
if record.SanctionCode == code {
return true
}
}
return false
}
// HasActiveLimit reports whether aggregate currently contains code in its
// active user-specific limit set.
func (aggregate Aggregate) HasActiveLimit(code policy.LimitCode) bool {
for _, record := range aggregate.ActiveLimits {
if record.LimitCode == code {
return true
}
}
return false
}
// View materializes Aggregate into the shared transport-ready account view.
func (aggregate Aggregate) View() AccountView {
view := AccountView{
UserID: aggregate.AccountRecord.UserID.String(),
Email: aggregate.AccountRecord.Email.String(),
RaceName: aggregate.AccountRecord.RaceName.String(),
PreferredLanguage: aggregate.AccountRecord.PreferredLanguage.String(),
TimeZone: aggregate.AccountRecord.TimeZone.String(),
Entitlement: EntitlementSnapshotView{
PlanCode: string(aggregate.EntitlementSnapshot.PlanCode),
IsPaid: aggregate.EntitlementSnapshot.IsPaid,
Source: aggregate.EntitlementSnapshot.Source.String(),
Actor: actorRefView(aggregate.EntitlementSnapshot.Actor),
ReasonCode: aggregate.EntitlementSnapshot.ReasonCode.String(),
StartsAt: aggregate.EntitlementSnapshot.StartsAt.UTC(),
EndsAt: cloneOptionalTime(aggregate.EntitlementSnapshot.EndsAt),
UpdatedAt: aggregate.EntitlementSnapshot.UpdatedAt.UTC(),
},
ActiveSanctions: make([]ActiveSanctionView, 0, len(aggregate.ActiveSanctions)),
ActiveLimits: make([]ActiveLimitView, 0, len(aggregate.ActiveLimits)),
CreatedAt: aggregate.AccountRecord.CreatedAt.UTC(),
UpdatedAt: aggregate.AccountRecord.UpdatedAt.UTC(),
}
if !aggregate.AccountRecord.DeclaredCountry.IsZero() {
view.DeclaredCountry = aggregate.AccountRecord.DeclaredCountry.String()
}
for _, sanctionRecord := range aggregate.ActiveSanctions {
view.ActiveSanctions = append(view.ActiveSanctions, ActiveSanctionView{
SanctionCode: string(sanctionRecord.SanctionCode),
Scope: sanctionRecord.Scope.String(),
ReasonCode: sanctionRecord.ReasonCode.String(),
Actor: actorRefView(sanctionRecord.Actor),
AppliedAt: sanctionRecord.AppliedAt.UTC(),
ExpiresAt: cloneOptionalTime(sanctionRecord.ExpiresAt),
})
}
for _, limitRecord := range aggregate.ActiveLimits {
view.ActiveLimits = append(view.ActiveLimits, ActiveLimitView{
LimitCode: string(limitRecord.LimitCode),
Value: limitRecord.Value,
ReasonCode: limitRecord.ReasonCode.String(),
Actor: actorRefView(limitRecord.Actor),
AppliedAt: limitRecord.AppliedAt.UTC(),
ExpiresAt: cloneOptionalTime(limitRecord.ExpiresAt),
})
}
return view
}
type entitlementReader interface {
GetByUserID(ctx context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error)
}
// Loader materializes the shared current account aggregate for one user id.
type Loader struct {
accounts ports.UserAccountStore
entitlements entitlementReader
sanctions ports.SanctionStore
limits ports.LimitStore
clock ports.Clock
}
// NewLoader constructs one shared account-aggregate loader.
func NewLoader(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
) (*Loader, error) {
switch {
case accounts == nil:
return nil, fmt.Errorf("account view loader: user account store must not be nil")
case entitlements == nil:
return nil, fmt.Errorf("account view loader: entitlement reader must not be nil")
case sanctions == nil:
return nil, fmt.Errorf("account view loader: sanction store must not be nil")
case limits == nil:
return nil, fmt.Errorf("account view loader: limit store must not be nil")
case clock == nil:
return nil, fmt.Errorf("account view loader: clock must not be nil")
default:
return &Loader{
accounts: accounts,
entitlements: entitlements,
sanctions: sanctions,
limits: limits,
clock: clock,
}, nil
}
}
// Load materializes the shared account aggregate identified by userID.
func (loader *Loader) Load(ctx context.Context, userID common.UserID) (Aggregate, error) {
if loader == nil {
return Aggregate{}, shared.InternalError(fmt.Errorf("account view loader must not be nil"))
}
accountRecord, err := loader.accounts.GetByUserID(ctx, userID)
switch {
case err == nil:
case errors.Is(err, ports.ErrNotFound):
return Aggregate{}, shared.SubjectNotFound()
default:
return Aggregate{}, shared.ServiceUnavailable(err)
}
entitlementSnapshot, err := loader.entitlements.GetByUserID(ctx, userID)
switch {
case err == nil:
case errors.Is(err, ports.ErrNotFound):
return Aggregate{}, shared.InternalError(fmt.Errorf("user %q is missing entitlement snapshot", userID))
default:
return Aggregate{}, shared.ServiceUnavailable(err)
}
sanctionRecords, err := loader.sanctions.ListByUserID(ctx, userID)
if err != nil {
return Aggregate{}, shared.ServiceUnavailable(err)
}
limitRecords, err := loader.limits.ListByUserID(ctx, userID)
if err != nil {
return Aggregate{}, shared.ServiceUnavailable(err)
}
now := loader.clock.Now().UTC()
activeSanctions, err := policy.ActiveSanctionsAt(sanctionRecords, now)
if err != nil {
return Aggregate{}, shared.InternalError(fmt.Errorf("evaluate active sanctions for user %q: %w", userID, err))
}
activeLimits, err := policy.ActiveLimitsAt(limitRecords, now)
if err != nil {
return Aggregate{}, shared.InternalError(fmt.Errorf("evaluate active limits for user %q: %w", userID, err))
}
return Aggregate{
AccountRecord: accountRecord,
EntitlementSnapshot: entitlementSnapshot,
ActiveSanctions: activeSanctions,
ActiveLimits: activeLimits,
}, nil
}
func actorRefView(ref common.ActorRef) ActorRefView {
return ActorRefView{
Type: ref.Type.String(),
ID: ref.ID.String(),
}
}
func cloneOptionalTime(value *time.Time) *time.Time {
if value == nil {
return nil
}
cloned := value.UTC()
return &cloned
}
+504
View File
@@ -0,0 +1,504 @@
// Package adminusers implements the trusted administrative user-read surface
// owned by User Service.
package adminusers
import (
"context"
"errors"
"fmt"
"strings"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/accountview"
"galaxy/user/internal/service/shared"
)
// LookupResult stores one exact trusted admin user lookup result.
type LookupResult struct {
// User stores the shared account aggregate of the resolved user.
User accountview.AccountView `json:"user"`
}
// GetUserByIDInput stores one exact trusted lookup by stable user identifier.
type GetUserByIDInput struct {
// UserID stores the stable regular-user identifier to resolve.
UserID string
}
// GetUserByEmailInput stores one exact trusted lookup by normalized e-mail.
type GetUserByEmailInput struct {
// Email stores the normalized login/contact e-mail to resolve.
Email string
}
// GetUserByRaceNameInput stores one exact trusted lookup by exact stored race
// name.
type GetUserByRaceNameInput struct {
// RaceName stores the exact current race name to resolve.
RaceName string
}
// ListUsersInput stores one trusted administrative user-list request.
type ListUsersInput struct {
// PageSize stores the requested maximum number of returned users. The zero
// value selects the frozen default page size.
PageSize int
// PageToken stores the optional opaque continuation cursor.
PageToken string
// PaidState stores the optional coarse free-versus-paid filter.
PaidState string
// PaidExpiresBefore stores the optional strict finite paid-expiry upper
// bound.
PaidExpiresBefore *time.Time
// PaidExpiresAfter stores the optional strict finite paid-expiry lower
// bound.
PaidExpiresAfter *time.Time
// DeclaredCountry stores the optional current declared-country filter.
DeclaredCountry string
// SanctionCode stores the optional active-sanction filter.
SanctionCode string
// LimitCode stores the optional active user-specific limit filter.
LimitCode string
// CanLogin stores the optional derived login-eligibility filter.
CanLogin *bool
// CanCreatePrivateGame stores the optional derived private-game-create
// eligibility filter.
CanCreatePrivateGame *bool
// CanJoinGame stores the optional derived game-join eligibility filter.
CanJoinGame *bool
}
// ListUsersResult stores one trusted administrative page of user aggregates.
type ListUsersResult struct {
// Items stores the returned user aggregates in deterministic order.
Items []accountview.AccountView `json:"items"`
// NextPageToken stores the optional continuation cursor for the next page.
NextPageToken string `json:"next_page_token,omitempty"`
}
type entitlementReader interface {
GetByUserID(ctx context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error)
}
type readSupport struct {
accounts ports.UserAccountStore
loader *accountview.Loader
}
func newReadSupport(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
) (readSupport, error) {
loader, err := accountview.NewLoader(accounts, entitlements, sanctions, limits, clock)
if err != nil {
return readSupport{}, fmt.Errorf("account view loader: %w", err)
}
return readSupport{
accounts: accounts,
loader: loader,
}, nil
}
// ByIDGetter executes exact trusted lookups by stable user identifier.
type ByIDGetter struct {
support readSupport
}
// NewByIDGetter constructs one exact admin lookup by user id.
func NewByIDGetter(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
) (*ByIDGetter, error) {
support, err := newReadSupport(accounts, entitlements, sanctions, limits, clock)
if err != nil {
return nil, fmt.Errorf("admin users by-id getter: %w", err)
}
return &ByIDGetter{support: support}, nil
}
// Execute resolves one exact user by stable user identifier.
func (service *ByIDGetter) Execute(ctx context.Context, input GetUserByIDInput) (LookupResult, error) {
if ctx == nil {
return LookupResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
return LookupResult{}, err
}
aggregate, err := service.support.loader.Load(ctx, userID)
if err != nil {
return LookupResult{}, err
}
return LookupResult{User: aggregate.View()}, nil
}
// ByEmailGetter executes exact trusted lookups by normalized e-mail.
type ByEmailGetter struct {
support readSupport
}
// NewByEmailGetter constructs one exact admin lookup by normalized e-mail.
func NewByEmailGetter(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
) (*ByEmailGetter, error) {
support, err := newReadSupport(accounts, entitlements, sanctions, limits, clock)
if err != nil {
return nil, fmt.Errorf("admin users by-email getter: %w", err)
}
return &ByEmailGetter{support: support}, nil
}
// Execute resolves one exact user by normalized e-mail.
func (service *ByEmailGetter) Execute(ctx context.Context, input GetUserByEmailInput) (LookupResult, error) {
if ctx == nil {
return LookupResult{}, shared.InvalidRequest("context must not be nil")
}
email, err := shared.ParseEmail(input.Email)
if err != nil {
return LookupResult{}, err
}
record, err := service.support.accounts.GetByEmail(ctx, email)
switch {
case err == nil:
case errors.Is(err, ports.ErrNotFound):
return LookupResult{}, shared.SubjectNotFound()
default:
return LookupResult{}, shared.ServiceUnavailable(err)
}
aggregate, err := service.support.loader.Load(ctx, record.UserID)
if err != nil {
return LookupResult{}, err
}
return LookupResult{User: aggregate.View()}, nil
}
// ByRaceNameGetter executes exact trusted lookups by exact stored race name.
type ByRaceNameGetter struct {
support readSupport
}
// NewByRaceNameGetter constructs one exact admin lookup by exact stored race
// name.
func NewByRaceNameGetter(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
) (*ByRaceNameGetter, error) {
support, err := newReadSupport(accounts, entitlements, sanctions, limits, clock)
if err != nil {
return nil, fmt.Errorf("admin users by-race-name getter: %w", err)
}
return &ByRaceNameGetter{support: support}, nil
}
// Execute resolves one exact user by exact stored race name.
func (service *ByRaceNameGetter) Execute(ctx context.Context, input GetUserByRaceNameInput) (LookupResult, error) {
if ctx == nil {
return LookupResult{}, shared.InvalidRequest("context must not be nil")
}
raceName, err := shared.ParseRaceName(input.RaceName)
if err != nil {
return LookupResult{}, err
}
record, err := service.support.accounts.GetByRaceName(ctx, raceName)
switch {
case err == nil:
case errors.Is(err, ports.ErrNotFound):
return LookupResult{}, shared.SubjectNotFound()
default:
return LookupResult{}, shared.ServiceUnavailable(err)
}
aggregate, err := service.support.loader.Load(ctx, record.UserID)
if err != nil {
return LookupResult{}, err
}
return LookupResult{User: aggregate.View()}, nil
}
// Lister executes the trusted administrative filtered user listing.
type Lister struct {
support readSupport
listStore ports.UserListStore
}
// NewLister constructs one trusted administrative filtered user lister.
func NewLister(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
listStore ports.UserListStore,
) (*Lister, error) {
if listStore == nil {
return nil, fmt.Errorf("admin users lister: user list store must not be nil")
}
support, err := newReadSupport(accounts, entitlements, sanctions, limits, clock)
if err != nil {
return nil, fmt.Errorf("admin users lister: %w", err)
}
return &Lister{
support: support,
listStore: listStore,
}, nil
}
// Execute lists users in deterministic newest-first order and combines all
// supplied filters with logical AND semantics.
func (service *Lister) Execute(ctx context.Context, input ListUsersInput) (ListUsersResult, error) {
if ctx == nil {
return ListUsersResult{}, shared.InvalidRequest("context must not be nil")
}
if strings.TrimSpace(input.PageToken) != input.PageToken {
return ListUsersResult{}, shared.InvalidRequest("page_token must not contain surrounding whitespace")
}
pageSize, err := normalizePageSize(input.PageSize)
if err != nil {
return ListUsersResult{}, err
}
filters, err := parseListFilters(input)
if err != nil {
return ListUsersResult{}, err
}
result := ListUsersResult{
Items: make([]accountview.AccountView, 0, pageSize),
}
currentToken := input.PageToken
for len(result.Items) < pageSize {
candidatePage, err := service.listStore.ListUserIDs(ctx, ports.ListUsersInput{
PageSize: 1,
PageToken: currentToken,
Filters: filters,
})
switch {
case err == nil:
case errors.Is(err, ports.ErrInvalidPageToken):
return ListUsersResult{}, shared.InvalidRequest("page_token is invalid or does not match current filters")
default:
return ListUsersResult{}, shared.ServiceUnavailable(err)
}
if len(candidatePage.UserIDs) == 0 {
result.NextPageToken = ""
return result, nil
}
nextToken := candidatePage.NextPageToken
candidateID := candidatePage.UserIDs[0]
aggregate, err := service.support.loader.Load(ctx, candidateID)
if err != nil {
return ListUsersResult{}, err
}
if matchesFilters(aggregate, filters) {
result.Items = append(result.Items, aggregate.View())
result.NextPageToken = nextToken
}
if nextToken == "" {
result.NextPageToken = ""
return result, nil
}
currentToken = nextToken
}
return result, nil
}
func normalizePageSize(value int) (int, error) {
switch {
case value == 0:
return ports.DefaultUserListPageSize, nil
case value < 0:
return 0, shared.InvalidRequest("page_size must be between 1 and 200")
case value > ports.MaxUserListPageSize:
return 0, shared.InvalidRequest("page_size must be between 1 and 200")
default:
return value, nil
}
}
func parseListFilters(input ListUsersInput) (ports.UserListFilters, error) {
paidState, err := parsePaidState(input.PaidState)
if err != nil {
return ports.UserListFilters{}, err
}
declaredCountry, err := parseCountryCode(input.DeclaredCountry)
if err != nil {
return ports.UserListFilters{}, err
}
sanctionCode, err := parseSanctionCode(input.SanctionCode)
if err != nil {
return ports.UserListFilters{}, err
}
limitCode, err := parseLimitCode(input.LimitCode)
if err != nil {
return ports.UserListFilters{}, err
}
filters := ports.UserListFilters{
PaidState: paidState,
PaidExpiresBefore: input.PaidExpiresBefore,
PaidExpiresAfter: input.PaidExpiresAfter,
DeclaredCountry: declaredCountry,
SanctionCode: sanctionCode,
LimitCode: limitCode,
CanLogin: input.CanLogin,
CanCreatePrivateGame: input.CanCreatePrivateGame,
CanJoinGame: input.CanJoinGame,
}
if err := filters.Validate(); err != nil {
return ports.UserListFilters{}, shared.InvalidRequest(err.Error())
}
return filters, nil
}
func parsePaidState(value string) (entitlement.PaidState, error) {
state := entitlement.PaidState(shared.NormalizeString(value))
if !state.IsKnown() {
return "", shared.InvalidRequest(fmt.Sprintf("paid_state %q is unsupported", state))
}
return state, nil
}
func parseCountryCode(value string) (common.CountryCode, error) {
code := common.CountryCode(shared.NormalizeString(value))
if code.IsZero() {
return "", nil
}
if err := code.Validate(); err != nil {
return "", shared.InvalidRequest(fmt.Sprintf("declared_country: %s", err.Error()))
}
return code, nil
}
func parseSanctionCode(value string) (policy.SanctionCode, error) {
code := policy.SanctionCode(shared.NormalizeString(value))
if code == "" {
return "", nil
}
if !code.IsKnown() {
return "", shared.InvalidRequest(fmt.Sprintf("sanction_code %q is unsupported", code))
}
return code, nil
}
func parseLimitCode(value string) (policy.LimitCode, error) {
code := policy.LimitCode(shared.NormalizeString(value))
if code == "" {
return "", nil
}
if !code.IsKnown() {
return "", shared.InvalidRequest(fmt.Sprintf("limit_code %q is unsupported", code))
}
return code, nil
}
func matchesFilters(aggregate accountview.Aggregate, filters ports.UserListFilters) bool {
switch filters.PaidState {
case entitlement.PaidStateFree:
if aggregate.EntitlementSnapshot.IsPaid {
return false
}
case entitlement.PaidStatePaid:
if !aggregate.EntitlementSnapshot.IsPaid {
return false
}
}
if filters.PaidExpiresBefore != nil {
if !aggregate.EntitlementSnapshot.HasFiniteExpiry() || !aggregate.EntitlementSnapshot.EndsAt.Before(filters.PaidExpiresBefore.UTC()) {
return false
}
}
if filters.PaidExpiresAfter != nil {
if !aggregate.EntitlementSnapshot.HasFiniteExpiry() || !aggregate.EntitlementSnapshot.EndsAt.After(filters.PaidExpiresAfter.UTC()) {
return false
}
}
if !filters.DeclaredCountry.IsZero() && aggregate.AccountRecord.DeclaredCountry != filters.DeclaredCountry {
return false
}
if filters.SanctionCode != "" && !aggregate.HasActiveSanction(filters.SanctionCode) {
return false
}
if filters.LimitCode != "" && !aggregate.HasActiveLimit(filters.LimitCode) {
return false
}
canLogin, canCreatePrivateGame, canJoinGame := deriveFilterEligibility(aggregate)
if filters.CanLogin != nil && canLogin != *filters.CanLogin {
return false
}
if filters.CanCreatePrivateGame != nil && canCreatePrivateGame != *filters.CanCreatePrivateGame {
return false
}
if filters.CanJoinGame != nil && canJoinGame != *filters.CanJoinGame {
return false
}
return true
}
func deriveFilterEligibility(aggregate accountview.Aggregate) (bool, bool, bool) {
canLogin := !aggregate.HasActiveSanction(policy.SanctionCodeLoginBlock)
canCreatePrivateGame := canLogin &&
aggregate.EntitlementSnapshot.IsPaid &&
!aggregate.HasActiveSanction(policy.SanctionCodePrivateGameCreateBlock)
canJoinGame := canLogin &&
!aggregate.HasActiveSanction(policy.SanctionCodeGameJoinBlock)
return canLogin, canCreatePrivateGame, canJoinGame
}
@@ -0,0 +1,623 @@
package adminusers
import (
"context"
"errors"
"fmt"
"testing"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/entitlementsvc"
"galaxy/user/internal/service/shared"
"github.com/stretchr/testify/require"
)
func TestByIDGetterExecuteReturnsAggregate(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
service, err := NewByIDGetter(
newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "Pilot Nova", now)),
&fakeAdminEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validAdminFreeSnapshot(common.UserID("user-123"), now),
},
},
fakeAdminSanctionStore{
byUserID: map[common.UserID][]policy.SanctionRecord{
common.UserID("user-123"): {
validAdminActiveSanction(common.UserID("user-123"), policy.SanctionCodeLoginBlock, now.Add(-time.Hour)),
expiredAdminSanction(common.UserID("user-123"), policy.SanctionCodeGameJoinBlock, now.Add(-2*time.Hour)),
},
},
},
fakeAdminLimitStore{
byUserID: map[common.UserID][]policy.LimitRecord{
common.UserID("user-123"): {
validAdminActiveLimit(common.UserID("user-123"), policy.LimitCodeMaxOwnedPrivateGames, 3, now.Add(-time.Hour)),
},
},
},
adminFixedClock{now: now},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), GetUserByIDInput{UserID: " user-123 "})
require.NoError(t, err)
require.Equal(t, "user-123", result.User.UserID)
require.Equal(t, "pilot@example.com", result.User.Email)
require.Len(t, result.User.ActiveSanctions, 1)
require.Equal(t, string(policy.SanctionCodeLoginBlock), result.User.ActiveSanctions[0].SanctionCode)
require.Len(t, result.User.ActiveLimits, 1)
require.Equal(t, string(policy.LimitCodeMaxOwnedPrivateGames), result.User.ActiveLimits[0].LimitCode)
}
func TestByEmailGetterExecuteUnknownUserReturnsNotFound(t *testing.T) {
t.Parallel()
service, err := NewByEmailGetter(
newFakeAdminAccountStore(),
&fakeAdminEntitlementSnapshotStore{},
fakeAdminSanctionStore{},
fakeAdminLimitStore{},
adminFixedClock{now: time.Unix(1_775_240_500, 0).UTC()},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), GetUserByEmailInput{Email: "missing@example.com"})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err))
}
func TestByRaceNameGetterExecuteReturnsAggregate(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
service, err := NewByRaceNameGetter(
newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "Pilot Nova", now)),
&fakeAdminEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validAdminFreeSnapshot(common.UserID("user-123"), now),
},
},
fakeAdminSanctionStore{},
fakeAdminLimitStore{},
adminFixedClock{now: now},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), GetUserByRaceNameInput{RaceName: " Pilot Nova "})
require.NoError(t, err)
require.Equal(t, "user-123", result.User.UserID)
require.Equal(t, "Pilot Nova", result.User.RaceName)
}
func TestListerExecuteAppliesCombinedFiltersWithLogicalAND(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
firstExpiry := now.Add(48 * time.Hour)
secondExpiry := now.Add(72 * time.Hour)
before := now.Add(96 * time.Hour)
after := now.Add(24 * time.Hour)
canLogin := false
canCreatePrivateGame := false
canJoinGame := false
accountStore := newFakeAdminAccountStore(
validAdminUserAccount("user-300", "u300@example.com", "User 300", now),
validAdminUserAccount("user-200", "u200@example.com", "User 200", now),
validAdminUserAccount("user-100", "u100@example.com", "User 100", now),
)
snapshotStore := &fakeAdminEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-300"): validAdminPaidSnapshot(common.UserID("user-300"), now, firstExpiry),
common.UserID("user-200"): validAdminPaidSnapshot(common.UserID("user-200"), now, secondExpiry),
common.UserID("user-100"): validAdminPaidSnapshot(common.UserID("user-100"), now, secondExpiry),
},
}
sanctionStore := fakeAdminSanctionStore{
byUserID: map[common.UserID][]policy.SanctionRecord{
common.UserID("user-300"): {
validAdminActiveSanction(common.UserID("user-300"), policy.SanctionCodeLoginBlock, now.Add(-time.Hour)),
},
common.UserID("user-200"): {
validAdminActiveSanction(common.UserID("user-200"), policy.SanctionCodeLoginBlock, now.Add(-time.Hour)),
},
common.UserID("user-100"): {
validAdminActiveSanction(common.UserID("user-100"), policy.SanctionCodeLoginBlock, now.Add(-time.Hour)),
},
},
}
limitStore := fakeAdminLimitStore{
byUserID: map[common.UserID][]policy.LimitRecord{
common.UserID("user-300"): {
validAdminActiveLimit(common.UserID("user-300"), policy.LimitCodeMaxOwnedPrivateGames, 3, now.Add(-time.Hour)),
},
common.UserID("user-100"): {
validAdminActiveLimit(common.UserID("user-100"), policy.LimitCodeMaxOwnedPrivateGames, 3, now.Add(-time.Hour)),
},
},
}
listStore := &fakeAdminListStore{
pages: map[string]ports.ListUsersResult{
"": {
UserIDs: []common.UserID{common.UserID("user-300")},
NextPageToken: "cursor-1",
},
"cursor-1": {
UserIDs: []common.UserID{common.UserID("user-200")},
NextPageToken: "cursor-2",
},
"cursor-2": {
UserIDs: []common.UserID{common.UserID("user-100")},
NextPageToken: "",
},
},
}
service, err := NewLister(accountStore, snapshotStore, sanctionStore, limitStore, adminFixedClock{now: now}, listStore)
require.NoError(t, err)
result, err := service.Execute(context.Background(), ListUsersInput{
PageSize: 2,
PaidState: "paid",
PaidExpiresBefore: &before,
PaidExpiresAfter: &after,
DeclaredCountry: "DE",
SanctionCode: "login_block",
LimitCode: "max_owned_private_games",
CanLogin: &canLogin,
CanCreatePrivateGame: &canCreatePrivateGame,
CanJoinGame: &canJoinGame,
})
require.NoError(t, err)
require.Len(t, result.Items, 2)
require.Equal(t, "user-300", result.Items[0].UserID)
require.Equal(t, "user-100", result.Items[1].UserID)
require.Equal(t, "", result.NextPageToken)
require.Len(t, listStore.calls, 3)
for _, call := range listStore.calls {
require.Equal(t, 1, call.PageSize)
require.Equal(t, entitlement.PaidStatePaid, call.Filters.PaidState)
require.Equal(t, common.CountryCode("DE"), call.Filters.DeclaredCountry)
require.Equal(t, policy.SanctionCodeLoginBlock, call.Filters.SanctionCode)
require.Equal(t, policy.LimitCodeMaxOwnedPrivateGames, call.Filters.LimitCode)
}
}
func TestListerExecuteDefaultAndMaximumPageSize(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
accountStore := newFakeAdminAccountStore(
validAdminUserAccount("user-300", "u300@example.com", "User 300", now),
validAdminUserAccount("user-200", "u200@example.com", "User 200", now),
validAdminUserAccount("user-100", "u100@example.com", "User 100", now),
)
snapshotStore := &fakeAdminEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-300"): validAdminFreeSnapshot(common.UserID("user-300"), now),
common.UserID("user-200"): validAdminFreeSnapshot(common.UserID("user-200"), now),
common.UserID("user-100"): validAdminFreeSnapshot(common.UserID("user-100"), now),
},
}
t.Run("default page size", func(t *testing.T) {
t.Parallel()
listStore := &fakeAdminListStore{
pages: map[string]ports.ListUsersResult{
"": {
UserIDs: []common.UserID{common.UserID("user-300")},
NextPageToken: "cursor-1",
},
"cursor-1": {
UserIDs: []common.UserID{common.UserID("user-200")},
NextPageToken: "cursor-2",
},
"cursor-2": {
UserIDs: []common.UserID{common.UserID("user-100")},
NextPageToken: "",
},
},
}
service, err := NewLister(accountStore, snapshotStore, fakeAdminSanctionStore{}, fakeAdminLimitStore{}, adminFixedClock{now: now}, listStore)
require.NoError(t, err)
result, err := service.Execute(context.Background(), ListUsersInput{})
require.NoError(t, err)
require.Len(t, result.Items, 3)
})
t.Run("maximum page size", func(t *testing.T) {
t.Parallel()
listStore := &fakeAdminListStore{
pages: map[string]ports.ListUsersResult{
"": {
UserIDs: []common.UserID{common.UserID("user-300")},
NextPageToken: "",
},
},
}
service, err := NewLister(accountStore, snapshotStore, fakeAdminSanctionStore{}, fakeAdminLimitStore{}, adminFixedClock{now: now}, listStore)
require.NoError(t, err)
result, err := service.Execute(context.Background(), ListUsersInput{PageSize: ports.MaxUserListPageSize})
require.NoError(t, err)
require.Len(t, result.Items, 1)
})
t.Run("above maximum is rejected", func(t *testing.T) {
t.Parallel()
service, err := NewLister(accountStore, snapshotStore, fakeAdminSanctionStore{}, fakeAdminLimitStore{}, adminFixedClock{now: now}, &fakeAdminListStore{})
require.NoError(t, err)
_, err = service.Execute(context.Background(), ListUsersInput{PageSize: ports.MaxUserListPageSize + 1})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
require.Equal(t, "page_size must be between 1 and 200", err.Error())
})
}
func TestListerExecuteInvalidPageTokenReturnsInvalidRequest(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
service, err := NewLister(
newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "Pilot Nova", now)),
&fakeAdminEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validAdminFreeSnapshot(common.UserID("user-123"), now),
},
},
fakeAdminSanctionStore{},
fakeAdminLimitStore{},
adminFixedClock{now: now},
&fakeAdminListStore{err: fmt.Errorf("wrapped: %w", ports.ErrInvalidPageToken)},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), ListUsersInput{PageToken: "bad-token"})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
require.Equal(t, "page_token is invalid or does not match current filters", err.Error())
}
func TestListerExecuteRepairsExpiredPaidSnapshotBeforeFiltering(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
expiredAt := now.Add(-time.Hour)
accountStore := newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "Pilot Nova", now))
snapshotStore := &fakeAdminEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): {
UserID: common.UserID("user-123"),
PlanCode: entitlement.PlanCodePaidMonthly,
IsPaid: true,
StartsAt: now.Add(-30 * 24 * time.Hour),
EndsAt: adminTimePointer(expiredAt),
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_grant"),
UpdatedAt: expiredAt,
},
},
}
reader, err := entitlementsvc.NewReader(
snapshotStore,
&fakeAdminEntitlementLifecycleStore{snapshotStore: snapshotStore},
adminFixedClock{now: now},
adminReaderIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-repair-free-record")},
)
require.NoError(t, err)
listStore := &fakeAdminListStore{
pages: map[string]ports.ListUsersResult{
"": {
UserIDs: []common.UserID{common.UserID("user-123")},
NextPageToken: "",
},
},
}
service, err := NewLister(accountStore, reader, fakeAdminSanctionStore{}, fakeAdminLimitStore{}, adminFixedClock{now: now}, listStore)
require.NoError(t, err)
result, err := service.Execute(context.Background(), ListUsersInput{PaidState: "free"})
require.NoError(t, err)
require.Len(t, result.Items, 1)
require.Equal(t, "free", result.Items[0].Entitlement.PlanCode)
require.False(t, result.Items[0].Entitlement.IsPaid)
storedSnapshot, err := snapshotStore.GetByUserID(context.Background(), common.UserID("user-123"))
require.NoError(t, err)
require.Equal(t, entitlement.PlanCodeFree, storedSnapshot.PlanCode)
require.False(t, storedSnapshot.IsPaid)
require.Equal(t, expiredAt, storedSnapshot.StartsAt)
}
type adminFixedClock struct {
now time.Time
}
func (clock adminFixedClock) Now() time.Time {
return clock.now
}
type adminReaderIDGenerator struct {
recordID entitlement.EntitlementRecordID
}
func (generator adminReaderIDGenerator) NewUserID() (common.UserID, error) {
return "", errors.New("unexpected NewUserID call")
}
func (generator adminReaderIDGenerator) NewInitialRaceName() (common.RaceName, error) {
return "", errors.New("unexpected NewInitialRaceName call")
}
func (generator adminReaderIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
return generator.recordID, nil
}
func (generator adminReaderIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
return "", errors.New("unexpected NewSanctionRecordID call")
}
func (generator adminReaderIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
return "", errors.New("unexpected NewLimitRecordID call")
}
type fakeAdminAccountStore struct {
byUserID map[common.UserID]account.UserAccount
byEmail map[common.Email]common.UserID
byRaceName map[common.RaceName]common.UserID
updateErr error
renameErr error
createErr error
existsByID map[common.UserID]bool
}
func newFakeAdminAccountStore(records ...account.UserAccount) *fakeAdminAccountStore {
store := &fakeAdminAccountStore{
byUserID: make(map[common.UserID]account.UserAccount, len(records)),
byEmail: make(map[common.Email]common.UserID, len(records)),
byRaceName: make(map[common.RaceName]common.UserID, len(records)),
existsByID: make(map[common.UserID]bool, len(records)),
}
for _, record := range records {
store.byUserID[record.UserID] = record
store.byEmail[record.Email] = record.UserID
store.byRaceName[record.RaceName] = record.UserID
store.existsByID[record.UserID] = true
}
return store
}
func (store *fakeAdminAccountStore) Create(context.Context, ports.CreateAccountInput) error {
return store.createErr
}
func (store *fakeAdminAccountStore) GetByUserID(_ context.Context, userID common.UserID) (account.UserAccount, error) {
record, ok := store.byUserID[userID]
if !ok {
return account.UserAccount{}, ports.ErrNotFound
}
return record, nil
}
func (store *fakeAdminAccountStore) GetByEmail(_ context.Context, email common.Email) (account.UserAccount, error) {
userID, ok := store.byEmail[email]
if !ok {
return account.UserAccount{}, ports.ErrNotFound
}
return store.byUserID[userID], nil
}
func (store *fakeAdminAccountStore) GetByRaceName(_ context.Context, raceName common.RaceName) (account.UserAccount, error) {
userID, ok := store.byRaceName[raceName]
if !ok {
return account.UserAccount{}, ports.ErrNotFound
}
return store.byUserID[userID], nil
}
func (store *fakeAdminAccountStore) ExistsByUserID(_ context.Context, userID common.UserID) (bool, error) {
return store.existsByID[userID], nil
}
func (store *fakeAdminAccountStore) RenameRaceName(context.Context, ports.RenameRaceNameInput) error {
return store.renameErr
}
func (store *fakeAdminAccountStore) Update(context.Context, account.UserAccount) error {
return store.updateErr
}
type fakeAdminEntitlementSnapshotStore struct {
byUserID map[common.UserID]entitlement.CurrentSnapshot
}
func (store *fakeAdminEntitlementSnapshotStore) GetByUserID(_ context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error) {
record, ok := store.byUserID[userID]
if !ok {
return entitlement.CurrentSnapshot{}, ports.ErrNotFound
}
return record, nil
}
func (store *fakeAdminEntitlementSnapshotStore) Put(_ context.Context, record entitlement.CurrentSnapshot) error {
if store.byUserID == nil {
store.byUserID = make(map[common.UserID]entitlement.CurrentSnapshot)
}
store.byUserID[record.UserID] = record
return nil
}
type fakeAdminEntitlementLifecycleStore struct {
snapshotStore *fakeAdminEntitlementSnapshotStore
}
func (store *fakeAdminEntitlementLifecycleStore) Grant(context.Context, ports.GrantEntitlementInput) error {
return errors.New("unexpected Grant call")
}
func (store *fakeAdminEntitlementLifecycleStore) Extend(context.Context, ports.ExtendEntitlementInput) error {
return errors.New("unexpected Extend call")
}
func (store *fakeAdminEntitlementLifecycleStore) Revoke(context.Context, ports.RevokeEntitlementInput) error {
return errors.New("unexpected Revoke call")
}
func (store *fakeAdminEntitlementLifecycleStore) RepairExpired(ctx context.Context, input ports.RepairExpiredEntitlementInput) error {
return store.snapshotStore.Put(ctx, input.NewSnapshot)
}
type fakeAdminSanctionStore struct {
byUserID map[common.UserID][]policy.SanctionRecord
}
func (store fakeAdminSanctionStore) Create(context.Context, policy.SanctionRecord) error {
return nil
}
func (store fakeAdminSanctionStore) GetByRecordID(context.Context, policy.SanctionRecordID) (policy.SanctionRecord, error) {
return policy.SanctionRecord{}, ports.ErrNotFound
}
func (store fakeAdminSanctionStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.SanctionRecord, error) {
return append([]policy.SanctionRecord(nil), store.byUserID[userID]...), nil
}
func (store fakeAdminSanctionStore) Update(context.Context, policy.SanctionRecord) error {
return nil
}
type fakeAdminLimitStore struct {
byUserID map[common.UserID][]policy.LimitRecord
}
func (store fakeAdminLimitStore) Create(context.Context, policy.LimitRecord) error {
return nil
}
func (store fakeAdminLimitStore) GetByRecordID(context.Context, policy.LimitRecordID) (policy.LimitRecord, error) {
return policy.LimitRecord{}, ports.ErrNotFound
}
func (store fakeAdminLimitStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.LimitRecord, error) {
return append([]policy.LimitRecord(nil), store.byUserID[userID]...), nil
}
func (store fakeAdminLimitStore) Update(context.Context, policy.LimitRecord) error {
return nil
}
type fakeAdminListStore struct {
pages map[string]ports.ListUsersResult
err error
calls []ports.ListUsersInput
}
func (store *fakeAdminListStore) ListUserIDs(_ context.Context, input ports.ListUsersInput) (ports.ListUsersResult, error) {
store.calls = append(store.calls, input)
if store.err != nil {
return ports.ListUsersResult{}, store.err
}
result, ok := store.pages[input.PageToken]
if !ok {
return ports.ListUsersResult{}, nil
}
return result, nil
}
func validAdminUserAccount(userID string, email string, raceName string, now time.Time) account.UserAccount {
return account.UserAccount{
UserID: common.UserID(userID),
Email: common.Email(email),
RaceName: common.RaceName(raceName),
PreferredLanguage: common.LanguageTag("en"),
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
DeclaredCountry: common.CountryCode("DE"),
CreatedAt: now,
UpdatedAt: now,
}
}
func validAdminFreeSnapshot(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 validAdminPaidSnapshot(userID common.UserID, now time.Time, endsAt time.Time) entitlement.CurrentSnapshot {
return entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodePaidMonthly,
IsPaid: true,
StartsAt: now.Add(-24 * time.Hour),
EndsAt: adminTimePointer(endsAt),
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_grant"),
UpdatedAt: now,
}
}
func validAdminActiveSanction(userID common.UserID, code policy.SanctionCode, appliedAt time.Time) policy.SanctionRecord {
return policy.SanctionRecord{
RecordID: policy.SanctionRecordID("sanction-" + string(code) + "-" + userID.String()),
UserID: userID,
SanctionCode: code,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("manual_block"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: appliedAt,
}
}
func expiredAdminSanction(userID common.UserID, code policy.SanctionCode, appliedAt time.Time) policy.SanctionRecord {
record := validAdminActiveSanction(userID, code, appliedAt)
record.ExpiresAt = adminTimePointer(appliedAt.Add(30 * time.Minute))
return record
}
func validAdminActiveLimit(userID common.UserID, code policy.LimitCode, value int, appliedAt time.Time) policy.LimitRecord {
return policy.LimitRecord{
RecordID: policy.LimitRecordID("limit-" + string(code) + "-" + userID.String()),
UserID: userID,
LimitCode: code,
Value: value,
ReasonCode: common.ReasonCode("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: appliedAt,
}
}
func adminTimePointer(value time.Time) *time.Time {
copied := value.UTC()
return &copied
}
@@ -0,0 +1,614 @@
// Package authdirectory implements the auth-facing user-resolution, ensure,
// existence, and block use cases owned by the user service.
package authdirectory
import (
"context"
"errors"
"fmt"
"log/slog"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/shared"
"galaxy/user/internal/telemetry"
)
const (
initialEntitlementSource common.Source = "auth_registration"
initialEntitlementReasonCode common.ReasonCode = "initial_free_entitlement"
initialEntitlementActorType common.ActorType = "service"
initialEntitlementActorID common.ActorID = "user-service"
ensureCreateRetryLimit = 8
)
// ResolveByEmailInput stores one auth-facing resolve-by-email request.
type ResolveByEmailInput struct {
// Email stores the caller-supplied e-mail subject.
Email string
}
// ResolveByEmailResult stores one auth-facing resolve-by-email response.
type ResolveByEmailResult struct {
// Kind stores the coarse user-resolution outcome.
Kind string
// UserID is present only when Kind is `existing`.
UserID string
// BlockReasonCode is present only when Kind is `blocked`.
BlockReasonCode string
}
// Resolver executes the auth-facing resolve-by-email use case.
type Resolver struct {
store ports.AuthDirectoryStore
logger *slog.Logger
telemetry *telemetry.Runtime
}
// NewResolver returns one resolve-by-email use case instance.
func NewResolver(store ports.AuthDirectoryStore) (*Resolver, error) {
return NewResolverWithObservability(store, nil, nil)
}
// NewResolverWithObservability returns one resolve-by-email use case instance
// with optional structured logging and metrics hooks.
func NewResolverWithObservability(
store ports.AuthDirectoryStore,
logger *slog.Logger,
telemetryRuntime *telemetry.Runtime,
) (*Resolver, error) {
if store == nil {
return nil, fmt.Errorf("authdirectory resolver: auth directory store must not be nil")
}
return &Resolver{
store: store,
logger: logger,
telemetry: telemetryRuntime,
}, nil
}
// Execute resolves one e-mail subject without creating any account.
func (service *Resolver) Execute(ctx context.Context, input ResolveByEmailInput) (result ResolveByEmailResult, err error) {
outcome := "failed"
defer func() {
if service.telemetry != nil {
service.telemetry.RecordAuthResolutionOutcome(ctx, "resolve_by_email", outcome)
}
if err != nil {
shared.LogServiceOutcome(service.logger, ctx, "auth resolution failed", err,
"use_case", "resolve_by_email",
"outcome", outcome,
)
}
}()
if ctx == nil {
return ResolveByEmailResult{}, shared.InvalidRequest("context must not be nil")
}
email, err := shared.ParseEmail(input.Email)
if err != nil {
return ResolveByEmailResult{}, err
}
resolution, err := service.store.ResolveByEmail(ctx, email)
if err != nil {
return ResolveByEmailResult{}, shared.ServiceUnavailable(err)
}
if err := resolution.Validate(); err != nil {
return ResolveByEmailResult{}, shared.InternalError(err)
}
result = ResolveByEmailResult{
Kind: string(resolution.Kind),
}
if !resolution.UserID.IsZero() {
result.UserID = resolution.UserID.String()
}
if !resolution.BlockReasonCode.IsZero() {
result.BlockReasonCode = resolution.BlockReasonCode.String()
}
outcome = result.Kind
return result, nil
}
// RegistrationContext stores the create-only auth-facing initialization
// context forwarded by authsession.
type RegistrationContext struct {
// PreferredLanguage stores the initial preferred language.
PreferredLanguage string
// TimeZone stores the initial declared time-zone name.
TimeZone string
}
// EnsureByEmailInput stores one auth-facing ensure-by-email request.
type EnsureByEmailInput struct {
// Email stores the caller-supplied e-mail subject.
Email string
// RegistrationContext stores the required create-only registration context.
RegistrationContext *RegistrationContext
}
// EnsureByEmailResult stores one auth-facing ensure-by-email response.
type EnsureByEmailResult struct {
// Outcome stores the coarse ensure outcome.
Outcome string
// UserID is present only for `existing` and `created`.
UserID string
// BlockReasonCode is present only for `blocked`.
BlockReasonCode string
}
// Ensurer executes the auth-facing ensure-by-email use case.
type Ensurer struct {
store ports.AuthDirectoryStore
clock ports.Clock
idGenerator ports.IDGenerator
policy ports.RaceNamePolicy
logger *slog.Logger
telemetry *telemetry.Runtime
profilePublisher ports.ProfileChangedPublisher
settingsPublisher ports.SettingsChangedPublisher
entitlementPublisher ports.EntitlementChangedPublisher
}
// NewEnsurer returns one ensure-by-email use case instance.
func NewEnsurer(
store ports.AuthDirectoryStore,
clock ports.Clock,
idGenerator ports.IDGenerator,
policy ports.RaceNamePolicy,
) (*Ensurer, error) {
return NewEnsurerWithObservability(store, clock, idGenerator, policy, nil, nil, nil, nil, nil)
}
// NewEnsurerWithObservability returns one ensure-by-email use case instance
// with optional structured logging, metrics, and post-commit event
// publication hooks.
func NewEnsurerWithObservability(
store ports.AuthDirectoryStore,
clock ports.Clock,
idGenerator ports.IDGenerator,
policy ports.RaceNamePolicy,
logger *slog.Logger,
telemetryRuntime *telemetry.Runtime,
profilePublisher ports.ProfileChangedPublisher,
settingsPublisher ports.SettingsChangedPublisher,
entitlementPublisher ports.EntitlementChangedPublisher,
) (*Ensurer, error) {
switch {
case store == nil:
return nil, fmt.Errorf("authdirectory ensurer: auth directory store must not be nil")
case clock == nil:
return nil, fmt.Errorf("authdirectory ensurer: clock must not be nil")
case idGenerator == nil:
return nil, fmt.Errorf("authdirectory ensurer: id generator must not be nil")
case policy == nil:
return nil, fmt.Errorf("authdirectory ensurer: race-name policy must not be nil")
default:
return &Ensurer{
store: store,
clock: clock,
idGenerator: idGenerator,
policy: policy,
logger: logger,
telemetry: telemetryRuntime,
profilePublisher: profilePublisher,
settingsPublisher: settingsPublisher,
entitlementPublisher: entitlementPublisher,
}, nil
}
}
// Execute ensures that one e-mail subject maps to an existing user, a newly
// created user, or a blocked outcome.
func (service *Ensurer) Execute(ctx context.Context, input EnsureByEmailInput) (result EnsureByEmailResult, err error) {
outcome := "failed"
userIDString := ""
defer func() {
if service.telemetry != nil {
service.telemetry.RecordUserCreationOutcome(ctx, outcome)
}
shared.LogServiceOutcome(service.logger, ctx, "ensure by email completed", err,
"use_case", "ensure_by_email",
"outcome", outcome,
"user_id", userIDString,
"source", initialEntitlementSource.String(),
)
}()
if ctx == nil {
return EnsureByEmailResult{}, shared.InvalidRequest("context must not be nil")
}
email, err := shared.ParseEmail(input.Email)
if err != nil {
return EnsureByEmailResult{}, err
}
if input.RegistrationContext == nil {
return EnsureByEmailResult{}, shared.InvalidRequest("registration_context must be present")
}
preferredLanguage, err := shared.ParseRegistrationPreferredLanguage(input.RegistrationContext.PreferredLanguage)
if err != nil {
return EnsureByEmailResult{}, err
}
timeZone, err := shared.ParseRegistrationTimeZoneName(input.RegistrationContext.TimeZone)
if err != nil {
return EnsureByEmailResult{}, err
}
now := service.clock.Now().UTC()
for attempt := 0; attempt < ensureCreateRetryLimit; attempt++ {
userID, err := service.idGenerator.NewUserID()
if err != nil {
return EnsureByEmailResult{}, shared.ServiceUnavailable(err)
}
raceName, err := service.idGenerator.NewInitialRaceName()
if err != nil {
return EnsureByEmailResult{}, shared.ServiceUnavailable(err)
}
accountRecord := account.UserAccount{
UserID: userID,
Email: email,
RaceName: raceName,
PreferredLanguage: preferredLanguage,
TimeZone: timeZone,
CreatedAt: now,
UpdatedAt: now,
}
entitlementSnapshot := entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
IsPaid: false,
StartsAt: now,
Source: initialEntitlementSource,
Actor: common.ActorRef{Type: initialEntitlementActorType, ID: initialEntitlementActorID},
ReasonCode: initialEntitlementReasonCode,
UpdatedAt: now,
}
entitlementRecordID, err := service.idGenerator.NewEntitlementRecordID()
if err != nil {
return EnsureByEmailResult{}, shared.ServiceUnavailable(err)
}
entitlementRecord := entitlement.PeriodRecord{
RecordID: entitlementRecordID,
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
Source: initialEntitlementSource,
Actor: common.ActorRef{Type: initialEntitlementActorType, ID: initialEntitlementActorID},
ReasonCode: initialEntitlementReasonCode,
StartsAt: now,
CreatedAt: now,
}
reservation, err := shared.BuildRaceNameReservation(service.policy, userID, raceName, now)
if err != nil {
return EnsureByEmailResult{}, shared.ServiceUnavailable(err)
}
ensureResult, err := service.store.EnsureByEmail(ctx, ports.EnsureByEmailInput{
Email: email,
Account: accountRecord,
Entitlement: entitlementSnapshot,
EntitlementRecord: entitlementRecord,
Reservation: reservation,
})
if err != nil {
if errors.Is(err, ports.ErrRaceNameConflict) && service.telemetry != nil {
service.telemetry.RecordRaceNameReservationConflict(ctx, "ensure_by_email")
}
if errors.Is(err, ports.ErrConflict) {
continue
}
return EnsureByEmailResult{}, shared.ServiceUnavailable(err)
}
if err := ensureResult.Validate(); err != nil {
return EnsureByEmailResult{}, shared.InternalError(err)
}
result = EnsureByEmailResult{
Outcome: string(ensureResult.Outcome),
}
if !ensureResult.UserID.IsZero() {
result.UserID = ensureResult.UserID.String()
userIDString = result.UserID
}
if !ensureResult.BlockReasonCode.IsZero() {
result.BlockReasonCode = ensureResult.BlockReasonCode.String()
}
outcome = result.Outcome
if result.Outcome == string(ports.EnsureByEmailOutcomeCreated) {
service.publishInitializedEvents(ctx, accountRecord, entitlementSnapshot)
}
return result, nil
}
return EnsureByEmailResult{}, shared.ServiceUnavailable(fmt.Errorf("ensure-by-email conflict retry limit exceeded"))
}
func (service *Ensurer) publishInitializedEvents(
ctx context.Context,
accountRecord account.UserAccount,
entitlementSnapshot entitlement.CurrentSnapshot,
) {
occurredAt := accountRecord.UpdatedAt.UTC()
service.publishProfileChanged(ctx, ports.ProfileChangedEvent{
UserID: accountRecord.UserID,
OccurredAt: occurredAt,
Source: initialEntitlementSource,
Operation: ports.ProfileChangedOperationInitialized,
RaceName: accountRecord.RaceName,
})
service.publishSettingsChanged(ctx, ports.SettingsChangedEvent{
UserID: accountRecord.UserID,
OccurredAt: occurredAt,
Source: initialEntitlementSource,
Operation: ports.SettingsChangedOperationInitialized,
PreferredLanguage: accountRecord.PreferredLanguage,
TimeZone: accountRecord.TimeZone,
})
service.publishEntitlementChanged(ctx, ports.EntitlementChangedEvent{
UserID: entitlementSnapshot.UserID,
OccurredAt: occurredAt,
Source: initialEntitlementSource,
Operation: ports.EntitlementChangedOperationInitialized,
PlanCode: entitlementSnapshot.PlanCode,
IsPaid: entitlementSnapshot.IsPaid,
StartsAt: entitlementSnapshot.StartsAt,
EndsAt: entitlementSnapshot.EndsAt,
ReasonCode: entitlementSnapshot.ReasonCode,
Actor: entitlementSnapshot.Actor,
UpdatedAt: entitlementSnapshot.UpdatedAt,
})
}
func (service *Ensurer) publishProfileChanged(ctx context.Context, event ports.ProfileChangedEvent) {
if service.profilePublisher == nil {
return
}
if err := service.profilePublisher.PublishProfileChanged(ctx, event); err != nil {
if service.telemetry != nil {
service.telemetry.RecordEventPublicationFailure(ctx, ports.ProfileChangedEventType)
}
shared.LogEventPublicationFailure(service.logger, ctx, ports.ProfileChangedEventType, err,
"use_case", "ensure_by_email",
"user_id", event.UserID.String(),
"source", event.Source.String(),
)
}
}
func (service *Ensurer) publishSettingsChanged(ctx context.Context, event ports.SettingsChangedEvent) {
if service.settingsPublisher == nil {
return
}
if err := service.settingsPublisher.PublishSettingsChanged(ctx, event); err != nil {
if service.telemetry != nil {
service.telemetry.RecordEventPublicationFailure(ctx, ports.SettingsChangedEventType)
}
shared.LogEventPublicationFailure(service.logger, ctx, ports.SettingsChangedEventType, err,
"use_case", "ensure_by_email",
"user_id", event.UserID.String(),
"source", event.Source.String(),
)
}
}
func (service *Ensurer) publishEntitlementChanged(ctx context.Context, event ports.EntitlementChangedEvent) {
if service.entitlementPublisher == nil {
return
}
if err := service.entitlementPublisher.PublishEntitlementChanged(ctx, event); err != nil {
if service.telemetry != nil {
service.telemetry.RecordEventPublicationFailure(ctx, ports.EntitlementChangedEventType)
}
shared.LogEventPublicationFailure(service.logger, ctx, ports.EntitlementChangedEventType, err,
"use_case", "ensure_by_email",
"user_id", event.UserID.String(),
"source", event.Source.String(),
"reason_code", event.ReasonCode.String(),
"actor_type", event.Actor.Type.String(),
"actor_id", event.Actor.ID.String(),
)
}
}
// ExistsByUserIDInput stores one auth-facing existence check request.
type ExistsByUserIDInput struct {
// UserID stores the caller-supplied stable user identifier.
UserID string
}
// ExistsByUserIDResult stores one auth-facing existence check response.
type ExistsByUserIDResult struct {
// Exists reports whether the supplied user identifier currently exists.
Exists bool
}
// ExistenceChecker executes the auth-facing exists-by-user-id use case.
type ExistenceChecker struct {
store ports.AuthDirectoryStore
}
// NewExistenceChecker returns one exists-by-user-id use case instance.
func NewExistenceChecker(store ports.AuthDirectoryStore) (*ExistenceChecker, error) {
if store == nil {
return nil, fmt.Errorf("authdirectory existence checker: auth directory store must not be nil")
}
return &ExistenceChecker{store: store}, nil
}
// Execute reports whether one stable user identifier exists.
func (service *ExistenceChecker) Execute(ctx context.Context, input ExistsByUserIDInput) (ExistsByUserIDResult, error) {
if ctx == nil {
return ExistsByUserIDResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
return ExistsByUserIDResult{}, err
}
exists, err := service.store.ExistsByUserID(ctx, userID)
if err != nil {
return ExistsByUserIDResult{}, shared.ServiceUnavailable(err)
}
return ExistsByUserIDResult{Exists: exists}, nil
}
// BlockByUserIDInput stores one auth-facing block-by-user-id request.
type BlockByUserIDInput struct {
// UserID stores the stable account identifier that must be blocked.
UserID string
// ReasonCode stores the machine-readable block reason.
ReasonCode string
}
// BlockByEmailInput stores one auth-facing block-by-email request.
type BlockByEmailInput struct {
// Email stores the exact normalized e-mail subject that must be blocked.
Email string
// ReasonCode stores the machine-readable block reason.
ReasonCode string
}
// BlockResult stores one auth-facing block response.
type BlockResult struct {
// Outcome reports whether the current call created a new block.
Outcome string
// UserID stores the resolved account when the blocked subject belongs to an
// existing user.
UserID string
}
// BlockByUserIDService executes the auth-facing block-by-user-id use case.
type BlockByUserIDService struct {
store ports.AuthDirectoryStore
clock ports.Clock
}
// NewBlockByUserIDService returns one block-by-user-id use case instance.
func NewBlockByUserIDService(store ports.AuthDirectoryStore, clock ports.Clock) (*BlockByUserIDService, error) {
switch {
case store == nil:
return nil, fmt.Errorf("authdirectory block-by-user-id service: auth directory store must not be nil")
case clock == nil:
return nil, fmt.Errorf("authdirectory block-by-user-id service: clock must not be nil")
default:
return &BlockByUserIDService{store: store, clock: clock}, nil
}
}
// Execute blocks one account addressed by stable user identifier.
func (service *BlockByUserIDService) Execute(ctx context.Context, input BlockByUserIDInput) (BlockResult, error) {
if ctx == nil {
return BlockResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
return BlockResult{}, err
}
reasonCode, err := shared.ParseReasonCode(input.ReasonCode)
if err != nil {
return BlockResult{}, err
}
result, err := service.store.BlockByUserID(ctx, ports.BlockByUserIDInput{
UserID: userID,
ReasonCode: reasonCode,
BlockedAt: service.clock.Now().UTC(),
})
if err != nil {
switch {
case errors.Is(err, ports.ErrNotFound):
return BlockResult{}, shared.SubjectNotFound()
default:
return BlockResult{}, shared.ServiceUnavailable(err)
}
}
if err := result.Validate(); err != nil {
return BlockResult{}, shared.InternalError(err)
}
response := BlockResult{Outcome: string(result.Outcome)}
if !result.UserID.IsZero() {
response.UserID = result.UserID.String()
}
return response, nil
}
// BlockByEmailService executes the auth-facing block-by-email use case.
type BlockByEmailService struct {
store ports.AuthDirectoryStore
clock ports.Clock
}
// NewBlockByEmailService returns one block-by-email use case instance.
func NewBlockByEmailService(store ports.AuthDirectoryStore, clock ports.Clock) (*BlockByEmailService, error) {
switch {
case store == nil:
return nil, fmt.Errorf("authdirectory block-by-email service: auth directory store must not be nil")
case clock == nil:
return nil, fmt.Errorf("authdirectory block-by-email service: clock must not be nil")
default:
return &BlockByEmailService{store: store, clock: clock}, nil
}
}
// Execute blocks one exact normalized e-mail subject.
func (service *BlockByEmailService) Execute(ctx context.Context, input BlockByEmailInput) (BlockResult, error) {
if ctx == nil {
return BlockResult{}, shared.InvalidRequest("context must not be nil")
}
email, err := shared.ParseEmail(input.Email)
if err != nil {
return BlockResult{}, err
}
reasonCode, err := shared.ParseReasonCode(input.ReasonCode)
if err != nil {
return BlockResult{}, err
}
result, err := service.store.BlockByEmail(ctx, ports.BlockByEmailInput{
Email: email,
ReasonCode: reasonCode,
BlockedAt: service.clock.Now().UTC(),
})
if err != nil {
return BlockResult{}, shared.ServiceUnavailable(err)
}
if err := result.Validate(); err != nil {
return BlockResult{}, shared.InternalError(err)
}
response := BlockResult{Outcome: string(result.Outcome)}
if !result.UserID.IsZero() {
response.UserID = result.UserID.String()
}
return response, nil
}
@@ -0,0 +1,717 @@
package authdirectory
import (
"context"
"errors"
"testing"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/shared"
"galaxy/user/internal/telemetry"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/attribute"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
)
func TestResolverExecute(t *testing.T) {
t.Parallel()
tests := []struct {
name string
store stubAuthDirectoryStore
wantKind string
wantUserID string
wantBlock string
}{
{
name: "existing",
store: stubAuthDirectoryStore{
resolveByEmail: func(_ context.Context, email common.Email) (ports.ResolveByEmailResult, error) {
require.Equal(t, common.Email("pilot@example.com"), email)
return ports.ResolveByEmailResult{
Kind: ports.AuthResolutionKindExisting,
UserID: common.UserID("user-123"),
}, nil
},
},
wantKind: "existing",
wantUserID: "user-123",
},
{
name: "creatable",
store: stubAuthDirectoryStore{
resolveByEmail: func(_ context.Context, email common.Email) (ports.ResolveByEmailResult, error) {
require.Equal(t, common.Email("pilot@example.com"), email)
return ports.ResolveByEmailResult{
Kind: ports.AuthResolutionKindCreatable,
}, nil
},
},
wantKind: "creatable",
},
{
name: "blocked",
store: stubAuthDirectoryStore{
resolveByEmail: func(_ context.Context, email common.Email) (ports.ResolveByEmailResult, error) {
require.Equal(t, common.Email("pilot@example.com"), email)
return ports.ResolveByEmailResult{
Kind: ports.AuthResolutionKindBlocked,
BlockReasonCode: common.ReasonCode("policy_blocked"),
}, nil
},
},
wantKind: "blocked",
wantBlock: "policy_blocked",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
resolver, err := NewResolver(tt.store)
require.NoError(t, err)
result, err := resolver.Execute(context.Background(), ResolveByEmailInput{
Email: " pilot@example.com ",
})
require.NoError(t, err)
require.Equal(t, tt.wantKind, result.Kind)
require.Equal(t, tt.wantUserID, result.UserID)
require.Equal(t, tt.wantBlock, result.BlockReasonCode)
})
}
}
func TestEnsurerExecuteCreatedBuildsInitialRecords(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
ensurer, err := NewEnsurer(stubAuthDirectoryStore{
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
require.Equal(t, common.Email("created@example.com"), input.Email)
require.Equal(t, common.UserID("user-created"), input.Account.UserID)
require.Equal(t, common.RaceName("player-test123"), input.Account.RaceName)
require.Equal(t, common.LanguageTag("en-US"), input.Account.PreferredLanguage)
require.Equal(t, common.TimeZoneName("Europe/Kaliningrad"), input.Account.TimeZone)
require.Equal(t, input.Account.UserID, input.Reservation.UserID)
require.Equal(t, input.Account.RaceName, input.Reservation.RaceName)
require.Equal(t, accountTestCanonicalKey(input.Account.RaceName), input.Reservation.CanonicalKey)
require.Equal(t, entitlement.PlanCodeFree, input.Entitlement.PlanCode)
require.False(t, input.Entitlement.IsPaid)
require.Equal(t, input.Account.UserID, input.Entitlement.UserID)
require.Equal(t, entitlement.EntitlementRecordID("entitlement-created"), input.EntitlementRecord.RecordID)
require.Equal(t, input.Account.UserID, input.EntitlementRecord.UserID)
require.Equal(t, input.Entitlement.PlanCode, input.EntitlementRecord.PlanCode)
require.Equal(t, input.Entitlement.StartsAt, input.EntitlementRecord.StartsAt)
require.Equal(t, input.Entitlement.Source, input.EntitlementRecord.Source)
require.Equal(t, input.Entitlement.Actor, input.EntitlementRecord.Actor)
require.Equal(t, input.Entitlement.ReasonCode, input.EntitlementRecord.ReasonCode)
return ports.EnsureByEmailResult{
Outcome: ports.EnsureByEmailOutcomeCreated,
UserID: input.Account.UserID,
}, nil
},
}, fixedClock{now: now}, fixedIDGenerator{
userID: common.UserID("user-created"),
raceName: common.RaceName("player-test123"),
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
}, stubRaceNamePolicy{})
require.NoError(t, err)
result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{
Email: "created@example.com",
RegistrationContext: &RegistrationContext{
PreferredLanguage: "en-us",
TimeZone: "Europe/Kaliningrad",
},
})
require.NoError(t, err)
require.Equal(t, "created", result.Outcome)
require.Equal(t, "user-created", result.UserID)
}
func TestEnsurerExecuteRejectsInvalidRegistrationContext(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input EnsureByEmailInput
wantErr string
}{
{
name: "invalid preferred language",
input: EnsureByEmailInput{
Email: "pilot@example.com",
RegistrationContext: &RegistrationContext{
PreferredLanguage: "bad@@tag",
TimeZone: "Europe/Kaliningrad",
},
},
wantErr: "registration_context.preferred_language must be a valid BCP 47 language tag",
},
{
name: "invalid time zone",
input: EnsureByEmailInput{
Email: "pilot@example.com",
RegistrationContext: &RegistrationContext{
PreferredLanguage: "en",
TimeZone: "Mars/Olympus",
},
},
wantErr: "registration_context.time_zone must be a valid IANA time zone name",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ensurer, err := NewEnsurer(stubAuthDirectoryStore{}, fixedClock{now: time.Unix(1_775_240_000, 0).UTC()}, fixedIDGenerator{
userID: common.UserID("user-created"),
raceName: common.RaceName("player-test123"),
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
}, stubRaceNamePolicy{})
require.NoError(t, err)
_, err = ensurer.Execute(context.Background(), tt.input)
require.Error(t, err)
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
require.Equal(t, tt.wantErr, err.Error())
})
}
}
func TestEnsurerExecuteRetriesConflicts(t *testing.T) {
t.Parallel()
attempt := 0
ensurer, err := NewEnsurer(stubAuthDirectoryStore{
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
attempt++
if attempt == 1 {
return ports.EnsureByEmailResult{}, ports.ErrConflict
}
return ports.EnsureByEmailResult{
Outcome: ports.EnsureByEmailOutcomeCreated,
UserID: input.Account.UserID,
}, nil
},
}, fixedClock{now: time.Unix(1_775_240_000, 0).UTC()}, &sequenceIDGenerator{
userIDs: []common.UserID{"user-first", "user-second"},
raceNames: []common.RaceName{"player-first", "player-second"},
entitlementRecordIDs: []entitlement.EntitlementRecordID{"entitlement-first", "entitlement-second"},
}, stubRaceNamePolicy{})
require.NoError(t, err)
result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{
Email: "retry@example.com",
RegistrationContext: &RegistrationContext{
PreferredLanguage: "en",
TimeZone: "UTC",
},
})
require.NoError(t, err)
require.Equal(t, 2, attempt)
require.Equal(t, "user-second", result.UserID)
}
func TestEnsurerExecuteReturnsExistingAndBlocked(t *testing.T) {
t.Parallel()
tests := []struct {
name string
store stubAuthDirectoryStore
want EnsureByEmailResult
}{
{
name: "existing",
store: stubAuthDirectoryStore{
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
require.Equal(t, common.Email("pilot@example.com"), input.Email)
return ports.EnsureByEmailResult{
Outcome: ports.EnsureByEmailOutcomeExisting,
UserID: common.UserID("user-existing"),
}, nil
},
},
want: EnsureByEmailResult{
Outcome: "existing",
UserID: "user-existing",
},
},
{
name: "blocked",
store: stubAuthDirectoryStore{
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
require.Equal(t, common.Email("pilot@example.com"), input.Email)
return ports.EnsureByEmailResult{
Outcome: ports.EnsureByEmailOutcomeBlocked,
BlockReasonCode: common.ReasonCode("policy_blocked"),
}, nil
},
},
want: EnsureByEmailResult{
Outcome: "blocked",
BlockReasonCode: "policy_blocked",
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ensurer, err := NewEnsurer(tt.store, fixedClock{now: time.Unix(1_775_240_000, 0).UTC()}, fixedIDGenerator{
userID: common.UserID("user-created"),
raceName: common.RaceName("player-test123"),
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
}, stubRaceNamePolicy{})
require.NoError(t, err)
result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{
Email: "pilot@example.com",
RegistrationContext: &RegistrationContext{
PreferredLanguage: "en",
TimeZone: "UTC",
},
})
require.NoError(t, err)
require.Equal(t, tt.want, result)
})
}
}
func TestEnsurerExecuteCreatedPublishesInitializedEvents(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
publisher := &recordingAuthDomainEventPublisher{}
telemetryRuntime, reader := newObservedAuthTelemetryRuntime(t)
ensurer, err := NewEnsurerWithObservability(stubAuthDirectoryStore{
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
return ports.EnsureByEmailResult{
Outcome: ports.EnsureByEmailOutcomeCreated,
UserID: input.Account.UserID,
}, nil
},
}, fixedClock{now: now}, fixedIDGenerator{
userID: common.UserID("user-created"),
raceName: common.RaceName("player-test123"),
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
}, stubRaceNamePolicy{}, nil, telemetryRuntime, publisher, publisher, publisher)
require.NoError(t, err)
result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{
Email: "created@example.com",
RegistrationContext: &RegistrationContext{
PreferredLanguage: "en-us",
TimeZone: "Europe/Kaliningrad",
},
})
require.NoError(t, err)
require.Equal(t, "created", result.Outcome)
require.Len(t, publisher.profileEvents, 1)
require.Equal(t, ports.ProfileChangedOperationInitialized, publisher.profileEvents[0].Operation)
require.Equal(t, common.Source("auth_registration"), publisher.profileEvents[0].Source)
require.Len(t, publisher.settingsEvents, 1)
require.Equal(t, ports.SettingsChangedOperationInitialized, publisher.settingsEvents[0].Operation)
require.Len(t, publisher.entitlementEvents, 1)
require.Equal(t, ports.EntitlementChangedOperationInitialized, publisher.entitlementEvents[0].Operation)
assertMetricCount(t, reader, "user.user_creation.outcomes", map[string]string{
"outcome": "created",
}, 1)
}
func TestEnsurerExecuteExistingBlockedAndFailedDoNotPublishEvents(t *testing.T) {
t.Parallel()
tests := []struct {
name string
store stubAuthDirectoryStore
input EnsureByEmailInput
wantMetric string
wantErrCode string
wantProfileLen int
}{
{
name: "existing",
store: stubAuthDirectoryStore{
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
return ports.EnsureByEmailResult{
Outcome: ports.EnsureByEmailOutcomeExisting,
UserID: common.UserID("user-existing"),
}, nil
},
},
input: EnsureByEmailInput{
Email: "pilot@example.com",
RegistrationContext: &RegistrationContext{
PreferredLanguage: "en",
TimeZone: "UTC",
},
},
wantMetric: "existing",
},
{
name: "blocked",
store: stubAuthDirectoryStore{
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
return ports.EnsureByEmailResult{
Outcome: ports.EnsureByEmailOutcomeBlocked,
BlockReasonCode: common.ReasonCode("policy_blocked"),
}, nil
},
},
input: EnsureByEmailInput{
Email: "pilot@example.com",
RegistrationContext: &RegistrationContext{
PreferredLanguage: "en",
TimeZone: "UTC",
},
},
wantMetric: "blocked",
},
{
name: "failed",
store: stubAuthDirectoryStore{},
input: EnsureByEmailInput{
Email: "pilot@example.com",
},
wantMetric: "failed",
wantErrCode: shared.ErrorCodeInvalidRequest,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
publisher := &recordingAuthDomainEventPublisher{}
telemetryRuntime, reader := newObservedAuthTelemetryRuntime(t)
ensurer, err := NewEnsurerWithObservability(tt.store, fixedClock{now: time.Unix(1_775_240_000, 0).UTC()}, fixedIDGenerator{
userID: common.UserID("user-created"),
raceName: common.RaceName("player-test123"),
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
}, stubRaceNamePolicy{}, nil, telemetryRuntime, publisher, publisher, publisher)
require.NoError(t, err)
_, err = ensurer.Execute(context.Background(), tt.input)
if tt.wantErrCode != "" {
require.Error(t, err)
require.Equal(t, tt.wantErrCode, shared.CodeOf(err))
} else {
require.NoError(t, err)
}
require.Empty(t, publisher.profileEvents)
require.Empty(t, publisher.settingsEvents)
require.Empty(t, publisher.entitlementEvents)
assertMetricCount(t, reader, "user.user_creation.outcomes", map[string]string{
"outcome": tt.wantMetric,
}, 1)
})
}
}
func TestEnsurerExecutePublishFailureDoesNotRollbackCreatedUser(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
publisher := &recordingAuthDomainEventPublisher{err: errors.New("publisher unavailable")}
telemetryRuntime, reader := newObservedAuthTelemetryRuntime(t)
ensurer, err := NewEnsurerWithObservability(stubAuthDirectoryStore{
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
return ports.EnsureByEmailResult{
Outcome: ports.EnsureByEmailOutcomeCreated,
UserID: input.Account.UserID,
}, nil
},
}, fixedClock{now: now}, fixedIDGenerator{
userID: common.UserID("user-created"),
raceName: common.RaceName("player-test123"),
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
}, stubRaceNamePolicy{}, nil, telemetryRuntime, publisher, publisher, publisher)
require.NoError(t, err)
result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{
Email: "created@example.com",
RegistrationContext: &RegistrationContext{
PreferredLanguage: "en-us",
TimeZone: "Europe/Kaliningrad",
},
})
require.NoError(t, err)
require.Equal(t, "created", result.Outcome)
require.Len(t, publisher.profileEvents, 1)
require.Len(t, publisher.settingsEvents, 1)
require.Len(t, publisher.entitlementEvents, 1)
assertMetricCount(t, reader, "user.event_publication_failures", map[string]string{
"event_type": ports.ProfileChangedEventType,
}, 1)
assertMetricCount(t, reader, "user.event_publication_failures", map[string]string{
"event_type": ports.SettingsChangedEventType,
}, 1)
assertMetricCount(t, reader, "user.event_publication_failures", map[string]string{
"event_type": ports.EntitlementChangedEventType,
}, 1)
}
func TestBlockByUserIDServiceMapsNotFound(t *testing.T) {
t.Parallel()
service, err := NewBlockByUserIDService(stubAuthDirectoryStore{
blockByUserID: func(context.Context, ports.BlockByUserIDInput) (ports.BlockResult, error) {
return ports.BlockResult{}, ports.ErrNotFound
},
}, fixedClock{now: time.Unix(1_775_240_000, 0).UTC()})
require.NoError(t, err)
_, err = service.Execute(context.Background(), BlockByUserIDInput{
UserID: "user-missing",
ReasonCode: "policy_blocked",
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err))
}
type stubAuthDirectoryStore struct {
resolveByEmail func(context.Context, common.Email) (ports.ResolveByEmailResult, error)
ensureByEmail func(context.Context, ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error)
existsByUserID func(context.Context, common.UserID) (bool, error)
blockByUserID func(context.Context, ports.BlockByUserIDInput) (ports.BlockResult, error)
blockByEmail func(context.Context, ports.BlockByEmailInput) (ports.BlockResult, error)
}
func (store stubAuthDirectoryStore) ResolveByEmail(ctx context.Context, email common.Email) (ports.ResolveByEmailResult, error) {
if store.resolveByEmail == nil {
return ports.ResolveByEmailResult{}, errors.New("unexpected ResolveByEmail call")
}
return store.resolveByEmail(ctx, email)
}
func (store stubAuthDirectoryStore) ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error) {
if store.existsByUserID == nil {
return false, errors.New("unexpected ExistsByUserID call")
}
return store.existsByUserID(ctx, userID)
}
func (store stubAuthDirectoryStore) EnsureByEmail(ctx context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
if store.ensureByEmail == nil {
return ports.EnsureByEmailResult{}, errors.New("unexpected EnsureByEmail call")
}
return store.ensureByEmail(ctx, input)
}
func (store stubAuthDirectoryStore) BlockByUserID(ctx context.Context, input ports.BlockByUserIDInput) (ports.BlockResult, error) {
if store.blockByUserID == nil {
return ports.BlockResult{}, errors.New("unexpected BlockByUserID call")
}
return store.blockByUserID(ctx, input)
}
func (store stubAuthDirectoryStore) BlockByEmail(ctx context.Context, input ports.BlockByEmailInput) (ports.BlockResult, error) {
if store.blockByEmail == nil {
return ports.BlockResult{}, errors.New("unexpected BlockByEmail call")
}
return store.blockByEmail(ctx, input)
}
type fixedClock struct {
now time.Time
}
func (clock fixedClock) Now() time.Time {
return clock.now
}
type fixedIDGenerator struct {
userID common.UserID
raceName common.RaceName
entitlementRecordID entitlement.EntitlementRecordID
sanctionRecordID policy.SanctionRecordID
limitRecordID policy.LimitRecordID
}
func (generator fixedIDGenerator) NewUserID() (common.UserID, error) {
return generator.userID, nil
}
func (generator fixedIDGenerator) NewInitialRaceName() (common.RaceName, error) {
return generator.raceName, nil
}
func (generator fixedIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
return generator.entitlementRecordID, nil
}
func (generator fixedIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
return generator.sanctionRecordID, nil
}
func (generator fixedIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
return generator.limitRecordID, nil
}
type sequenceIDGenerator struct {
userIDs []common.UserID
raceNames []common.RaceName
entitlementRecordIDs []entitlement.EntitlementRecordID
sanctionRecordIDs []policy.SanctionRecordID
limitRecordIDs []policy.LimitRecordID
}
func (generator *sequenceIDGenerator) NewUserID() (common.UserID, error) {
value := generator.userIDs[0]
generator.userIDs = generator.userIDs[1:]
return value, nil
}
func (generator *sequenceIDGenerator) NewInitialRaceName() (common.RaceName, error) {
value := generator.raceNames[0]
generator.raceNames = generator.raceNames[1:]
return value, nil
}
func (generator *sequenceIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
value := generator.entitlementRecordIDs[0]
generator.entitlementRecordIDs = generator.entitlementRecordIDs[1:]
return value, nil
}
func (generator *sequenceIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
value := generator.sanctionRecordIDs[0]
generator.sanctionRecordIDs = generator.sanctionRecordIDs[1:]
return value, nil
}
func (generator *sequenceIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
value := generator.limitRecordIDs[0]
generator.limitRecordIDs = generator.limitRecordIDs[1:]
return value, nil
}
type stubRaceNamePolicy struct{}
func (stubRaceNamePolicy) CanonicalKey(raceName common.RaceName) (account.RaceNameCanonicalKey, error) {
return accountTestCanonicalKey(raceName), nil
}
func accountTestCanonicalKey(raceName common.RaceName) account.RaceNameCanonicalKey {
return account.RaceNameCanonicalKey("key:" + raceName.String())
}
type recordingAuthDomainEventPublisher struct {
err error
profileEvents []ports.ProfileChangedEvent
settingsEvents []ports.SettingsChangedEvent
entitlementEvents []ports.EntitlementChangedEvent
}
func (publisher *recordingAuthDomainEventPublisher) PublishProfileChanged(_ context.Context, event ports.ProfileChangedEvent) error {
if err := event.Validate(); err != nil {
return err
}
publisher.profileEvents = append(publisher.profileEvents, event)
return publisher.err
}
func (publisher *recordingAuthDomainEventPublisher) PublishSettingsChanged(_ context.Context, event ports.SettingsChangedEvent) error {
if err := event.Validate(); err != nil {
return err
}
publisher.settingsEvents = append(publisher.settingsEvents, event)
return publisher.err
}
func (publisher *recordingAuthDomainEventPublisher) PublishEntitlementChanged(_ context.Context, event ports.EntitlementChangedEvent) error {
if err := event.Validate(); err != nil {
return err
}
publisher.entitlementEvents = append(publisher.entitlementEvents, event)
return publisher.err
}
func newObservedAuthTelemetryRuntime(t *testing.T) (*telemetry.Runtime, *sdkmetric.ManualReader) {
t.Helper()
reader := sdkmetric.NewManualReader()
meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
tracerProvider := sdktrace.NewTracerProvider()
runtime, err := telemetry.NewWithProviders(meterProvider, tracerProvider)
require.NoError(t, err)
return runtime, reader
}
func assertMetricCount(t *testing.T, reader *sdkmetric.ManualReader, metricName string, wantAttrs map[string]string, wantValue int64) {
t.Helper()
var resourceMetrics metricdata.ResourceMetrics
require.NoError(t, reader.Collect(context.Background(), &resourceMetrics))
for _, scopeMetrics := range resourceMetrics.ScopeMetrics {
for _, metric := range scopeMetrics.Metrics {
if metric.Name != metricName {
continue
}
sum, ok := metric.Data.(metricdata.Sum[int64])
require.True(t, ok)
for _, point := range sum.DataPoints {
if hasMetricAttributes(point.Attributes.ToSlice(), wantAttrs) {
require.Equal(t, wantValue, point.Value)
return
}
}
}
}
require.Failf(t, "test failed", "metric %q with attrs %v not found", metricName, wantAttrs)
}
func hasMetricAttributes(values []attribute.KeyValue, want map[string]string) bool {
if len(values) != len(want) {
return false
}
for _, value := range values {
if want[string(value.Key)] != value.Value.AsString() {
return false
}
}
return true
}
var (
_ ports.AuthDirectoryStore = stubAuthDirectoryStore{}
_ ports.Clock = fixedClock{}
_ ports.IDGenerator = fixedIDGenerator{}
_ ports.IDGenerator = (*sequenceIDGenerator)(nil)
_ ports.RaceNamePolicy = stubRaceNamePolicy{}
_ ports.ProfileChangedPublisher = (*recordingAuthDomainEventPublisher)(nil)
_ ports.SettingsChangedPublisher = (*recordingAuthDomainEventPublisher)(nil)
_ ports.EntitlementChangedPublisher = (*recordingAuthDomainEventPublisher)(nil)
)
@@ -0,0 +1,121 @@
package entitlementsvc
import (
"context"
"errors"
"testing"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/ports"
"github.com/stretchr/testify/require"
)
func TestReaderGetByUserIDPublishesExpiredRepairEvent(t *testing.T) {
t.Parallel()
userID := common.UserID("user-123")
startsAt := time.Unix(1_775_240_000, 0).UTC()
endsAt := startsAt.Add(24 * time.Hour)
now := endsAt.Add(time.Hour)
snapshotStore := &fakeSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
userID: paidSnapshot(
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
endsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
),
},
}
historyStore := &fakeHistoryStore{
byUserID: map[common.UserID][]entitlement.PeriodRecord{
userID: {
paidRecord(
entitlement.EntitlementRecordID("entitlement-paid"),
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
endsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
),
},
},
}
lifecycleStore := &fakeLifecycleStore{
historyStore: historyStore,
snapshotStore: snapshotStore,
}
publisher := &recordingEntitlementPublisher{}
reader, err := NewReaderWithObservability(snapshotStore, lifecycleStore, fixedClock{now: now}, fixedIDGenerator{
recordID: entitlement.EntitlementRecordID("entitlement-free"),
}, nil, nil, publisher)
require.NoError(t, err)
got, err := reader.GetByUserID(context.Background(), userID)
require.NoError(t, err)
require.Equal(t, entitlement.PlanCodeFree, got.PlanCode)
require.Len(t, publisher.events, 1)
require.Equal(t, ports.EntitlementChangedOperationExpiredRepaired, publisher.events[0].Operation)
require.Equal(t, common.Source("entitlement_expiry_repair"), publisher.events[0].Source)
}
func TestGrantServiceExecutePublisherFailureDoesNotRollbackResult(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
currentFreeStartsAt := now.Add(-24 * time.Hour)
currentSnapshot := freeSnapshot(userID, currentFreeStartsAt, common.Source("auth_registration"), common.ReasonCode("initial_free_entitlement"))
currentRecord := freeRecord(entitlement.EntitlementRecordID("entitlement-free"), userID, currentFreeStartsAt, common.Source("auth_registration"), common.ReasonCode("initial_free_entitlement"))
lifecycleStore := &fakeLifecycleStore{}
publisher := &recordingEntitlementPublisher{err: errors.New("publisher unavailable")}
service, err := NewGrantServiceWithObservability(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
&fakeHistoryStore{byUserID: map[common.UserID][]entitlement.PeriodRecord{userID: {currentRecord}}},
fakeEffectiveReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: currentSnapshot}},
lifecycleStore,
fixedClock{now: now},
fixedIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-paid")},
nil,
nil,
publisher,
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), GrantInput{
UserID: userID.String(),
PlanCode: string(entitlement.PlanCodePaidMonthly),
Source: "admin",
ReasonCode: "manual_grant",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
StartsAt: now.Format(time.RFC3339Nano),
EndsAt: now.Add(30 * 24 * time.Hour).Format(time.RFC3339Nano),
})
require.NoError(t, err)
require.Equal(t, entitlement.PlanCodePaidMonthly, result.Entitlement.PlanCode)
require.Len(t, publisher.events, 1)
require.Equal(t, ports.EntitlementChangedOperationGranted, publisher.events[0].Operation)
}
type recordingEntitlementPublisher struct {
err error
events []ports.EntitlementChangedEvent
}
func (publisher *recordingEntitlementPublisher) PublishEntitlementChanged(_ context.Context, event ports.EntitlementChangedEvent) error {
if err := event.Validate(); err != nil {
return err
}
publisher.events = append(publisher.events, event)
return publisher.err
}
var _ ports.EntitlementChangedPublisher = (*recordingEntitlementPublisher)(nil)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,565 @@
package entitlementsvc
import (
"context"
"testing"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/shared"
"github.com/stretchr/testify/require"
)
func TestReaderGetByUserIDRepairsExpiredFinitePaidSnapshot(t *testing.T) {
t.Parallel()
userID := common.UserID("user-123")
startsAt := time.Unix(1_775_240_000, 0).UTC()
endsAt := startsAt.Add(24 * time.Hour)
now := endsAt.Add(2 * time.Hour)
snapshotStore := &fakeSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
userID: paidSnapshot(
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
endsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
),
},
}
historyStore := &fakeHistoryStore{
byUserID: map[common.UserID][]entitlement.PeriodRecord{
userID: {
paidRecord(
entitlement.EntitlementRecordID("entitlement-paid"),
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
endsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
),
},
},
}
lifecycleStore := &fakeLifecycleStore{
historyStore: historyStore,
snapshotStore: snapshotStore,
}
reader, err := NewReader(snapshotStore, lifecycleStore, fixedClock{now: now}, fixedIDGenerator{
recordID: entitlement.EntitlementRecordID("entitlement-free"),
})
require.NoError(t, err)
got, err := reader.GetByUserID(context.Background(), userID)
require.NoError(t, err)
require.Equal(t, entitlement.PlanCodeFree, got.PlanCode)
require.False(t, got.IsPaid)
require.Equal(t, endsAt, got.StartsAt)
require.Equal(t, expiryRepairSource, got.Source)
require.Equal(t, expiryRepairReasonCode, got.ReasonCode)
require.Equal(t, common.ActorRef{Type: expiryRepairActorType, ID: expiryRepairActorID}, got.Actor)
require.Len(t, historyStore.byUserID[userID], 2)
require.Equal(t, got, snapshotStore.byUserID[userID])
require.Equal(t, entitlement.EntitlementRecordID("entitlement-free"), lifecycleStore.repairInput.NewRecord.RecordID)
}
func TestGrantServiceExecuteRejectsInvalidPlanRules(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
freeSnapshot := freeSnapshot(userID, now.Add(-24*time.Hour), common.Source("auth_registration"), common.ReasonCode("initial_free_entitlement"))
freeRecord := freeRecord(entitlement.EntitlementRecordID("entitlement-free"), userID, now.Add(-24*time.Hour), common.Source("auth_registration"), common.ReasonCode("initial_free_entitlement"))
tests := []struct {
name string
input GrantInput
wantErr string
}{
{
name: "free plan not allowed",
input: GrantInput{
UserID: userID.String(),
PlanCode: string(entitlement.PlanCodeFree),
Source: "admin",
ReasonCode: "manual_grant",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
StartsAt: now.Format(time.RFC3339Nano),
},
wantErr: shared.ErrorCodeInvalidRequest,
},
{
name: "future starts at rejected",
input: GrantInput{
UserID: userID.String(),
PlanCode: string(entitlement.PlanCodePaidMonthly),
Source: "admin",
ReasonCode: "manual_grant",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
StartsAt: now.Add(time.Hour).Format(time.RFC3339Nano),
EndsAt: now.Add(31 * 24 * time.Hour).Format(time.RFC3339Nano),
},
wantErr: shared.ErrorCodeInvalidRequest,
},
{
name: "finite plan requires ends at",
input: GrantInput{
UserID: userID.String(),
PlanCode: string(entitlement.PlanCodePaidMonthly),
Source: "admin",
ReasonCode: "manual_grant",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
StartsAt: now.Format(time.RFC3339Nano),
},
wantErr: shared.ErrorCodeInvalidRequest,
},
{
name: "lifetime plan forbids ends at",
input: GrantInput{
UserID: userID.String(),
PlanCode: string(entitlement.PlanCodePaidLifetime),
Source: "admin",
ReasonCode: "manual_grant",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
StartsAt: now.Format(time.RFC3339Nano),
EndsAt: now.Add(24 * time.Hour).Format(time.RFC3339Nano),
},
wantErr: shared.ErrorCodeInvalidRequest,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
service, err := NewGrantService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
&fakeHistoryStore{byUserID: map[common.UserID][]entitlement.PeriodRecord{userID: {freeRecord}}},
fakeEffectiveReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: freeSnapshot}},
&fakeLifecycleStore{},
fixedClock{now: now},
fixedIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-paid")},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), tt.input)
require.Error(t, err)
require.Equal(t, tt.wantErr, shared.CodeOf(err))
})
}
}
func TestGrantServiceExecuteBuildsTransition(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
currentFreeStartsAt := now.Add(-24 * time.Hour)
currentSnapshot := freeSnapshot(userID, currentFreeStartsAt, common.Source("auth_registration"), common.ReasonCode("initial_free_entitlement"))
currentRecord := freeRecord(entitlement.EntitlementRecordID("entitlement-free"), userID, currentFreeStartsAt, common.Source("auth_registration"), common.ReasonCode("initial_free_entitlement"))
lifecycleStore := &fakeLifecycleStore{}
service, err := NewGrantService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
&fakeHistoryStore{byUserID: map[common.UserID][]entitlement.PeriodRecord{userID: {currentRecord}}},
fakeEffectiveReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: currentSnapshot}},
lifecycleStore,
fixedClock{now: now},
fixedIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-paid")},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), GrantInput{
UserID: userID.String(),
PlanCode: string(entitlement.PlanCodePaidMonthly),
Source: "admin",
ReasonCode: "manual_grant",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
StartsAt: now.Format(time.RFC3339Nano),
EndsAt: now.Add(30 * 24 * time.Hour).Format(time.RFC3339Nano),
})
require.NoError(t, err)
require.Equal(t, userID.String(), result.UserID)
require.Equal(t, entitlement.PlanCodePaidMonthly, result.Entitlement.PlanCode)
require.Equal(t, entitlement.EntitlementRecordID("entitlement-paid"), lifecycleStore.grantInput.NewRecord.RecordID)
require.Equal(t, currentSnapshot, lifecycleStore.grantInput.ExpectedCurrentSnapshot)
require.Equal(t, currentRecord.RecordID, lifecycleStore.grantInput.UpdatedCurrentRecord.RecordID)
require.NotNil(t, lifecycleStore.grantInput.UpdatedCurrentRecord.ClosedAt)
require.True(t, lifecycleStore.grantInput.UpdatedCurrentRecord.ClosedAt.Equal(now))
}
func TestExtendServiceExecuteBuildsExtensionSegment(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
startsAt := now.Add(-24 * time.Hour)
currentEndsAt := now.Add(24 * time.Hour)
currentSnapshot := paidSnapshot(
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
currentEndsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
currentRecord := paidRecord(
entitlement.EntitlementRecordID("entitlement-paid-1"),
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
currentEndsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
lifecycleStore := &fakeLifecycleStore{}
service, err := NewExtendService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
&fakeHistoryStore{byUserID: map[common.UserID][]entitlement.PeriodRecord{userID: {currentRecord}}},
fakeEffectiveReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: currentSnapshot}},
lifecycleStore,
fixedClock{now: now},
fixedIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-paid-2")},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), ExtendInput{
UserID: userID.String(),
Source: "admin",
ReasonCode: "manual_extend",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
EndsAt: currentEndsAt.Add(30 * 24 * time.Hour).Format(time.RFC3339Nano),
})
require.NoError(t, err)
require.Equal(t, currentEndsAt, lifecycleStore.extendInput.NewRecord.StartsAt)
require.Equal(t, startsAt, lifecycleStore.extendInput.NewSnapshot.StartsAt)
require.Equal(t, entitlement.PlanCodePaidMonthly, result.Entitlement.PlanCode)
}
func TestRevokeServiceExecuteBuildsFreeTransition(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
startsAt := now.Add(-24 * time.Hour)
currentEndsAt := now.Add(24 * time.Hour)
currentSnapshot := paidSnapshot(
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
currentEndsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
currentRecord := paidRecord(
entitlement.EntitlementRecordID("entitlement-paid-1"),
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
currentEndsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
lifecycleStore := &fakeLifecycleStore{}
service, err := NewRevokeService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
&fakeHistoryStore{byUserID: map[common.UserID][]entitlement.PeriodRecord{userID: {currentRecord}}},
fakeEffectiveReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: currentSnapshot}},
lifecycleStore,
fixedClock{now: now},
fixedIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-free-2")},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), RevokeInput{
UserID: userID.String(),
Source: "admin",
ReasonCode: "manual_revoke",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
})
require.NoError(t, err)
require.Equal(t, entitlement.PlanCodeFree, result.Entitlement.PlanCode)
require.NotNil(t, lifecycleStore.revokeInput.UpdatedCurrentRecord.ClosedAt)
require.True(t, lifecycleStore.revokeInput.UpdatedCurrentRecord.ClosedAt.Equal(now))
require.Equal(t, now, lifecycleStore.revokeInput.NewRecord.StartsAt)
}
type fakeAccountStore struct {
existsByUserID map[common.UserID]bool
}
func (store fakeAccountStore) Create(context.Context, ports.CreateAccountInput) error {
return nil
}
func (store fakeAccountStore) GetByUserID(context.Context, common.UserID) (account.UserAccount, error) {
return account.UserAccount{}, ports.ErrNotFound
}
func (store fakeAccountStore) GetByEmail(context.Context, common.Email) (account.UserAccount, error) {
return account.UserAccount{}, ports.ErrNotFound
}
func (store fakeAccountStore) GetByRaceName(context.Context, common.RaceName) (account.UserAccount, error) {
return account.UserAccount{}, ports.ErrNotFound
}
func (store fakeAccountStore) ExistsByUserID(_ context.Context, userID common.UserID) (bool, error) {
return store.existsByUserID[userID], nil
}
func (store fakeAccountStore) RenameRaceName(context.Context, ports.RenameRaceNameInput) error {
return nil
}
func (store fakeAccountStore) Update(context.Context, account.UserAccount) error {
return nil
}
type fakeSnapshotStore struct {
byUserID map[common.UserID]entitlement.CurrentSnapshot
}
func (store *fakeSnapshotStore) GetByUserID(_ context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error) {
record, ok := store.byUserID[userID]
if !ok {
return entitlement.CurrentSnapshot{}, ports.ErrNotFound
}
return record, nil
}
func (store *fakeSnapshotStore) Put(_ context.Context, record entitlement.CurrentSnapshot) error {
store.byUserID[record.UserID] = record
return nil
}
type fakeHistoryStore struct {
byUserID map[common.UserID][]entitlement.PeriodRecord
}
func (store *fakeHistoryStore) Create(_ context.Context, record entitlement.PeriodRecord) error {
store.byUserID[record.UserID] = append(store.byUserID[record.UserID], record)
return nil
}
func (store *fakeHistoryStore) GetByRecordID(_ context.Context, recordID entitlement.EntitlementRecordID) (entitlement.PeriodRecord, error) {
for _, records := range store.byUserID {
for _, record := range records {
if record.RecordID == recordID {
return record, nil
}
}
}
return entitlement.PeriodRecord{}, ports.ErrNotFound
}
func (store *fakeHistoryStore) ListByUserID(_ context.Context, userID common.UserID) ([]entitlement.PeriodRecord, error) {
records := store.byUserID[userID]
cloned := make([]entitlement.PeriodRecord, len(records))
copy(cloned, records)
return cloned, nil
}
func (store *fakeHistoryStore) Update(_ context.Context, record entitlement.PeriodRecord) error {
records := store.byUserID[record.UserID]
for idx := range records {
if records[idx].RecordID == record.RecordID {
records[idx] = record
store.byUserID[record.UserID] = records
return nil
}
}
return ports.ErrNotFound
}
type fakeEffectiveReader struct {
byUserID map[common.UserID]entitlement.CurrentSnapshot
}
func (reader fakeEffectiveReader) GetByUserID(_ context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error) {
record, ok := reader.byUserID[userID]
if !ok {
return entitlement.CurrentSnapshot{}, ports.ErrNotFound
}
return record, nil
}
type fakeLifecycleStore struct {
historyStore *fakeHistoryStore
snapshotStore *fakeSnapshotStore
grantInput ports.GrantEntitlementInput
extendInput ports.ExtendEntitlementInput
revokeInput ports.RevokeEntitlementInput
repairInput ports.RepairExpiredEntitlementInput
}
func (store *fakeLifecycleStore) Grant(_ context.Context, input ports.GrantEntitlementInput) error {
store.grantInput = input
return nil
}
func (store *fakeLifecycleStore) Extend(_ context.Context, input ports.ExtendEntitlementInput) error {
store.extendInput = input
return nil
}
func (store *fakeLifecycleStore) Revoke(_ context.Context, input ports.RevokeEntitlementInput) error {
store.revokeInput = input
return nil
}
func (store *fakeLifecycleStore) RepairExpired(_ context.Context, input ports.RepairExpiredEntitlementInput) error {
store.repairInput = input
if store.historyStore != nil {
store.historyStore.byUserID[input.NewRecord.UserID] = append(store.historyStore.byUserID[input.NewRecord.UserID], input.NewRecord)
}
if store.snapshotStore != nil {
store.snapshotStore.byUserID[input.NewSnapshot.UserID] = input.NewSnapshot
}
return nil
}
type fixedClock struct {
now time.Time
}
func (clock fixedClock) Now() time.Time {
return clock.now
}
type fixedIDGenerator struct {
recordID entitlement.EntitlementRecordID
sanctionRecordID policy.SanctionRecordID
limitRecordID policy.LimitRecordID
}
func (generator fixedIDGenerator) NewUserID() (common.UserID, error) {
return "", nil
}
func (generator fixedIDGenerator) NewInitialRaceName() (common.RaceName, error) {
return "", nil
}
func (generator fixedIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
return generator.recordID, nil
}
func (generator fixedIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
return generator.sanctionRecordID, nil
}
func (generator fixedIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
return generator.limitRecordID, nil
}
func freeSnapshot(
userID common.UserID,
startsAt time.Time,
source common.Source,
reasonCode common.ReasonCode,
) entitlement.CurrentSnapshot {
return entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
IsPaid: false,
StartsAt: startsAt,
Source: source,
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
ReasonCode: reasonCode,
UpdatedAt: startsAt,
}
}
func freeRecord(
recordID entitlement.EntitlementRecordID,
userID common.UserID,
startsAt time.Time,
source common.Source,
reasonCode common.ReasonCode,
) entitlement.PeriodRecord {
return entitlement.PeriodRecord{
RecordID: recordID,
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
Source: source,
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
ReasonCode: reasonCode,
StartsAt: startsAt,
CreatedAt: startsAt,
}
}
func paidSnapshot(
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 paidRecord(
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 timePointer(value time.Time) *time.Time {
utcValue := value.UTC()
return &utcValue
}
var (
_ ports.UserAccountStore = fakeAccountStore{}
_ ports.EntitlementSnapshotStore = (*fakeSnapshotStore)(nil)
_ ports.EntitlementHistoryStore = (*fakeHistoryStore)(nil)
_ ports.EntitlementLifecycleStore = (*fakeLifecycleStore)(nil)
_ ports.Clock = fixedClock{}
_ ports.IDGenerator = fixedIDGenerator{}
)
+194
View File
@@ -0,0 +1,194 @@
// Package geosync implements the trusted geo-facing declared-country sync
// command owned by User Service.
package geosync
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/shared"
"galaxy/user/internal/telemetry"
"golang.org/x/text/language"
)
const geoProfileServiceSource = common.Source("geo_profile_service")
// SyncDeclaredCountryInput stores one trusted geo-facing country-sync request.
type SyncDeclaredCountryInput struct {
// UserID identifies the regular user whose current declared country must be
// synchronized.
UserID string
// DeclaredCountry stores the new current effective declared country.
DeclaredCountry string
}
// SyncDeclaredCountryResult stores one trusted geo-facing country-sync result.
type SyncDeclaredCountryResult struct {
// UserID identifies the synchronized user.
UserID string `json:"user_id"`
// DeclaredCountry stores the current effective declared country after the
// command completes.
DeclaredCountry string `json:"declared_country"`
// UpdatedAt stores the effective account mutation timestamp. Same-value
// no-op syncs return the current stored timestamp unchanged.
UpdatedAt time.Time `json:"updated_at"`
}
// SyncService executes the trusted geo-facing declared-country sync command.
type SyncService struct {
accounts ports.UserAccountStore
clock ports.Clock
publisher ports.DeclaredCountryChangedPublisher
logger *slog.Logger
telemetry *telemetry.Runtime
}
// NewSyncService constructs one trusted declared-country sync command.
func NewSyncService(
accounts ports.UserAccountStore,
clock ports.Clock,
publisher ports.DeclaredCountryChangedPublisher,
) (*SyncService, error) {
return NewSyncServiceWithObservability(accounts, clock, publisher, nil, nil)
}
// NewSyncServiceWithObservability constructs one trusted declared-country sync
// command with optional structured logging and event-publication metrics.
func NewSyncServiceWithObservability(
accounts ports.UserAccountStore,
clock ports.Clock,
publisher ports.DeclaredCountryChangedPublisher,
logger *slog.Logger,
telemetryRuntime *telemetry.Runtime,
) (*SyncService, error) {
switch {
case accounts == nil:
return nil, fmt.Errorf("geo declared-country sync service: user account store must not be nil")
case clock == nil:
return nil, fmt.Errorf("geo declared-country sync service: clock must not be nil")
case publisher == nil:
return nil, fmt.Errorf("geo declared-country sync service: declared-country changed publisher must not be nil")
default:
return &SyncService{
accounts: accounts,
clock: clock,
publisher: publisher,
logger: logger,
telemetry: telemetryRuntime,
}, nil
}
}
// Execute synchronizes the current effective declared country of one user.
func (service *SyncService) Execute(
ctx context.Context,
input SyncDeclaredCountryInput,
) (result SyncDeclaredCountryResult, err error) {
outcome := "failed"
userIDString := ""
defer func() {
shared.LogServiceOutcome(service.logger, ctx, "declared-country sync completed", err,
"use_case", "sync_declared_country",
"outcome", outcome,
"user_id", userIDString,
"source", geoProfileServiceSource.String(),
)
}()
if ctx == nil {
return SyncDeclaredCountryResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
return SyncDeclaredCountryResult{}, err
}
userIDString = userID.String()
declaredCountry, err := parseDeclaredCountry(input.DeclaredCountry)
if err != nil {
return SyncDeclaredCountryResult{}, err
}
record, err := service.accounts.GetByUserID(ctx, userID)
switch {
case err == nil:
case errors.Is(err, ports.ErrNotFound):
return SyncDeclaredCountryResult{}, shared.SubjectNotFound()
default:
return SyncDeclaredCountryResult{}, shared.ServiceUnavailable(err)
}
if record.DeclaredCountry == declaredCountry {
outcome = "noop"
return resultFromAccount(record), nil
}
record.DeclaredCountry = declaredCountry
record.UpdatedAt = service.clock.Now().UTC()
if err := service.accounts.Update(ctx, record); err != nil {
switch {
case errors.Is(err, ports.ErrNotFound):
return SyncDeclaredCountryResult{}, shared.SubjectNotFound()
case errors.Is(err, ports.ErrConflict):
return SyncDeclaredCountryResult{}, shared.ServiceUnavailable(err)
default:
return SyncDeclaredCountryResult{}, shared.ServiceUnavailable(err)
}
}
result = resultFromAccount(record)
outcome = "updated"
if err := service.publisher.PublishDeclaredCountryChanged(ctx, ports.DeclaredCountryChangedEvent{
UserID: record.UserID,
DeclaredCountry: record.DeclaredCountry,
UpdatedAt: record.UpdatedAt,
Source: geoProfileServiceSource,
}); err != nil {
if service.telemetry != nil {
service.telemetry.RecordEventPublicationFailure(ctx, ports.DeclaredCountryChangedEventType)
}
shared.LogEventPublicationFailure(service.logger, ctx, ports.DeclaredCountryChangedEventType, err,
"use_case", "sync_declared_country",
"user_id", record.UserID.String(),
"source", geoProfileServiceSource.String(),
)
}
return result, nil
}
func parseDeclaredCountry(value string) (common.CountryCode, error) {
const message = "declared_country must be a valid ISO 3166-1 alpha-2 country code"
code := common.CountryCode(shared.NormalizeString(value))
if err := code.Validate(); err != nil {
return "", shared.InvalidRequest(message)
}
region, err := language.ParseRegion(code.String())
if err != nil || !region.IsCountry() || region.Canonicalize().String() != code.String() {
return "", shared.InvalidRequest(message)
}
return code, nil
}
func resultFromAccount(record account.UserAccount) SyncDeclaredCountryResult {
return SyncDeclaredCountryResult{
UserID: record.UserID.String(),
DeclaredCountry: record.DeclaredCountry.String(),
UpdatedAt: record.UpdatedAt.UTC(),
}
}
@@ -0,0 +1,299 @@
package geosync
import (
"context"
"errors"
"testing"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/shared"
"github.com/stretchr/testify/require"
)
func TestSyncServiceExecuteUpdatesDeclaredCountryAndPublishesEvent(t *testing.T) {
t.Parallel()
createdAt := time.Unix(1_775_240_000, 0).UTC()
updatedAt := createdAt.Add(5 * time.Minute)
record := validAccountRecord(createdAt, createdAt)
store := newFakeAccountStore(record)
publisher := &recordingDeclaredCountryChangedPublisher{
publishHook: func(event ports.DeclaredCountryChangedEvent) error {
stored, err := store.GetByUserID(context.Background(), record.UserID)
require.NoError(t, err)
require.Equal(t, common.CountryCode("FR"), stored.DeclaredCountry)
require.Equal(t, updatedAt, stored.UpdatedAt)
require.Equal(t, common.Source("geo_profile_service"), event.Source)
return nil
},
}
service, err := NewSyncService(store, fixedClock{now: updatedAt}, publisher)
require.NoError(t, err)
result, err := service.Execute(context.Background(), SyncDeclaredCountryInput{
UserID: record.UserID.String(),
DeclaredCountry: "FR",
})
require.NoError(t, err)
require.Equal(t, record.UserID.String(), result.UserID)
require.Equal(t, "FR", result.DeclaredCountry)
require.Equal(t, updatedAt, result.UpdatedAt)
require.Equal(t, 1, store.updateCalls)
stored, err := store.GetByUserID(context.Background(), record.UserID)
require.NoError(t, err)
require.Equal(t, record.Email, stored.Email)
require.Equal(t, record.RaceName, stored.RaceName)
require.Equal(t, record.PreferredLanguage, stored.PreferredLanguage)
require.Equal(t, record.TimeZone, stored.TimeZone)
require.Equal(t, common.CountryCode("FR"), stored.DeclaredCountry)
require.Equal(t, record.CreatedAt, stored.CreatedAt)
require.Equal(t, updatedAt, stored.UpdatedAt)
published := publisher.PublishedEvents()
require.Len(t, published, 1)
require.Equal(t, record.UserID, published[0].UserID)
require.Equal(t, common.CountryCode("FR"), published[0].DeclaredCountry)
require.Equal(t, updatedAt, published[0].UpdatedAt)
require.Equal(t, common.Source("geo_profile_service"), published[0].Source)
}
func TestSyncServiceExecuteSameCountryIsNoOp(t *testing.T) {
t.Parallel()
createdAt := time.Unix(1_775_240_000, 0).UTC()
record := validAccountRecord(createdAt, createdAt.Add(5*time.Minute))
store := newFakeAccountStore(record)
publisher := &recordingDeclaredCountryChangedPublisher{}
service, err := NewSyncService(store, fixedClock{now: createdAt.Add(time.Hour)}, publisher)
require.NoError(t, err)
result, err := service.Execute(context.Background(), SyncDeclaredCountryInput{
UserID: record.UserID.String(),
DeclaredCountry: record.DeclaredCountry.String(),
})
require.NoError(t, err)
require.Equal(t, record.UserID.String(), result.UserID)
require.Equal(t, record.DeclaredCountry.String(), result.DeclaredCountry)
require.Equal(t, record.UpdatedAt, result.UpdatedAt)
require.Zero(t, store.updateCalls)
require.Empty(t, publisher.PublishedEvents())
}
func TestSyncServiceExecuteRejectsInvalidDeclaredCountry(t *testing.T) {
t.Parallel()
service, err := NewSyncService(
newFakeAccountStore(validAccountRecord(time.Unix(1_775_240_000, 0).UTC(), time.Unix(1_775_240_000, 0).UTC())),
fixedClock{now: time.Unix(1_775_240_000, 0).UTC()},
&recordingDeclaredCountryChangedPublisher{},
)
require.NoError(t, err)
tests := []struct {
name string
value string
}{
{name: "alias country code", value: "UK"},
{name: "lowercase", value: "de"},
{name: "non-country region", value: "EU"},
{name: "wrong length", value: "DEU"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := service.Execute(context.Background(), SyncDeclaredCountryInput{
UserID: "user-123",
DeclaredCountry: tt.value,
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
require.EqualError(t, err, "declared_country must be a valid ISO 3166-1 alpha-2 country code")
})
}
}
func TestSyncServiceExecuteUnknownUserReturnsNotFound(t *testing.T) {
t.Parallel()
service, err := NewSyncService(
newFakeAccountStore(),
fixedClock{now: time.Unix(1_775_240_000, 0).UTC()},
&recordingDeclaredCountryChangedPublisher{},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), SyncDeclaredCountryInput{
UserID: "user-missing",
DeclaredCountry: "DE",
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err))
}
func TestSyncServiceExecutePublisherFailureDoesNotRollbackCommit(t *testing.T) {
t.Parallel()
createdAt := time.Unix(1_775_240_000, 0).UTC()
updatedAt := createdAt.Add(time.Minute)
record := validAccountRecord(createdAt, createdAt)
store := newFakeAccountStore(record)
publisher := &recordingDeclaredCountryChangedPublisher{
err: errors.New("publisher unavailable"),
}
service, err := NewSyncService(store, fixedClock{now: updatedAt}, publisher)
require.NoError(t, err)
result, err := service.Execute(context.Background(), SyncDeclaredCountryInput{
UserID: record.UserID.String(),
DeclaredCountry: "FR",
})
require.NoError(t, err)
require.Equal(t, "FR", result.DeclaredCountry)
require.Equal(t, updatedAt, result.UpdatedAt)
stored, err := store.GetByUserID(context.Background(), record.UserID)
require.NoError(t, err)
require.Equal(t, common.CountryCode("FR"), stored.DeclaredCountry)
require.Equal(t, updatedAt, stored.UpdatedAt)
published := publisher.PublishedEvents()
require.Len(t, published, 1)
require.Equal(t, common.CountryCode("FR"), published[0].DeclaredCountry)
}
type fakeAccountStore struct {
records map[common.UserID]account.UserAccount
updateCalls int
updateErr error
}
func newFakeAccountStore(records ...account.UserAccount) *fakeAccountStore {
byUserID := make(map[common.UserID]account.UserAccount, len(records))
for _, record := range records {
byUserID[record.UserID] = record
}
return &fakeAccountStore{records: byUserID}
}
func (store *fakeAccountStore) Create(context.Context, ports.CreateAccountInput) error {
return nil
}
func (store *fakeAccountStore) GetByUserID(_ context.Context, userID common.UserID) (account.UserAccount, error) {
record, ok := store.records[userID]
if !ok {
return account.UserAccount{}, ports.ErrNotFound
}
return record, nil
}
func (store *fakeAccountStore) GetByEmail(_ context.Context, email common.Email) (account.UserAccount, error) {
for _, record := range store.records {
if record.Email == email {
return record, nil
}
}
return account.UserAccount{}, ports.ErrNotFound
}
func (store *fakeAccountStore) GetByRaceName(_ context.Context, raceName common.RaceName) (account.UserAccount, error) {
for _, record := range store.records {
if record.RaceName == raceName {
return record, nil
}
}
return account.UserAccount{}, ports.ErrNotFound
}
func (store *fakeAccountStore) ExistsByUserID(_ context.Context, userID common.UserID) (bool, error) {
_, ok := store.records[userID]
return ok, nil
}
func (store *fakeAccountStore) RenameRaceName(context.Context, ports.RenameRaceNameInput) error {
return nil
}
func (store *fakeAccountStore) Update(_ context.Context, record account.UserAccount) error {
store.updateCalls++
if store.updateErr != nil {
return store.updateErr
}
if _, ok := store.records[record.UserID]; !ok {
return ports.ErrNotFound
}
store.records[record.UserID] = record
return nil
}
type recordingDeclaredCountryChangedPublisher struct {
err error
publishHook func(event ports.DeclaredCountryChangedEvent) error
published []ports.DeclaredCountryChangedEvent
}
func (publisher *recordingDeclaredCountryChangedPublisher) PublishDeclaredCountryChanged(
_ context.Context,
event ports.DeclaredCountryChangedEvent,
) error {
if err := event.Validate(); err != nil {
return err
}
publisher.published = append(publisher.published, event)
if publisher.publishHook != nil {
if err := publisher.publishHook(event); err != nil {
return err
}
}
return publisher.err
}
func (publisher *recordingDeclaredCountryChangedPublisher) PublishedEvents() []ports.DeclaredCountryChangedEvent {
events := make([]ports.DeclaredCountryChangedEvent, len(publisher.published))
copy(events, publisher.published)
return events
}
type fixedClock struct {
now time.Time
}
func (clock fixedClock) Now() time.Time {
return clock.now
}
func validAccountRecord(createdAt time.Time, updatedAt time.Time) account.UserAccount {
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"),
DeclaredCountry: common.CountryCode("DE"),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
}
var (
_ ports.UserAccountStore = (*fakeAccountStore)(nil)
_ ports.DeclaredCountryChangedPublisher = (*recordingDeclaredCountryChangedPublisher)(nil)
_ ports.Clock = fixedClock{}
)
@@ -0,0 +1,397 @@
// Package lobbyeligibility implements the trusted lobby-facing eligibility
// snapshot read owned by User Service.
package lobbyeligibility
import (
"context"
"errors"
"fmt"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/shared"
)
// limitCatalogEntry stores one frozen default quota for free and paid
// entitlement states.
type limitCatalogEntry struct {
code policy.LimitCode
freeValue int
paidValue int
freeEnabled bool
}
// limitCatalog stores the frozen lobby-facing effective limit defaults used
// to materialize numeric quotas from the current entitlement state.
var limitCatalog = []limitCatalogEntry{
{
code: policy.LimitCodeMaxOwnedPrivateGames,
paidValue: 3,
},
{
code: policy.LimitCodeMaxPendingPublicApplications,
freeValue: 3,
paidValue: 10,
freeEnabled: true,
},
{
code: policy.LimitCodeMaxActiveGameMemberships,
freeValue: 3,
paidValue: 10,
freeEnabled: true,
},
}
// ActorRefView stores transport-ready audit actor metadata.
type ActorRefView struct {
// Type stores the machine-readable actor type.
Type string `json:"type"`
// ID stores the optional stable actor identifier.
ID string `json:"id,omitempty"`
}
// EntitlementSnapshotView stores the transport-ready current entitlement
// snapshot used by lobby reads.
type EntitlementSnapshotView struct {
// PlanCode stores the effective entitlement plan code.
PlanCode string `json:"plan_code"`
// IsPaid reports whether the effective plan is paid.
IsPaid bool `json:"is_paid"`
// Source stores the machine-readable mutation source.
Source string `json:"source"`
// Actor stores the audit actor metadata attached to the snapshot.
Actor ActorRefView `json:"actor"`
// ReasonCode stores the machine-readable reason attached to the snapshot.
ReasonCode string `json:"reason_code"`
// StartsAt stores when the effective state started.
StartsAt time.Time `json:"starts_at"`
// EndsAt stores the optional finite effective expiry.
EndsAt *time.Time `json:"ends_at,omitempty"`
// UpdatedAt stores when the snapshot was last recomputed.
UpdatedAt time.Time `json:"updated_at"`
}
// ActiveSanctionView stores one transport-ready active sanction that matters
// to lobby flows.
type ActiveSanctionView struct {
// SanctionCode stores the active sanction code.
SanctionCode string `json:"sanction_code"`
// Scope stores the machine-readable sanction scope.
Scope string `json:"scope"`
// ReasonCode stores the machine-readable sanction reason.
ReasonCode string `json:"reason_code"`
// Actor stores the audit actor metadata attached to the sanction.
Actor ActorRefView `json:"actor"`
// AppliedAt stores when the sanction became active.
AppliedAt time.Time `json:"applied_at"`
// ExpiresAt stores the optional planned sanction expiry.
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// EffectiveLimitView stores one materialized effective lobby quota.
type EffectiveLimitView struct {
// LimitCode stores the machine-readable quota identifier.
LimitCode string `json:"limit_code"`
// Value stores the effective numeric quota after defaults and user
// overrides are applied.
Value int `json:"value"`
}
// EligibilityMarkersView stores the derived booleans consumed by Game Lobby.
type EligibilityMarkersView struct {
// CanLogin reports whether the user may currently log in.
CanLogin bool `json:"can_login"`
// CanCreatePrivateGame reports whether the user may currently create a
// private game.
CanCreatePrivateGame bool `json:"can_create_private_game"`
// CanManagePrivateGame reports whether the user may currently manage a
// private game.
CanManagePrivateGame bool `json:"can_manage_private_game"`
// CanJoinGame reports whether the user may currently join a game.
CanJoinGame bool `json:"can_join_game"`
// CanUpdateProfile reports whether the user may currently update self-
// service profile and settings fields.
CanUpdateProfile bool `json:"can_update_profile"`
}
// GetUserEligibilityInput stores one lobby-facing eligibility read request.
type GetUserEligibilityInput struct {
// UserID identifies the regular user whose effective lobby state is needed.
UserID string
}
// GetUserEligibilityResult stores one lobby-facing eligibility snapshot.
type GetUserEligibilityResult struct {
// Exists reports whether UserID currently identifies a stored user.
Exists bool `json:"exists"`
// UserID echoes the requested stable user identifier.
UserID string `json:"user_id"`
// Entitlement stores the current effective entitlement snapshot for known
// users.
Entitlement *EntitlementSnapshotView `json:"entitlement,omitempty"`
// ActiveSanctions stores only the currently active sanctions relevant to
// lobby decisions.
ActiveSanctions []ActiveSanctionView `json:"active_sanctions"`
// EffectiveLimits stores the materialized numeric quotas used by Game
// Lobby.
EffectiveLimits []EffectiveLimitView `json:"effective_limits"`
// Markers stores the derived decision booleans consumed by Game Lobby.
Markers EligibilityMarkersView `json:"markers"`
}
type entitlementReader interface {
GetByUserID(ctx context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error)
}
// SnapshotReader executes the trusted lobby-facing eligibility snapshot read.
type SnapshotReader struct {
accounts ports.UserAccountStore
entitlements entitlementReader
sanctions ports.SanctionStore
limits ports.LimitStore
clock ports.Clock
}
// NewSnapshotReader constructs one lobby-facing eligibility snapshot reader.
func NewSnapshotReader(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
) (*SnapshotReader, error) {
switch {
case accounts == nil:
return nil, fmt.Errorf("lobby eligibility snapshot reader: user account store must not be nil")
case entitlements == nil:
return nil, fmt.Errorf("lobby eligibility snapshot reader: entitlement reader must not be nil")
case sanctions == nil:
return nil, fmt.Errorf("lobby eligibility snapshot reader: sanction store must not be nil")
case limits == nil:
return nil, fmt.Errorf("lobby eligibility snapshot reader: limit store must not be nil")
case clock == nil:
return nil, fmt.Errorf("lobby eligibility snapshot reader: clock must not be nil")
default:
return &SnapshotReader{
accounts: accounts,
entitlements: entitlements,
sanctions: sanctions,
limits: limits,
clock: clock,
}, nil
}
}
// Execute returns one read-optimized eligibility snapshot for Game Lobby.
func (service *SnapshotReader) Execute(
ctx context.Context,
input GetUserEligibilityInput,
) (GetUserEligibilityResult, error) {
if ctx == nil {
return GetUserEligibilityResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
return GetUserEligibilityResult{}, err
}
result := GetUserEligibilityResult{
UserID: userID.String(),
ActiveSanctions: []ActiveSanctionView{},
EffectiveLimits: []EffectiveLimitView{},
}
exists, err := service.accounts.ExistsByUserID(ctx, userID)
if err != nil {
return GetUserEligibilityResult{}, shared.ServiceUnavailable(err)
}
if !exists {
return result, nil
}
now := service.clock.Now().UTC()
entitlementSnapshot, err := service.entitlements.GetByUserID(ctx, userID)
switch {
case err == nil:
case errors.Is(err, ports.ErrNotFound):
return GetUserEligibilityResult{}, shared.InternalError(fmt.Errorf("user %q is missing entitlement snapshot", userID))
default:
return GetUserEligibilityResult{}, shared.ServiceUnavailable(err)
}
sanctionRecords, err := service.sanctions.ListByUserID(ctx, userID)
if err != nil {
return GetUserEligibilityResult{}, shared.ServiceUnavailable(err)
}
activeSanctions, err := policy.ActiveSanctionsAt(sanctionRecords, now)
if err != nil {
return GetUserEligibilityResult{}, shared.InternalError(fmt.Errorf("evaluate active sanctions for user %q: %w", userID, err))
}
limitRecords, err := service.limits.ListByUserID(ctx, userID)
if err != nil {
return GetUserEligibilityResult{}, shared.ServiceUnavailable(err)
}
activeLimits, err := policy.ActiveLimitsAt(limitRecords, now)
if err != nil {
return GetUserEligibilityResult{}, shared.InternalError(fmt.Errorf("evaluate active limits for user %q: %w", userID, err))
}
result.Exists = true
result.Entitlement = entitlementSnapshotView(entitlementSnapshot)
result.ActiveSanctions = lobbyRelevantSanctionViews(activeSanctions)
result.EffectiveLimits = materializeEffectiveLimits(entitlementSnapshot.IsPaid, activeLimits)
result.Markers = deriveEligibilityMarkers(entitlementSnapshot.IsPaid, activeSanctions)
return result, nil
}
func entitlementSnapshotView(snapshot entitlement.CurrentSnapshot) *EntitlementSnapshotView {
return &EntitlementSnapshotView{
PlanCode: string(snapshot.PlanCode),
IsPaid: snapshot.IsPaid,
Source: snapshot.Source.String(),
Actor: actorRefView(snapshot.Actor),
ReasonCode: snapshot.ReasonCode.String(),
StartsAt: snapshot.StartsAt.UTC(),
EndsAt: cloneOptionalTime(snapshot.EndsAt),
UpdatedAt: snapshot.UpdatedAt.UTC(),
}
}
func lobbyRelevantSanctionViews(records []policy.SanctionRecord) []ActiveSanctionView {
views := make([]ActiveSanctionView, 0, len(records))
for _, record := range records {
if !isLobbyRelevantSanction(record.SanctionCode) {
continue
}
views = append(views, ActiveSanctionView{
SanctionCode: string(record.SanctionCode),
Scope: record.Scope.String(),
ReasonCode: record.ReasonCode.String(),
Actor: actorRefView(record.Actor),
AppliedAt: record.AppliedAt.UTC(),
ExpiresAt: cloneOptionalTime(record.ExpiresAt),
})
}
return views
}
func materializeEffectiveLimits(isPaid bool, overrides []policy.LimitRecord) []EffectiveLimitView {
overrideValues := make(map[policy.LimitCode]int, len(overrides))
for _, record := range overrides {
overrideValues[record.LimitCode] = record.Value
}
limits := make([]EffectiveLimitView, 0, len(limitCatalog))
for _, entry := range limitCatalog {
if !isPaid && !entry.freeEnabled {
continue
}
value := entry.freeValue
if isPaid {
value = entry.paidValue
}
if override, ok := overrideValues[entry.code]; ok {
value = override
}
limits = append(limits, EffectiveLimitView{
LimitCode: string(entry.code),
Value: value,
})
}
return limits
}
func deriveEligibilityMarkers(
isPaid bool,
activeSanctions []policy.SanctionRecord,
) EligibilityMarkersView {
loginBlocked := hasActiveSanction(activeSanctions, policy.SanctionCodeLoginBlock)
createBlocked := hasActiveSanction(activeSanctions, policy.SanctionCodePrivateGameCreateBlock)
manageBlocked := hasActiveSanction(activeSanctions, policy.SanctionCodePrivateGameManageBlock)
joinBlocked := hasActiveSanction(activeSanctions, policy.SanctionCodeGameJoinBlock)
profileBlocked := hasActiveSanction(activeSanctions, policy.SanctionCodeProfileUpdateBlock)
canLogin := !loginBlocked
return EligibilityMarkersView{
CanLogin: canLogin,
CanCreatePrivateGame: canLogin && isPaid && !createBlocked,
CanManagePrivateGame: canLogin && isPaid && !manageBlocked,
CanJoinGame: canLogin && !joinBlocked,
CanUpdateProfile: canLogin && !profileBlocked,
}
}
func hasActiveSanction(records []policy.SanctionRecord, code policy.SanctionCode) bool {
for _, record := range records {
if record.SanctionCode == code {
return true
}
}
return false
}
func isLobbyRelevantSanction(code policy.SanctionCode) bool {
switch code {
case policy.SanctionCodeLoginBlock,
policy.SanctionCodePrivateGameCreateBlock,
policy.SanctionCodePrivateGameManageBlock,
policy.SanctionCodeGameJoinBlock:
return true
default:
return false
}
}
func actorRefView(actor common.ActorRef) ActorRefView {
return ActorRefView{
Type: actor.Type.String(),
ID: actor.ID.String(),
}
}
func cloneOptionalTime(value *time.Time) *time.Time {
if value == nil {
return nil
}
cloned := value.UTC()
return &cloned
}
@@ -0,0 +1,524 @@
package lobbyeligibility
import (
"context"
"testing"
"time"
"galaxy/user/internal/adapters/redis/userstore"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/entitlementsvc"
"github.com/alicebob/miniredis/v2"
"github.com/stretchr/testify/require"
)
func TestSnapshotReaderExecuteReturnsStableNotFound(t *testing.T) {
t.Parallel()
service, err := NewSnapshotReader(
fakeAccountStore{existsByUserID: map[common.UserID]bool{}},
fakeEntitlementReader{},
fakeSanctionStore{},
fakeLimitStore{},
fixedClock{now: time.Unix(1_775_240_500, 0).UTC()},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), GetUserEligibilityInput{UserID: " user-missing "})
require.NoError(t, err)
require.False(t, result.Exists)
require.Equal(t, "user-missing", result.UserID)
require.Nil(t, result.Entitlement)
require.Empty(t, result.ActiveSanctions)
require.Empty(t, result.EffectiveLimits)
require.Equal(t, EligibilityMarkersView{}, result.Markers)
}
func TestSnapshotReaderExecuteBuildsPaidSnapshotAndDerivedState(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
userID := common.UserID("user-123")
service, err := NewSnapshotReader(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
fakeEntitlementReader{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
userID: paidEntitlementSnapshot(userID, now.Add(-24*time.Hour), now.Add(24*time.Hour)),
},
},
fakeSanctionStore{
byUserID: map[common.UserID][]policy.SanctionRecord{
userID: {
activeSanction(userID, policy.SanctionCodePrivateGameManageBlock, "lobby", now.Add(-time.Hour)),
activeSanction(userID, policy.SanctionCodeProfileUpdateBlock, "profile", now.Add(-30*time.Minute)),
expiredSanction(userID, policy.SanctionCodeGameJoinBlock, "lobby", now.Add(-2*time.Hour)),
},
},
},
fakeLimitStore{
byUserID: map[common.UserID][]policy.LimitRecord{
userID: {
activeLimit(userID, policy.LimitCodeMaxPendingPrivateInvitesSent, 17, now.Add(-time.Hour)),
activeLimit(userID, policy.LimitCodeMaxActivePrivateGames, 2, now.Add(-2*time.Hour)),
},
},
},
fixedClock{now: now},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), GetUserEligibilityInput{UserID: userID.String()})
require.NoError(t, err)
require.True(t, result.Exists)
require.NotNil(t, result.Entitlement)
require.Equal(t, "paid_monthly", result.Entitlement.PlanCode)
require.True(t, result.Entitlement.IsPaid)
require.Len(t, result.ActiveSanctions, 1)
require.Equal(t, "private_game_manage_block", result.ActiveSanctions[0].SanctionCode)
require.Equal(t, EligibilityMarkersView{
CanLogin: true,
CanCreatePrivateGame: true,
CanManagePrivateGame: false,
CanJoinGame: true,
CanUpdateProfile: false,
}, result.Markers)
require.Equal(t, []EffectiveLimitView{
{LimitCode: "max_owned_private_games", Value: 3},
{LimitCode: "max_pending_public_applications", Value: 10},
{LimitCode: "max_active_game_memberships", Value: 10},
}, result.EffectiveLimits)
}
func TestSnapshotReaderExecuteDeniesUnpaidAndLoginBlockedUsers(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
userID := common.UserID("user-123")
tests := []struct {
name string
snapshot entitlement.CurrentSnapshot
sanctions []policy.SanctionRecord
limits []policy.LimitRecord
wantSanctions []string
wantMarkers EligibilityMarkersView
wantLimits []EffectiveLimitView
}{
{
name: "unpaid defaults",
snapshot: freeEntitlementSnapshot(userID, now.Add(-24*time.Hour)),
limits: []policy.LimitRecord{activeLimit(userID, policy.LimitCodeMaxOwnedPrivateGames, 9, now.Add(-time.Hour))},
wantSanctions: []string{},
wantMarkers: EligibilityMarkersView{
CanLogin: true,
CanCreatePrivateGame: false,
CanManagePrivateGame: false,
CanJoinGame: true,
CanUpdateProfile: true,
},
wantLimits: []EffectiveLimitView{
{LimitCode: "max_pending_public_applications", Value: 3},
{LimitCode: "max_active_game_memberships", Value: 3},
},
},
{
name: "login block denies all markers",
snapshot: paidEntitlementSnapshot(userID, now.Add(-24*time.Hour), now.Add(24*time.Hour)),
sanctions: []policy.SanctionRecord{
activeSanction(userID, policy.SanctionCodeLoginBlock, "auth", now.Add(-time.Hour)),
activeSanction(userID, policy.SanctionCodeGameJoinBlock, "lobby", now.Add(-30*time.Minute)),
},
wantSanctions: []string{"game_join_block", "login_block"},
wantMarkers: EligibilityMarkersView{
CanLogin: false,
CanCreatePrivateGame: false,
CanManagePrivateGame: false,
CanJoinGame: false,
CanUpdateProfile: false,
},
wantLimits: []EffectiveLimitView{
{LimitCode: "max_owned_private_games", Value: 3},
{LimitCode: "max_pending_public_applications", Value: 10},
{LimitCode: "max_active_game_memberships", Value: 10},
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
service, err := NewSnapshotReader(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
fakeEntitlementReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: tt.snapshot}},
fakeSanctionStore{byUserID: map[common.UserID][]policy.SanctionRecord{userID: tt.sanctions}},
fakeLimitStore{byUserID: map[common.UserID][]policy.LimitRecord{userID: tt.limits}},
fixedClock{now: now},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), GetUserEligibilityInput{UserID: userID.String()})
require.NoError(t, err)
require.Equal(t, tt.wantMarkers, result.Markers)
require.Equal(t, tt.wantLimits, result.EffectiveLimits)
gotSanctions := make([]string, 0, len(result.ActiveSanctions))
for _, sanction := range result.ActiveSanctions {
gotSanctions = append(gotSanctions, sanction.SanctionCode)
}
require.Equal(t, tt.wantSanctions, gotSanctions)
})
}
}
func TestSnapshotReaderExecuteRepairsExpiredPaidSnapshotWithStore(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
store := newRedisStore(t)
userID := common.UserID("user-123")
accountRecord := validAccountRecord()
require.NoError(t, store.Accounts().Create(context.Background(), ports.CreateAccountInput{
Account: accountRecord,
Reservation: account.RaceNameReservation{
CanonicalKey: account.RaceNameCanonicalKey("pilot nova"),
UserID: userID,
RaceName: accountRecord.RaceName,
ReservedAt: accountRecord.UpdatedAt,
},
}))
expiredEndsAt := now.Add(-time.Minute)
require.NoError(t, store.EntitlementSnapshots().Put(context.Background(), entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodePaidMonthly,
IsPaid: true,
StartsAt: now.Add(-30 * 24 * time.Hour),
EndsAt: timePointer(expiredEndsAt),
Source: common.Source("billing"),
Actor: common.ActorRef{Type: common.ActorType("billing"), ID: common.ActorID("invoice-1")},
ReasonCode: common.ReasonCode("renewal"),
UpdatedAt: now.Add(-2 * time.Hour),
}))
entitlementReader, err := entitlementsvc.NewReader(
store.EntitlementSnapshots(),
store.EntitlementLifecycle(),
fixedClock{now: now},
fixedIDGenerator{entitlementRecordID: entitlement.EntitlementRecordID("entitlement-expiry-repair")},
)
require.NoError(t, err)
service, err := NewSnapshotReader(
store.Accounts(),
entitlementReader,
store.Sanctions(),
store.Limits(),
fixedClock{now: now},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), GetUserEligibilityInput{UserID: userID.String()})
require.NoError(t, err)
require.True(t, result.Exists)
require.NotNil(t, result.Entitlement)
require.Equal(t, "free", result.Entitlement.PlanCode)
require.False(t, result.Entitlement.IsPaid)
require.Equal(t, expiredEndsAt, result.Entitlement.StartsAt)
require.Equal(t, []EffectiveLimitView{
{LimitCode: "max_pending_public_applications", Value: 3},
{LimitCode: "max_active_game_memberships", Value: 3},
}, result.EffectiveLimits)
storedSnapshot, err := store.EntitlementSnapshots().GetByUserID(context.Background(), userID)
require.NoError(t, err)
require.Equal(t, entitlement.PlanCodeFree, storedSnapshot.PlanCode)
require.False(t, storedSnapshot.IsPaid)
}
type fakeAccountStore struct {
existsByUserID map[common.UserID]bool
err error
}
func (store fakeAccountStore) Create(context.Context, ports.CreateAccountInput) error {
return nil
}
func (store fakeAccountStore) GetByUserID(context.Context, common.UserID) (account.UserAccount, error) {
return account.UserAccount{}, ports.ErrNotFound
}
func (store fakeAccountStore) GetByEmail(context.Context, common.Email) (account.UserAccount, error) {
return account.UserAccount{}, ports.ErrNotFound
}
func (store fakeAccountStore) GetByRaceName(context.Context, common.RaceName) (account.UserAccount, error) {
return account.UserAccount{}, ports.ErrNotFound
}
func (store fakeAccountStore) ExistsByUserID(_ context.Context, userID common.UserID) (bool, error) {
if store.err != nil {
return false, store.err
}
return store.existsByUserID[userID], nil
}
func (store fakeAccountStore) RenameRaceName(context.Context, ports.RenameRaceNameInput) error {
return nil
}
func (store fakeAccountStore) Update(context.Context, account.UserAccount) error {
return nil
}
type fakeEntitlementReader struct {
byUserID map[common.UserID]entitlement.CurrentSnapshot
err error
}
func (reader fakeEntitlementReader) GetByUserID(_ context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error) {
if reader.err != nil {
return entitlement.CurrentSnapshot{}, reader.err
}
record, ok := reader.byUserID[userID]
if !ok {
return entitlement.CurrentSnapshot{}, ports.ErrNotFound
}
return record, nil
}
type fakeSanctionStore struct {
byUserID map[common.UserID][]policy.SanctionRecord
err error
}
func (store fakeSanctionStore) Create(context.Context, policy.SanctionRecord) error {
return nil
}
func (store fakeSanctionStore) GetByRecordID(context.Context, policy.SanctionRecordID) (policy.SanctionRecord, error) {
return policy.SanctionRecord{}, ports.ErrNotFound
}
func (store fakeSanctionStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.SanctionRecord, error) {
if store.err != nil {
return nil, store.err
}
records := store.byUserID[userID]
cloned := make([]policy.SanctionRecord, len(records))
copy(cloned, records)
return cloned, nil
}
func (store fakeSanctionStore) Update(context.Context, policy.SanctionRecord) error {
return nil
}
type fakeLimitStore struct {
byUserID map[common.UserID][]policy.LimitRecord
err error
}
func (store fakeLimitStore) Create(context.Context, policy.LimitRecord) error {
return nil
}
func (store fakeLimitStore) GetByRecordID(context.Context, policy.LimitRecordID) (policy.LimitRecord, error) {
return policy.LimitRecord{}, ports.ErrNotFound
}
func (store fakeLimitStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.LimitRecord, error) {
if store.err != nil {
return nil, store.err
}
records := store.byUserID[userID]
cloned := make([]policy.LimitRecord, len(records))
copy(cloned, records)
return cloned, nil
}
func (store fakeLimitStore) Update(context.Context, policy.LimitRecord) error {
return nil
}
type fixedClock struct {
now time.Time
}
func (clock fixedClock) Now() time.Time {
return clock.now
}
type fixedIDGenerator struct {
entitlementRecordID entitlement.EntitlementRecordID
}
func (generator fixedIDGenerator) NewUserID() (common.UserID, error) {
return "", nil
}
func (generator fixedIDGenerator) NewInitialRaceName() (common.RaceName, error) {
return "", nil
}
func (generator fixedIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
return generator.entitlementRecordID, nil
}
func (generator fixedIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
return "", nil
}
func (generator fixedIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
return "", nil
}
func activeSanction(
userID common.UserID,
code policy.SanctionCode,
scope string,
appliedAt time.Time,
) policy.SanctionRecord {
return policy.SanctionRecord{
RecordID: policy.SanctionRecordID("sanction-" + string(code)),
UserID: userID,
SanctionCode: code,
Scope: common.Scope(scope),
ReasonCode: common.ReasonCode("manual_block"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: appliedAt.UTC(),
}
}
func expiredSanction(
userID common.UserID,
code policy.SanctionCode,
scope string,
appliedAt time.Time,
) policy.SanctionRecord {
record := activeSanction(userID, code, scope, appliedAt)
expiresAt := appliedAt.Add(30 * time.Minute)
record.ExpiresAt = &expiresAt
return record
}
func activeLimit(
userID common.UserID,
code policy.LimitCode,
value int,
appliedAt time.Time,
) policy.LimitRecord {
return policy.LimitRecord{
RecordID: policy.LimitRecordID("limit-" + string(code)),
UserID: userID,
LimitCode: code,
Value: value,
ReasonCode: common.ReasonCode("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: appliedAt.UTC(),
}
}
func removedLimit(
userID common.UserID,
code policy.LimitCode,
value int,
appliedAt time.Time,
) policy.LimitRecord {
record := activeLimit(userID, code, value, appliedAt)
removedAt := appliedAt.Add(15 * time.Minute)
record.RemovedAt = &removedAt
record.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")}
record.RemovedReasonCode = common.ReasonCode("manual_remove")
return record
}
func paidEntitlementSnapshot(
userID common.UserID,
startsAt time.Time,
endsAt time.Time,
) entitlement.CurrentSnapshot {
return entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodePaidMonthly,
IsPaid: true,
StartsAt: startsAt.UTC(),
EndsAt: timePointer(endsAt),
Source: common.Source("billing"),
Actor: common.ActorRef{Type: common.ActorType("billing"), ID: common.ActorID("invoice-1")},
ReasonCode: common.ReasonCode("renewal"),
UpdatedAt: startsAt.UTC(),
}
}
func freeEntitlementSnapshot(userID common.UserID, startsAt time.Time) entitlement.CurrentSnapshot {
return entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
IsPaid: false,
StartsAt: startsAt.UTC(),
Source: common.Source("auth_registration"),
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
ReasonCode: common.ReasonCode("initial_free_entitlement"),
UpdatedAt: startsAt.UTC(),
}
}
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 newRedisStore(t *testing.T) *userstore.Store {
t.Helper()
server := miniredis.RunT(t)
store, err := userstore.New(userstore.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 timePointer(value time.Time) *time.Time {
utcValue := value.UTC()
return &utcValue
}
var _ ports.UserAccountStore = fakeAccountStore{}
var _ ports.SanctionStore = fakeSanctionStore{}
var _ ports.LimitStore = fakeLimitStore{}
var _ ports.Clock = fixedClock{}
var _ ports.IDGenerator = fixedIDGenerator{}
@@ -0,0 +1,178 @@
package policysvc
import (
"context"
"testing"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"github.com/stretchr/testify/require"
)
func TestApplySanctionServiceExecutePublishesEvent(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
sanctionStore := newFakeSanctionStore()
limitStore := newFakeLimitStore()
publisher := &recordingPolicyPublisher{}
service, err := NewApplySanctionServiceWithObservability(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
sanctionStore,
limitStore,
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
fixedClock{now: now},
fixedIDGenerator{sanctionRecordID: policy.SanctionRecordID("sanction-1")},
nil,
nil,
publisher,
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), ApplySanctionInput{
UserID: userID.String(),
SanctionCode: string(policy.SanctionCodeLoginBlock),
Scope: "auth",
ReasonCode: "policy_blocked",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
AppliedAt: now.Add(-time.Minute).Format(time.RFC3339Nano),
ExpiresAt: now.Add(time.Hour).Format(time.RFC3339Nano),
})
require.NoError(t, err)
require.Len(t, publisher.sanctionEvents, 1)
require.Equal(t, ports.SanctionChangedOperationApplied, publisher.sanctionEvents[0].Operation)
require.Equal(t, common.Source("admin_internal_api"), publisher.sanctionEvents[0].Source)
}
func TestRemoveSanctionServiceExecuteMissingDoesNotPublishEvent(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
sanctionStore := newFakeSanctionStore()
limitStore := newFakeLimitStore()
publisher := &recordingPolicyPublisher{}
service, err := NewRemoveSanctionServiceWithObservability(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
sanctionStore,
limitStore,
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
fixedClock{now: now},
fixedIDGenerator{},
nil,
nil,
publisher,
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), RemoveSanctionInput{
UserID: userID.String(),
SanctionCode: string(policy.SanctionCodeLoginBlock),
ReasonCode: "manual_remove",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
})
require.NoError(t, err)
require.Empty(t, publisher.sanctionEvents)
}
func TestSetLimitServiceExecutePublishesEvent(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
sanctionStore := newFakeSanctionStore()
limitStore := newFakeLimitStore()
publisher := &recordingPolicyPublisher{}
service, err := NewSetLimitServiceWithObservability(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
sanctionStore,
limitStore,
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
fixedClock{now: now},
fixedIDGenerator{limitRecordID: policy.LimitRecordID("limit-1")},
nil,
nil,
publisher,
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), SetLimitInput{
UserID: userID.String(),
LimitCode: string(policy.LimitCodeMaxOwnedPrivateGames),
Value: 5,
ReasonCode: "manual_override",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
AppliedAt: now.Add(-time.Minute).Format(time.RFC3339Nano),
ExpiresAt: now.Add(time.Hour).Format(time.RFC3339Nano),
})
require.NoError(t, err)
require.Len(t, publisher.limitEvents, 1)
require.Equal(t, ports.LimitChangedOperationSet, publisher.limitEvents[0].Operation)
require.NotNil(t, publisher.limitEvents[0].Value)
require.Equal(t, 5, *publisher.limitEvents[0].Value)
}
func TestRemoveLimitServiceExecuteMissingDoesNotPublishEvent(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
sanctionStore := newFakeSanctionStore()
limitStore := newFakeLimitStore()
publisher := &recordingPolicyPublisher{}
service, err := NewRemoveLimitServiceWithObservability(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
sanctionStore,
limitStore,
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
fixedClock{now: now},
fixedIDGenerator{},
nil,
nil,
publisher,
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), RemoveLimitInput{
UserID: userID.String(),
LimitCode: string(policy.LimitCodeMaxOwnedPrivateGames),
ReasonCode: "manual_remove",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
})
require.NoError(t, err)
require.Empty(t, publisher.limitEvents)
}
type recordingPolicyPublisher struct {
sanctionEvents []ports.SanctionChangedEvent
limitEvents []ports.LimitChangedEvent
}
func (publisher *recordingPolicyPublisher) PublishSanctionChanged(_ context.Context, event ports.SanctionChangedEvent) error {
if err := event.Validate(); err != nil {
return err
}
publisher.sanctionEvents = append(publisher.sanctionEvents, event)
return nil
}
func (publisher *recordingPolicyPublisher) PublishLimitChanged(_ context.Context, event ports.LimitChangedEvent) error {
if err := event.Validate(); err != nil {
return err
}
publisher.limitEvents = append(publisher.limitEvents, event)
return nil
}
var (
_ ports.SanctionChangedPublisher = (*recordingPolicyPublisher)(nil)
_ ports.LimitChangedPublisher = (*recordingPolicyPublisher)(nil)
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,705 @@
package policysvc
import (
"context"
"testing"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/shared"
"github.com/stretchr/testify/require"
)
func TestApplySanctionServiceExecuteBuildsActiveRecord(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
sanctionStore := newFakeSanctionStore()
limitStore := newFakeLimitStore()
service, err := NewApplySanctionService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
sanctionStore,
limitStore,
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
fixedClock{now: now},
fixedIDGenerator{sanctionRecordID: policy.SanctionRecordID("sanction-1")},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), ApplySanctionInput{
UserID: userID.String(),
SanctionCode: string(policy.SanctionCodeLoginBlock),
Scope: "auth",
ReasonCode: "policy_blocked",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
AppliedAt: now.Add(-time.Minute).Format(time.RFC3339Nano),
ExpiresAt: now.Add(time.Hour).Format(time.RFC3339Nano),
})
require.NoError(t, err)
require.Equal(t, userID.String(), result.UserID)
require.Len(t, result.ActiveSanctions, 1)
require.Equal(t, string(policy.SanctionCodeLoginBlock), result.ActiveSanctions[0].SanctionCode)
records, err := sanctionStore.ListByUserID(context.Background(), userID)
require.NoError(t, err)
require.Len(t, records, 1)
require.Equal(t, policy.SanctionRecordID("sanction-1"), records[0].RecordID)
}
func TestApplySanctionServiceExecuteRejectsExpiredSanction(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
sanctionStore := newFakeSanctionStore()
limitStore := newFakeLimitStore()
service, err := NewApplySanctionService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
sanctionStore,
limitStore,
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
fixedClock{now: now},
fixedIDGenerator{sanctionRecordID: policy.SanctionRecordID("sanction-1")},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), ApplySanctionInput{
UserID: userID.String(),
SanctionCode: string(policy.SanctionCodeLoginBlock),
Scope: "auth",
ReasonCode: "policy_blocked",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
AppliedAt: now.Add(-2 * time.Hour).Format(time.RFC3339Nano),
ExpiresAt: now.Add(-time.Minute).Format(time.RFC3339Nano),
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
}
func TestApplySanctionServiceExecuteReturnsConflictWhenActiveSanctionExists(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
sanctionStore := newFakeSanctionStore()
existing := policy.SanctionRecord{
RecordID: policy.SanctionRecordID("sanction-existing"),
UserID: userID,
SanctionCode: policy.SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("policy_blocked"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(-time.Hour),
}
require.NoError(t, sanctionStore.Create(context.Background(), existing))
service, err := NewApplySanctionService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
sanctionStore,
newFakeLimitStore(),
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: newFakeLimitStore()},
fixedClock{now: now},
fixedIDGenerator{sanctionRecordID: policy.SanctionRecordID("sanction-1")},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), ApplySanctionInput{
UserID: userID.String(),
SanctionCode: string(policy.SanctionCodeLoginBlock),
Scope: "auth",
ReasonCode: "policy_blocked",
Actor: ActorInput{Type: "admin", ID: "admin-2"},
AppliedAt: now.Add(-time.Minute).Format(time.RFC3339Nano),
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeConflict, shared.CodeOf(err))
}
func TestApplySanctionServiceExecuteReturnsNotFoundForUnknownUser(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
service, err := NewApplySanctionService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{}},
newFakeSanctionStore(),
newFakeLimitStore(),
&fakePolicyLifecycleStore{sanctions: newFakeSanctionStore(), limits: newFakeLimitStore()},
fixedClock{now: now},
fixedIDGenerator{sanctionRecordID: policy.SanctionRecordID("sanction-1")},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), ApplySanctionInput{
UserID: "user-missing",
SanctionCode: string(policy.SanctionCodeLoginBlock),
Scope: "auth",
ReasonCode: "policy_blocked",
Actor: ActorInput{Type: "admin"},
AppliedAt: now.Format(time.RFC3339Nano),
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err))
}
func TestRemoveSanctionServiceExecuteIsIdempotentWhenMissing(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
sanctionStore := newFakeSanctionStore()
limitStore := newFakeLimitStore()
service, err := NewRemoveSanctionService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
sanctionStore,
limitStore,
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
fixedClock{now: now},
fixedIDGenerator{},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), RemoveSanctionInput{
UserID: userID.String(),
SanctionCode: string(policy.SanctionCodeLoginBlock),
ReasonCode: "manual_remove",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
})
require.NoError(t, err)
require.Equal(t, userID.String(), result.UserID)
require.Empty(t, result.ActiveSanctions)
}
func TestRemoveSanctionServiceExecuteTreatsConcurrentRemovalAsSuccess(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
sanctionStore := newFakeSanctionStore()
limitStore := newFakeLimitStore()
record := policy.SanctionRecord{
RecordID: policy.SanctionRecordID("sanction-1"),
UserID: userID,
SanctionCode: policy.SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("policy_blocked"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(-time.Hour),
}
require.NoError(t, sanctionStore.Create(context.Background(), record))
lifecycle := &fakePolicyLifecycleStore{
sanctions: sanctionStore,
limits: limitStore,
removeSanctionHook: func(input ports.RemoveSanctionInput) error {
updated := input.ExpectedActiveRecord
removedAt := now.Add(-time.Minute)
updated.RemovedAt = &removedAt
updated.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")}
updated.RemovedReasonCode = common.ReasonCode("manual_remove")
if err := sanctionStore.Update(context.Background(), updated); err != nil {
return err
}
return ports.ErrConflict
},
}
service, err := NewRemoveSanctionService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
sanctionStore,
limitStore,
lifecycle,
fixedClock{now: now},
fixedIDGenerator{},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), RemoveSanctionInput{
UserID: userID.String(),
SanctionCode: string(policy.SanctionCodeLoginBlock),
ReasonCode: "manual_remove",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
})
require.NoError(t, err)
require.Empty(t, result.ActiveSanctions)
}
func TestSetLimitServiceExecuteReplacesActiveLimit(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
sanctionStore := newFakeSanctionStore()
limitStore := newFakeLimitStore()
current := policy.LimitRecord{
RecordID: policy.LimitRecordID("limit-existing"),
UserID: userID,
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
Value: 3,
ReasonCode: common.ReasonCode("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(-time.Hour),
}
require.NoError(t, limitStore.Create(context.Background(), current))
service, err := NewSetLimitService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
sanctionStore,
limitStore,
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
fixedClock{now: now},
fixedIDGenerator{limitRecordID: policy.LimitRecordID("limit-new")},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), SetLimitInput{
UserID: userID.String(),
LimitCode: string(policy.LimitCodeMaxOwnedPrivateGames),
Value: 5,
ReasonCode: "manual_override",
Actor: ActorInput{Type: "admin", ID: "admin-2"},
AppliedAt: now.Format(time.RFC3339Nano),
})
require.NoError(t, err)
require.Len(t, result.ActiveLimits, 1)
require.Equal(t, 5, result.ActiveLimits[0].Value)
storedCurrent, err := limitStore.GetByRecordID(context.Background(), current.RecordID)
require.NoError(t, err)
require.NotNil(t, storedCurrent.RemovedAt)
require.True(t, storedCurrent.RemovedAt.Equal(now))
}
func TestSetLimitServiceExecuteRejectsRetroactiveReplacement(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
limitStore := newFakeLimitStore()
current := policy.LimitRecord{
RecordID: policy.LimitRecordID("limit-existing"),
UserID: userID,
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
Value: 3,
ReasonCode: common.ReasonCode("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(-time.Hour),
}
require.NoError(t, limitStore.Create(context.Background(), current))
service, err := NewSetLimitService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
newFakeSanctionStore(),
limitStore,
&fakePolicyLifecycleStore{sanctions: newFakeSanctionStore(), limits: limitStore},
fixedClock{now: now},
fixedIDGenerator{limitRecordID: policy.LimitRecordID("limit-new")},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), SetLimitInput{
UserID: userID.String(),
LimitCode: string(policy.LimitCodeMaxOwnedPrivateGames),
Value: 5,
ReasonCode: "manual_override",
Actor: ActorInput{Type: "admin", ID: "admin-2"},
AppliedAt: now.Add(-2 * time.Hour).Format(time.RFC3339Nano),
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
}
func TestSetLimitServiceExecuteRejectsRetiredLimitCodes(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
tests := []string{
string(policy.LimitCodeMaxActivePrivateGames),
string(policy.LimitCodeMaxPendingPrivateJoinRequests),
string(policy.LimitCodeMaxPendingPrivateInvitesSent),
}
for _, limitCode := range tests {
limitCode := limitCode
t.Run(limitCode, func(t *testing.T) {
t.Parallel()
service, err := NewSetLimitService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
newFakeSanctionStore(),
newFakeLimitStore(),
&fakePolicyLifecycleStore{sanctions: newFakeSanctionStore(), limits: newFakeLimitStore()},
fixedClock{now: now},
fixedIDGenerator{limitRecordID: policy.LimitRecordID("limit-new")},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), SetLimitInput{
UserID: userID.String(),
LimitCode: limitCode,
Value: 5,
ReasonCode: "manual_override",
Actor: ActorInput{Type: "admin", ID: "admin-2"},
AppliedAt: now.Format(time.RFC3339Nano),
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
})
}
}
func TestSetLimitServiceExecuteIgnoresRetiredRecordsDuringReload(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
limitStore := newFakeLimitStore()
require.NoError(t, limitStore.Create(context.Background(), policy.LimitRecord{
RecordID: policy.LimitRecordID("limit-legacy"),
UserID: userID,
LimitCode: policy.LimitCodeMaxActivePrivateGames,
Value: 9,
ReasonCode: common.ReasonCode("legacy_override"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(-time.Hour),
}))
service, err := NewSetLimitService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
newFakeSanctionStore(),
limitStore,
&fakePolicyLifecycleStore{sanctions: newFakeSanctionStore(), limits: limitStore},
fixedClock{now: now},
fixedIDGenerator{limitRecordID: policy.LimitRecordID("limit-new")},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), SetLimitInput{
UserID: userID.String(),
LimitCode: string(policy.LimitCodeMaxOwnedPrivateGames),
Value: 5,
ReasonCode: "manual_override",
Actor: ActorInput{Type: "admin", ID: "admin-2"},
AppliedAt: now.Format(time.RFC3339Nano),
})
require.NoError(t, err)
require.Len(t, result.ActiveLimits, 1)
require.Equal(t, string(policy.LimitCodeMaxOwnedPrivateGames), result.ActiveLimits[0].LimitCode)
}
func TestRemoveLimitServiceExecuteIsIdempotentWhenMissing(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
sanctionStore := newFakeSanctionStore()
limitStore := newFakeLimitStore()
service, err := NewRemoveLimitService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
sanctionStore,
limitStore,
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
fixedClock{now: now},
fixedIDGenerator{},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), RemoveLimitInput{
UserID: userID.String(),
LimitCode: string(policy.LimitCodeMaxOwnedPrivateGames),
ReasonCode: "manual_remove",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
})
require.NoError(t, err)
require.Empty(t, result.ActiveLimits)
}
func TestRemoveLimitServiceExecuteRejectsRetiredLimitCode(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
service, err := NewRemoveLimitService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
newFakeSanctionStore(),
newFakeLimitStore(),
&fakePolicyLifecycleStore{sanctions: newFakeSanctionStore(), limits: newFakeLimitStore()},
fixedClock{now: now},
fixedIDGenerator{},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), RemoveLimitInput{
UserID: userID.String(),
LimitCode: string(policy.LimitCodeMaxPendingPrivateJoinRequests),
ReasonCode: "manual_remove",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
}
type fakeAccountStore struct {
existsByUserID map[common.UserID]bool
}
func (store fakeAccountStore) Create(context.Context, ports.CreateAccountInput) error {
return nil
}
func (store fakeAccountStore) GetByUserID(context.Context, common.UserID) (account.UserAccount, error) {
return account.UserAccount{}, ports.ErrNotFound
}
func (store fakeAccountStore) GetByEmail(context.Context, common.Email) (account.UserAccount, error) {
return account.UserAccount{}, ports.ErrNotFound
}
func (store fakeAccountStore) GetByRaceName(context.Context, common.RaceName) (account.UserAccount, error) {
return account.UserAccount{}, ports.ErrNotFound
}
func (store fakeAccountStore) ExistsByUserID(_ context.Context, userID common.UserID) (bool, error) {
return store.existsByUserID[userID], nil
}
func (store fakeAccountStore) RenameRaceName(context.Context, ports.RenameRaceNameInput) error {
return nil
}
func (store fakeAccountStore) Update(context.Context, account.UserAccount) error {
return nil
}
type fakeSanctionStore struct {
byUserID map[common.UserID][]policy.SanctionRecord
byRecordID map[policy.SanctionRecordID]policy.SanctionRecord
}
func newFakeSanctionStore() *fakeSanctionStore {
return &fakeSanctionStore{
byUserID: make(map[common.UserID][]policy.SanctionRecord),
byRecordID: make(map[policy.SanctionRecordID]policy.SanctionRecord),
}
}
func (store *fakeSanctionStore) Create(_ context.Context, record policy.SanctionRecord) error {
if err := record.Validate(); err != nil {
return err
}
if _, exists := store.byRecordID[record.RecordID]; exists {
return ports.ErrConflict
}
store.byRecordID[record.RecordID] = record
store.byUserID[record.UserID] = append(store.byUserID[record.UserID], record)
return nil
}
func (store *fakeSanctionStore) GetByRecordID(_ context.Context, recordID policy.SanctionRecordID) (policy.SanctionRecord, error) {
record, ok := store.byRecordID[recordID]
if !ok {
return policy.SanctionRecord{}, ports.ErrNotFound
}
return record, nil
}
func (store *fakeSanctionStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.SanctionRecord, error) {
records := store.byUserID[userID]
cloned := make([]policy.SanctionRecord, len(records))
copy(cloned, records)
return cloned, nil
}
func (store *fakeSanctionStore) Update(_ context.Context, record policy.SanctionRecord) error {
if err := record.Validate(); err != nil {
return err
}
if _, exists := store.byRecordID[record.RecordID]; !exists {
return ports.ErrNotFound
}
store.byRecordID[record.RecordID] = record
records := store.byUserID[record.UserID]
for index := range records {
if records[index].RecordID == record.RecordID {
records[index] = record
store.byUserID[record.UserID] = records
return nil
}
}
return ports.ErrNotFound
}
type fakeLimitStore struct {
byUserID map[common.UserID][]policy.LimitRecord
byRecordID map[policy.LimitRecordID]policy.LimitRecord
}
func newFakeLimitStore() *fakeLimitStore {
return &fakeLimitStore{
byUserID: make(map[common.UserID][]policy.LimitRecord),
byRecordID: make(map[policy.LimitRecordID]policy.LimitRecord),
}
}
func (store *fakeLimitStore) Create(_ context.Context, record policy.LimitRecord) error {
if err := record.Validate(); err != nil {
return err
}
if _, exists := store.byRecordID[record.RecordID]; exists {
return ports.ErrConflict
}
store.byRecordID[record.RecordID] = record
store.byUserID[record.UserID] = append(store.byUserID[record.UserID], record)
return nil
}
func (store *fakeLimitStore) GetByRecordID(_ context.Context, recordID policy.LimitRecordID) (policy.LimitRecord, error) {
record, ok := store.byRecordID[recordID]
if !ok {
return policy.LimitRecord{}, ports.ErrNotFound
}
return record, nil
}
func (store *fakeLimitStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.LimitRecord, error) {
records := store.byUserID[userID]
cloned := make([]policy.LimitRecord, len(records))
copy(cloned, records)
return cloned, nil
}
func (store *fakeLimitStore) Update(_ context.Context, record policy.LimitRecord) error {
if err := record.Validate(); err != nil {
return err
}
if _, exists := store.byRecordID[record.RecordID]; !exists {
return ports.ErrNotFound
}
store.byRecordID[record.RecordID] = record
records := store.byUserID[record.UserID]
for index := range records {
if records[index].RecordID == record.RecordID {
records[index] = record
store.byUserID[record.UserID] = records
return nil
}
}
return ports.ErrNotFound
}
type fakePolicyLifecycleStore struct {
sanctions *fakeSanctionStore
limits *fakeLimitStore
applySanctionHook func(input ports.ApplySanctionInput) error
removeSanctionHook func(input ports.RemoveSanctionInput) error
setLimitHook func(input ports.SetLimitInput) error
removeLimitHook func(input ports.RemoveLimitInput) error
}
func (store *fakePolicyLifecycleStore) ApplySanction(ctx context.Context, input ports.ApplySanctionInput) error {
if store.applySanctionHook != nil {
return store.applySanctionHook(input)
}
records, err := store.sanctions.ListByUserID(ctx, input.NewRecord.UserID)
if err != nil {
return err
}
active, err := policy.ActiveSanctionsAt(records, input.NewRecord.AppliedAt)
if err != nil {
return err
}
for _, record := range active {
if record.SanctionCode == input.NewRecord.SanctionCode {
return ports.ErrConflict
}
}
return store.sanctions.Create(ctx, input.NewRecord)
}
func (store *fakePolicyLifecycleStore) RemoveSanction(ctx context.Context, input ports.RemoveSanctionInput) error {
if store.removeSanctionHook != nil {
return store.removeSanctionHook(input)
}
return store.sanctions.Update(ctx, input.UpdatedRecord)
}
func (store *fakePolicyLifecycleStore) SetLimit(ctx context.Context, input ports.SetLimitInput) error {
if store.setLimitHook != nil {
return store.setLimitHook(input)
}
if input.ExpectedActiveRecord != nil {
if err := store.limits.Update(ctx, *input.UpdatedActiveRecord); err != nil {
return err
}
}
return store.limits.Create(ctx, input.NewRecord)
}
func (store *fakePolicyLifecycleStore) RemoveLimit(ctx context.Context, input ports.RemoveLimitInput) error {
if store.removeLimitHook != nil {
return store.removeLimitHook(input)
}
return store.limits.Update(ctx, input.UpdatedRecord)
}
type fixedClock struct {
now time.Time
}
func (clock fixedClock) Now() time.Time {
return clock.now
}
type fixedIDGenerator struct {
sanctionRecordID policy.SanctionRecordID
limitRecordID policy.LimitRecordID
}
func (generator fixedIDGenerator) NewUserID() (common.UserID, error) {
return "", nil
}
func (generator fixedIDGenerator) NewInitialRaceName() (common.RaceName, error) {
return "", nil
}
func (generator fixedIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
return "", nil
}
func (generator fixedIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
return generator.sanctionRecordID, nil
}
func (generator fixedIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
return generator.limitRecordID, nil
}
var (
_ ports.UserAccountStore = fakeAccountStore{}
_ ports.SanctionStore = (*fakeSanctionStore)(nil)
_ ports.LimitStore = (*fakeLimitStore)(nil)
_ ports.PolicyLifecycleStore = (*fakePolicyLifecycleStore)(nil)
_ ports.Clock = fixedClock{}
_ ports.IDGenerator = fixedIDGenerator{}
)
@@ -0,0 +1,159 @@
package selfservice
import (
"context"
"errors"
"testing"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/ports"
"github.com/stretchr/testify/require"
)
func TestProfileUpdaterExecutePublishesProfileChangedEvent(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
accountStore := newFakeAccountStore(validUserAccount())
publisher := &recordingSelfServicePublisher{}
service, err := NewProfileUpdaterWithObservability(
accountStore,
&fakeEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
},
},
fakeSanctionStore{},
fakeLimitStore{},
fixedClock{now: now},
stubRaceNamePolicy{},
nil,
nil,
publisher,
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), UpdateMyProfileInput{
UserID: "user-123",
RaceName: "Nova Prime",
})
require.NoError(t, err)
require.Equal(t, "Nova Prime", result.Account.RaceName)
require.Len(t, publisher.profileEvents, 1)
require.Equal(t, ports.ProfileChangedOperationUpdated, publisher.profileEvents[0].Operation)
require.Equal(t, common.Source("gateway_self_service"), publisher.profileEvents[0].Source)
require.Equal(t, common.RaceName("Nova Prime"), publisher.profileEvents[0].RaceName)
}
func TestProfileUpdaterExecutePublisherFailureDoesNotRollbackCommit(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
accountStore := newFakeAccountStore(validUserAccount())
publisher := &recordingSelfServicePublisher{profileErr: errors.New("publisher unavailable")}
service, err := NewProfileUpdaterWithObservability(
accountStore,
&fakeEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
},
},
fakeSanctionStore{},
fakeLimitStore{},
fixedClock{now: now},
stubRaceNamePolicy{},
nil,
nil,
publisher,
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), UpdateMyProfileInput{
UserID: "user-123",
RaceName: "Nova Prime",
})
require.NoError(t, err)
require.Equal(t, "Nova Prime", result.Account.RaceName)
require.Len(t, publisher.profileEvents, 1)
storedAccount, err := accountStore.GetByUserID(context.Background(), common.UserID("user-123"))
require.NoError(t, err)
require.Equal(t, common.RaceName("Nova Prime"), storedAccount.RaceName)
}
func TestSettingsUpdaterExecuteNoOpDoesNotPublishEvent(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
accountStore := newFakeAccountStore(account.UserAccount{
UserID: common.UserID("user-123"),
Email: common.Email("pilot@example.com"),
RaceName: common.RaceName("Pilot Nova"),
PreferredLanguage: common.LanguageTag("en-US"),
TimeZone: common.TimeZoneName("UTC"),
DeclaredCountry: common.CountryCode("DE"),
CreatedAt: time.Unix(1_775_240_000, 0).UTC(),
UpdatedAt: time.Unix(1_775_240_100, 0).UTC(),
})
publisher := &recordingSelfServicePublisher{}
service, err := NewSettingsUpdaterWithObservability(
accountStore,
&fakeEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
},
},
fakeSanctionStore{},
fakeLimitStore{},
fixedClock{now: now},
nil,
nil,
publisher,
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), UpdateMySettingsInput{
UserID: "user-123",
PreferredLanguage: "en-us",
TimeZone: " UTC ",
})
require.NoError(t, err)
require.Equal(t, "en-US", result.Account.PreferredLanguage)
require.Equal(t, "UTC", result.Account.TimeZone)
require.Empty(t, publisher.settingsEvents)
}
type recordingSelfServicePublisher struct {
profileErr error
settingsErr error
profileEvents []ports.ProfileChangedEvent
settingsEvents []ports.SettingsChangedEvent
}
func (publisher *recordingSelfServicePublisher) PublishProfileChanged(_ context.Context, event ports.ProfileChangedEvent) error {
if err := event.Validate(); err != nil {
return err
}
publisher.profileEvents = append(publisher.profileEvents, event)
return publisher.profileErr
}
func (publisher *recordingSelfServicePublisher) PublishSettingsChanged(_ context.Context, event ports.SettingsChangedEvent) error {
if err := event.Validate(); err != nil {
return err
}
publisher.settingsEvents = append(publisher.settingsEvents, event)
return publisher.settingsErr
}
var (
_ ports.ProfileChangedPublisher = (*recordingSelfServicePublisher)(nil)
_ ports.SettingsChangedPublisher = (*recordingSelfServicePublisher)(nil)
)
@@ -0,0 +1,467 @@
// Package selfservice implements the authenticated self-service account read
// and mutation use cases owned by User Service.
package selfservice
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/accountview"
"galaxy/user/internal/service/shared"
"galaxy/user/internal/telemetry"
)
const gatewaySelfServiceSource = common.Source("gateway_self_service")
// ActorRefView stores transport-ready audit actor metadata.
type ActorRefView = accountview.ActorRefView
// EntitlementSnapshotView stores the transport-ready current entitlement
// snapshot of one account.
type EntitlementSnapshotView = accountview.EntitlementSnapshotView
// ActiveSanctionView stores one transport-ready active sanction.
type ActiveSanctionView = accountview.ActiveSanctionView
// ActiveLimitView stores one transport-ready active user-specific limit.
type ActiveLimitView = accountview.ActiveLimitView
// AccountView stores the transport-ready authenticated self-service account
// aggregate.
type AccountView = accountview.AccountView
// GetMyAccountInput stores one authenticated account-read request.
type GetMyAccountInput struct {
// UserID stores the authenticated regular-user identifier.
UserID string
}
// GetMyAccountResult stores one authenticated account-read result.
type GetMyAccountResult struct {
// Account stores the read-optimized current account aggregate.
Account AccountView `json:"account"`
}
// UpdateMyProfileInput stores one self-service profile mutation request.
type UpdateMyProfileInput struct {
// UserID stores the authenticated regular-user identifier.
UserID string
// RaceName stores the requested exact replacement race name.
RaceName string
}
// UpdateMyProfileResult stores one self-service profile mutation result.
type UpdateMyProfileResult struct {
// Account stores the refreshed account aggregate after the mutation.
Account AccountView `json:"account"`
}
// UpdateMySettingsInput stores one self-service settings mutation request.
type UpdateMySettingsInput struct {
// UserID stores the authenticated regular-user identifier.
UserID string
// PreferredLanguage stores the requested BCP 47 preferred language.
PreferredLanguage string
// TimeZone stores the requested IANA time-zone name.
TimeZone string
}
// UpdateMySettingsResult stores one self-service settings mutation result.
type UpdateMySettingsResult struct {
// Account stores the refreshed account aggregate after the mutation.
Account AccountView `json:"account"`
}
type entitlementReader interface {
GetByUserID(ctx context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error)
}
// AccountGetter executes the `GetMyAccount` use case.
type AccountGetter struct {
loader *accountview.Loader
}
// NewAccountGetter constructs one authenticated account-read use case.
func NewAccountGetter(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
) (*AccountGetter, error) {
loader, err := accountview.NewLoader(accounts, entitlements, sanctions, limits, clock)
if err != nil {
return nil, fmt.Errorf("selfservice account getter: %w", err)
}
return &AccountGetter{loader: loader}, nil
}
// Execute reads the current self-service account aggregate of input.UserID.
func (service *AccountGetter) Execute(ctx context.Context, input GetMyAccountInput) (GetMyAccountResult, error) {
if ctx == nil {
return GetMyAccountResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
return GetMyAccountResult{}, err
}
state, err := service.loader.Load(ctx, userID)
if err != nil {
return GetMyAccountResult{}, err
}
return GetMyAccountResult{Account: state.View()}, nil
}
// ProfileUpdater executes the `UpdateMyProfile` use case.
type ProfileUpdater struct {
accounts ports.UserAccountStore
loader *accountview.Loader
policy ports.RaceNamePolicy
clock ports.Clock
logger *slog.Logger
telemetry *telemetry.Runtime
profilePublisher ports.ProfileChangedPublisher
}
// NewProfileUpdater constructs one self-service profile-mutation use case.
func NewProfileUpdater(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
policy ports.RaceNamePolicy,
) (*ProfileUpdater, error) {
return NewProfileUpdaterWithObservability(accounts, entitlements, sanctions, limits, clock, policy, nil, nil, nil)
}
// NewProfileUpdaterWithObservability constructs one self-service
// profile-mutation use case with optional observability hooks.
func NewProfileUpdaterWithObservability(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
policy ports.RaceNamePolicy,
logger *slog.Logger,
telemetryRuntime *telemetry.Runtime,
profilePublisher ports.ProfileChangedPublisher,
) (*ProfileUpdater, error) {
loader, err := accountview.NewLoader(accounts, entitlements, sanctions, limits, clock)
if err != nil {
return nil, fmt.Errorf("selfservice profile updater: %w", err)
}
if policy == nil {
return nil, fmt.Errorf("selfservice profile updater: race-name policy must not be nil")
}
return &ProfileUpdater{
accounts: accounts,
loader: loader,
policy: policy,
clock: clock,
logger: logger,
telemetry: telemetryRuntime,
profilePublisher: profilePublisher,
}, nil
}
// Execute updates the current self-service profile fields of input.UserID.
func (service *ProfileUpdater) Execute(ctx context.Context, input UpdateMyProfileInput) (result UpdateMyProfileResult, err error) {
outcome := "failed"
userIDString := ""
defer func() {
shared.LogServiceOutcome(service.logger, ctx, "profile update completed", err,
"use_case", "update_my_profile",
"outcome", outcome,
"user_id", userIDString,
"source", gatewaySelfServiceSource.String(),
)
}()
if ctx == nil {
return UpdateMyProfileResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
return UpdateMyProfileResult{}, err
}
userIDString = userID.String()
raceName, err := parseRaceName(input.RaceName)
if err != nil {
return UpdateMyProfileResult{}, err
}
state, err := service.loader.Load(ctx, userID)
if err != nil {
return UpdateMyProfileResult{}, err
}
if state.HasActiveSanction(policy.SanctionCodeProfileUpdateBlock) {
return UpdateMyProfileResult{}, shared.Conflict()
}
if state.AccountRecord.RaceName == raceName {
outcome = "noop"
return UpdateMyProfileResult{Account: state.View()}, nil
}
now := service.clock.Now().UTC()
currentCanonicalKey, err := service.policy.CanonicalKey(state.AccountRecord.RaceName)
if err != nil {
return UpdateMyProfileResult{}, shared.ServiceUnavailable(fmt.Errorf("canonicalize current race name: %w", err))
}
reservation, err := shared.BuildRaceNameReservation(service.policy, userID, raceName, now)
if err != nil {
return UpdateMyProfileResult{}, shared.ServiceUnavailable(err)
}
if err := service.accounts.RenameRaceName(ctx, ports.RenameRaceNameInput{
UserID: userID,
CurrentCanonicalKey: currentCanonicalKey,
NewRaceName: raceName,
NewReservation: reservation,
UpdatedAt: now,
}); err != nil {
if errors.Is(err, ports.ErrRaceNameConflict) && service.telemetry != nil {
service.telemetry.RecordRaceNameReservationConflict(ctx, "update_my_profile")
}
switch {
case errors.Is(err, ports.ErrNotFound):
return UpdateMyProfileResult{}, shared.SubjectNotFound()
case errors.Is(err, ports.ErrConflict):
return UpdateMyProfileResult{}, shared.Conflict()
default:
return UpdateMyProfileResult{}, shared.ServiceUnavailable(err)
}
}
updatedState, err := service.loader.Load(ctx, userID)
if err != nil {
return UpdateMyProfileResult{}, err
}
outcome = "updated"
result = UpdateMyProfileResult{Account: updatedState.View()}
service.publishProfileChanged(ctx, updatedState.AccountRecord)
return result, nil
}
// SettingsUpdater executes the `UpdateMySettings` use case.
type SettingsUpdater struct {
accounts ports.UserAccountStore
loader *accountview.Loader
clock ports.Clock
logger *slog.Logger
telemetry *telemetry.Runtime
settingsPublisher ports.SettingsChangedPublisher
}
// NewSettingsUpdater constructs one self-service settings-mutation use case.
func NewSettingsUpdater(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
) (*SettingsUpdater, error) {
return NewSettingsUpdaterWithObservability(accounts, entitlements, sanctions, limits, clock, nil, nil, nil)
}
// NewSettingsUpdaterWithObservability constructs one self-service
// settings-mutation use case with optional observability hooks.
func NewSettingsUpdaterWithObservability(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
logger *slog.Logger,
telemetryRuntime *telemetry.Runtime,
settingsPublisher ports.SettingsChangedPublisher,
) (*SettingsUpdater, error) {
loader, err := accountview.NewLoader(accounts, entitlements, sanctions, limits, clock)
if err != nil {
return nil, fmt.Errorf("selfservice settings updater: %w", err)
}
return &SettingsUpdater{
accounts: accounts,
loader: loader,
clock: clock,
logger: logger,
telemetry: telemetryRuntime,
settingsPublisher: settingsPublisher,
}, nil
}
// Execute updates the current self-service settings fields of input.UserID.
func (service *SettingsUpdater) Execute(ctx context.Context, input UpdateMySettingsInput) (result UpdateMySettingsResult, err error) {
outcome := "failed"
userIDString := ""
defer func() {
shared.LogServiceOutcome(service.logger, ctx, "settings update completed", err,
"use_case", "update_my_settings",
"outcome", outcome,
"user_id", userIDString,
"source", gatewaySelfServiceSource.String(),
)
}()
if ctx == nil {
return UpdateMySettingsResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
return UpdateMySettingsResult{}, err
}
userIDString = userID.String()
preferredLanguage, err := parsePreferredLanguage(input.PreferredLanguage)
if err != nil {
return UpdateMySettingsResult{}, err
}
timeZone, err := parseTimeZoneName(input.TimeZone)
if err != nil {
return UpdateMySettingsResult{}, err
}
state, err := service.loader.Load(ctx, userID)
if err != nil {
return UpdateMySettingsResult{}, err
}
if state.HasActiveSanction(policy.SanctionCodeProfileUpdateBlock) {
return UpdateMySettingsResult{}, shared.Conflict()
}
if state.AccountRecord.PreferredLanguage == preferredLanguage && state.AccountRecord.TimeZone == timeZone {
outcome = "noop"
return UpdateMySettingsResult{Account: state.View()}, nil
}
record := state.AccountRecord
record.PreferredLanguage = preferredLanguage
record.TimeZone = timeZone
record.UpdatedAt = service.clock.Now().UTC()
if err := service.accounts.Update(ctx, record); err != nil {
switch {
case errors.Is(err, ports.ErrNotFound):
return UpdateMySettingsResult{}, shared.SubjectNotFound()
case errors.Is(err, ports.ErrConflict):
return UpdateMySettingsResult{}, shared.Conflict()
default:
return UpdateMySettingsResult{}, shared.ServiceUnavailable(err)
}
}
updatedState, err := service.loader.Load(ctx, userID)
if err != nil {
return UpdateMySettingsResult{}, err
}
outcome = "updated"
result = UpdateMySettingsResult{Account: updatedState.View()}
service.publishSettingsChanged(ctx, updatedState.AccountRecord)
return result, nil
}
func parseRaceName(value string) (common.RaceName, error) {
return shared.ParseRaceName(value)
}
func parsePreferredLanguage(value string) (common.LanguageTag, error) {
languageTag, err := shared.ParseLanguageTag(value)
if err != nil {
return "", reframeFieldError("preferred_language", "language tag", err)
}
return languageTag, nil
}
func parseTimeZoneName(value string) (common.TimeZoneName, error) {
timeZoneName, err := shared.ParseTimeZoneName(value)
if err != nil {
return "", reframeFieldError("time_zone", "time zone name", err)
}
return timeZoneName, nil
}
func reframeFieldError(fieldName string, valueName string, err error) error {
if err == nil {
return nil
}
message := err.Error()
prefix := valueName + " "
if strings.HasPrefix(message, prefix) {
message = fieldName + " " + strings.TrimPrefix(message, prefix)
} else {
message = fmt.Sprintf("%s: %s", fieldName, message)
}
return shared.InvalidRequest(message)
}
func (service *ProfileUpdater) publishProfileChanged(ctx context.Context, record account.UserAccount) {
if service.profilePublisher == nil {
return
}
event := ports.ProfileChangedEvent{
UserID: record.UserID,
OccurredAt: record.UpdatedAt.UTC(),
Source: gatewaySelfServiceSource,
Operation: ports.ProfileChangedOperationUpdated,
RaceName: record.RaceName,
}
if err := service.profilePublisher.PublishProfileChanged(ctx, event); err != nil {
if service.telemetry != nil {
service.telemetry.RecordEventPublicationFailure(ctx, ports.ProfileChangedEventType)
}
shared.LogEventPublicationFailure(service.logger, ctx, ports.ProfileChangedEventType, err,
"use_case", "update_my_profile",
"user_id", record.UserID.String(),
"source", gatewaySelfServiceSource.String(),
)
}
}
func (service *SettingsUpdater) publishSettingsChanged(ctx context.Context, record account.UserAccount) {
if service.settingsPublisher == nil {
return
}
event := ports.SettingsChangedEvent{
UserID: record.UserID,
OccurredAt: record.UpdatedAt.UTC(),
Source: gatewaySelfServiceSource,
Operation: ports.SettingsChangedOperationUpdated,
PreferredLanguage: record.PreferredLanguage,
TimeZone: record.TimeZone,
}
if err := service.settingsPublisher.PublishSettingsChanged(ctx, event); err != nil {
if service.telemetry != nil {
service.telemetry.RecordEventPublicationFailure(ctx, ports.SettingsChangedEventType)
}
shared.LogEventPublicationFailure(service.logger, ctx, ports.SettingsChangedEventType, err,
"use_case", "update_my_settings",
"user_id", record.UserID.String(),
"source", gatewaySelfServiceSource.String(),
)
}
}
@@ -0,0 +1,732 @@
package selfservice
import (
"context"
"strings"
"testing"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/entitlementsvc"
"galaxy/user/internal/service/shared"
"github.com/stretchr/testify/require"
)
func TestAccountGetterExecuteReturnsAggregate(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
accountStore := newFakeAccountStore(validUserAccount())
snapshotStore := &fakeEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
},
}
sanctionStore := fakeSanctionStore{
byUserID: map[common.UserID][]policy.SanctionRecord{
common.UserID("user-123"): {
validActiveSanction(common.UserID("user-123"), policy.SanctionCodeLoginBlock, now.Add(-time.Hour)),
expiredSanction(common.UserID("user-123"), policy.SanctionCodeGameJoinBlock, now.Add(-2*time.Hour)),
},
},
}
limitStore := fakeLimitStore{
byUserID: map[common.UserID][]policy.LimitRecord{
common.UserID("user-123"): {
validActiveLimit(common.UserID("user-123"), policy.LimitCodeMaxOwnedPrivateGames, 3, now.Add(-time.Hour)),
validActiveLimit(common.UserID("user-123"), policy.LimitCodeMaxActivePrivateGames, 1, now.Add(-2*time.Hour)),
},
},
}
service, err := NewAccountGetter(accountStore, snapshotStore, sanctionStore, limitStore, fixedClock{now: now})
require.NoError(t, err)
result, err := service.Execute(context.Background(), GetMyAccountInput{UserID: " user-123 "})
require.NoError(t, err)
require.Equal(t, "user-123", result.Account.UserID)
require.Equal(t, "DE", result.Account.DeclaredCountry)
require.Len(t, result.Account.ActiveSanctions, 1)
require.Equal(t, string(policy.SanctionCodeLoginBlock), result.Account.ActiveSanctions[0].SanctionCode)
require.Len(t, result.Account.ActiveLimits, 1)
require.Equal(t, string(policy.LimitCodeMaxOwnedPrivateGames), result.Account.ActiveLimits[0].LimitCode)
}
func TestAccountGetterExecuteUnknownUserReturnsNotFound(t *testing.T) {
t.Parallel()
service, err := NewAccountGetter(
newFakeAccountStore(),
&fakeEntitlementSnapshotStore{},
fakeSanctionStore{},
fakeLimitStore{},
fixedClock{now: time.Unix(1_775_240_500, 0).UTC()},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), GetMyAccountInput{UserID: "user-missing"})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err))
}
func TestAccountGetterExecuteMissingSnapshotReturnsInternalError(t *testing.T) {
t.Parallel()
service, err := NewAccountGetter(
newFakeAccountStore(validUserAccount()),
&fakeEntitlementSnapshotStore{},
fakeSanctionStore{},
fakeLimitStore{},
fixedClock{now: time.Unix(1_775_240_500, 0).UTC()},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), GetMyAccountInput{UserID: "user-123"})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeInternalError, shared.CodeOf(err))
}
func TestAccountGetterExecuteRepairsExpiredPaidSnapshot(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
expiredAt := now.Add(-time.Hour)
snapshotStore := &fakeEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): {
UserID: common.UserID("user-123"),
PlanCode: entitlement.PlanCodePaidMonthly,
IsPaid: true,
StartsAt: now.Add(-30 * 24 * time.Hour),
EndsAt: timePointer(expiredAt),
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_grant"),
UpdatedAt: expiredAt,
},
},
}
reader, err := entitlementsvc.NewReader(
snapshotStore,
&fakeEntitlementLifecycleStore{snapshotStore: snapshotStore},
fixedClock{now: now},
readerIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-free-after-expiry")},
)
require.NoError(t, err)
service, err := NewAccountGetter(
newFakeAccountStore(validUserAccount()),
reader,
fakeSanctionStore{},
fakeLimitStore{},
fixedClock{now: now},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), GetMyAccountInput{UserID: "user-123"})
require.NoError(t, err)
require.Equal(t, "free", result.Account.Entitlement.PlanCode)
require.False(t, result.Account.Entitlement.IsPaid)
require.Equal(t, expiredAt, result.Account.Entitlement.StartsAt)
}
func TestProfileUpdaterExecuteBlockedBySanction(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
accountStore := newFakeAccountStore(validUserAccount())
service, err := NewProfileUpdater(
accountStore,
&fakeEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
},
},
fakeSanctionStore{
byUserID: map[common.UserID][]policy.SanctionRecord{
common.UserID("user-123"): {
validActiveSanction(common.UserID("user-123"), policy.SanctionCodeProfileUpdateBlock, now.Add(-time.Minute)),
},
},
},
fakeLimitStore{},
fixedClock{now: now},
stubRaceNamePolicy{},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), UpdateMyProfileInput{
UserID: "user-123",
RaceName: "Nova Prime",
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeConflict, shared.CodeOf(err))
require.Equal(t, 0, accountStore.renameCalls)
}
func TestProfileUpdaterExecuteSuccessNoOpAndConflict(t *testing.T) {
t.Parallel()
tests := []struct {
name string
inputRaceName string
renameErr error
wantCode string
wantRaceName string
wantRenameCalls int
wantCurrentKey account.RaceNameCanonicalKey
wantNewKey account.RaceNameCanonicalKey
}{
{
name: "success",
inputRaceName: "Nova Prime",
wantRaceName: "Nova Prime",
wantRenameCalls: 1,
wantCurrentKey: canonicalKey(common.RaceName("Pilot Nova")),
wantNewKey: canonicalKey(common.RaceName("Nova Prime")),
},
{
name: "same canonical different exact",
inputRaceName: "P1lot Nova",
wantRaceName: "P1lot Nova",
wantRenameCalls: 1,
wantCurrentKey: canonicalKey(common.RaceName("Pilot Nova")),
wantNewKey: canonicalKey(common.RaceName("P1lot Nova")),
},
{
name: "no-op",
inputRaceName: " Pilot Nova ",
wantRaceName: "Pilot Nova",
wantRenameCalls: 0,
},
{
name: "conflict",
inputRaceName: "Taken Name",
renameErr: ports.ErrConflict,
wantCode: shared.ErrorCodeConflict,
wantRaceName: "Pilot Nova",
wantRenameCalls: 1,
wantCurrentKey: canonicalKey(common.RaceName("Pilot Nova")),
wantNewKey: canonicalKey(common.RaceName("Taken Name")),
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
accountStore := newFakeAccountStore(validUserAccount())
accountStore.renameErr = tt.renameErr
service, err := NewProfileUpdater(
accountStore,
&fakeEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
},
},
fakeSanctionStore{},
fakeLimitStore{},
fixedClock{now: now},
stubRaceNamePolicy{},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), UpdateMyProfileInput{
UserID: "user-123",
RaceName: tt.inputRaceName,
})
if tt.wantCode != "" {
require.Error(t, err)
require.Equal(t, tt.wantCode, shared.CodeOf(err))
} else {
require.NoError(t, err)
}
require.Equal(t, tt.wantRenameCalls, accountStore.renameCalls)
if tt.wantRenameCalls > 0 {
require.Equal(t, tt.wantCurrentKey, accountStore.lastRenameInput.CurrentCanonicalKey)
require.Equal(t, tt.wantNewKey, accountStore.lastRenameInput.NewReservation.CanonicalKey)
}
storedAccount, err := accountStore.GetByUserID(context.Background(), common.UserID("user-123"))
require.NoError(t, err)
require.Equal(t, tt.wantRaceName, storedAccount.RaceName.String())
if tt.wantCode == "" {
require.Equal(t, tt.wantRaceName, result.Account.RaceName)
}
})
}
}
func TestSettingsUpdaterExecuteBlockedBySanction(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
accountStore := newFakeAccountStore(validUserAccount())
service, err := NewSettingsUpdater(
accountStore,
&fakeEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
},
},
fakeSanctionStore{
byUserID: map[common.UserID][]policy.SanctionRecord{
common.UserID("user-123"): {
validActiveSanction(common.UserID("user-123"), policy.SanctionCodeProfileUpdateBlock, now.Add(-time.Minute)),
},
},
},
fakeLimitStore{},
fixedClock{now: now},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), UpdateMySettingsInput{
UserID: "user-123",
PreferredLanguage: "en-US",
TimeZone: "UTC",
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeConflict, shared.CodeOf(err))
require.Equal(t, 0, accountStore.updateCalls)
}
func TestSettingsUpdaterExecuteCanonicalizedNoOpAndInvalidInputs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
accountRecord account.UserAccount
inputLanguage string
inputTimeZone string
wantCode string
wantLanguage string
wantTimeZone string
wantUpdateCalls int
}{
{
name: "canonicalized success",
accountRecord: validUserAccount(),
inputLanguage: " en-us ",
inputTimeZone: " UTC ",
wantLanguage: "en-US",
wantTimeZone: "UTC",
wantUpdateCalls: 1,
},
{
name: "no-op",
accountRecord: account.UserAccount{
UserID: common.UserID("user-123"),
Email: common.Email("pilot@example.com"),
RaceName: common.RaceName("Pilot Nova"),
PreferredLanguage: common.LanguageTag("en-US"),
TimeZone: common.TimeZoneName("UTC"),
DeclaredCountry: common.CountryCode("DE"),
CreatedAt: time.Unix(1_775_240_000, 0).UTC(),
UpdatedAt: time.Unix(1_775_240_000, 0).UTC(),
},
inputLanguage: "en-us",
inputTimeZone: " UTC ",
wantLanguage: "en-US",
wantTimeZone: "UTC",
wantUpdateCalls: 0,
},
{
name: "invalid preferred language",
accountRecord: validUserAccount(),
inputLanguage: "bad@@tag",
inputTimeZone: "UTC",
wantCode: shared.ErrorCodeInvalidRequest,
wantLanguage: "en",
wantTimeZone: "Europe/Kaliningrad",
},
{
name: "invalid time zone",
accountRecord: validUserAccount(),
inputLanguage: "en",
inputTimeZone: "Mars/Olympus",
wantCode: shared.ErrorCodeInvalidRequest,
wantLanguage: "en",
wantTimeZone: "Europe/Kaliningrad",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
accountStore := newFakeAccountStore(tt.accountRecord)
service, err := NewSettingsUpdater(
accountStore,
&fakeEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
},
},
fakeSanctionStore{},
fakeLimitStore{},
fixedClock{now: now},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), UpdateMySettingsInput{
UserID: "user-123",
PreferredLanguage: tt.inputLanguage,
TimeZone: tt.inputTimeZone,
})
if tt.wantCode != "" {
require.Error(t, err)
require.Equal(t, tt.wantCode, shared.CodeOf(err))
} else {
require.NoError(t, err)
}
require.Equal(t, tt.wantUpdateCalls, accountStore.updateCalls)
storedAccount, err := accountStore.GetByUserID(context.Background(), common.UserID("user-123"))
require.NoError(t, err)
require.Equal(t, tt.wantLanguage, storedAccount.PreferredLanguage.String())
require.Equal(t, tt.wantTimeZone, storedAccount.TimeZone.String())
if tt.wantCode == "" {
require.Equal(t, tt.wantLanguage, result.Account.PreferredLanguage)
require.Equal(t, tt.wantTimeZone, result.Account.TimeZone)
}
})
}
}
type fakeAccountStore struct {
records map[common.UserID]account.UserAccount
renameErr error
updateErr error
renameCalls int
updateCalls int
lastRenameInput ports.RenameRaceNameInput
}
func newFakeAccountStore(records ...account.UserAccount) *fakeAccountStore {
byUserID := make(map[common.UserID]account.UserAccount, len(records))
for _, record := range records {
byUserID[record.UserID] = record
}
return &fakeAccountStore{records: byUserID}
}
func (store *fakeAccountStore) Create(_ context.Context, input ports.CreateAccountInput) error {
if input.Account.Validate() != nil || input.Reservation.Validate() != nil {
return ports.ErrConflict
}
return nil
}
func (store *fakeAccountStore) GetByUserID(_ context.Context, userID common.UserID) (account.UserAccount, error) {
record, ok := store.records[userID]
if !ok {
return account.UserAccount{}, ports.ErrNotFound
}
return record, nil
}
func (store *fakeAccountStore) GetByEmail(_ context.Context, email common.Email) (account.UserAccount, error) {
for _, record := range store.records {
if record.Email == email {
return record, nil
}
}
return account.UserAccount{}, ports.ErrNotFound
}
func (store *fakeAccountStore) GetByRaceName(_ context.Context, raceName common.RaceName) (account.UserAccount, error) {
for _, record := range store.records {
if record.RaceName == raceName {
return record, nil
}
}
return account.UserAccount{}, ports.ErrNotFound
}
func (store *fakeAccountStore) ExistsByUserID(_ context.Context, userID common.UserID) (bool, error) {
_, ok := store.records[userID]
return ok, nil
}
func (store *fakeAccountStore) RenameRaceName(_ context.Context, input ports.RenameRaceNameInput) error {
store.renameCalls++
store.lastRenameInput = input
if store.renameErr != nil {
return store.renameErr
}
if err := input.Validate(); err != nil {
return err
}
record, ok := store.records[input.UserID]
if !ok {
return ports.ErrNotFound
}
record.RaceName = input.NewRaceName
record.UpdatedAt = input.UpdatedAt.UTC()
store.records[input.UserID] = record
return nil
}
func (store *fakeAccountStore) Update(_ context.Context, record account.UserAccount) error {
store.updateCalls++
if store.updateErr != nil {
return store.updateErr
}
if _, ok := store.records[record.UserID]; !ok {
return ports.ErrNotFound
}
store.records[record.UserID] = record
return nil
}
type fakeEntitlementSnapshotStore struct {
byUserID map[common.UserID]entitlement.CurrentSnapshot
}
func (store *fakeEntitlementSnapshotStore) GetByUserID(_ context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error) {
record, ok := store.byUserID[userID]
if !ok {
return entitlement.CurrentSnapshot{}, ports.ErrNotFound
}
return record, nil
}
func (store *fakeEntitlementSnapshotStore) Put(_ context.Context, record entitlement.CurrentSnapshot) error {
if store.byUserID != nil {
store.byUserID[record.UserID] = record
}
return nil
}
type fakeEntitlementLifecycleStore struct {
snapshotStore *fakeEntitlementSnapshotStore
}
func (store *fakeEntitlementLifecycleStore) Grant(context.Context, ports.GrantEntitlementInput) error {
return nil
}
func (store *fakeEntitlementLifecycleStore) Extend(context.Context, ports.ExtendEntitlementInput) error {
return nil
}
func (store *fakeEntitlementLifecycleStore) Revoke(context.Context, ports.RevokeEntitlementInput) error {
return nil
}
func (store *fakeEntitlementLifecycleStore) RepairExpired(ctx context.Context, input ports.RepairExpiredEntitlementInput) error {
if store.snapshotStore != nil {
return store.snapshotStore.Put(ctx, input.NewSnapshot)
}
return nil
}
type readerIDGenerator struct {
recordID entitlement.EntitlementRecordID
sanctionRecordID policy.SanctionRecordID
limitRecordID policy.LimitRecordID
}
func (generator readerIDGenerator) NewUserID() (common.UserID, error) {
return "", nil
}
func (generator readerIDGenerator) NewInitialRaceName() (common.RaceName, error) {
return "", nil
}
func (generator readerIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
return generator.recordID, nil
}
func (generator readerIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
return generator.sanctionRecordID, nil
}
func (generator readerIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
return generator.limitRecordID, nil
}
type fakeSanctionStore struct {
byUserID map[common.UserID][]policy.SanctionRecord
err error
}
func (store fakeSanctionStore) Create(context.Context, policy.SanctionRecord) error {
return nil
}
func (store fakeSanctionStore) GetByRecordID(context.Context, policy.SanctionRecordID) (policy.SanctionRecord, error) {
return policy.SanctionRecord{}, ports.ErrNotFound
}
func (store fakeSanctionStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.SanctionRecord, error) {
if store.err != nil {
return nil, store.err
}
records := store.byUserID[userID]
cloned := make([]policy.SanctionRecord, len(records))
copy(cloned, records)
return cloned, nil
}
func (store fakeSanctionStore) Update(context.Context, policy.SanctionRecord) error {
return nil
}
type fakeLimitStore struct {
byUserID map[common.UserID][]policy.LimitRecord
err error
}
func (store fakeLimitStore) Create(context.Context, policy.LimitRecord) error {
return nil
}
func (store fakeLimitStore) GetByRecordID(context.Context, policy.LimitRecordID) (policy.LimitRecord, error) {
return policy.LimitRecord{}, ports.ErrNotFound
}
func (store fakeLimitStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.LimitRecord, error) {
if store.err != nil {
return nil, store.err
}
records := store.byUserID[userID]
cloned := make([]policy.LimitRecord, len(records))
copy(cloned, records)
return cloned, nil
}
func (store fakeLimitStore) Update(context.Context, policy.LimitRecord) error {
return nil
}
type fixedClock struct {
now time.Time
}
func (clock fixedClock) Now() time.Time {
return clock.now
}
type stubRaceNamePolicy struct{}
func (stubRaceNamePolicy) CanonicalKey(raceName common.RaceName) (account.RaceNameCanonicalKey, error) {
return canonicalKey(raceName), nil
}
func canonicalKey(raceName common.RaceName) account.RaceNameCanonicalKey {
return account.RaceNameCanonicalKey(strings.NewReplacer(
"1", "i",
"0", "o",
"8", "b",
).Replace(strings.ToLower(raceName.String())))
}
func validUserAccount() 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"),
DeclaredCountry: common.CountryCode("DE"),
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.Add(-time.Hour),
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 validActiveSanction(userID common.UserID, code policy.SanctionCode, appliedAt time.Time) policy.SanctionRecord {
return policy.SanctionRecord{
RecordID: policy.SanctionRecordID("sanction-" + string(code)),
UserID: userID,
SanctionCode: code,
Scope: common.Scope("self_service"),
ReasonCode: common.ReasonCode("policy_enforced"),
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
AppliedAt: appliedAt.UTC(),
}
}
func expiredSanction(userID common.UserID, code policy.SanctionCode, appliedAt time.Time) policy.SanctionRecord {
expiresAt := appliedAt.Add(30 * time.Minute)
record := validActiveSanction(userID, code, appliedAt)
record.RecordID = policy.SanctionRecordID(record.RecordID.String() + "-expired")
record.ExpiresAt = &expiresAt
return record
}
func validActiveLimit(userID common.UserID, code policy.LimitCode, value int, appliedAt time.Time) policy.LimitRecord {
return policy.LimitRecord{
RecordID: policy.LimitRecordID("limit-" + string(code)),
UserID: userID,
LimitCode: code,
Value: value,
ReasonCode: common.ReasonCode("policy_enforced"),
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
AppliedAt: appliedAt.UTC(),
}
}
func removedLimit(userID common.UserID, code policy.LimitCode, value int, appliedAt time.Time) policy.LimitRecord {
removedAt := appliedAt.Add(30 * time.Minute)
record := validActiveLimit(userID, code, value, appliedAt)
record.RecordID = policy.LimitRecordID(record.RecordID.String() + "-removed")
record.RemovedAt = &removedAt
record.RemovedBy = common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")}
record.RemovedReasonCode = common.ReasonCode("policy_reset")
return record
}
func timePointer(value time.Time) *time.Time {
utcValue := value.UTC()
return &utcValue
}
var (
_ ports.UserAccountStore = (*fakeAccountStore)(nil)
_ ports.EntitlementSnapshotStore = (*fakeEntitlementSnapshotStore)(nil)
_ ports.EntitlementLifecycleStore = (*fakeEntitlementLifecycleStore)(nil)
_ ports.SanctionStore = fakeSanctionStore{}
_ ports.LimitStore = fakeLimitStore{}
_ ports.RaceNamePolicy = stubRaceNamePolicy{}
_ ports.IDGenerator = readerIDGenerator{}
)
+175
View File
@@ -0,0 +1,175 @@
// Package shared provides shared request parsing and error normalization used
// by the user-service application and transport layers.
package shared
import (
"errors"
"net/http"
"strings"
)
const (
// ErrorCodeInvalidRequest reports malformed or semantically invalid caller
// input.
ErrorCodeInvalidRequest = "invalid_request"
// ErrorCodeConflict reports that the requested mutation conflicts with the
// current source-of-truth state.
ErrorCodeConflict = "conflict"
// ErrorCodeSubjectNotFound reports that the requested user subject does not
// exist.
ErrorCodeSubjectNotFound = "subject_not_found"
// ErrorCodeServiceUnavailable reports that a required dependency is
// temporarily unavailable.
ErrorCodeServiceUnavailable = "service_unavailable"
// ErrorCodeInternalError reports that a local invariant failed unexpectedly.
ErrorCodeInternalError = "internal_error"
)
var internalErrorStatusCodes = map[string]int{
ErrorCodeInvalidRequest: http.StatusBadRequest,
ErrorCodeConflict: http.StatusConflict,
ErrorCodeSubjectNotFound: http.StatusNotFound,
ErrorCodeServiceUnavailable: http.StatusServiceUnavailable,
ErrorCodeInternalError: http.StatusInternalServerError,
}
var internalStableMessages = map[string]string{
ErrorCodeConflict: "request conflicts with current state",
ErrorCodeSubjectNotFound: "subject not found",
ErrorCodeServiceUnavailable: "service is unavailable",
ErrorCodeInternalError: "internal server error",
}
// InternalErrorProjection stores the transport-ready representation of one
// normalized trusted-internal error.
type InternalErrorProjection struct {
// StatusCode stores the HTTP status returned to the trusted caller.
StatusCode int
// Code stores the stable machine-readable error code written into the JSON
// envelope.
Code string
// Message stores the stable or caller-safe message written into the JSON
// envelope.
Message string
}
// ServiceError stores one normalized application-layer failure.
type ServiceError struct {
// Code stores the stable machine-readable error code.
Code string
// Message stores the caller-safe error message.
Message string
// Err stores the wrapped underlying cause when one exists.
Err error
}
// Error returns the caller-safe message of ServiceError.
func (err *ServiceError) Error() string {
if err == nil {
return ""
}
if strings.TrimSpace(err.Message) != "" {
return err.Message
}
if strings.TrimSpace(err.Code) != "" {
return err.Code
}
if err.Err != nil {
return err.Err.Error()
}
return ErrorCodeInternalError
}
// Unwrap returns the wrapped underlying cause.
func (err *ServiceError) Unwrap() error {
if err == nil {
return nil
}
return err.Err
}
// NewServiceError returns one new normalized application-layer error.
func NewServiceError(code string, message string, err error) *ServiceError {
return &ServiceError{
Code: strings.TrimSpace(code),
Message: strings.TrimSpace(message),
Err: err,
}
}
// InvalidRequest returns one normalized invalid-request error.
func InvalidRequest(message string) *ServiceError {
return NewServiceError(ErrorCodeInvalidRequest, strings.TrimSpace(message), nil)
}
// Conflict returns one normalized conflict error.
func Conflict() *ServiceError {
return NewServiceError(ErrorCodeConflict, "", nil)
}
// SubjectNotFound returns one normalized subject-not-found error.
func SubjectNotFound() *ServiceError {
return NewServiceError(ErrorCodeSubjectNotFound, "", nil)
}
// ServiceUnavailable returns one normalized dependency-unavailable error.
func ServiceUnavailable(err error) *ServiceError {
return NewServiceError(ErrorCodeServiceUnavailable, "", err)
}
// InternalError returns one normalized invariant-failure error.
func InternalError(err error) *ServiceError {
return NewServiceError(ErrorCodeInternalError, "", err)
}
// CodeOf returns the normalized service error code carried by err when one is
// available.
func CodeOf(err error) string {
serviceErr, ok := errors.AsType[*ServiceError](err)
if !ok || serviceErr == nil {
return ""
}
return serviceErr.Code
}
// ProjectInternalError normalizes err to the frozen trusted-internal HTTP
// error surface.
func ProjectInternalError(err error) InternalErrorProjection {
serviceErr, ok := errors.AsType[*ServiceError](err)
code := CodeOf(err)
if _, exists := internalErrorStatusCodes[code]; !exists {
return InternalErrorProjection{
StatusCode: http.StatusInternalServerError,
Code: ErrorCodeInternalError,
Message: internalStableMessages[ErrorCodeInternalError],
}
}
message := ""
if ok && serviceErr != nil {
message = serviceErr.Message
}
if stable, exists := internalStableMessages[code]; exists {
message = stable
}
if strings.TrimSpace(message) == "" {
message = internalStableMessages[ErrorCodeInternalError]
}
return InternalErrorProjection{
StatusCode: internalErrorStatusCodes[code],
Code: code,
Message: message,
}
}
+131
View File
@@ -0,0 +1,131 @@
package shared
import (
"fmt"
"strings"
"time"
"galaxy/user/internal/domain/common"
"golang.org/x/text/language"
)
// NormalizeString trims surrounding Unicode whitespace from value.
func NormalizeString(value string) string {
return strings.TrimSpace(value)
}
// ParseEmail trims value and validates it as one exact normalized e-mail
// subject used by the auth-facing contract.
func ParseEmail(value string) (common.Email, error) {
email := common.Email(NormalizeString(value))
if err := email.Validate(); err != nil {
return "", InvalidRequest(err.Error())
}
return email, nil
}
// ParseUserID trims value and validates it as one stable user identifier.
func ParseUserID(value string) (common.UserID, error) {
userID := common.UserID(NormalizeString(value))
if err := userID.Validate(); err != nil {
return "", InvalidRequest(err.Error())
}
return userID, nil
}
// ParseRaceName trims value and validates it as one exact stored race name.
func ParseRaceName(value string) (common.RaceName, error) {
raceName := common.RaceName(NormalizeString(value))
if err := raceName.Validate(); err != nil {
return "", InvalidRequest(err.Error())
}
return raceName, nil
}
// ParseReasonCode trims value and validates it as one machine-readable reason
// code.
func ParseReasonCode(value string) (common.ReasonCode, error) {
reasonCode := common.ReasonCode(NormalizeString(value))
if err := reasonCode.Validate(); err != nil {
return "", InvalidRequest(err.Error())
}
return reasonCode, nil
}
// ParseLanguageTag trims value and validates it against the current Stage 03
// boundary and BCP 47 semantics, returning the canonical tag form.
func ParseLanguageTag(value string) (common.LanguageTag, error) {
languageTag := common.LanguageTag(NormalizeString(value))
if err := languageTag.Validate(); err != nil {
return "", InvalidRequest(err.Error())
}
parsedTag, err := language.Parse(languageTag.String())
if err != nil {
return "", InvalidRequest("language tag must be a valid BCP 47 language tag")
}
canonicalTag := common.LanguageTag(parsedTag.String())
if err := canonicalTag.Validate(); err != nil {
return "", InvalidRequest(err.Error())
}
return canonicalTag, nil
}
// ParseTimeZoneName trims value and validates it against the current Stage 03
// boundary and IANA time-zone semantics.
func ParseTimeZoneName(value string) (common.TimeZoneName, error) {
timeZoneName := common.TimeZoneName(NormalizeString(value))
if err := timeZoneName.Validate(); err != nil {
return "", InvalidRequest(err.Error())
}
if _, err := time.LoadLocation(timeZoneName.String()); err != nil {
return "", InvalidRequest("time zone name must be a valid IANA time zone name")
}
return timeZoneName, nil
}
// ParseRegistrationPreferredLanguage trims value, validates it as one create-
// only BCP 47 registration language tag, and returns the canonical tag form.
func ParseRegistrationPreferredLanguage(value string) (common.LanguageTag, error) {
languageTag, err := ParseLanguageTag(value)
if err != nil {
return "", reframeFieldError("registration_context.preferred_language", "language tag", err)
}
return languageTag, nil
}
// ParseRegistrationTimeZoneName trims value and validates it as one create-
// only IANA registration time-zone name.
func ParseRegistrationTimeZoneName(value string) (common.TimeZoneName, error) {
timeZoneName, err := ParseTimeZoneName(value)
if err != nil {
return "", reframeFieldError("registration_context.time_zone", "time zone name", err)
}
return timeZoneName, nil
}
func reframeFieldError(fieldName string, valueName string, err error) error {
if err == nil {
return nil
}
message := err.Error()
prefix := valueName + " "
if strings.HasPrefix(message, prefix) {
message = fieldName + " " + strings.TrimPrefix(message, prefix)
} else {
message = fmt.Sprintf("%s: %s", fieldName, message)
}
return InvalidRequest(message)
}
@@ -0,0 +1,119 @@
package shared
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestParseLanguageTag(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
want string
wantErrCode string
wantErr string
}{
{
name: "canonicalizes valid tag",
input: " en-us ",
want: "en-US",
},
{
name: "rejects invalid tag",
input: "en-@",
wantErrCode: ErrorCodeInvalidRequest,
wantErr: "language tag must be a valid BCP 47 language tag",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := ParseLanguageTag(tt.input)
if tt.wantErr != "" {
require.Error(t, err)
require.Empty(t, got)
require.Equal(t, tt.wantErrCode, CodeOf(err))
require.Equal(t, tt.wantErr, err.Error())
return
}
require.NoError(t, err)
require.Equal(t, tt.want, got.String())
})
}
}
func TestParseTimeZoneName(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
want string
wantErrCode string
wantErr string
}{
{
name: "accepts valid zone",
input: " Europe/Kaliningrad ",
want: "Europe/Kaliningrad",
},
{
name: "rejects invalid zone",
input: "Mars/Olympus",
wantErrCode: ErrorCodeInvalidRequest,
wantErr: "time zone name must be a valid IANA time zone name",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := ParseTimeZoneName(tt.input)
if tt.wantErr != "" {
require.Error(t, err)
require.Empty(t, got)
require.Equal(t, tt.wantErrCode, CodeOf(err))
require.Equal(t, tt.wantErr, err.Error())
return
}
require.NoError(t, err)
require.Equal(t, tt.want, got.String())
})
}
}
func TestParseRegistrationPreferredLanguage(t *testing.T) {
t.Parallel()
got, err := ParseRegistrationPreferredLanguage(" en-us ")
require.NoError(t, err)
require.Equal(t, "en-US", got.String())
_, err = ParseRegistrationPreferredLanguage("bad@@tag")
require.Error(t, err)
require.Equal(t, ErrorCodeInvalidRequest, CodeOf(err))
require.Equal(t, "registration_context.preferred_language must be a valid BCP 47 language tag", err.Error())
}
func TestParseRegistrationTimeZoneName(t *testing.T) {
t.Parallel()
got, err := ParseRegistrationTimeZoneName(" Europe/Kaliningrad ")
require.NoError(t, err)
require.Equal(t, "Europe/Kaliningrad", got.String())
_, err = ParseRegistrationTimeZoneName("Mars/Olympus")
require.Error(t, err)
require.Equal(t, ErrorCodeInvalidRequest, CodeOf(err))
require.Equal(t, "registration_context.time_zone must be a valid IANA time zone name", err.Error())
}
@@ -0,0 +1,73 @@
package shared
import (
"context"
"log/slog"
"galaxy/user/internal/logging"
)
// LogServiceOutcome writes one structured service-level outcome log with a
// stable severity derived from err and with trace fields attached when ctx
// carries an active span.
func LogServiceOutcome(logger *slog.Logger, ctx context.Context, message string, err error, attrs ...any) {
if logger == nil {
logger = slog.Default()
}
attrs = append(attrs, logging.TraceAttrsFromContext(ctx)...)
switch {
case err == nil:
logger.InfoContext(ctx, message, attrs...)
case isExpectedServiceErrorCode(CodeOf(err)):
logger.WarnContext(ctx, message, append(attrs, "error", err.Error())...)
default:
logger.ErrorContext(ctx, message, append(attrs, "error", err.Error())...)
}
}
// MetricOutcome returns the stable low-cardinality outcome label derived from
// err for service metrics.
func MetricOutcome(err error) string {
if err == nil {
return "success"
}
code := CodeOf(err)
if code == "" {
return ErrorCodeInternalError
}
return code
}
// LogEventPublicationFailure writes one structured error log for an auxiliary
// post-commit event publication failure.
func LogEventPublicationFailure(logger *slog.Logger, ctx context.Context, eventType string, err error, attrs ...any) {
if err == nil {
return
}
if logger == nil {
logger = slog.Default()
}
attrs = append(attrs,
"event_type", eventType,
"error", err.Error(),
)
attrs = append(attrs, logging.TraceAttrsFromContext(ctx)...)
logger.ErrorContext(ctx, "auxiliary event publication failed", attrs...)
}
func isExpectedServiceErrorCode(code string) bool {
switch code {
case ErrorCodeInvalidRequest,
ErrorCodeConflict,
ErrorCodeSubjectNotFound:
return true
default:
return false
}
}
+49
View File
@@ -0,0 +1,49 @@
package shared
import (
"fmt"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/ports"
)
// BuildRaceNameReservation constructs one validated race-name reservation
// record for userID and raceName at reservedAt.
func BuildRaceNameReservation(
policy ports.RaceNamePolicy,
userID common.UserID,
raceName common.RaceName,
reservedAt time.Time,
) (account.RaceNameReservation, error) {
if policy == nil {
return account.RaceNameReservation{}, fmt.Errorf("build race-name reservation: race-name policy must not be nil")
}
if err := userID.Validate(); err != nil {
return account.RaceNameReservation{}, fmt.Errorf("build race-name reservation: %w", err)
}
if err := raceName.Validate(); err != nil {
return account.RaceNameReservation{}, fmt.Errorf("build race-name reservation: %w", err)
}
if err := common.ValidateTimestamp("build race-name reservation reserved at", reservedAt); err != nil {
return account.RaceNameReservation{}, err
}
canonicalKey, err := policy.CanonicalKey(raceName)
if err != nil {
return account.RaceNameReservation{}, fmt.Errorf("build race-name reservation: %w", err)
}
record := account.RaceNameReservation{
CanonicalKey: canonicalKey,
UserID: userID,
RaceName: raceName,
ReservedAt: reservedAt.UTC(),
}
if err := record.Validate(); err != nil {
return account.RaceNameReservation{}, fmt.Errorf("build race-name reservation: %w", err)
}
return record, nil
}
+549
View File
@@ -0,0 +1,549 @@
// Package telemetry provides shared OpenTelemetry runtime helpers and
// low-cardinality user-service instruments.
package telemetry
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
otelprom "go.opentelemetry.io/otel/exporters/prometheus"
"go.opentelemetry.io/otel/exporters/stdout/stdoutmetric"
"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/propagation"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
oteltrace "go.opentelemetry.io/otel/trace"
)
const meterName = "galaxy/user"
const (
defaultServiceName = "galaxy-user"
processExporterNone = "none"
processExporterOTLP = "otlp"
processProtocolHTTPProtobuf = "http/protobuf"
processProtocolGRPC = "grpc"
)
// ProcessConfig configures the process-wide OpenTelemetry runtime.
type ProcessConfig struct {
// ServiceName overrides the default OpenTelemetry service name.
ServiceName string
// TracesExporter selects the external traces exporter. Supported values are
// `none` and `otlp`.
TracesExporter string
// MetricsExporter selects the external metrics exporter. Supported values
// are `none` and `otlp`.
MetricsExporter string
// TracesProtocol selects the OTLP traces protocol when TracesExporter is
// `otlp`.
TracesProtocol string
// MetricsProtocol selects the OTLP metrics protocol when MetricsExporter is
// `otlp`.
MetricsProtocol string
// StdoutTracesEnabled enables the additional stdout trace exporter used for
// local development and debugging.
StdoutTracesEnabled bool
// StdoutMetricsEnabled enables the additional stdout metric exporter used
// for local development and debugging.
StdoutMetricsEnabled bool
}
// Validate reports whether cfg contains a supported OpenTelemetry exporter
// configuration.
func (cfg ProcessConfig) Validate() error {
switch cfg.TracesExporter {
case processExporterNone, processExporterOTLP:
default:
return fmt.Errorf("unsupported traces exporter %q", cfg.TracesExporter)
}
switch cfg.MetricsExporter {
case processExporterNone, processExporterOTLP:
default:
return fmt.Errorf("unsupported metrics exporter %q", cfg.MetricsExporter)
}
if cfg.TracesProtocol != "" && cfg.TracesProtocol != processProtocolHTTPProtobuf && cfg.TracesProtocol != processProtocolGRPC {
return fmt.Errorf("unsupported OTLP traces protocol %q", cfg.TracesProtocol)
}
if cfg.MetricsProtocol != "" && cfg.MetricsProtocol != processProtocolHTTPProtobuf && cfg.MetricsProtocol != processProtocolGRPC {
return fmt.Errorf("unsupported OTLP metrics protocol %q", cfg.MetricsProtocol)
}
return nil
}
// Runtime owns the user-service OpenTelemetry providers, the Prometheus
// metrics handler, and the custom low-cardinality instruments.
type Runtime struct {
tracerProvider oteltrace.TracerProvider
meterProvider metric.MeterProvider
promHandler http.Handler
shutdownMu sync.Mutex
shutdownDone bool
shutdownErr error
shutdownFns []func(context.Context) error
internalHTTPRequests metric.Int64Counter
internalHTTPDuration metric.Float64Histogram
authResolutionOutcomes metric.Int64Counter
userCreationOutcomes metric.Int64Counter
raceNameReservationConflicts metric.Int64Counter
entitlementMutations metric.Int64Counter
sanctionMutations metric.Int64Counter
limitMutations metric.Int64Counter
eventPublicationFailures metric.Int64Counter
}
// New constructs a lightweight telemetry runtime around meterProvider for
// tests and embedded use cases that do not need process-level exporter wiring.
func New(meterProvider metric.MeterProvider) (*Runtime, error) {
return NewWithProviders(meterProvider, nil)
}
// NewWithProviders constructs a telemetry runtime around explicitly supplied
// meterProvider and tracerProvider values.
func NewWithProviders(meterProvider metric.MeterProvider, tracerProvider oteltrace.TracerProvider) (*Runtime, error) {
if meterProvider == nil {
meterProvider = otel.GetMeterProvider()
}
if tracerProvider == nil {
tracerProvider = otel.GetTracerProvider()
}
if meterProvider == nil {
return nil, errors.New("new user telemetry runtime: nil meter provider")
}
if tracerProvider == nil {
return nil, errors.New("new user telemetry runtime: nil tracer provider")
}
return buildRuntime(meterProvider, tracerProvider, http.NotFoundHandler(), nil)
}
// NewProcess constructs the process-wide user-service OpenTelemetry runtime
// from cfg, installs the resulting providers globally, and returns the
// runtime.
func NewProcess(ctx context.Context, cfg ProcessConfig, logger *slog.Logger) (*Runtime, error) {
return newProcess(ctx, cfg, logger, os.Stdout, os.Stdout)
}
// TracerProvider returns the runtime tracer provider.
func (r *Runtime) TracerProvider() oteltrace.TracerProvider {
if r == nil || r.tracerProvider == nil {
return otel.GetTracerProvider()
}
return r.tracerProvider
}
// MeterProvider returns the runtime meter provider.
func (r *Runtime) MeterProvider() metric.MeterProvider {
if r == nil || r.meterProvider == nil {
return otel.GetMeterProvider()
}
return r.meterProvider
}
// Handler returns the Prometheus handler that should be mounted on the admin
// listener.
func (r *Runtime) Handler() http.Handler {
if r == nil || r.promHandler == nil {
return http.NotFoundHandler()
}
return r.promHandler
}
// Shutdown flushes and stops the configured telemetry providers. Shutdown is
// idempotent.
func (r *Runtime) Shutdown(ctx context.Context) error {
if r == nil {
return nil
}
r.shutdownMu.Lock()
if r.shutdownDone {
err := r.shutdownErr
r.shutdownMu.Unlock()
return err
}
r.shutdownDone = true
r.shutdownMu.Unlock()
var shutdownErr error
for index := len(r.shutdownFns) - 1; index >= 0; index-- {
shutdownErr = errors.Join(shutdownErr, r.shutdownFns[index](ctx))
}
r.shutdownMu.Lock()
r.shutdownErr = shutdownErr
r.shutdownMu.Unlock()
return shutdownErr
}
// RecordInternalHTTPRequest records one internal HTTP request outcome.
func (r *Runtime) RecordInternalHTTPRequest(ctx context.Context, attrs []attribute.KeyValue, duration time.Duration) {
if r == nil {
return
}
options := metric.WithAttributes(attrs...)
r.internalHTTPRequests.Add(normalizeContext(ctx), 1, options)
r.internalHTTPDuration.Record(normalizeContext(ctx), duration.Seconds()*1000, options)
}
// RecordAuthResolutionOutcome records one auth-facing resolution outcome.
func (r *Runtime) RecordAuthResolutionOutcome(ctx context.Context, operation string, outcome string) {
if r == nil {
return
}
r.authResolutionOutcomes.Add(
normalizeContext(ctx),
1,
metric.WithAttributes(
attribute.String("operation", strings.TrimSpace(operation)),
attribute.String("outcome", strings.TrimSpace(outcome)),
),
)
}
// RecordUserCreationOutcome records one ensure-by-email coarse outcome.
func (r *Runtime) RecordUserCreationOutcome(ctx context.Context, outcome string) {
if r == nil {
return
}
r.userCreationOutcomes.Add(
normalizeContext(ctx),
1,
metric.WithAttributes(attribute.String("outcome", strings.TrimSpace(outcome))),
)
}
// RecordRaceNameReservationConflict records one race-name reservation conflict
// for operation.
func (r *Runtime) RecordRaceNameReservationConflict(ctx context.Context, operation string) {
if r == nil {
return
}
r.raceNameReservationConflicts.Add(
normalizeContext(ctx),
1,
metric.WithAttributes(attribute.String("operation", strings.TrimSpace(operation))),
)
}
// RecordEntitlementMutation records one entitlement command outcome.
func (r *Runtime) RecordEntitlementMutation(ctx context.Context, command string, outcome string) {
if r == nil {
return
}
r.entitlementMutations.Add(
normalizeContext(ctx),
1,
metric.WithAttributes(
attribute.String("command", strings.TrimSpace(command)),
attribute.String("outcome", strings.TrimSpace(outcome)),
),
)
}
// RecordSanctionMutation records one sanction command outcome.
func (r *Runtime) RecordSanctionMutation(ctx context.Context, command string, outcome string) {
if r == nil {
return
}
r.sanctionMutations.Add(
normalizeContext(ctx),
1,
metric.WithAttributes(
attribute.String("command", strings.TrimSpace(command)),
attribute.String("outcome", strings.TrimSpace(outcome)),
),
)
}
// RecordLimitMutation records one limit command outcome.
func (r *Runtime) RecordLimitMutation(ctx context.Context, command string, outcome string) {
if r == nil {
return
}
r.limitMutations.Add(
normalizeContext(ctx),
1,
metric.WithAttributes(
attribute.String("command", strings.TrimSpace(command)),
attribute.String("outcome", strings.TrimSpace(outcome)),
),
)
}
// RecordEventPublicationFailure records one post-commit auxiliary event
// publication failure.
func (r *Runtime) RecordEventPublicationFailure(ctx context.Context, eventType string) {
if r == nil {
return
}
r.eventPublicationFailures.Add(
normalizeContext(ctx),
1,
metric.WithAttributes(attribute.String("event_type", strings.TrimSpace(eventType))),
)
}
func newProcess(ctx context.Context, cfg ProcessConfig, logger *slog.Logger, stdoutTraceWriter io.Writer, stdoutMetricWriter io.Writer) (*Runtime, error) {
if ctx == nil {
return nil, errors.New("new user telemetry process: nil context")
}
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("new user telemetry process: %w", err)
}
if logger == nil {
logger = slog.Default()
}
if strings.TrimSpace(cfg.ServiceName) == "" {
cfg.ServiceName = defaultServiceName
}
res, err := resource.New(
ctx,
resource.WithAttributes(attribute.String("service.name", cfg.ServiceName)),
)
if err != nil {
return nil, fmt.Errorf("new user telemetry process: resource: %w", err)
}
tracerProvider, err := newTracerProvider(ctx, res, cfg, stdoutTraceWriter)
if err != nil {
return nil, fmt.Errorf("new user telemetry process: tracer provider: %w", err)
}
registry := prometheus.NewRegistry()
prometheusExporter, err := otelprom.New(otelprom.WithRegisterer(registry))
if err != nil {
return nil, fmt.Errorf("new user telemetry process: prometheus exporter: %w", err)
}
meterProvider, err := newMeterProvider(ctx, res, cfg, prometheusExporter, stdoutMetricWriter)
if err != nil {
return nil, fmt.Errorf("new user telemetry process: meter provider: %w", err)
}
otel.SetTracerProvider(tracerProvider)
otel.SetMeterProvider(meterProvider)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
))
runtime, err := buildRuntime(
meterProvider,
tracerProvider,
promhttp.HandlerFor(registry, promhttp.HandlerOpts{}),
[]func(context.Context) error{
meterProvider.Shutdown,
tracerProvider.Shutdown,
},
)
if err != nil {
return nil, fmt.Errorf("new user telemetry process: %w", err)
}
logger.InfoContext(ctx, "user telemetry configured",
"service_name", cfg.ServiceName,
"traces_exporter", cfg.TracesExporter,
"metrics_exporter", cfg.MetricsExporter,
"stdout_traces_enabled", cfg.StdoutTracesEnabled,
"stdout_metrics_enabled", cfg.StdoutMetricsEnabled,
)
return runtime, nil
}
func buildRuntime(
meterProvider metric.MeterProvider,
tracerProvider oteltrace.TracerProvider,
promHandler http.Handler,
shutdownFns []func(context.Context) error,
) (*Runtime, error) {
meter := meterProvider.Meter(meterName)
internalHTTPRequests, err := meter.Int64Counter("user.internal_http.requests")
if err != nil {
return nil, fmt.Errorf("build user telemetry runtime: internal_http.requests: %w", err)
}
internalHTTPDuration, err := meter.Float64Histogram("user.internal_http.duration", metric.WithUnit("ms"))
if err != nil {
return nil, fmt.Errorf("build user telemetry runtime: internal_http.duration: %w", err)
}
authResolutionOutcomes, err := meter.Int64Counter("user.auth_resolution.outcomes")
if err != nil {
return nil, fmt.Errorf("build user telemetry runtime: auth_resolution.outcomes: %w", err)
}
userCreationOutcomes, err := meter.Int64Counter("user.user_creation.outcomes")
if err != nil {
return nil, fmt.Errorf("build user telemetry runtime: user_creation.outcomes: %w", err)
}
raceNameReservationConflicts, err := meter.Int64Counter("user.race_name.reservation_conflicts")
if err != nil {
return nil, fmt.Errorf("build user telemetry runtime: race_name.reservation_conflicts: %w", err)
}
entitlementMutations, err := meter.Int64Counter("user.entitlement.mutations")
if err != nil {
return nil, fmt.Errorf("build user telemetry runtime: entitlement.mutations: %w", err)
}
sanctionMutations, err := meter.Int64Counter("user.sanction.mutations")
if err != nil {
return nil, fmt.Errorf("build user telemetry runtime: sanction.mutations: %w", err)
}
limitMutations, err := meter.Int64Counter("user.limit.mutations")
if err != nil {
return nil, fmt.Errorf("build user telemetry runtime: limit.mutations: %w", err)
}
eventPublicationFailures, err := meter.Int64Counter("user.event_publication_failures")
if err != nil {
return nil, fmt.Errorf("build user telemetry runtime: event_publication_failures: %w", err)
}
if promHandler == nil {
promHandler = http.NotFoundHandler()
}
return &Runtime{
tracerProvider: tracerProvider,
meterProvider: meterProvider,
promHandler: promHandler,
shutdownFns: shutdownFns,
internalHTTPRequests: internalHTTPRequests,
internalHTTPDuration: internalHTTPDuration,
authResolutionOutcomes: authResolutionOutcomes,
userCreationOutcomes: userCreationOutcomes,
raceNameReservationConflicts: raceNameReservationConflicts,
entitlementMutations: entitlementMutations,
sanctionMutations: sanctionMutations,
limitMutations: limitMutations,
eventPublicationFailures: eventPublicationFailures,
}, nil
}
func newTracerProvider(ctx context.Context, res *resource.Resource, cfg ProcessConfig, stdoutWriter io.Writer) (*sdktrace.TracerProvider, error) {
options := []sdktrace.TracerProviderOption{sdktrace.WithResource(res)}
if cfg.TracesExporter == processExporterOTLP {
exporter, err := newOTLPTraceExporter(ctx, cfg.TracesProtocol)
if err != nil {
return nil, err
}
options = append(options, sdktrace.WithBatcher(exporter))
}
if cfg.StdoutTracesEnabled {
exporter, err := stdouttrace.New(
stdouttrace.WithPrettyPrint(),
stdouttrace.WithWriter(stdoutWriter),
)
if err != nil {
return nil, err
}
options = append(options, sdktrace.WithBatcher(exporter))
}
return sdktrace.NewTracerProvider(options...), nil
}
func newMeterProvider(
ctx context.Context,
res *resource.Resource,
cfg ProcessConfig,
prometheusExporter sdkmetric.Reader,
stdoutWriter io.Writer,
) (*sdkmetric.MeterProvider, error) {
options := []sdkmetric.Option{
sdkmetric.WithResource(res),
sdkmetric.WithReader(prometheusExporter),
}
if cfg.MetricsExporter == processExporterOTLP {
exporter, err := newOTLPMetricExporter(ctx, cfg.MetricsProtocol)
if err != nil {
return nil, err
}
options = append(options, sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exporter)))
}
if cfg.StdoutMetricsEnabled {
exporter, err := stdoutmetric.New(
stdoutmetric.WithPrettyPrint(),
stdoutmetric.WithWriter(stdoutWriter),
)
if err != nil {
return nil, err
}
options = append(options, sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exporter)))
}
return sdkmetric.NewMeterProvider(options...), nil
}
func newOTLPTraceExporter(ctx context.Context, protocol string) (sdktrace.SpanExporter, error) {
switch protocol {
case "", processProtocolHTTPProtobuf:
return otlptracehttp.New(ctx)
case processProtocolGRPC:
return otlptracegrpc.New(ctx)
default:
return nil, fmt.Errorf("unsupported OTLP traces protocol %q", protocol)
}
}
func newOTLPMetricExporter(ctx context.Context, protocol string) (sdkmetric.Exporter, error) {
switch protocol {
case "", processProtocolHTTPProtobuf:
return otlpmetrichttp.New(ctx)
case processProtocolGRPC:
return otlpmetricgrpc.New(ctx)
default:
return nil, fmt.Errorf("unsupported OTLP metrics protocol %q", protocol)
}
}
func normalizeContext(ctx context.Context) context.Context {
if ctx == nil {
return context.Background()
}
return ctx
}
+186
View File
@@ -0,0 +1,186 @@
package telemetry
import (
"bytes"
"context"
"io"
"log/slog"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/attribute"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
)
func TestNewProcessBuildsWithoutExporters(t *testing.T) {
t.Parallel()
runtime, err := newProcess(context.Background(), ProcessConfig{
ServiceName: "galaxy-user-test",
TracesExporter: processExporterNone,
MetricsExporter: processExporterNone,
}, slog.New(slog.NewTextHandler(io.Discard, nil)), io.Discard, io.Discard)
require.NoError(t, err)
assert.NotNil(t, runtime.TracerProvider())
assert.NotNil(t, runtime.MeterProvider())
assert.NotNil(t, runtime.Handler())
require.NoError(t, runtime.Shutdown(context.Background()))
require.NoError(t, runtime.Shutdown(context.Background()))
}
func TestNewProcessBuildsWithStdoutExporters(t *testing.T) {
t.Parallel()
traceBuffer := &bytes.Buffer{}
metricBuffer := &bytes.Buffer{}
runtime, err := newProcess(context.Background(), ProcessConfig{
ServiceName: "galaxy-user-test",
TracesExporter: processExporterNone,
MetricsExporter: processExporterNone,
StdoutTracesEnabled: true,
StdoutMetricsEnabled: true,
}, slog.New(slog.NewTextHandler(io.Discard, nil)), traceBuffer, metricBuffer)
require.NoError(t, err)
ctx, span := runtime.TracerProvider().Tracer("test").Start(context.Background(), "internal-request")
runtime.RecordUserCreationOutcome(ctx, "created")
span.End()
require.NoError(t, runtime.Shutdown(context.Background()))
assert.NotEmpty(t, traceBuffer.String())
assert.NotEmpty(t, metricBuffer.String())
}
func TestNewPreservesBusinessMetrics(t *testing.T) {
t.Parallel()
reader := sdkmetric.NewManualReader()
meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
tracerProvider := sdktrace.NewTracerProvider()
runtime, err := NewWithProviders(meterProvider, tracerProvider)
require.NoError(t, err)
runtime.RecordInternalHTTPRequest(context.Background(), []attribute.KeyValue{
attribute.String("route", "/api/v1/internal/users/:user_id/exists"),
attribute.String("method", "GET"),
attribute.String("edge_outcome", "success"),
}, 125*time.Millisecond)
runtime.RecordAuthResolutionOutcome(context.Background(), "resolve_by_email", "existing")
runtime.RecordUserCreationOutcome(context.Background(), "created")
runtime.RecordRaceNameReservationConflict(context.Background(), "update_my_profile")
runtime.RecordEntitlementMutation(context.Background(), "grant", "success")
runtime.RecordSanctionMutation(context.Background(), "apply", "conflict")
runtime.RecordLimitMutation(context.Background(), "remove", "subject_not_found")
runtime.RecordEventPublicationFailure(context.Background(), "user.profile.changed")
assertMetricCount(t, reader, "user.internal_http.requests", map[string]string{
"route": "/api/v1/internal/users/:user_id/exists",
"method": "GET",
"edge_outcome": "success",
}, 1)
assertHistogramCount(t, reader, "user.internal_http.duration", map[string]string{
"route": "/api/v1/internal/users/:user_id/exists",
"method": "GET",
"edge_outcome": "success",
}, 1)
assertMetricCount(t, reader, "user.auth_resolution.outcomes", map[string]string{
"operation": "resolve_by_email",
"outcome": "existing",
}, 1)
assertMetricCount(t, reader, "user.user_creation.outcomes", map[string]string{
"outcome": "created",
}, 1)
assertMetricCount(t, reader, "user.race_name.reservation_conflicts", map[string]string{
"operation": "update_my_profile",
}, 1)
assertMetricCount(t, reader, "user.entitlement.mutations", map[string]string{
"command": "grant",
"outcome": "success",
}, 1)
assertMetricCount(t, reader, "user.sanction.mutations", map[string]string{
"command": "apply",
"outcome": "conflict",
}, 1)
assertMetricCount(t, reader, "user.limit.mutations", map[string]string{
"command": "remove",
"outcome": "subject_not_found",
}, 1)
assertMetricCount(t, reader, "user.event_publication_failures", map[string]string{
"event_type": "user.profile.changed",
}, 1)
}
func assertMetricCount(t *testing.T, reader *sdkmetric.ManualReader, metricName string, wantAttrs map[string]string, wantValue int64) {
t.Helper()
var resourceMetrics metricdata.ResourceMetrics
require.NoError(t, reader.Collect(context.Background(), &resourceMetrics))
for _, scopeMetrics := range resourceMetrics.ScopeMetrics {
for _, metric := range scopeMetrics.Metrics {
if metric.Name != metricName {
continue
}
sum, ok := metric.Data.(metricdata.Sum[int64])
require.True(t, ok)
for _, point := range sum.DataPoints {
if hasMetricAttributes(point.Attributes.ToSlice(), wantAttrs) {
assert.Equal(t, wantValue, point.Value)
return
}
}
}
}
require.Failf(t, "test failed", "metric %q with attrs %v not found", metricName, wantAttrs)
}
func assertHistogramCount(t *testing.T, reader *sdkmetric.ManualReader, metricName string, wantAttrs map[string]string, wantCount uint64) {
t.Helper()
var resourceMetrics metricdata.ResourceMetrics
require.NoError(t, reader.Collect(context.Background(), &resourceMetrics))
for _, scopeMetrics := range resourceMetrics.ScopeMetrics {
for _, metric := range scopeMetrics.Metrics {
if metric.Name != metricName {
continue
}
histogram, ok := metric.Data.(metricdata.Histogram[float64])
require.True(t, ok)
for _, point := range histogram.DataPoints {
if hasMetricAttributes(point.Attributes.ToSlice(), wantAttrs) {
assert.Equal(t, wantCount, point.Count)
return
}
}
}
}
require.Failf(t, "test failed", "histogram %q with attrs %v not found", metricName, wantAttrs)
}
func hasMetricAttributes(values []attribute.KeyValue, want map[string]string) bool {
if len(values) != len(want) {
return false
}
for _, value := range values {
if want[string(value.Key)] != value.Value.AsString() {
return false
}
}
return true
}