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
+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
}