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
@@ -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)
)