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
+103 -32
View File
@@ -1,5 +1,9 @@
# User Service Implementation Plan
This plan has been already implemented and stays here for historical reasons.
It should NOT be threated as source of truth for service functionality.
## Planning Principles
This plan is aligned with the current repository architecture and is written
@@ -17,7 +21,9 @@ Execution priorities:
- keep the first version storage-agnostic at the domain boundary even if Redis
is the initial backend
## Stage 01 — Freeze Vocabulary, Contracts, and Cross-Service Ownership
## ~~Stage 01~~ — Freeze Vocabulary, Contracts, and Cross-Service Ownership
Status: implemented.
### Goal
@@ -38,8 +44,10 @@ Remove naming ambiguity and freeze the service boundary before implementation.
- workflow and history in `Geo Profile Service`
- Freeze the auth-facing internal REST endpoints already reserved by
`Auth / Session Service`.
- Freeze the need for create-only registration context on
`EnsureUserByEmail`.
- Freeze the exact create-only registration context shape on
`EnsureUserByEmail`:
- `preferred_language`
- `time_zone`
### Deliverables
@@ -58,7 +66,9 @@ Remove naming ambiguity and freeze the service boundary before implementation.
- none yet beyond documentation review
## Stage 02 — Define Domain Entities and Redis-Backed Logical State
## ~~Stage 02~~ — Define Domain Entities and Redis-Backed Logical State
Status: implemented.
### Goal
@@ -97,7 +107,9 @@ without revisiting core semantics.
- domain validation tests for required fields
- tests for effective-state evaluation of active versus expired records
## Stage 03 — Implement Auth-Facing Resolution, Ensure, Existence, and E-Mail Blocking
## ~~Stage 03~~ — Implement Auth-Facing Resolution, Ensure, Existence, and E-Mail Blocking
Status: implemented.
### Goal
@@ -122,6 +134,12 @@ Provide the minimum trusted API needed by `Auth / Session Service`.
- trusted internal REST handlers for auth-facing endpoints
- domain services for resolution and block behavior
- Redis-backed storage for user existence and blocked-email subjects
- runnable `cmd/userservice` process using `Gin` and `go-redis/v9`
- durable create path that already materializes:
- opaque `user_id`
- generated `player-<shortid>` race name
- stored `preferred_language` and `time_zone`
- initial free entitlement snapshot
### Exit Criteria
@@ -137,22 +155,26 @@ Provide the minimum trusted API needed by `Auth / Session Service`.
- block by user id on unknown user returns not found
- repeated block calls stay idempotent
## Stage 04 — Add New-User Creation Context from Auth
## ~~Stage 04~~Implement New-User Creation Context from Auth
Status: implemented.
### Goal
Support first-login user creation with initial settings captured at confirm
time.
Tighten the already-implemented first-login create path with stricter semantic
validation.
### Tasks
- Extend `EnsureUserByEmail` contract with create-only registration context:
- Preserve the already-frozen create-only `EnsureUserByEmail`
registration context with:
- `preferred_language`
- `time_zone`
- Validate `preferred_language` as BCP 47.
- Validate `time_zone` as IANA TZ name.
- Generate initial `race_name` in `player-<shortid>` form during creation.
- Initialize the newly created user with:
- Tighten `preferred_language` validation to BCP 47 semantics.
- Tighten `time_zone` validation to IANA TZ semantics.
- Preserve generated initial `race_name` in `player-<shortid>` form during
creation.
- Preserve the newly created user initialization with:
- free entitlement
- no active sanctions
- no custom limits
@@ -161,9 +183,9 @@ time.
### Deliverables
- extended ensure-by-email request model
- create-user domain service
- create-user domain service using the frozen ensure-by-email request model
- generated-race-name helper
- create-path validation for `preferred_language` and `time_zone`
### Exit Criteria
@@ -177,7 +199,9 @@ time.
- existing user ensure ignores create-only registration context
- invalid BCP 47 or IANA inputs are rejected on create path
## Stage 05 — Implement Self-Service Account Read and Split Profile/Settings Mutations
## ~~Stage 05~~ — Implement Self-Service Account Read and Split Profile/Settings Mutations
Status: implemented.
### Goal
@@ -220,7 +244,9 @@ Expose the minimal authenticated account surface routed by `Edge Gateway`.
- `UpdateMySettings` validates BCP 47 and IANA values
- active `profile_update_block` denies both update flows
## Stage 06 — Implement race_name Uniqueness Policy Behind a Dedicated Interface
## ~~Stage 06~~ — Implement race_name Uniqueness Policy Behind a Dedicated Interface
Status: implemented.
### Goal
@@ -256,7 +282,9 @@ Keep `race_name` uniqueness strict and replaceable.
- rename releases the old reservation only after the new one is secured
- failed reservation backend causes mutation to fail closed
## Stage 07 — Implement Entitlement History Plus Materialized Current Snapshot
## ~~Stage 07~~ — Implement Entitlement History Plus Materialized Current Snapshot
Status: implemented.
### Goal
@@ -298,7 +326,9 @@ Support both auditability and fast synchronous entitlement reads.
- free default is created for new users
- extending or revoking access preserves deterministic current-state behavior
## Stage 08 — Implement Sanctions and Limit Records with Active/Effective Evaluation
## ~~Stage 08~~ — Implement Sanctions and Limit Records with Active/Effective Evaluation
Status: implemented.
### Goal
@@ -317,11 +347,23 @@ consumers.
- `profile_update_block`
- Freeze v1 limit catalog:
- `max_owned_private_games`
- `max_active_private_games`
- `max_pending_public_applications`
- `max_pending_private_join_requests`
- `max_pending_private_invites_sent`
- `max_active_game_memberships`
- Freeze supported v1 limit semantics:
- paid effective defaults:
- `max_owned_private_games=3`
- `max_pending_public_applications=10`
- `max_active_game_memberships=10`
- free effective defaults:
- `max_owned_private_games` is omitted
- `max_pending_public_applications=3`
- `max_active_game_memberships=3`
- `max_active_game_memberships` applies only to public games
- `max_pending_public_applications` is the total public-games budget and is
interpreted by `Game Lobby` together with current active public
memberships
- Keep legacy retired limit codes backward-compatible on reads, but reject
them for new trusted limit commands.
- Implement active/effective evaluation with current time.
- Implement trusted explicit commands to apply/remove sanctions and set/remove
limits.
@@ -343,9 +385,14 @@ consumers.
- active sanctions appear in account reads
- expired sanctions and limits stop affecting effective state
- retired legacy limit records are ignored during reads and effective
evaluation
- retired legacy limit codes are rejected by trusted limit commands
- applying and removing sanctions/limits is idempotent where appropriate
## Stage 09 — Implement Lobby Eligibility Snapshot API
## ~~Stage 09~~ — Implement Lobby Eligibility Snapshot API
Status: implemented.
### Goal
@@ -361,6 +408,16 @@ user-level access decisions.
- active lobby-relevant sanctions
- effective lobby-relevant limits
- derived booleans for lobby decisions
- Freeze the lobby-facing effective limit catalog:
- paid users receive `max_owned_private_games=3`,
`max_pending_public_applications=10`, and
`max_active_game_memberships=10`
- free users omit `max_owned_private_games` and receive
`max_pending_public_applications=3` and
`max_active_game_memberships=3`
- `max_pending_public_applications` remains the total public-games budget
consumed together with current active public memberships inside
`Game Lobby`
- Keep the response read-optimized so lobby does not need multiple dependent
calls back into `User Service`.
- Define deterministic not-found behavior.
@@ -381,8 +438,12 @@ user-level access decisions.
- lobby eligibility snapshot reflects paid status, sanctions, and limits
- unknown user returns stable not-found behavior
- derived booleans remain consistent with raw effective state
- free and paid snapshots materialize the reduced three-code effective limit
catalog correctly
## Stage 10 — Implement Geo declared_country Sync Command
## ~~Stage 10~~ — Implement Geo declared_country Sync Command
Status: implemented.
### Goal
@@ -416,7 +477,9 @@ Support the current-country denormalization path owned by `Geo Profile Service`.
- invalid country codes are rejected
- country sync emits the correct auxiliary event after commit
## Stage 11 — Implement Admin Lookup, Filtered Listing, and Explicit Trusted Mutations
## ~~Stage 11~~ — Implement Admin Lookup, Filtered Listing, and Explicit Trusted Mutations
Status: implemented.
### Goal
@@ -462,7 +525,9 @@ operations.
- exact lookups by `user_id`, email, and `race_name` resolve the correct user
- every trusted mutation preserves actor and reason metadata
## Stage 12 — Add Per-Domain-Area Async Events and Observability
## ~~Stage 12~~ — Add Per-Domain-Area Async Events and Observability
Status: implemented.
### Goal
@@ -505,7 +570,9 @@ truth.
- event payloads include minimum required metadata
- observability hooks do not change business behavior
## Stage 13 — Add Contract Tests Against Auth, Lobby, and Geo Expectations
## ~~Stage 13~~ — Add Contract Tests Against Auth, Lobby, and Geo Expectations
Status: implemented.
### Goal
@@ -542,7 +609,9 @@ must satisfy for other services.
- lobby eligibility snapshot reflects paid status, sanctions, and limits
- geo country sync changes only current `declared_country`
## Stage 14 — Add Rollout Notes for Gateway/Auth/OpenAPI Updates and Shared geoip
## ~~Stage 14~~ — Add Rollout Notes for Gateway/Auth/OpenAPI Updates and Shared geoip
Status: implemented.
### Goal
@@ -551,11 +620,13 @@ its intended end-to-end form.
### Tasks
- Document the required `gateway` public `confirm-email-code` addition of
- Document the required `gateway` public `confirm-email-code` dependency on
`time_zone`.
- Document the required `authsession` public OpenAPI preservation of the same
`time_zone` requirement.
- Document that the frozen `authsession -> user` ensure contract requires
create-only `registration_context` with `preferred_language` and
`time_zone`.
- Document the required `authsession` public OpenAPI mirror change.
- Document the required `authsession -> user` ensure contract extension for
create-only registration context.
- Document the required shared `pkg/geoip` package for gateway and geo.
- Document README follow-up updates needed in `gateway` and `geoprofile`.
- Define rollout order so the cross-service contract changes do not land in an
+267 -689
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"galaxy/user/internal/app"
"galaxy/user/internal/config"
"galaxy/user/internal/logging"
)
func main() {
if err := run(); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "userservice: %v\n", err)
os.Exit(1)
}
}
func run() error {
cfg, err := config.LoadFromEnv()
if err != nil {
return err
}
logger, err := logging.New(cfg.Logging.Level)
if err != nil {
return err
}
rootCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
runtime, err := app.NewRuntime(rootCtx, cfg, logger)
if err != nil {
return err
}
defer func() {
_ = runtime.Close()
}()
return runtime.Run(rootCtx)
}
+19
View File
@@ -0,0 +1,19 @@
# User Service Docs
This directory keeps service-local documentation that is more operational or
more example-heavy than [`../README.md`](../README.md).
Sections:
- [Runtime and components](runtime.md)
- [Main flows and boundaries](flows.md)
- [Operator runbook](runbook.md)
- [Contract examples](examples.md)
Primary references:
- [`../README.md`](../README.md) for stable service scope and business rules
- [`../openapi.yaml`](../openapi.yaml) for the trusted internal REST contract
- [`../../ARCHITECTURE.md`](../../ARCHITECTURE.md) for system-level transport
and ownership rules
- [`../../TESTING.md`](../../TESTING.md) for the cross-service testing matrix
+206
View File
@@ -0,0 +1,206 @@
# Contract Examples
## ensure-by-email
Request:
```json
{
"email": "pilot@example.com",
"registration_context": {
"preferred_language": "en",
"time_zone": "Europe/Kaliningrad"
}
}
```
Created response:
```json
{
"outcome": "created",
"user_id": "user-123"
}
```
Existing response:
```json
{
"outcome": "existing",
"user_id": "user-123"
}
```
Blocked response:
```json
{
"outcome": "blocked",
"block_reason_code": "policy_blocked"
}
```
## account aggregate
```json
{
"account": {
"user_id": "user-123",
"email": "pilot@example.com",
"race_name": "Pilot Nova",
"preferred_language": "en",
"time_zone": "Europe/Kaliningrad",
"declared_country": "DE",
"entitlement": {
"plan_code": "free",
"is_paid": false,
"source": "auth_registration",
"actor": {
"type": "service",
"id": "user-service"
},
"reason_code": "initial_free_entitlement",
"starts_at": "2026-04-09T10:00:00Z",
"updated_at": "2026-04-09T10:00:00Z"
},
"active_sanctions": [],
"active_limits": [],
"created_at": "2026-04-09T10:00:00Z",
"updated_at": "2026-04-09T10:00:00Z"
}
}
```
## update profile
Request:
```json
{
"race_name": "Nova Prime"
}
```
Success:
```json
{
"account": {
"user_id": "user-123",
"email": "pilot@example.com",
"race_name": "Nova Prime",
"preferred_language": "en",
"time_zone": "Europe/Kaliningrad",
"entitlement": {
"plan_code": "free",
"is_paid": false,
"source": "auth_registration",
"actor": {
"type": "service",
"id": "user-service"
},
"reason_code": "initial_free_entitlement",
"starts_at": "2026-04-09T10:00:00Z",
"updated_at": "2026-04-09T10:00:00Z"
},
"active_sanctions": [],
"active_limits": [],
"created_at": "2026-04-09T10:00:00Z",
"updated_at": "2026-04-09T10:05:00Z"
}
}
```
Conflict:
```json
{
"error": {
"code": "conflict",
"message": "request conflicts with current state"
}
}
```
## update settings
Request:
```json
{
"preferred_language": "fr-FR",
"time_zone": "Europe/Paris"
}
```
## admin lookup by e-mail
Request:
```json
{
"email": "pilot@example.com"
}
```
Success:
```json
{
"user": {
"user_id": "user-123",
"email": "pilot@example.com",
"race_name": "Pilot Nova",
"preferred_language": "en",
"time_zone": "Europe/Kaliningrad",
"entitlement": {
"plan_code": "free",
"is_paid": false,
"source": "auth_registration",
"actor": {
"type": "service",
"id": "user-service"
},
"reason_code": "initial_free_entitlement",
"starts_at": "2026-04-09T10:00:00Z",
"updated_at": "2026-04-09T10:00:00Z"
},
"active_sanctions": [],
"active_limits": [],
"created_at": "2026-04-09T10:00:00Z",
"updated_at": "2026-04-09T10:00:00Z"
}
}
```
## declared-country sync
Request:
```json
{
"declared_country": "DE"
}
```
Response:
```json
{
"user_id": "user-123",
"declared_country": "DE",
"updated_at": "2026-04-09T10:10:00Z"
}
```
## shared error envelope
```json
{
"error": {
"code": "invalid_request",
"message": "request is invalid"
}
}
```
+163
View File
@@ -0,0 +1,163 @@
# Main Flows and Boundaries
## Auth / Session -> User
`Auth / Session Service` uses synchronous REST calls for user ownership
decisions during public auth.
### Resolve by e-mail
`POST /api/v1/internal/user-resolutions/by-email`
Outcome vocabulary:
- `creatable`
- `existing`
- `blocked`
The decision is based on exact-after-trim e-mail matching plus the current
block state for that subject.
### Ensure by e-mail
`POST /api/v1/internal/users/ensure-by-email`
Rules:
- `registration_context` is required
- `registration_context` is create-only
- existing users ignore the supplied registration context
- blocked subjects return `blocked` rather than creating a user
- the current rollout sends temporary `preferred_language="en"` from
authsession and forwards the public confirm `time_zone`
Create side effects:
- generate opaque `user_id`
- generate default `player-*` race name
- store initial preferred language and time zone
- materialize the initial free entitlement snapshot
- publish initialization-style profile, settings, and entitlement events
## Gateway -> User
Gateway owns the external authenticated gRPC contract and transcodes to this
service's internal REST API.
External authenticated message types:
- `user.account.get`
- `user.profile.update`
- `user.settings.update`
Internal REST routes:
- `GET /api/v1/internal/users/{user_id}/account`
- `POST /api/v1/internal/users/{user_id}/profile`
- `POST /api/v1/internal/users/{user_id}/settings`
Rules:
- gateway derives `user_id` from authenticated session context only
- success returns the shared account aggregate
- business errors return stable `code` and `message`
- timeout or upstream `503` stay transport-level unavailable at gateway
### Profile update
`UpdateMyProfile` changes only `race_name`.
Rules:
- preserve stored casing on success
- enforce canonical reservation uniqueness
- reject conflicts as `409 conflict`
- reject writes while `profile_update_block` is active
- return current aggregate on no-op rename
### Settings update
`UpdateMySettings` changes only:
- `preferred_language`
- `time_zone`
Rules:
- validate BCP 47 and IANA semantics
- reject writes while `profile_update_block` is active
- return the refreshed account aggregate
## Lobby -> User
`Game Lobby Service` reads one synchronous eligibility snapshot through:
- `GET /api/v1/internal/users/{user_id}/eligibility`
Rules:
- unknown users return `exists=false`
- current entitlement is expiry-repaired lazily
- active sanctions are filtered to the lobby-relevant set
- effective limits combine default catalog values plus active overrides
- markers are derived from sanctions, entitlement, and limits
## Geo -> User
`Geo Profile Service` synchronizes the latest approved effective declared
country through:
- `POST /api/v1/internal/users/{user_id}/declared-country/sync`
Rules:
- input must be uppercase ISO 3166-1 alpha-2
- syncing the stored value is a no-op
- `User Service` stores only the current effective value
- geo owns review workflow and history
- successful updates publish `user.declared_country.changed`
## Admin Reads And Commands
Trusted admin callers use:
- exact reads by `user_id`, e-mail, and race name
- deterministic filtered listing
- explicit entitlement commands
- explicit sanction commands
- explicit limit commands
Listing rules:
- order by `created_at desc`, then `user_id desc`
- combine filters with `AND`
- `page_token` is opaque and filter-bound
## Domain Events
The shared auxiliary event stream contains post-commit state propagation for:
- `user.profile.changed`
- `user.settings.changed`
- `user.entitlement.changed`
- `user.sanction.changed`
- `user.limit.changed`
- `user.declared_country.changed`
Operation vocabularies:
- profile and settings:
- `initialized`
- `updated`
- entitlement:
- `initialized`
- `granted`
- `extended`
- `revoked`
- `expired_repaired`
- sanction:
- `applied`
- `removed`
- limit:
- `set`
- `removed`
+106
View File
@@ -0,0 +1,106 @@
# Runbook
## Startup Checklist
Before starting `userservice`, verify:
- `USERSERVICE_REDIS_ADDR` points to the intended Redis instance
- internal HTTP bind address is free
- optional admin metrics listener does not collide with another process
- domain-events stream settings match the environment that consumes them
Expected startup behavior:
- configuration is loaded and validated first
- Redis-backed stores and publishers are constructed
- startup fails fast on Redis misconfiguration or connectivity failure
## Health And Readiness
`userservice` does not expose public health endpoints.
Operational readiness is typically checked through one trusted internal route,
for example:
- `GET /api/v1/internal/users/{user_id}/exists`
with a guaranteed-missing `user_id`. A healthy process returns `200` with
`{"exists":false}`.
If admin metrics are enabled, `/metrics` on the admin listener is the
additional process-level operational endpoint.
## Common Failure Modes
### Redis unavailable
Symptoms:
- process fails during startup
- internal API returns `503 service_unavailable`
- domain events stop being published
Checks:
- connectivity to `USERSERVICE_REDIS_ADDR`
- Redis ACL credentials
- Redis DB number
- TLS setting mismatch
### Invalid registration context
Symptoms:
- `ensure-by-email` returns `400 invalid_request`
Checks:
- `preferred_language` is a valid BCP 47 tag
- `time_zone` is a valid IANA time-zone name
### race_name conflict
Symptoms:
- profile update returns `409 conflict`
Checks:
- desired race name is not already reserved under canonical uniqueness rules
- user is not currently blocked by `profile_update_block`
### declared-country sync rejected
Symptoms:
- geo sync returns `400 invalid_request`
Checks:
- country code is uppercase ISO 3166-1 alpha-2
- trusted caller is using the intended internal route
## Safe Rollout Notes
- Keep `Auth / Session Service` and `User Service` aligned on the current
`registration_context` shape.
- During the current rollout, treat authsession-provided
`preferred_language="en"` as the active create-path contract.
- Gateway direct `user.*` self-service routing depends on the internal REST
routes staying stable.
- Do not roll out billing-driven entitlement mutations assuming another
service owns current entitlement state. `User Service` remains the source of
truth for current entitlement.
## Debugging Data Mismatches
When a caller reports mismatched user state:
1. Read the current account aggregate through the trusted internal route.
2. Confirm whether the discrepancy is in source-of-truth state or in a
downstream projection.
3. If the issue concerns declared-country workflow history, switch to `Geo
Profile Service`; `User Service` stores only the current effective value.
4. If the issue concerns authenticated edge transport, verify the same user
through gateway `user.account.get` to distinguish transport problems from
source-of-truth problems.
+151
View File
@@ -0,0 +1,151 @@
# Runtime and Components
The diagram below focuses on the deployed `galaxy/user` process and its
runtime dependencies.
```mermaid
flowchart LR
subgraph Callers
Auth["Auth / Session Service"]
Gateway["Edge Gateway"]
Lobby["Game Lobby Service"]
Geo["Geo Profile Service"]
Admin["Trusted admin callers"]
end
subgraph User["User Service process"]
InternalHTTP["Trusted internal HTTP listener\n/api/v1/internal/*"]
AdminHTTP["Optional admin HTTP listener\n/metrics"]
Services["Application services"]
Telemetry["Logs, traces, metrics"]
end
Redis["Redis\nkeyspace + domain-events stream"]
Auth --> InternalHTTP
Gateway --> InternalHTTP
Lobby --> InternalHTTP
Geo --> InternalHTTP
Admin --> InternalHTTP
InternalHTTP --> Services
Services --> Redis
InternalHTTP --> Telemetry
AdminHTTP --> Telemetry
```
## Listeners
`userservice` exposes two HTTP listeners:
| Listener | Default addr | Purpose |
| --- | --- | --- |
| Internal HTTP | `:8091` | Trusted business API under `/api/v1/internal/*` |
| Admin HTTP | disabled | Optional Prometheus metrics on `/metrics` |
Shared listener defaults:
- read-header timeout: `2s`
- read timeout: `10s`
- idle timeout: `1m`
The internal application timeout is configured separately through
`USERSERVICE_INTERNAL_HTTP_REQUEST_TIMEOUT`.
Intentional omissions:
- no public listener
- no authenticated edge gRPC listener
- no built-in `/healthz`
- no built-in `/readyz`
## Startup Wiring
`cmd/userservice` loads config, constructs logging and telemetry, and then
creates the runtime through `internal/app.NewRuntime`.
The runtime wires:
- Redis-backed stores for accounts, entitlement snapshots, sanctions, limits,
and listing indexes
- the trusted internal HTTP router
- the optional admin metrics listener
- the optional Redis-backed domain-event publishers
- service-local helpers for clock, IDs, and validation/policy adapters
Startup fails fast when Redis connectivity is unavailable or configuration is
invalid.
## Redis Namespaces
The service uses one Redis keyspace prefix plus one auxiliary domain-events
stream.
Configuration:
- `USERSERVICE_REDIS_KEYSPACE_PREFIX`
- `USERSERVICE_REDIS_DOMAIN_EVENTS_STREAM`
- `USERSERVICE_REDIS_DOMAIN_EVENTS_STREAM_MAX_LEN`
The keyspace stores source-of-truth business state. The stream carries
post-commit auxiliary domain events and must not be treated as the source of
truth.
## Configuration Groups
Required for all process starts:
- `USERSERVICE_REDIS_ADDR`
Core process config:
- `USERSERVICE_SHUTDOWN_TIMEOUT`
- `USERSERVICE_LOG_LEVEL`
Internal HTTP config:
- `USERSERVICE_INTERNAL_HTTP_ADDR`
- `USERSERVICE_INTERNAL_HTTP_READ_HEADER_TIMEOUT`
- `USERSERVICE_INTERNAL_HTTP_READ_TIMEOUT`
- `USERSERVICE_INTERNAL_HTTP_IDLE_TIMEOUT`
- `USERSERVICE_INTERNAL_HTTP_REQUEST_TIMEOUT`
Admin HTTP config:
- `USERSERVICE_ADMIN_HTTP_ADDR`
- `USERSERVICE_ADMIN_HTTP_READ_HEADER_TIMEOUT`
- `USERSERVICE_ADMIN_HTTP_READ_TIMEOUT`
- `USERSERVICE_ADMIN_HTTP_IDLE_TIMEOUT`
Redis connectivity and namespace config:
- `USERSERVICE_REDIS_USERNAME`
- `USERSERVICE_REDIS_PASSWORD`
- `USERSERVICE_REDIS_DB`
- `USERSERVICE_REDIS_TLS_ENABLED`
- `USERSERVICE_REDIS_OPERATION_TIMEOUT`
- `USERSERVICE_REDIS_KEYSPACE_PREFIX`
- `USERSERVICE_REDIS_DOMAIN_EVENTS_STREAM`
- `USERSERVICE_REDIS_DOMAIN_EVENTS_STREAM_MAX_LEN`
Telemetry:
- `OTEL_SERVICE_NAME`
- `OTEL_TRACES_EXPORTER`
- `OTEL_METRICS_EXPORTER`
- `OTEL_EXPORTER_OTLP_PROTOCOL`
- `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL`
- `OTEL_EXPORTER_OTLP_METRICS_PROTOCOL`
- `USERSERVICE_OTEL_STDOUT_TRACES_ENABLED`
- `USERSERVICE_OTEL_STDOUT_METRICS_ENABLED`
## Runtime Notes
- The service remains internal REST only; gateway owns external authenticated
gRPC and FlatBuffers.
- Gateway self-service traffic reaches this service over REST/JSON after
gateway-side authentication and FlatBuffers transcoding.
- Current direct synchronous callers are `Auth / Session Service`,
`Edge Gateway`, `Game Lobby Service`, `Geo Profile Service`, and trusted
admin callers.
- Domain-event publication is auxiliary. A failed auxiliary consumer must not
become the source of truth for current account state.
+89
View File
@@ -1,3 +1,92 @@
module galaxy/user
go 1.26.1
require (
github.com/alicebob/miniredis/v2 v2.37.0
github.com/disciplinedware/go-confusables v0.1.1
github.com/getkin/kin-openapi v0.135.0
github.com/gin-gonic/gin v1.12.0
github.com/prometheus/client_golang v1.23.2
github.com/redis/go-redis/v9 v9.18.0
github.com/stretchr/testify v1.11.1
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
go.opentelemetry.io/otel/exporters/prometheus v0.65.0
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0
go.opentelemetry.io/otel/metric v1.43.0
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/sdk/metric v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
golang.org/x/text v0.35.0
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.2 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/oasdiff/yaml v0.0.9 // indirect
github.com/oasdiff/yaml3 v0.0.9 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/woodsbury/decimal128 v1.3.0 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/arch v0.25.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.42.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+218
View File
@@ -0,0 +1,218 @@
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/disciplinedware/go-confusables v0.1.1 h1:l/JVOsdrEDHo7nvL+tQfRO1F14UyuuDm1Uvv3Nqmq9Q=
github.com/disciplinedware/go-confusables v0.1.1/go.mod h1:2hAXIAtpSqx+tMKdCzgRNv4J/kmz/oGfSHTBGJjVgfc=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/getkin/kin-openapi v0.135.0 h1:751SjYfbiwqukYuVjwYEIKNfrSwS5YpA7DZnKSwQgtg=
github.com/getkin/kin-openapi v0.135.0/go.mod h1:6dd5FJl6RdX4usBtFBaQhk9q62Yb2J0Mk5IhUO/QqFI=
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48=
github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM=
github.com/oasdiff/yaml3 v0.0.9 h1:rWPrKccrdUm8J0F3sGuU+fuh9+1K/RdJlWF7O/9yw2g=
github.com/oasdiff/yaml3 v0.0.9/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0 h1:5FXSL2s6afUC1bzNzl1iedZZ8yqR7GOhbCoEXtyeK6Q=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0/go.mod h1:MdHW7tLtkeGJnR4TyOrnd5D0zUGZQB1l84uHCe8hRpE=
go.opentelemetry.io/contrib/propagators/b3 v1.43.0 h1:CETqV3QLLPTy5yNrqyMr41VnAOOD4lsRved7n4QG00A=
go.opentelemetry.io/contrib/propagators/b3 v1.43.0/go.mod h1:Q4mCiCdziYzpNR0g+6UqVotAlCDZdzz6L8jwY4knOrw=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE=
go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+13
View File
@@ -0,0 +1,13 @@
// Package local provides small in-process runtime adapters used by the user
// service process.
package local
import "time"
// Clock returns the current wall-clock time.
type Clock struct{}
// Now returns the current time.
func (Clock) Now() time.Time {
return time.Now()
}
@@ -0,0 +1,29 @@
package local
import (
"context"
"fmt"
"galaxy/user/internal/ports"
)
// NoopDeclaredCountryChangedPublisher validates and discards auxiliary
// declared-country change events.
type NoopDeclaredCountryChangedPublisher struct{}
// PublishDeclaredCountryChanged validates event and discards it.
func (NoopDeclaredCountryChangedPublisher) PublishDeclaredCountryChanged(
ctx context.Context,
event ports.DeclaredCountryChangedEvent,
) error {
if ctx == nil {
return fmt.Errorf("publish declared-country changed event: nil context")
}
if err := ctx.Err(); err != nil {
return err
}
return event.Validate()
}
var _ ports.DeclaredCountryChangedPublisher = NoopDeclaredCountryChangedPublisher{}
@@ -0,0 +1,62 @@
package local
import (
"context"
"fmt"
"galaxy/user/internal/ports"
)
// NoopDomainEventPublisher validates and discards auxiliary user-domain
// events.
type NoopDomainEventPublisher struct{}
// PublishProfileChanged validates event and discards it.
func (NoopDomainEventPublisher) PublishProfileChanged(ctx context.Context, event ports.ProfileChangedEvent) error {
return validateNoopPublish(ctx, "publish profile changed event", event.Validate)
}
// PublishSettingsChanged validates event and discards it.
func (NoopDomainEventPublisher) PublishSettingsChanged(ctx context.Context, event ports.SettingsChangedEvent) error {
return validateNoopPublish(ctx, "publish settings changed event", event.Validate)
}
// PublishEntitlementChanged validates event and discards it.
func (NoopDomainEventPublisher) PublishEntitlementChanged(ctx context.Context, event ports.EntitlementChangedEvent) error {
return validateNoopPublish(ctx, "publish entitlement changed event", event.Validate)
}
// PublishSanctionChanged validates event and discards it.
func (NoopDomainEventPublisher) PublishSanctionChanged(ctx context.Context, event ports.SanctionChangedEvent) error {
return validateNoopPublish(ctx, "publish sanction changed event", event.Validate)
}
// PublishLimitChanged validates event and discards it.
func (NoopDomainEventPublisher) PublishLimitChanged(ctx context.Context, event ports.LimitChangedEvent) error {
return validateNoopPublish(ctx, "publish limit changed event", event.Validate)
}
// PublishDeclaredCountryChanged validates event and discards it.
func (NoopDomainEventPublisher) PublishDeclaredCountryChanged(ctx context.Context, event ports.DeclaredCountryChangedEvent) error {
return validateNoopPublish(ctx, "publish declared-country changed event", event.Validate)
}
func validateNoopPublish(ctx context.Context, operation string, validate func() error) error {
if ctx == nil {
return fmt.Errorf("%s: nil context", operation)
}
if err := ctx.Err(); err != nil {
return err
}
return validate()
}
var (
_ ports.ProfileChangedPublisher = NoopDomainEventPublisher{}
_ ports.SettingsChangedPublisher = NoopDomainEventPublisher{}
_ ports.EntitlementChangedPublisher = NoopDomainEventPublisher{}
_ ports.SanctionChangedPublisher = NoopDomainEventPublisher{}
_ ports.LimitChangedPublisher = NoopDomainEventPublisher{}
_ ports.DeclaredCountryChangedPublisher = NoopDomainEventPublisher{}
)
@@ -0,0 +1,105 @@
package local
import (
"crypto/rand"
"encoding/base32"
"fmt"
"strings"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
)
var base32NoPadding = base32.StdEncoding.WithPadding(base32.NoPadding)
// IDGenerator creates opaque stable user identifiers and generated initial
// race names.
type IDGenerator struct{}
// NewUserID returns one newly generated opaque user identifier.
func (IDGenerator) NewUserID() (common.UserID, error) {
token, err := randomToken(10)
if err != nil {
return "", fmt.Errorf("generate user id: %w", err)
}
userID := common.UserID("user-" + token)
if err := userID.Validate(); err != nil {
return "", fmt.Errorf("generate user id: %w", err)
}
return userID, nil
}
// NewInitialRaceName returns one generated race name in the `player-<shortid>`
// form.
func (IDGenerator) NewInitialRaceName() (common.RaceName, error) {
token, err := randomToken(5)
if err != nil {
return "", fmt.Errorf("generate initial race name: %w", err)
}
raceName := common.RaceName("player-" + token)
if err := raceName.Validate(); err != nil {
return "", fmt.Errorf("generate initial race name: %w", err)
}
return raceName, nil
}
// NewEntitlementRecordID returns one generated entitlement history record
// identifier.
func (IDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
token, err := randomToken(10)
if err != nil {
return "", fmt.Errorf("generate entitlement record id: %w", err)
}
recordID := entitlement.EntitlementRecordID("entitlement-" + token)
if err := recordID.Validate(); err != nil {
return "", fmt.Errorf("generate entitlement record id: %w", err)
}
return recordID, nil
}
// NewSanctionRecordID returns one generated sanction history record
// identifier.
func (IDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
token, err := randomToken(10)
if err != nil {
return "", fmt.Errorf("generate sanction record id: %w", err)
}
recordID := policy.SanctionRecordID("sanction-" + token)
if err := recordID.Validate(); err != nil {
return "", fmt.Errorf("generate sanction record id: %w", err)
}
return recordID, nil
}
// NewLimitRecordID returns one generated limit history record identifier.
func (IDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
token, err := randomToken(10)
if err != nil {
return "", fmt.Errorf("generate limit record id: %w", err)
}
recordID := policy.LimitRecordID("limit-" + token)
if err := recordID.Validate(); err != nil {
return "", fmt.Errorf("generate limit record id: %w", err)
}
return recordID, nil
}
func randomToken(size int) (string, error) {
buffer := make([]byte, size)
if _, err := rand.Read(buffer); err != nil {
return "", err
}
return strings.ToLower(base32NoPadding.EncodeToString(buffer)), nil
}
@@ -0,0 +1,65 @@
package local
import (
"fmt"
"strings"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/ports"
confusables "github.com/disciplinedware/go-confusables"
"golang.org/x/text/cases"
)
type confusableSkeletoner interface {
Skeleton(string) string
}
type raceNamePolicy struct {
caseFolder cases.Caser
skeletoner confusableSkeletoner
}
var raceNameAntiFraudReplacer = strings.NewReplacer(
"1", "i",
"0", "o",
"8", "b",
)
// NewRaceNamePolicy returns the local Stage 06 race-name canonicalization
// policy backed by Unicode case folding, explicit ASCII anti-fraud mappings,
// and a TR39 confusable skeleton.
func NewRaceNamePolicy() (ports.RaceNamePolicy, error) {
policy := &raceNamePolicy{
caseFolder: cases.Fold(),
skeletoner: confusables.Default(),
}
if policy.skeletoner == nil {
return nil, fmt.Errorf("new race-name policy: nil confusable skeletoner")
}
return policy, nil
}
// CanonicalKey returns the stable uniqueness key for raceName.
func (policy *raceNamePolicy) CanonicalKey(raceName common.RaceName) (account.RaceNameCanonicalKey, error) {
switch {
case policy == nil:
return "", fmt.Errorf("canonicalize race name: nil policy")
case policy.skeletoner == nil:
return "", fmt.Errorf("canonicalize race name: nil confusable skeletoner")
}
if err := raceName.Validate(); err != nil {
return "", fmt.Errorf("canonicalize race name: %w", err)
}
folded := policy.caseFolder.String(raceName.String())
antiFraudMapped := raceNameAntiFraudReplacer.Replace(folded)
key := account.RaceNameCanonicalKey(policy.skeletoner.Skeleton(antiFraudMapped))
if err := key.Validate(); err != nil {
return "", fmt.Errorf("canonicalize race name: %w", err)
}
return key, nil
}
@@ -0,0 +1,72 @@
package local
import (
"testing"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/service/shared"
"github.com/stretchr/testify/require"
)
func TestRaceNamePolicyCanonicalKey(t *testing.T) {
t.Parallel()
policy, err := NewRaceNamePolicy()
require.NoError(t, err)
tests := []struct {
name string
left common.RaceName
right common.RaceName
}{
{
name: "case insensitive collision",
left: common.RaceName("Pilot Nova"),
right: common.RaceName("pilot nova"),
},
{
name: "ascii anti fraud collision",
left: common.RaceName("Pilot Nova"),
right: common.RaceName("P1lot N0va"),
},
{
name: "unicode confusable collision",
left: common.RaceName("paypal"),
right: common.RaceName("раураl"),
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
leftKey, err := policy.CanonicalKey(tt.left)
require.NoError(t, err)
rightKey, err := policy.CanonicalKey(tt.right)
require.NoError(t, err)
require.Equal(t, rightKey, leftKey)
})
}
}
func TestBuildRaceNameReservationPreservesOriginalDisplayValue(t *testing.T) {
t.Parallel()
policy, err := NewRaceNamePolicy()
require.NoError(t, err)
record, err := shared.BuildRaceNameReservation(
policy,
common.UserID("user-123"),
common.RaceName("P1lot Nova"),
time.Unix(1_775_240_000, 0).UTC(),
)
require.NoError(t, err)
require.Equal(t, common.RaceName("P1lot Nova"), record.RaceName)
require.NotEqual(t, account.RaceNameCanonicalKey(""), record.CanonicalKey)
}
@@ -0,0 +1,311 @@
// Package domainevents implements Redis Stream-backed auxiliary user-domain
// event publishers.
package domainevents
import (
"context"
"crypto/tls"
"errors"
"fmt"
"strconv"
"strings"
"time"
"galaxy/user/internal/ports"
"github.com/redis/go-redis/v9"
"go.opentelemetry.io/otel/trace"
)
// Config configures one Redis-backed user domain-event publisher.
type Config struct {
// Addr is the Redis network address in host:port form.
Addr string
// Username is the optional Redis ACL username.
Username string
// Password is the optional Redis ACL password.
Password string
// DB is the Redis logical database index.
DB int
// TLSEnabled enables TLS with a conservative minimum protocol version.
TLSEnabled bool
// Stream identifies the Redis Stream key used for domain events.
Stream string
// StreamMaxLen bounds the stream with approximate trimming via
// `XADD MAXLEN ~`.
StreamMaxLen int64
// OperationTimeout bounds each Redis round trip performed by the adapter.
OperationTimeout time.Duration
}
// Publisher publishes auxiliary user-domain events into one Redis Stream.
type Publisher struct {
client *redis.Client
stream string
streamMaxLen int64
operationTimeout time.Duration
}
// New constructs a Redis-backed domain-event publisher from cfg.
func New(cfg Config) (*Publisher, error) {
switch {
case strings.TrimSpace(cfg.Addr) == "":
return nil, errors.New("new redis domain-event publisher: redis addr must not be empty")
case cfg.DB < 0:
return nil, errors.New("new redis domain-event publisher: redis db must not be negative")
case strings.TrimSpace(cfg.Stream) == "":
return nil, errors.New("new redis domain-event publisher: stream must not be empty")
case cfg.StreamMaxLen <= 0:
return nil, errors.New("new redis domain-event publisher: stream max len must be positive")
case cfg.OperationTimeout <= 0:
return nil, errors.New("new redis domain-event publisher: operation timeout must be positive")
}
options := &redis.Options{
Addr: cfg.Addr,
Username: cfg.Username,
Password: cfg.Password,
DB: cfg.DB,
Protocol: 2,
DisableIdentity: true,
}
if cfg.TLSEnabled {
options.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12}
}
return &Publisher{
client: redis.NewClient(options),
stream: cfg.Stream,
streamMaxLen: cfg.StreamMaxLen,
operationTimeout: cfg.OperationTimeout,
}, nil
}
// Close releases the underlying Redis client resources.
func (publisher *Publisher) Close() error {
if publisher == nil || publisher.client == nil {
return nil
}
return publisher.client.Close()
}
// Ping verifies that the configured Redis backend is reachable within the
// adapter operation timeout budget.
func (publisher *Publisher) Ping(ctx context.Context) error {
operationCtx, cancel, err := publisher.operationContext(ctx, "ping redis domain-event publisher")
if err != nil {
return err
}
defer cancel()
if err := publisher.client.Ping(operationCtx).Err(); err != nil {
return fmt.Errorf("ping redis domain-event publisher: %w", err)
}
return nil
}
// PublishProfileChanged publishes one committed profile-change event.
func (publisher *Publisher) PublishProfileChanged(ctx context.Context, event ports.ProfileChangedEvent) error {
if err := event.Validate(); err != nil {
return fmt.Errorf("publish profile changed event: %w", err)
}
values := buildEnvelope(ports.ProfileChangedEventType, event.UserID.String(), event.OccurredAt, event.Source.String(), traceIDFromContext(ctx, event.TraceID))
values["operation"] = string(event.Operation)
values["race_name"] = event.RaceName.String()
return publisher.publish(ctx, "publish profile changed event", values)
}
// PublishSettingsChanged publishes one committed settings-change event.
func (publisher *Publisher) PublishSettingsChanged(ctx context.Context, event ports.SettingsChangedEvent) error {
if err := event.Validate(); err != nil {
return fmt.Errorf("publish settings changed event: %w", err)
}
values := buildEnvelope(ports.SettingsChangedEventType, event.UserID.String(), event.OccurredAt, event.Source.String(), traceIDFromContext(ctx, event.TraceID))
values["operation"] = string(event.Operation)
values["preferred_language"] = event.PreferredLanguage.String()
values["time_zone"] = event.TimeZone.String()
return publisher.publish(ctx, "publish settings changed event", values)
}
// PublishEntitlementChanged publishes one committed entitlement-change event.
func (publisher *Publisher) PublishEntitlementChanged(ctx context.Context, event ports.EntitlementChangedEvent) error {
if err := event.Validate(); err != nil {
return fmt.Errorf("publish entitlement changed event: %w", err)
}
values := buildEnvelope(ports.EntitlementChangedEventType, event.UserID.String(), event.OccurredAt, event.Source.String(), traceIDFromContext(ctx, event.TraceID))
values["operation"] = string(event.Operation)
values["plan_code"] = string(event.PlanCode)
values["is_paid"] = strconv.FormatBool(event.IsPaid)
values["starts_at_ms"] = strconv.FormatInt(event.StartsAt.UTC().UnixMilli(), 10)
values["reason_code"] = event.ReasonCode.String()
values["actor_type"] = event.Actor.Type.String()
values["updated_at_ms"] = strconv.FormatInt(event.UpdatedAt.UTC().UnixMilli(), 10)
if !event.Actor.ID.IsZero() {
values["actor_id"] = event.Actor.ID.String()
}
if event.EndsAt != nil {
values["ends_at_ms"] = strconv.FormatInt(event.EndsAt.UTC().UnixMilli(), 10)
}
return publisher.publish(ctx, "publish entitlement changed event", values)
}
// PublishSanctionChanged publishes one committed sanction-change event.
func (publisher *Publisher) PublishSanctionChanged(ctx context.Context, event ports.SanctionChangedEvent) error {
if err := event.Validate(); err != nil {
return fmt.Errorf("publish sanction changed event: %w", err)
}
values := buildEnvelope(ports.SanctionChangedEventType, event.UserID.String(), event.OccurredAt, event.Source.String(), traceIDFromContext(ctx, event.TraceID))
values["operation"] = string(event.Operation)
values["sanction_code"] = string(event.SanctionCode)
values["scope"] = event.Scope.String()
values["reason_code"] = event.ReasonCode.String()
values["actor_type"] = event.Actor.Type.String()
values["applied_at_ms"] = strconv.FormatInt(event.AppliedAt.UTC().UnixMilli(), 10)
if !event.Actor.ID.IsZero() {
values["actor_id"] = event.Actor.ID.String()
}
if event.ExpiresAt != nil {
values["expires_at_ms"] = strconv.FormatInt(event.ExpiresAt.UTC().UnixMilli(), 10)
}
if event.RemovedAt != nil {
values["removed_at_ms"] = strconv.FormatInt(event.RemovedAt.UTC().UnixMilli(), 10)
}
return publisher.publish(ctx, "publish sanction changed event", values)
}
// PublishLimitChanged publishes one committed limit-change event.
func (publisher *Publisher) PublishLimitChanged(ctx context.Context, event ports.LimitChangedEvent) error {
if err := event.Validate(); err != nil {
return fmt.Errorf("publish limit changed event: %w", err)
}
values := buildEnvelope(ports.LimitChangedEventType, event.UserID.String(), event.OccurredAt, event.Source.String(), traceIDFromContext(ctx, event.TraceID))
values["operation"] = string(event.Operation)
values["limit_code"] = string(event.LimitCode)
values["reason_code"] = event.ReasonCode.String()
values["actor_type"] = event.Actor.Type.String()
values["applied_at_ms"] = strconv.FormatInt(event.AppliedAt.UTC().UnixMilli(), 10)
if event.Value != nil {
values["value"] = strconv.Itoa(*event.Value)
}
if !event.Actor.ID.IsZero() {
values["actor_id"] = event.Actor.ID.String()
}
if event.ExpiresAt != nil {
values["expires_at_ms"] = strconv.FormatInt(event.ExpiresAt.UTC().UnixMilli(), 10)
}
if event.RemovedAt != nil {
values["removed_at_ms"] = strconv.FormatInt(event.RemovedAt.UTC().UnixMilli(), 10)
}
return publisher.publish(ctx, "publish limit changed event", values)
}
// PublishDeclaredCountryChanged publishes one committed declared-country change
// event.
func (publisher *Publisher) PublishDeclaredCountryChanged(ctx context.Context, event ports.DeclaredCountryChangedEvent) error {
if err := event.Validate(); err != nil {
return fmt.Errorf("publish declared-country changed event: %w", err)
}
values := buildEnvelope(
ports.DeclaredCountryChangedEventType,
event.UserID.String(),
event.UpdatedAt,
event.Source.String(),
traceIDFromContext(ctx, event.TraceID),
)
values["declared_country"] = event.DeclaredCountry.String()
values["updated_at_ms"] = strconv.FormatInt(event.UpdatedAt.UTC().UnixMilli(), 10)
return publisher.publish(ctx, "publish declared-country changed event", values)
}
func (publisher *Publisher) publish(ctx context.Context, operation string, values map[string]any) error {
operationCtx, cancel, err := publisher.operationContext(ctx, operation)
if err != nil {
return err
}
defer cancel()
if err := publisher.client.XAdd(operationCtx, &redis.XAddArgs{
Stream: publisher.stream,
MaxLen: publisher.streamMaxLen,
Approx: true,
Values: values,
}).Err(); err != nil {
return fmt.Errorf("%s: %w", operation, err)
}
return nil
}
func (publisher *Publisher) operationContext(ctx context.Context, operation string) (context.Context, context.CancelFunc, error) {
if publisher == nil || publisher.client == nil {
return nil, nil, fmt.Errorf("%s: nil publisher", operation)
}
if ctx == nil {
return nil, nil, fmt.Errorf("%s: nil context", operation)
}
operationCtx, cancel := context.WithTimeout(ctx, publisher.operationTimeout)
return operationCtx, cancel, nil
}
func buildEnvelope(eventType string, userID string, occurredAt time.Time, source string, traceID string) map[string]any {
values := map[string]any{
"event_type": eventType,
"user_id": userID,
"occurred_at_ms": strconv.FormatInt(occurredAt.UTC().UnixMilli(), 10),
"source": source,
}
if traceID != "" {
values["trace_id"] = traceID
}
return values
}
func traceIDFromContext(ctx context.Context, fallback string) string {
if strings.TrimSpace(fallback) != "" {
return fallback
}
if ctx == nil {
return ""
}
spanContext := trace.SpanContextFromContext(ctx)
if !spanContext.IsValid() {
return ""
}
return spanContext.TraceID().String()
}
var (
_ interface{ Close() error } = (*Publisher)(nil)
_ interface{ Ping(context.Context) error } = (*Publisher)(nil)
_ ports.ProfileChangedPublisher = (*Publisher)(nil)
_ ports.SettingsChangedPublisher = (*Publisher)(nil)
_ ports.EntitlementChangedPublisher = (*Publisher)(nil)
_ ports.SanctionChangedPublisher = (*Publisher)(nil)
_ ports.LimitChangedPublisher = (*Publisher)(nil)
_ ports.DeclaredCountryChangedPublisher = (*Publisher)(nil)
)
@@ -0,0 +1,90 @@
package domainevents
import (
"context"
"strconv"
"testing"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/ports"
"github.com/alicebob/miniredis/v2"
"github.com/stretchr/testify/require"
)
func TestPublisherPublishesFlatRedisStreamEntry(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
publisher, err := New(Config{
Addr: server.Addr(),
Stream: "user:test_events",
StreamMaxLen: 5,
OperationTimeout: time.Second,
})
require.NoError(t, err)
occurredAt := time.Unix(1_775_240_000, 0).UTC()
err = publisher.PublishProfileChanged(context.Background(), ports.ProfileChangedEvent{
UserID: common.UserID("user-123"),
OccurredAt: occurredAt,
Source: common.Source("gateway_self_service"),
TraceID: "4bf92f3577b34da6a3ce929d0e0e4736",
Operation: ports.ProfileChangedOperationUpdated,
RaceName: common.RaceName("Nova Prime"),
})
require.NoError(t, err)
entries, err := publisher.client.XRange(context.Background(), publisher.stream, "-", "+").Result()
require.NoError(t, err)
require.Len(t, entries, 1)
require.Equal(t, ports.ProfileChangedEventType, entries[0].Values["event_type"])
require.Equal(t, "user-123", entries[0].Values["user_id"])
require.Equal(t, strconv.FormatInt(occurredAt.UnixMilli(), 10), entries[0].Values["occurred_at_ms"])
require.Equal(t, "gateway_self_service", entries[0].Values["source"])
require.Equal(t, "4bf92f3577b34da6a3ce929d0e0e4736", entries[0].Values["trace_id"])
require.Equal(t, string(ports.ProfileChangedOperationUpdated), entries[0].Values["operation"])
require.Equal(t, "Nova Prime", entries[0].Values["race_name"])
for index := 0; index < 20; index++ {
err = publisher.PublishSettingsChanged(context.Background(), ports.SettingsChangedEvent{
UserID: common.UserID("user-123"),
OccurredAt: occurredAt.Add(time.Duration(index+1) * time.Second),
Source: common.Source("gateway_self_service"),
Operation: ports.SettingsChangedOperationUpdated,
PreferredLanguage: common.LanguageTag("en-US"),
TimeZone: common.TimeZoneName("UTC"),
})
require.NoError(t, err)
}
length, err := publisher.client.XLen(context.Background(), publisher.stream).Result()
require.NoError(t, err)
require.LessOrEqual(t, length, int64(20))
}
func TestPublisherRejectsInvalidEventBeforeXAdd(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
publisher, err := New(Config{
Addr: server.Addr(),
Stream: "user:test_events",
StreamMaxLen: 5,
OperationTimeout: time.Second,
})
require.NoError(t, err)
err = publisher.PublishProfileChanged(context.Background(), ports.ProfileChangedEvent{
UserID: common.UserID("user-123"),
OccurredAt: time.Unix(1_775_240_000, 0).UTC(),
Operation: ports.ProfileChangedOperationUpdated,
RaceName: common.RaceName("Nova Prime"),
})
require.Error(t, err)
length, xLenErr := publisher.client.XLen(context.Background(), publisher.stream).Result()
require.NoError(t, xLenErr)
require.Zero(t, length)
}
@@ -0,0 +1,215 @@
package userstore
import (
"context"
"errors"
"galaxy/user/internal/adapters/redisstate"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"github.com/redis/go-redis/v9"
)
var knownSanctionCodes = []policy.SanctionCode{
policy.SanctionCodeLoginBlock,
policy.SanctionCodePrivateGameCreateBlock,
policy.SanctionCodePrivateGameManageBlock,
policy.SanctionCodeGameJoinBlock,
policy.SanctionCodeProfileUpdateBlock,
}
var knownLimitCodes = []policy.LimitCode{
policy.LimitCodeMaxOwnedPrivateGames,
policy.LimitCodeMaxPendingPublicApplications,
policy.LimitCodeMaxActiveGameMemberships,
}
var knownEligibilityMarkers = []policy.EligibilityMarker{
policy.EligibilityMarkerCanLogin,
policy.EligibilityMarkerCanCreatePrivateGame,
policy.EligibilityMarkerCanManagePrivateGame,
policy.EligibilityMarkerCanJoinGame,
policy.EligibilityMarkerCanUpdateProfile,
}
func (store *Store) addCreatedAtIndex(
pipe redis.Pipeliner,
ctx context.Context,
record account.UserAccount,
) {
pipe.ZAdd(ctx, store.keyspace.CreatedAtIndex(), redis.Z{
Score: redisstate.CreatedAtScore(record.CreatedAt),
Member: record.UserID.String(),
})
}
func (store *Store) syncDeclaredCountryIndex(
pipe redis.Pipeliner,
ctx context.Context,
previous account.UserAccount,
current account.UserAccount,
) {
if !previous.DeclaredCountry.IsZero() {
pipe.SRem(ctx, store.keyspace.DeclaredCountryIndex(previous.DeclaredCountry), current.UserID.String())
}
if !current.DeclaredCountry.IsZero() {
pipe.SAdd(ctx, store.keyspace.DeclaredCountryIndex(current.DeclaredCountry), current.UserID.String())
}
}
func (store *Store) syncEntitlementIndexes(
pipe redis.Pipeliner,
ctx context.Context,
snapshot entitlement.CurrentSnapshot,
) {
pipe.SRem(ctx, store.keyspace.PaidStateIndex(entitlement.PaidStateFree), snapshot.UserID.String())
pipe.SRem(ctx, store.keyspace.PaidStateIndex(entitlement.PaidStatePaid), snapshot.UserID.String())
pipe.SAdd(ctx, store.keyspace.PaidStateIndex(paidStateFromSnapshot(snapshot)), snapshot.UserID.String())
pipe.ZRem(ctx, store.keyspace.FinitePaidExpiryIndex(), snapshot.UserID.String())
if snapshot.HasFiniteExpiry() {
pipe.ZAdd(ctx, store.keyspace.FinitePaidExpiryIndex(), redis.Z{
Score: redisstate.ExpiryScore(*snapshot.EndsAt),
Member: snapshot.UserID.String(),
})
}
}
func (store *Store) syncActiveSanctionCodeIndexes(
pipe redis.Pipeliner,
ctx context.Context,
userID common.UserID,
activeCodes map[policy.SanctionCode]struct{},
) {
for _, code := range knownSanctionCodes {
pipe.SRem(ctx, store.keyspace.ActiveSanctionCodeIndex(code), userID.String())
if _, ok := activeCodes[code]; ok {
pipe.SAdd(ctx, store.keyspace.ActiveSanctionCodeIndex(code), userID.String())
}
}
}
func (store *Store) syncActiveLimitCodeIndexes(
pipe redis.Pipeliner,
ctx context.Context,
userID common.UserID,
activeCodes map[policy.LimitCode]struct{},
) {
for _, code := range knownLimitCodes {
pipe.SRem(ctx, store.keyspace.ActiveLimitCodeIndex(code), userID.String())
if _, ok := activeCodes[code]; ok {
pipe.SAdd(ctx, store.keyspace.ActiveLimitCodeIndex(code), userID.String())
}
}
}
func (store *Store) syncEligibilityMarkerIndexes(
pipe redis.Pipeliner,
ctx context.Context,
userID common.UserID,
isPaid bool,
activeSanctionCodes map[policy.SanctionCode]struct{},
) {
values := deriveEligibilityMarkerValues(isPaid, activeSanctionCodes)
for _, marker := range knownEligibilityMarkers {
pipe.SRem(ctx, store.keyspace.EligibilityMarkerIndex(marker, true), userID.String())
pipe.SRem(ctx, store.keyspace.EligibilityMarkerIndex(marker, false), userID.String())
pipe.SAdd(ctx, store.keyspace.EligibilityMarkerIndex(marker, values[marker]), userID.String())
}
}
func (store *Store) loadActiveSanctionCodeSet(
ctx context.Context,
getter bytesGetter,
userID common.UserID,
) (map[policy.SanctionCode]struct{}, error) {
activeCodes := make(map[policy.SanctionCode]struct{}, len(knownSanctionCodes))
for _, code := range knownSanctionCodes {
_, err := store.loadActiveSanctionRecordID(ctx, getter, store.keyspace.ActiveSanction(userID, code))
switch {
case err == nil:
activeCodes[code] = struct{}{}
case errors.Is(err, ports.ErrNotFound):
continue
default:
return nil, err
}
}
return activeCodes, nil
}
func (store *Store) loadActiveLimitCodeSet(
ctx context.Context,
getter bytesGetter,
userID common.UserID,
) (map[policy.LimitCode]struct{}, error) {
activeCodes := make(map[policy.LimitCode]struct{}, len(knownLimitCodes))
for _, code := range knownLimitCodes {
_, err := store.loadActiveLimitRecordID(ctx, getter, store.keyspace.ActiveLimit(userID, code))
switch {
case err == nil:
activeCodes[code] = struct{}{}
case errors.Is(err, ports.ErrNotFound):
continue
default:
return nil, err
}
}
return activeCodes, nil
}
func (store *Store) activeSanctionWatchKeys(userID common.UserID) []string {
keys := make([]string, 0, len(knownSanctionCodes))
for _, code := range knownSanctionCodes {
keys = append(keys, store.keyspace.ActiveSanction(userID, code))
}
return keys
}
func (store *Store) activeLimitWatchKeys(userID common.UserID) []string {
keys := make([]string, 0, len(knownLimitCodes))
for _, code := range knownLimitCodes {
keys = append(keys, store.keyspace.ActiveLimit(userID, code))
}
return keys
}
func deriveEligibilityMarkerValues(
isPaid bool,
activeSanctionCodes map[policy.SanctionCode]struct{},
) map[policy.EligibilityMarker]bool {
_, loginBlocked := activeSanctionCodes[policy.SanctionCodeLoginBlock]
_, createBlocked := activeSanctionCodes[policy.SanctionCodePrivateGameCreateBlock]
_, manageBlocked := activeSanctionCodes[policy.SanctionCodePrivateGameManageBlock]
_, joinBlocked := activeSanctionCodes[policy.SanctionCodeGameJoinBlock]
_, profileBlocked := activeSanctionCodes[policy.SanctionCodeProfileUpdateBlock]
canLogin := !loginBlocked
return map[policy.EligibilityMarker]bool{
policy.EligibilityMarkerCanLogin: canLogin,
policy.EligibilityMarkerCanCreatePrivateGame: canLogin && isPaid && !createBlocked,
policy.EligibilityMarkerCanManagePrivateGame: canLogin && isPaid && !manageBlocked,
policy.EligibilityMarkerCanJoinGame: canLogin && !joinBlocked,
policy.EligibilityMarkerCanUpdateProfile: canLogin && !profileBlocked,
}
}
func paidStateFromSnapshot(snapshot entitlement.CurrentSnapshot) entitlement.PaidState {
if snapshot.IsPaid {
return entitlement.PaidStatePaid
}
return entitlement.PaidStateFree
}
@@ -0,0 +1,449 @@
package userstore
import (
"context"
"testing"
"time"
"galaxy/user/internal/adapters/redisstate"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/adminusers"
"galaxy/user/internal/service/entitlementsvc"
"github.com/stretchr/testify/require"
)
func TestListUserIDsCreatedAtPagination(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := time.Unix(1_775_240_000, 0).UTC()
first := validAccountRecord()
first.UserID = common.UserID("user-100")
first.Email = common.Email("u100@example.com")
first.RaceName = common.RaceName("User 100")
first.CreatedAt = base.Add(-time.Hour)
first.UpdatedAt = first.CreatedAt
second := validAccountRecord()
second.UserID = common.UserID("user-200")
second.Email = common.Email("u200@example.com")
second.RaceName = common.RaceName("User 200")
second.CreatedAt = base
second.UpdatedAt = second.CreatedAt
third := validAccountRecord()
third.UserID = common.UserID("user-300")
third.Email = common.Email("u300@example.com")
third.RaceName = common.RaceName("User 300")
third.CreatedAt = base
third.UpdatedAt = third.CreatedAt
require.NoError(t, store.Create(context.Background(), createAccountInput(first)))
require.NoError(t, store.Create(context.Background(), createAccountInput(second)))
require.NoError(t, store.Create(context.Background(), createAccountInput(third)))
firstPage, err := store.ListUserIDs(context.Background(), ports.ListUsersInput{
PageSize: 2,
Filters: ports.UserListFilters{},
})
require.NoError(t, err)
require.Equal(t, []common.UserID{third.UserID, second.UserID}, firstPage.UserIDs)
require.NotEmpty(t, firstPage.NextPageToken)
secondPage, err := store.ListUserIDs(context.Background(), ports.ListUsersInput{
PageSize: 2,
PageToken: firstPage.NextPageToken,
Filters: ports.UserListFilters{},
})
require.NoError(t, err)
require.Equal(t, []common.UserID{first.UserID}, secondPage.UserIDs)
require.Empty(t, secondPage.NextPageToken)
}
func TestEnsureByEmailInitialAdminIndexes(t *testing.T) {
t.Parallel()
store := newTestStore(t)
now := time.Unix(1_775_240_000, 0).UTC()
record := validAccountRecord()
record.DeclaredCountry = common.CountryCode("DE")
record.CreatedAt = now
record.UpdatedAt = now
result, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
Email: record.Email,
Account: record,
Entitlement: validEntitlementSnapshot(record.UserID, now),
EntitlementRecord: validEntitlementRecord(record.UserID, now),
Reservation: raceNameReservation(record.UserID, record.RaceName, now),
})
require.NoError(t, err)
require.Equal(t, ports.EnsureByEmailOutcomeCreated, result.Outcome)
requireSortedSetScore(t, store, store.keyspace.CreatedAtIndex(), record.UserID.String(), redisstate.CreatedAtScore(record.CreatedAt))
requireSetContains(t, store, store.keyspace.PaidStateIndex(entitlement.PaidStateFree), record.UserID.String())
requireSetNotContains(t, store, store.keyspace.PaidStateIndex(entitlement.PaidStatePaid), record.UserID.String())
requireSetContains(t, store, store.keyspace.DeclaredCountryIndex(record.DeclaredCountry), record.UserID.String())
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanLogin, true), record.UserID.String())
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanCreatePrivateGame, false), record.UserID.String())
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanJoinGame, true), record.UserID.String())
}
func TestAccountUpdateSyncsDeclaredCountryIndex(t *testing.T) {
t.Parallel()
store := newTestStore(t)
accountStore := store.Accounts()
record := validAccountRecord()
record.DeclaredCountry = common.CountryCode("DE")
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record)))
updated := record
updated.DeclaredCountry = common.CountryCode("FR")
updated.UpdatedAt = record.UpdatedAt.Add(time.Minute)
require.NoError(t, accountStore.Update(context.Background(), updated))
requireSetNotContains(t, store, store.keyspace.DeclaredCountryIndex(common.CountryCode("DE")), record.UserID.String())
requireSetContains(t, store, store.keyspace.DeclaredCountryIndex(common.CountryCode("FR")), record.UserID.String())
}
func TestEntitlementLifecycleSyncsAdminIndexes(t *testing.T) {
t.Parallel()
store := newTestStore(t)
now := time.Unix(1_775_240_000, 0).UTC()
record := validAccountRecord()
record.CreatedAt = now
record.UpdatedAt = now
_, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
Email: record.Email,
Account: record,
Entitlement: validEntitlementSnapshot(record.UserID, now),
EntitlementRecord: validEntitlementRecord(record.UserID, now),
Reservation: raceNameReservation(record.UserID, record.RaceName, now),
})
require.NoError(t, err)
lifecycleStore := store.EntitlementLifecycle()
freeRecord := validEntitlementRecord(record.UserID, now)
freeSnapshot := validEntitlementSnapshot(record.UserID, now)
grantStartsAt := now.Add(time.Hour)
grantEndsAt := grantStartsAt.Add(30 * 24 * time.Hour)
grantedRecord := paidEntitlementRecord(
entitlement.EntitlementRecordID("entitlement-paid-1"),
record.UserID,
entitlement.PlanCodePaidMonthly,
grantStartsAt,
grantEndsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
grantedSnapshot := paidEntitlementSnapshot(
record.UserID,
entitlement.PlanCodePaidMonthly,
grantStartsAt,
grantEndsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
closedFreeRecord := freeRecord
closedFreeRecord.ClosedAt = timePointer(grantStartsAt)
closedFreeRecord.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
closedFreeRecord.ClosedReasonCode = common.ReasonCode("manual_grant")
require.NoError(t, lifecycleStore.Grant(context.Background(), ports.GrantEntitlementInput{
ExpectedCurrentSnapshot: freeSnapshot,
ExpectedCurrentRecord: freeRecord,
UpdatedCurrentRecord: closedFreeRecord,
NewRecord: grantedRecord,
NewSnapshot: grantedSnapshot,
}))
requireSetContains(t, store, store.keyspace.PaidStateIndex(entitlement.PaidStatePaid), record.UserID.String())
requireSetNotContains(t, store, store.keyspace.PaidStateIndex(entitlement.PaidStateFree), record.UserID.String())
requireSortedSetScore(t, store, store.keyspace.FinitePaidExpiryIndex(), record.UserID.String(), redisstate.ExpiryScore(grantEndsAt))
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanCreatePrivateGame, true), record.UserID.String())
extendedEndsAt := grantEndsAt.Add(30 * 24 * time.Hour)
extensionRecord := paidEntitlementRecord(
entitlement.EntitlementRecordID("entitlement-paid-2"),
record.UserID,
entitlement.PlanCodePaidMonthly,
grantEndsAt,
extendedEndsAt,
common.Source("admin"),
common.ReasonCode("manual_extend"),
)
extendedSnapshot := paidEntitlementSnapshot(
record.UserID,
entitlement.PlanCodePaidMonthly,
grantStartsAt,
extendedEndsAt,
common.Source("admin"),
common.ReasonCode("manual_extend"),
)
require.NoError(t, lifecycleStore.Extend(context.Background(), ports.ExtendEntitlementInput{
ExpectedCurrentSnapshot: grantedSnapshot,
NewRecord: extensionRecord,
NewSnapshot: extendedSnapshot,
}))
requireSortedSetScore(t, store, store.keyspace.FinitePaidExpiryIndex(), record.UserID.String(), redisstate.ExpiryScore(extendedEndsAt))
revokeAt := grantEndsAt.Add(12 * time.Hour)
revokedCurrentRecord := extensionRecord
revokedCurrentRecord.ClosedAt = timePointer(revokeAt)
revokedCurrentRecord.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
revokedCurrentRecord.ClosedReasonCode = common.ReasonCode("manual_revoke")
freeAfterRevokeRecord := entitlement.PeriodRecord{
RecordID: entitlement.EntitlementRecordID("entitlement-free-2"),
UserID: record.UserID,
PlanCode: entitlement.PlanCodeFree,
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_revoke"),
StartsAt: revokeAt,
CreatedAt: revokeAt,
}
freeAfterRevokeSnapshot := entitlement.CurrentSnapshot{
UserID: record.UserID,
PlanCode: entitlement.PlanCodeFree,
IsPaid: false,
StartsAt: revokeAt,
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_revoke"),
UpdatedAt: revokeAt,
}
require.NoError(t, lifecycleStore.Revoke(context.Background(), ports.RevokeEntitlementInput{
ExpectedCurrentSnapshot: extendedSnapshot,
ExpectedCurrentRecord: extensionRecord,
UpdatedCurrentRecord: revokedCurrentRecord,
NewRecord: freeAfterRevokeRecord,
NewSnapshot: freeAfterRevokeSnapshot,
}))
requireSetContains(t, store, store.keyspace.PaidStateIndex(entitlement.PaidStateFree), record.UserID.String())
requireSetNotContains(t, store, store.keyspace.PaidStateIndex(entitlement.PaidStatePaid), record.UserID.String())
requireSortedSetMissing(t, store, store.keyspace.FinitePaidExpiryIndex(), record.UserID.String())
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanCreatePrivateGame, false), record.UserID.String())
}
func TestPolicyLifecycleSyncsAdminIndexes(t *testing.T) {
t.Parallel()
store := newTestStore(t)
now := time.Unix(1_775_240_000, 0).UTC()
record := validAccountRecord()
record.CreatedAt = now
record.UpdatedAt = now
_, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
Email: record.Email,
Account: record,
Entitlement: validEntitlementSnapshot(record.UserID, now),
EntitlementRecord: validEntitlementRecord(record.UserID, now),
Reservation: raceNameReservation(record.UserID, record.RaceName, now),
})
require.NoError(t, err)
lifecycleStore := store.PolicyLifecycle()
sanctionRecord := policy.SanctionRecord{
RecordID: policy.SanctionRecordID("sanction-1"),
UserID: record.UserID,
SanctionCode: policy.SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("manual_block"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now,
}
require.NoError(t, lifecycleStore.ApplySanction(context.Background(), ports.ApplySanctionInput{
NewRecord: sanctionRecord,
}))
requireSetContains(t, store, store.keyspace.ActiveSanctionCodeIndex(policy.SanctionCodeLoginBlock), record.UserID.String())
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanLogin, false), record.UserID.String())
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanJoinGame, false), record.UserID.String())
removedSanction := sanctionRecord
removedAt := now.Add(time.Minute)
removedSanction.RemovedAt = &removedAt
removedSanction.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")}
removedSanction.RemovedReasonCode = common.ReasonCode("manual_remove")
require.NoError(t, lifecycleStore.RemoveSanction(context.Background(), ports.RemoveSanctionInput{
ExpectedActiveRecord: sanctionRecord,
UpdatedRecord: removedSanction,
}))
requireSetNotContains(t, store, store.keyspace.ActiveSanctionCodeIndex(policy.SanctionCodeLoginBlock), record.UserID.String())
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanLogin, true), record.UserID.String())
limitRecord := policy.LimitRecord{
RecordID: policy.LimitRecordID("limit-1"),
UserID: record.UserID,
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
Value: 5,
ReasonCode: common.ReasonCode("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(2 * time.Minute),
}
require.NoError(t, lifecycleStore.SetLimit(context.Background(), ports.SetLimitInput{
NewRecord: limitRecord,
}))
requireSetContains(t, store, store.keyspace.ActiveLimitCodeIndex(policy.LimitCodeMaxOwnedPrivateGames), record.UserID.String())
removedLimit := limitRecord
limitRemovedAt := now.Add(3 * time.Minute)
removedLimit.RemovedAt = &limitRemovedAt
removedLimit.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")}
removedLimit.RemovedReasonCode = common.ReasonCode("manual_remove")
require.NoError(t, lifecycleStore.RemoveLimit(context.Background(), ports.RemoveLimitInput{
ExpectedActiveRecord: limitRecord,
UpdatedRecord: removedLimit,
}))
requireSetNotContains(t, store, store.keyspace.ActiveLimitCodeIndex(policy.LimitCodeMaxOwnedPrivateGames), record.UserID.String())
}
func TestAdminListerReevaluatesExpiredPaidSnapshots(t *testing.T) {
t.Parallel()
store := newTestStore(t)
userID := common.UserID("user-123")
now := time.Unix(1_775_240_000, 0).UTC()
record := validAccountRecord()
record.CreatedAt = now.Add(-2 * time.Hour)
record.UpdatedAt = record.CreatedAt
_, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
Email: record.Email,
Account: record,
Entitlement: validEntitlementSnapshot(userID, record.CreatedAt),
EntitlementRecord: validEntitlementRecord(userID, record.CreatedAt),
Reservation: raceNameReservation(userID, record.RaceName, record.CreatedAt),
})
require.NoError(t, err)
grantStartsAt := now.Add(-90 * time.Minute)
grantEndsAt := now.Add(-30 * time.Minute)
freeRecord := validEntitlementRecord(userID, record.CreatedAt)
freeSnapshot := validEntitlementSnapshot(userID, record.CreatedAt)
grantedRecord := paidEntitlementRecord(
entitlement.EntitlementRecordID("entitlement-paid-expired"),
userID,
entitlement.PlanCodePaidMonthly,
grantStartsAt,
grantEndsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
grantedSnapshot := paidEntitlementSnapshot(
userID,
entitlement.PlanCodePaidMonthly,
grantStartsAt,
grantEndsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
closedFreeRecord := freeRecord
closedFreeRecord.ClosedAt = timePointer(grantStartsAt)
closedFreeRecord.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
closedFreeRecord.ClosedReasonCode = common.ReasonCode("manual_grant")
require.NoError(t, store.EntitlementLifecycle().Grant(context.Background(), ports.GrantEntitlementInput{
ExpectedCurrentSnapshot: freeSnapshot,
ExpectedCurrentRecord: freeRecord,
UpdatedCurrentRecord: closedFreeRecord,
NewRecord: grantedRecord,
NewSnapshot: grantedSnapshot,
}))
reader, err := entitlementsvc.NewReader(
store.EntitlementSnapshots(),
store.EntitlementLifecycle(),
adminStoreClock{now: now},
adminStoreIDGenerator{entitlementRecordID: entitlement.EntitlementRecordID("entitlement-free-after-expiry")},
)
require.NoError(t, err)
lister, err := adminusers.NewLister(store.Accounts(), reader, store.Sanctions(), store.Limits(), adminStoreClock{now: now}, store)
require.NoError(t, err)
result, err := lister.Execute(context.Background(), adminusers.ListUsersInput{PaidState: "free"})
require.NoError(t, err)
require.Len(t, result.Items, 1)
require.Equal(t, "user-123", result.Items[0].UserID)
require.Equal(t, "free", result.Items[0].Entitlement.PlanCode)
require.False(t, result.Items[0].Entitlement.IsPaid)
storedSnapshot, err := store.EntitlementSnapshots().GetByUserID(context.Background(), userID)
require.NoError(t, err)
require.Equal(t, entitlement.PlanCodeFree, storedSnapshot.PlanCode)
require.False(t, storedSnapshot.IsPaid)
}
type adminStoreClock struct {
now time.Time
}
func (clock adminStoreClock) Now() time.Time {
return clock.now
}
type adminStoreIDGenerator struct {
entitlementRecordID entitlement.EntitlementRecordID
}
func (generator adminStoreIDGenerator) NewUserID() (common.UserID, error) {
return "", nil
}
func (generator adminStoreIDGenerator) NewInitialRaceName() (common.RaceName, error) {
return "", nil
}
func (generator adminStoreIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
return generator.entitlementRecordID, nil
}
func (generator adminStoreIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
return "", nil
}
func (generator adminStoreIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
return "", nil
}
func requireSetContains(t *testing.T, store *Store, key string, member string) {
t.Helper()
exists, err := store.client.SIsMember(context.Background(), key, member).Result()
require.NoError(t, err)
require.True(t, exists, "expected %q to contain %q", key, member)
}
func requireSetNotContains(t *testing.T, store *Store, key string, member string) {
t.Helper()
exists, err := store.client.SIsMember(context.Background(), key, member).Result()
require.NoError(t, err)
require.False(t, exists, "expected %q not to contain %q", key, member)
}
func requireSortedSetScore(t *testing.T, store *Store, key string, member string, want float64) {
t.Helper()
got, err := store.client.ZScore(context.Background(), key, member).Result()
require.NoError(t, err)
require.Equal(t, want, got)
}
func requireSortedSetMissing(t *testing.T, store *Store, key string, member string) {
t.Helper()
_, err := store.client.ZScore(context.Background(), key, member).Result()
require.Error(t, err)
}
@@ -0,0 +1,752 @@
package userstore
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/ports"
"github.com/redis/go-redis/v9"
)
type entitlementPeriodRecord struct {
RecordID string `json:"record_id"`
UserID string `json:"user_id"`
PlanCode string `json:"plan_code"`
Source string `json:"source"`
ActorType string `json:"actor_type"`
ActorID *string `json:"actor_id,omitempty"`
ReasonCode string `json:"reason_code"`
StartsAt string `json:"starts_at"`
EndsAt *string `json:"ends_at,omitempty"`
CreatedAt string `json:"created_at"`
ClosedAt *string `json:"closed_at,omitempty"`
ClosedByType *string `json:"closed_by_type,omitempty"`
ClosedByID *string `json:"closed_by_id,omitempty"`
ClosedReasonCode *string `json:"closed_reason_code,omitempty"`
}
// CreateEntitlementRecord stores one new entitlement history record.
func (store *Store) CreateEntitlementRecord(ctx context.Context, record entitlement.PeriodRecord) error {
if err := record.Validate(); err != nil {
return fmt.Errorf("create entitlement record in redis: %w", err)
}
payload, err := marshalEntitlementPeriodRecord(record)
if err != nil {
return fmt.Errorf("create entitlement record in redis: %w", err)
}
recordKey := store.keyspace.EntitlementRecord(record.RecordID)
historyKey := store.keyspace.EntitlementHistory(record.UserID)
operationCtx, cancel, err := store.operationContext(ctx, "create entitlement record in redis")
if err != nil {
return err
}
defer cancel()
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
if err := ensureKeyAbsent(operationCtx, tx, recordKey); err != nil {
return fmt.Errorf("create entitlement record %q in redis: %w", record.RecordID, err)
}
_, err := tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
pipe.Set(operationCtx, recordKey, payload, 0)
pipe.ZAdd(operationCtx, historyKey, redis.Z{
Score: float64(record.StartsAt.UTC().UnixMicro()),
Member: record.RecordID.String(),
})
return nil
})
if err != nil {
return fmt.Errorf("create entitlement record %q in redis: %w", record.RecordID, err)
}
return nil
}, recordKey, historyKey)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("create entitlement record %q in redis: %w", record.RecordID, ports.ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
// GetEntitlementRecordByRecordID returns the entitlement history record
// identified by recordID.
func (store *Store) GetEntitlementRecordByRecordID(
ctx context.Context,
recordID entitlement.EntitlementRecordID,
) (entitlement.PeriodRecord, error) {
if err := recordID.Validate(); err != nil {
return entitlement.PeriodRecord{}, fmt.Errorf("get entitlement record by record id from redis: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "get entitlement record by record id from redis")
if err != nil {
return entitlement.PeriodRecord{}, err
}
defer cancel()
record, err := store.loadEntitlementRecord(operationCtx, store.client, recordID)
if err != nil {
switch {
case errors.Is(err, ports.ErrNotFound):
return entitlement.PeriodRecord{}, fmt.Errorf("get entitlement record by record id %q from redis: %w", recordID, ports.ErrNotFound)
default:
return entitlement.PeriodRecord{}, fmt.Errorf("get entitlement record by record id %q from redis: %w", recordID, err)
}
}
return record, nil
}
// ListEntitlementRecordsByUserID returns every entitlement history record
// owned by userID.
func (store *Store) ListEntitlementRecordsByUserID(
ctx context.Context,
userID common.UserID,
) ([]entitlement.PeriodRecord, error) {
if err := userID.Validate(); err != nil {
return nil, fmt.Errorf("list entitlement records by user id from redis: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "list entitlement records by user id from redis")
if err != nil {
return nil, err
}
defer cancel()
recordIDs, err := store.client.ZRange(operationCtx, store.keyspace.EntitlementHistory(userID), 0, -1).Result()
if err != nil {
return nil, fmt.Errorf("list entitlement records by user id %q from redis: %w", userID, err)
}
records := make([]entitlement.PeriodRecord, 0, len(recordIDs))
for _, rawRecordID := range recordIDs {
record, err := store.loadEntitlementRecord(operationCtx, store.client, entitlement.EntitlementRecordID(rawRecordID))
if err != nil {
return nil, fmt.Errorf("list entitlement records by user id %q from redis: %w", userID, err)
}
records = append(records, record)
}
return records, nil
}
// UpdateEntitlementRecord replaces one stored entitlement history record.
func (store *Store) UpdateEntitlementRecord(ctx context.Context, record entitlement.PeriodRecord) error {
if err := record.Validate(); err != nil {
return fmt.Errorf("update entitlement record in redis: %w", err)
}
payload, err := marshalEntitlementPeriodRecord(record)
if err != nil {
return fmt.Errorf("update entitlement record in redis: %w", err)
}
recordKey := store.keyspace.EntitlementRecord(record.RecordID)
operationCtx, cancel, err := store.operationContext(ctx, "update entitlement record in redis")
if err != nil {
return err
}
defer cancel()
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
if _, err := store.loadEntitlementRecord(operationCtx, tx, record.RecordID); err != nil {
return fmt.Errorf("update entitlement record %q in redis: %w", record.RecordID, err)
}
_, err := tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
pipe.Set(operationCtx, recordKey, payload, 0)
return nil
})
if err != nil {
return fmt.Errorf("update entitlement record %q in redis: %w", record.RecordID, err)
}
return nil
}, recordKey)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("update entitlement record %q in redis: %w", record.RecordID, ports.ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
// GrantEntitlement atomically closes the current free history record, creates
// one paid history record, and replaces the current snapshot.
func (store *Store) GrantEntitlement(ctx context.Context, input ports.GrantEntitlementInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("grant entitlement in redis: %w", err)
}
updatedCurrentRecordPayload, err := marshalEntitlementPeriodRecord(input.UpdatedCurrentRecord)
if err != nil {
return fmt.Errorf("grant entitlement in redis: %w", err)
}
newRecordPayload, err := marshalEntitlementPeriodRecord(input.NewRecord)
if err != nil {
return fmt.Errorf("grant entitlement in redis: %w", err)
}
newSnapshotPayload, err := marshalEntitlementSnapshotRecord(input.NewSnapshot)
if err != nil {
return fmt.Errorf("grant entitlement in redis: %w", err)
}
currentRecordKey := store.keyspace.EntitlementRecord(input.ExpectedCurrentRecord.RecordID)
newRecordKey := store.keyspace.EntitlementRecord(input.NewRecord.RecordID)
historyKey := store.keyspace.EntitlementHistory(input.NewRecord.UserID)
snapshotKey := store.keyspace.EntitlementSnapshot(input.NewSnapshot.UserID)
watchedKeys := append(
[]string{currentRecordKey, newRecordKey, historyKey, snapshotKey},
store.activeSanctionWatchKeys(input.NewSnapshot.UserID)...,
)
operationCtx, cancel, err := store.operationContext(ctx, "grant entitlement in redis")
if err != nil {
return err
}
defer cancel()
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
storedSnapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedCurrentSnapshot.UserID)
if err != nil {
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
if !equalEntitlementSnapshots(storedSnapshot, input.ExpectedCurrentSnapshot) {
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
}
storedCurrentRecord, err := store.loadEntitlementRecord(operationCtx, tx, input.ExpectedCurrentRecord.RecordID)
if err != nil {
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
if !equalEntitlementPeriodRecords(storedCurrentRecord, input.ExpectedCurrentRecord) {
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
}
if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil {
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewSnapshot.UserID)
if err != nil {
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
pipe.Set(operationCtx, currentRecordKey, updatedCurrentRecordPayload, 0)
pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0)
pipe.ZAdd(operationCtx, historyKey, redis.Z{
Score: float64(input.NewRecord.StartsAt.UTC().UnixMicro()),
Member: input.NewRecord.RecordID.String(),
})
pipe.Set(operationCtx, snapshotKey, newSnapshotPayload, 0)
store.syncEntitlementIndexes(pipe, operationCtx, input.NewSnapshot)
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewSnapshot.UserID, input.NewSnapshot.IsPaid, activeSanctionCodes)
return nil
})
if err != nil {
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
return nil
}, watchedKeys...)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
// ExtendEntitlement atomically appends one paid history segment and replaces
// the current paid snapshot.
func (store *Store) ExtendEntitlement(ctx context.Context, input ports.ExtendEntitlementInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("extend entitlement in redis: %w", err)
}
newRecordPayload, err := marshalEntitlementPeriodRecord(input.NewRecord)
if err != nil {
return fmt.Errorf("extend entitlement in redis: %w", err)
}
newSnapshotPayload, err := marshalEntitlementSnapshotRecord(input.NewSnapshot)
if err != nil {
return fmt.Errorf("extend entitlement in redis: %w", err)
}
newRecordKey := store.keyspace.EntitlementRecord(input.NewRecord.RecordID)
historyKey := store.keyspace.EntitlementHistory(input.NewRecord.UserID)
snapshotKey := store.keyspace.EntitlementSnapshot(input.NewSnapshot.UserID)
watchedKeys := append(
[]string{newRecordKey, historyKey, snapshotKey},
store.activeSanctionWatchKeys(input.NewSnapshot.UserID)...,
)
operationCtx, cancel, err := store.operationContext(ctx, "extend entitlement in redis")
if err != nil {
return err
}
defer cancel()
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
storedSnapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedCurrentSnapshot.UserID)
if err != nil {
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
if !equalEntitlementSnapshots(storedSnapshot, input.ExpectedCurrentSnapshot) {
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
}
if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil {
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewSnapshot.UserID)
if err != nil {
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0)
pipe.ZAdd(operationCtx, historyKey, redis.Z{
Score: float64(input.NewRecord.StartsAt.UTC().UnixMicro()),
Member: input.NewRecord.RecordID.String(),
})
pipe.Set(operationCtx, snapshotKey, newSnapshotPayload, 0)
store.syncEntitlementIndexes(pipe, operationCtx, input.NewSnapshot)
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewSnapshot.UserID, input.NewSnapshot.IsPaid, activeSanctionCodes)
return nil
})
if err != nil {
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
return nil
}, watchedKeys...)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
// RevokeEntitlement atomically closes the current paid history record,
// creates one free history record, and replaces the current snapshot.
func (store *Store) RevokeEntitlement(ctx context.Context, input ports.RevokeEntitlementInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("revoke entitlement in redis: %w", err)
}
updatedCurrentRecordPayload, err := marshalEntitlementPeriodRecord(input.UpdatedCurrentRecord)
if err != nil {
return fmt.Errorf("revoke entitlement in redis: %w", err)
}
newRecordPayload, err := marshalEntitlementPeriodRecord(input.NewRecord)
if err != nil {
return fmt.Errorf("revoke entitlement in redis: %w", err)
}
newSnapshotPayload, err := marshalEntitlementSnapshotRecord(input.NewSnapshot)
if err != nil {
return fmt.Errorf("revoke entitlement in redis: %w", err)
}
currentRecordKey := store.keyspace.EntitlementRecord(input.ExpectedCurrentRecord.RecordID)
newRecordKey := store.keyspace.EntitlementRecord(input.NewRecord.RecordID)
historyKey := store.keyspace.EntitlementHistory(input.NewRecord.UserID)
snapshotKey := store.keyspace.EntitlementSnapshot(input.NewSnapshot.UserID)
watchedKeys := append(
[]string{currentRecordKey, newRecordKey, historyKey, snapshotKey},
store.activeSanctionWatchKeys(input.NewSnapshot.UserID)...,
)
operationCtx, cancel, err := store.operationContext(ctx, "revoke entitlement in redis")
if err != nil {
return err
}
defer cancel()
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
storedSnapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedCurrentSnapshot.UserID)
if err != nil {
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
if !equalEntitlementSnapshots(storedSnapshot, input.ExpectedCurrentSnapshot) {
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
}
storedCurrentRecord, err := store.loadEntitlementRecord(operationCtx, tx, input.ExpectedCurrentRecord.RecordID)
if err != nil {
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
if !equalEntitlementPeriodRecords(storedCurrentRecord, input.ExpectedCurrentRecord) {
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
}
if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil {
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewSnapshot.UserID)
if err != nil {
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
pipe.Set(operationCtx, currentRecordKey, updatedCurrentRecordPayload, 0)
pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0)
pipe.ZAdd(operationCtx, historyKey, redis.Z{
Score: float64(input.NewRecord.StartsAt.UTC().UnixMicro()),
Member: input.NewRecord.RecordID.String(),
})
pipe.Set(operationCtx, snapshotKey, newSnapshotPayload, 0)
store.syncEntitlementIndexes(pipe, operationCtx, input.NewSnapshot)
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewSnapshot.UserID, input.NewSnapshot.IsPaid, activeSanctionCodes)
return nil
})
if err != nil {
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
return nil
}, watchedKeys...)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
// RepairExpiredEntitlement atomically replaces one expired finite paid
// snapshot with a materialized free state.
func (store *Store) RepairExpiredEntitlement(ctx context.Context, input ports.RepairExpiredEntitlementInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("repair expired entitlement in redis: %w", err)
}
newRecordPayload, err := marshalEntitlementPeriodRecord(input.NewRecord)
if err != nil {
return fmt.Errorf("repair expired entitlement in redis: %w", err)
}
newSnapshotPayload, err := marshalEntitlementSnapshotRecord(input.NewSnapshot)
if err != nil {
return fmt.Errorf("repair expired entitlement in redis: %w", err)
}
newRecordKey := store.keyspace.EntitlementRecord(input.NewRecord.RecordID)
historyKey := store.keyspace.EntitlementHistory(input.NewRecord.UserID)
snapshotKey := store.keyspace.EntitlementSnapshot(input.NewSnapshot.UserID)
watchedKeys := append(
[]string{newRecordKey, historyKey, snapshotKey},
store.activeSanctionWatchKeys(input.NewSnapshot.UserID)...,
)
operationCtx, cancel, err := store.operationContext(ctx, "repair expired entitlement in redis")
if err != nil {
return err
}
defer cancel()
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
storedSnapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedExpiredSnapshot.UserID)
if err != nil {
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, err)
}
if !equalEntitlementSnapshots(storedSnapshot, input.ExpectedExpiredSnapshot) {
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, ports.ErrConflict)
}
if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil {
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, err)
}
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewSnapshot.UserID)
if err != nil {
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, err)
}
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0)
pipe.ZAdd(operationCtx, historyKey, redis.Z{
Score: float64(input.NewRecord.StartsAt.UTC().UnixMicro()),
Member: input.NewRecord.RecordID.String(),
})
pipe.Set(operationCtx, snapshotKey, newSnapshotPayload, 0)
store.syncEntitlementIndexes(pipe, operationCtx, input.NewSnapshot)
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewSnapshot.UserID, input.NewSnapshot.IsPaid, activeSanctionCodes)
return nil
})
if err != nil {
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, err)
}
return nil
}, watchedKeys...)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, ports.ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
func (store *Store) loadEntitlementRecord(
ctx context.Context,
getter bytesGetter,
recordID entitlement.EntitlementRecordID,
) (entitlement.PeriodRecord, error) {
payload, err := getter.Get(ctx, store.keyspace.EntitlementRecord(recordID)).Bytes()
switch {
case errors.Is(err, redis.Nil):
return entitlement.PeriodRecord{}, ports.ErrNotFound
case err != nil:
return entitlement.PeriodRecord{}, err
}
return decodeEntitlementPeriodRecord(payload)
}
func marshalEntitlementPeriodRecord(record entitlement.PeriodRecord) ([]byte, error) {
encoded := entitlementPeriodRecord{
RecordID: record.RecordID.String(),
UserID: record.UserID.String(),
PlanCode: string(record.PlanCode),
Source: record.Source.String(),
ActorType: record.Actor.Type.String(),
ReasonCode: record.ReasonCode.String(),
StartsAt: record.StartsAt.UTC().Format(time.RFC3339Nano),
CreatedAt: record.CreatedAt.UTC().Format(time.RFC3339Nano),
}
if !record.Actor.ID.IsZero() {
value := record.Actor.ID.String()
encoded.ActorID = &value
}
if record.EndsAt != nil {
value := record.EndsAt.UTC().Format(time.RFC3339Nano)
encoded.EndsAt = &value
}
if record.ClosedAt != nil {
value := record.ClosedAt.UTC().Format(time.RFC3339Nano)
encoded.ClosedAt = &value
}
if !record.ClosedBy.Type.IsZero() {
value := record.ClosedBy.Type.String()
encoded.ClosedByType = &value
}
if !record.ClosedBy.ID.IsZero() {
value := record.ClosedBy.ID.String()
encoded.ClosedByID = &value
}
if !record.ClosedReasonCode.IsZero() {
value := record.ClosedReasonCode.String()
encoded.ClosedReasonCode = &value
}
return json.Marshal(encoded)
}
func decodeEntitlementPeriodRecord(payload []byte) (entitlement.PeriodRecord, error) {
var encoded entitlementPeriodRecord
if err := decodeJSONPayload(payload, &encoded); err != nil {
return entitlement.PeriodRecord{}, err
}
startsAt, err := time.Parse(time.RFC3339Nano, encoded.StartsAt)
if err != nil {
return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record starts_at: %w", err)
}
createdAt, err := time.Parse(time.RFC3339Nano, encoded.CreatedAt)
if err != nil {
return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record created_at: %w", err)
}
record := entitlement.PeriodRecord{
RecordID: entitlement.EntitlementRecordID(encoded.RecordID),
UserID: common.UserID(encoded.UserID),
PlanCode: entitlement.PlanCode(encoded.PlanCode),
Source: common.Source(encoded.Source),
Actor: common.ActorRef{Type: common.ActorType(encoded.ActorType)},
ReasonCode: common.ReasonCode(encoded.ReasonCode),
StartsAt: startsAt.UTC(),
CreatedAt: createdAt.UTC(),
}
if encoded.ActorID != nil {
record.Actor.ID = common.ActorID(*encoded.ActorID)
}
if encoded.EndsAt != nil {
value, err := time.Parse(time.RFC3339Nano, *encoded.EndsAt)
if err != nil {
return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record ends_at: %w", err)
}
value = value.UTC()
record.EndsAt = &value
}
if encoded.ClosedAt != nil {
value, err := time.Parse(time.RFC3339Nano, *encoded.ClosedAt)
if err != nil {
return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record closed_at: %w", err)
}
value = value.UTC()
record.ClosedAt = &value
}
if encoded.ClosedByType != nil {
record.ClosedBy.Type = common.ActorType(*encoded.ClosedByType)
}
if encoded.ClosedByID != nil {
record.ClosedBy.ID = common.ActorID(*encoded.ClosedByID)
}
if encoded.ClosedReasonCode != nil {
record.ClosedReasonCode = common.ReasonCode(*encoded.ClosedReasonCode)
}
if err := record.Validate(); err != nil {
return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record: %w", err)
}
return record, nil
}
func equalEntitlementSnapshots(left entitlement.CurrentSnapshot, right entitlement.CurrentSnapshot) bool {
return left.UserID == right.UserID &&
left.PlanCode == right.PlanCode &&
left.IsPaid == right.IsPaid &&
left.StartsAt.Equal(right.StartsAt) &&
equalOptionalTime(left.EndsAt, right.EndsAt) &&
left.Source == right.Source &&
left.Actor == right.Actor &&
left.ReasonCode == right.ReasonCode &&
left.UpdatedAt.Equal(right.UpdatedAt)
}
func equalEntitlementPeriodRecords(left entitlement.PeriodRecord, right entitlement.PeriodRecord) bool {
return left.RecordID == right.RecordID &&
left.UserID == right.UserID &&
left.PlanCode == right.PlanCode &&
left.Source == right.Source &&
left.Actor == right.Actor &&
left.ReasonCode == right.ReasonCode &&
left.StartsAt.Equal(right.StartsAt) &&
equalOptionalTime(left.EndsAt, right.EndsAt) &&
left.CreatedAt.Equal(right.CreatedAt) &&
equalOptionalTime(left.ClosedAt, right.ClosedAt) &&
left.ClosedBy == right.ClosedBy &&
left.ClosedReasonCode == right.ClosedReasonCode
}
func equalOptionalTime(left *time.Time, right *time.Time) bool {
switch {
case left == nil && right == nil:
return true
case left == nil || right == nil:
return false
default:
return left.Equal(*right)
}
}
// EntitlementHistoryStore adapts Store to the existing
// EntitlementHistoryStore port.
type EntitlementHistoryStore struct {
store *Store
}
// EntitlementHistory returns one adapter that exposes the entitlement-history
// store port over Store.
func (store *Store) EntitlementHistory() *EntitlementHistoryStore {
if store == nil {
return nil
}
return &EntitlementHistoryStore{store: store}
}
// Create stores one new entitlement history record.
func (adapter *EntitlementHistoryStore) Create(ctx context.Context, record entitlement.PeriodRecord) error {
return adapter.store.CreateEntitlementRecord(ctx, record)
}
// GetByRecordID returns the entitlement history record identified by recordID.
func (adapter *EntitlementHistoryStore) GetByRecordID(
ctx context.Context,
recordID entitlement.EntitlementRecordID,
) (entitlement.PeriodRecord, error) {
return adapter.store.GetEntitlementRecordByRecordID(ctx, recordID)
}
// ListByUserID returns every entitlement history record owned by userID.
func (adapter *EntitlementHistoryStore) ListByUserID(
ctx context.Context,
userID common.UserID,
) ([]entitlement.PeriodRecord, error) {
return adapter.store.ListEntitlementRecordsByUserID(ctx, userID)
}
// Update replaces one stored entitlement history record.
func (adapter *EntitlementHistoryStore) Update(ctx context.Context, record entitlement.PeriodRecord) error {
return adapter.store.UpdateEntitlementRecord(ctx, record)
}
var _ ports.EntitlementHistoryStore = (*EntitlementHistoryStore)(nil)
// EntitlementLifecycleStore adapts Store to the existing
// EntitlementLifecycleStore port.
type EntitlementLifecycleStore struct {
store *Store
}
// EntitlementLifecycle returns one adapter that exposes the atomic
// entitlement-lifecycle store port over Store.
func (store *Store) EntitlementLifecycle() *EntitlementLifecycleStore {
if store == nil {
return nil
}
return &EntitlementLifecycleStore{store: store}
}
// Grant atomically applies one free-to-paid transition.
func (adapter *EntitlementLifecycleStore) Grant(ctx context.Context, input ports.GrantEntitlementInput) error {
return adapter.store.GrantEntitlement(ctx, input)
}
// Extend atomically appends one paid extension segment and updates the current
// snapshot.
func (adapter *EntitlementLifecycleStore) Extend(ctx context.Context, input ports.ExtendEntitlementInput) error {
return adapter.store.ExtendEntitlement(ctx, input)
}
// Revoke atomically applies one paid-to-free transition.
func (adapter *EntitlementLifecycleStore) Revoke(ctx context.Context, input ports.RevokeEntitlementInput) error {
return adapter.store.RevokeEntitlement(ctx, input)
}
// RepairExpired atomically repairs one expired finite paid snapshot.
func (adapter *EntitlementLifecycleStore) RepairExpired(
ctx context.Context,
input ports.RepairExpiredEntitlementInput,
) error {
return adapter.store.RepairExpiredEntitlement(ctx, input)
}
var _ ports.EntitlementLifecycleStore = (*EntitlementLifecycleStore)(nil)
@@ -0,0 +1,137 @@
package userstore
import (
"context"
"errors"
"fmt"
"time"
"galaxy/user/internal/adapters/redisstate"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/ports"
"github.com/redis/go-redis/v9"
)
// ListUserIDs returns one deterministic page of user identifiers ordered by
// `created_at desc`, then `user_id desc`.
func (store *Store) ListUserIDs(ctx context.Context, input ports.ListUsersInput) (ports.ListUsersResult, error) {
if err := input.Validate(); err != nil {
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "list users in redis")
if err != nil {
return ports.ListUsersResult{}, err
}
defer cancel()
startIndex := int64(0)
filters := userListFiltersFromPorts(input.Filters)
if input.PageToken != "" {
cursor, err := redisstate.DecodePageToken(input.PageToken, filters)
if err != nil {
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", ports.ErrInvalidPageToken)
}
score, err := store.client.ZScore(operationCtx, store.keyspace.CreatedAtIndex(), cursor.UserID.String()).Result()
switch {
case errors.Is(err, redis.Nil):
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", ports.ErrInvalidPageToken)
case err != nil:
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
}
if !time.UnixMicro(int64(score)).UTC().Equal(cursor.CreatedAt.UTC()) {
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", ports.ErrInvalidPageToken)
}
rank, err := store.client.ZRevRank(operationCtx, store.keyspace.CreatedAtIndex(), cursor.UserID.String()).Result()
switch {
case errors.Is(err, redis.Nil):
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", ports.ErrInvalidPageToken)
case err != nil:
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
}
startIndex = rank + 1
}
rawPage, err := store.client.ZRevRangeWithScores(
operationCtx,
store.keyspace.CreatedAtIndex(),
startIndex,
startIndex+int64(input.PageSize),
).Result()
if err != nil {
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
}
result := ports.ListUsersResult{
UserIDs: make([]common.UserID, 0, min(len(rawPage), input.PageSize)),
}
visibleCount := min(len(rawPage), input.PageSize)
for index := 0; index < visibleCount; index++ {
userID, err := memberUserID(rawPage[index].Member)
if err != nil {
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
}
result.UserIDs = append(result.UserIDs, userID)
}
if len(rawPage) > input.PageSize {
lastVisible := rawPage[input.PageSize-1]
lastUserID, err := memberUserID(lastVisible.Member)
if err != nil {
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
}
token, err := redisstate.EncodePageToken(redisstate.PageCursor{
CreatedAt: time.UnixMicro(int64(lastVisible.Score)).UTC(),
UserID: lastUserID,
}, filters)
if err != nil {
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
}
result.NextPageToken = token
}
return result, nil
}
func userListFiltersFromPorts(filters ports.UserListFilters) redisstate.UserListFilters {
return redisstate.UserListFilters{
PaidState: filters.PaidState,
PaidExpiresBefore: filters.PaidExpiresBefore,
PaidExpiresAfter: filters.PaidExpiresAfter,
DeclaredCountry: filters.DeclaredCountry,
SanctionCode: filters.SanctionCode,
LimitCode: filters.LimitCode,
CanLogin: filters.CanLogin,
CanCreatePrivateGame: filters.CanCreatePrivateGame,
CanJoinGame: filters.CanJoinGame,
}
}
func memberUserID(member any) (common.UserID, error) {
value, ok := member.(string)
if !ok {
return "", fmt.Errorf("unexpected created-at index member type %T", member)
}
userID := common.UserID(value)
if err := userID.Validate(); err != nil {
return "", fmt.Errorf("created-at index member user id: %w", err)
}
return userID, nil
}
func min(left int, right int) int {
if left < right {
return left
}
return right
}
var _ ports.UserListStore = (*Store)(nil)
@@ -0,0 +1,445 @@
package userstore
import (
"context"
"errors"
"fmt"
"time"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"github.com/redis/go-redis/v9"
)
// ApplySanction atomically creates one new active sanction record.
func (store *Store) ApplySanction(ctx context.Context, input ports.ApplySanctionInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("apply sanction in redis: %w", err)
}
recordPayload, err := marshalSanctionRecord(input.NewRecord)
if err != nil {
return fmt.Errorf("apply sanction in redis: %w", err)
}
recordKey := store.keyspace.SanctionRecord(input.NewRecord.RecordID)
historyKey := store.keyspace.SanctionHistory(input.NewRecord.UserID)
activeKey := store.keyspace.ActiveSanction(input.NewRecord.UserID, input.NewRecord.SanctionCode)
snapshotKey := store.keyspace.EntitlementSnapshot(input.NewRecord.UserID)
watchedKeys := append(
[]string{recordKey, historyKey, activeKey, snapshotKey},
store.activeSanctionWatchKeys(input.NewRecord.UserID)...,
)
operationCtx, cancel, err := store.operationContext(ctx, "apply sanction in redis")
if err != nil {
return err
}
defer cancel()
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
if err := ensureKeyAbsent(operationCtx, tx, recordKey); err != nil {
return fmt.Errorf("apply sanction for user %q in redis: %w", input.NewRecord.UserID, err)
}
if err := ensureKeyAbsent(operationCtx, tx, activeKey); err != nil {
return fmt.Errorf("apply sanction for user %q in redis: %w", input.NewRecord.UserID, err)
}
snapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.NewRecord.UserID)
if err != nil {
return fmt.Errorf("apply sanction for user %q in redis: %w", input.NewRecord.UserID, err)
}
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewRecord.UserID)
if err != nil {
return fmt.Errorf("apply sanction for user %q in redis: %w", input.NewRecord.UserID, err)
}
activeSanctionCodes[input.NewRecord.SanctionCode] = struct{}{}
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
pipe.Set(operationCtx, recordKey, recordPayload, 0)
pipe.ZAdd(operationCtx, historyKey, redis.Z{
Score: float64(input.NewRecord.AppliedAt.UTC().UnixMicro()),
Member: input.NewRecord.RecordID.String(),
})
setActiveSlot(pipe, operationCtx, activeKey, input.NewRecord.RecordID.String(), input.NewRecord.ExpiresAt)
store.syncActiveSanctionCodeIndexes(pipe, operationCtx, input.NewRecord.UserID, activeSanctionCodes)
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewRecord.UserID, snapshot.IsPaid, activeSanctionCodes)
return nil
})
if err != nil {
return fmt.Errorf("apply sanction for user %q in redis: %w", input.NewRecord.UserID, err)
}
return nil
}, watchedKeys...)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("apply sanction for user %q in redis: %w", input.NewRecord.UserID, ports.ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
// RemoveSanction atomically removes one active sanction record.
func (store *Store) RemoveSanction(ctx context.Context, input ports.RemoveSanctionInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("remove sanction in redis: %w", err)
}
updatedPayload, err := marshalSanctionRecord(input.UpdatedRecord)
if err != nil {
return fmt.Errorf("remove sanction in redis: %w", err)
}
recordKey := store.keyspace.SanctionRecord(input.ExpectedActiveRecord.RecordID)
activeKey := store.keyspace.ActiveSanction(input.ExpectedActiveRecord.UserID, input.ExpectedActiveRecord.SanctionCode)
snapshotKey := store.keyspace.EntitlementSnapshot(input.ExpectedActiveRecord.UserID)
watchedKeys := append(
[]string{recordKey, activeKey, snapshotKey},
store.activeSanctionWatchKeys(input.ExpectedActiveRecord.UserID)...,
)
operationCtx, cancel, err := store.operationContext(ctx, "remove sanction in redis")
if err != nil {
return err
}
defer cancel()
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
activeRecordID, err := store.loadActiveSanctionRecordID(operationCtx, tx, activeKey)
if err != nil {
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
}
if activeRecordID != input.ExpectedActiveRecord.RecordID {
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, ports.ErrConflict)
}
storedRecord, err := store.loadSanctionRecord(operationCtx, tx, input.ExpectedActiveRecord.RecordID)
if err != nil {
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
}
if !equalSanctionRecords(storedRecord, input.ExpectedActiveRecord) {
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, ports.ErrConflict)
}
snapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedActiveRecord.UserID)
if err != nil {
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
}
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.ExpectedActiveRecord.UserID)
if err != nil {
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
}
delete(activeSanctionCodes, input.ExpectedActiveRecord.SanctionCode)
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
pipe.Set(operationCtx, recordKey, updatedPayload, 0)
pipe.Del(operationCtx, activeKey)
store.syncActiveSanctionCodeIndexes(pipe, operationCtx, input.ExpectedActiveRecord.UserID, activeSanctionCodes)
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.ExpectedActiveRecord.UserID, snapshot.IsPaid, activeSanctionCodes)
return nil
})
if err != nil {
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
}
return nil
}, watchedKeys...)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, ports.ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
// SetLimit atomically creates or replaces one active limit record.
func (store *Store) SetLimit(ctx context.Context, input ports.SetLimitInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("set limit in redis: %w", err)
}
newRecordPayload, err := marshalLimitRecord(input.NewRecord)
if err != nil {
return fmt.Errorf("set limit in redis: %w", err)
}
newRecordKey := store.keyspace.LimitRecord(input.NewRecord.RecordID)
historyKey := store.keyspace.LimitHistory(input.NewRecord.UserID)
activeKey := store.keyspace.ActiveLimit(input.NewRecord.UserID, input.NewRecord.LimitCode)
watchedKeys := append(
[]string{newRecordKey, historyKey, activeKey},
store.activeLimitWatchKeys(input.NewRecord.UserID)...,
)
operationCtx, cancel, err := store.operationContext(ctx, "set limit in redis")
if err != nil {
return err
}
defer cancel()
if input.ExpectedActiveRecord != nil {
watchedKeys = append(watchedKeys, store.keyspace.LimitRecord(input.ExpectedActiveRecord.RecordID))
}
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil {
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
}
var updatedPayload []byte
if input.ExpectedActiveRecord == nil {
if err := ensureKeyAbsent(operationCtx, tx, activeKey); err != nil {
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
}
} else {
activeRecordID, err := store.loadActiveLimitRecordID(operationCtx, tx, activeKey)
if err != nil {
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
}
if activeRecordID != input.ExpectedActiveRecord.RecordID {
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, ports.ErrConflict)
}
storedRecord, err := store.loadLimitRecord(operationCtx, tx, input.ExpectedActiveRecord.RecordID)
if err != nil {
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
}
if !equalLimitRecords(storedRecord, *input.ExpectedActiveRecord) {
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, ports.ErrConflict)
}
updatedPayload, err = marshalLimitRecord(*input.UpdatedActiveRecord)
if err != nil {
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
}
}
activeLimitCodes, err := store.loadActiveLimitCodeSet(operationCtx, tx, input.NewRecord.UserID)
if err != nil {
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
}
activeLimitCodes[input.NewRecord.LimitCode] = struct{}{}
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
if input.ExpectedActiveRecord != nil {
pipe.Set(operationCtx, store.keyspace.LimitRecord(input.ExpectedActiveRecord.RecordID), updatedPayload, 0)
}
pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0)
pipe.ZAdd(operationCtx, historyKey, redis.Z{
Score: float64(input.NewRecord.AppliedAt.UTC().UnixMicro()),
Member: input.NewRecord.RecordID.String(),
})
setActiveSlot(pipe, operationCtx, activeKey, input.NewRecord.RecordID.String(), input.NewRecord.ExpiresAt)
store.syncActiveLimitCodeIndexes(pipe, operationCtx, input.NewRecord.UserID, activeLimitCodes)
return nil
})
if err != nil {
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
}
return nil
}, watchedKeys...)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, ports.ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
// RemoveLimit atomically removes one active limit record.
func (store *Store) RemoveLimit(ctx context.Context, input ports.RemoveLimitInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("remove limit in redis: %w", err)
}
updatedPayload, err := marshalLimitRecord(input.UpdatedRecord)
if err != nil {
return fmt.Errorf("remove limit in redis: %w", err)
}
recordKey := store.keyspace.LimitRecord(input.ExpectedActiveRecord.RecordID)
activeKey := store.keyspace.ActiveLimit(input.ExpectedActiveRecord.UserID, input.ExpectedActiveRecord.LimitCode)
watchedKeys := append(
[]string{recordKey, activeKey},
store.activeLimitWatchKeys(input.ExpectedActiveRecord.UserID)...,
)
operationCtx, cancel, err := store.operationContext(ctx, "remove limit in redis")
if err != nil {
return err
}
defer cancel()
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
activeRecordID, err := store.loadActiveLimitRecordID(operationCtx, tx, activeKey)
if err != nil {
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
}
if activeRecordID != input.ExpectedActiveRecord.RecordID {
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, ports.ErrConflict)
}
storedRecord, err := store.loadLimitRecord(operationCtx, tx, input.ExpectedActiveRecord.RecordID)
if err != nil {
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
}
if !equalLimitRecords(storedRecord, input.ExpectedActiveRecord) {
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, ports.ErrConflict)
}
activeLimitCodes, err := store.loadActiveLimitCodeSet(operationCtx, tx, input.ExpectedActiveRecord.UserID)
if err != nil {
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
}
delete(activeLimitCodes, input.ExpectedActiveRecord.LimitCode)
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
pipe.Set(operationCtx, recordKey, updatedPayload, 0)
pipe.Del(operationCtx, activeKey)
store.syncActiveLimitCodeIndexes(pipe, operationCtx, input.ExpectedActiveRecord.UserID, activeLimitCodes)
return nil
})
if err != nil {
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
}
return nil
}, watchedKeys...)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, ports.ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
func (store *Store) loadActiveSanctionRecordID(
ctx context.Context,
getter bytesGetter,
key string,
) (policy.SanctionRecordID, error) {
value, err := getter.Get(ctx, key).Result()
switch {
case errors.Is(err, redis.Nil):
return "", ports.ErrNotFound
case err != nil:
return "", err
}
recordID := policy.SanctionRecordID(value)
if err := recordID.Validate(); err != nil {
return "", fmt.Errorf("active sanction record id: %w", err)
}
return recordID, nil
}
func (store *Store) loadActiveLimitRecordID(
ctx context.Context,
getter bytesGetter,
key string,
) (policy.LimitRecordID, error) {
value, err := getter.Get(ctx, key).Result()
switch {
case errors.Is(err, redis.Nil):
return "", ports.ErrNotFound
case err != nil:
return "", err
}
recordID := policy.LimitRecordID(value)
if err := recordID.Validate(); err != nil {
return "", fmt.Errorf("active limit record id: %w", err)
}
return recordID, nil
}
func setActiveSlot(
pipe redis.Pipeliner,
ctx context.Context,
key string,
recordID string,
expiresAt *time.Time,
) {
pipe.Set(ctx, key, recordID, 0)
if expiresAt != nil {
pipe.PExpireAt(ctx, key, expiresAt.UTC())
}
}
func equalSanctionRecords(left policy.SanctionRecord, right policy.SanctionRecord) bool {
return left.RecordID == right.RecordID &&
left.UserID == right.UserID &&
left.SanctionCode == right.SanctionCode &&
left.Scope == right.Scope &&
left.ReasonCode == right.ReasonCode &&
left.Actor == right.Actor &&
left.AppliedAt.Equal(right.AppliedAt) &&
equalOptionalTime(left.ExpiresAt, right.ExpiresAt) &&
equalOptionalTime(left.RemovedAt, right.RemovedAt) &&
left.RemovedBy == right.RemovedBy &&
left.RemovedReasonCode == right.RemovedReasonCode
}
func equalLimitRecords(left policy.LimitRecord, right policy.LimitRecord) bool {
return left.RecordID == right.RecordID &&
left.UserID == right.UserID &&
left.LimitCode == right.LimitCode &&
left.Value == right.Value &&
left.ReasonCode == right.ReasonCode &&
left.Actor == right.Actor &&
left.AppliedAt.Equal(right.AppliedAt) &&
equalOptionalTime(left.ExpiresAt, right.ExpiresAt) &&
equalOptionalTime(left.RemovedAt, right.RemovedAt) &&
left.RemovedBy == right.RemovedBy &&
left.RemovedReasonCode == right.RemovedReasonCode
}
// PolicyLifecycleStore adapts Store to the existing PolicyLifecycleStore
// port.
type PolicyLifecycleStore struct {
store *Store
}
// PolicyLifecycle returns one adapter that exposes the atomic policy-lifecycle
// store port over Store.
func (store *Store) PolicyLifecycle() *PolicyLifecycleStore {
if store == nil {
return nil
}
return &PolicyLifecycleStore{store: store}
}
// ApplySanction atomically creates one new active sanction record.
func (adapter *PolicyLifecycleStore) ApplySanction(ctx context.Context, input ports.ApplySanctionInput) error {
return adapter.store.ApplySanction(ctx, input)
}
// RemoveSanction atomically removes one active sanction record.
func (adapter *PolicyLifecycleStore) RemoveSanction(ctx context.Context, input ports.RemoveSanctionInput) error {
return adapter.store.RemoveSanction(ctx, input)
}
// SetLimit atomically creates or replaces one active limit record.
func (adapter *PolicyLifecycleStore) SetLimit(ctx context.Context, input ports.SetLimitInput) error {
return adapter.store.SetLimit(ctx, input)
}
// RemoveLimit atomically removes one active limit record.
func (adapter *PolicyLifecycleStore) RemoveLimit(ctx context.Context, input ports.RemoveLimitInput) error {
return adapter.store.RemoveLimit(ctx, input)
}
var _ ports.PolicyLifecycleStore = (*PolicyLifecycleStore)(nil)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,930 @@
package userstore
import (
"context"
"strings"
"testing"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/authblock"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"github.com/alicebob/miniredis/v2"
"github.com/stretchr/testify/require"
)
func TestAccountStoreCreateAndLookups(t *testing.T) {
t.Parallel()
store := newTestStore(t)
accountStore := store.Accounts()
record := validAccountRecord()
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record)))
byUserID, err := accountStore.GetByUserID(context.Background(), record.UserID)
require.NoError(t, err)
require.Equal(t, record, byUserID)
byEmail, err := accountStore.GetByEmail(context.Background(), record.Email)
require.NoError(t, err)
require.Equal(t, record, byEmail)
byRaceName, err := accountStore.GetByRaceName(context.Background(), record.RaceName)
require.NoError(t, err)
require.Equal(t, record, byRaceName)
exists, err := accountStore.ExistsByUserID(context.Background(), record.UserID)
require.NoError(t, err)
require.True(t, exists)
reservation, err := store.loadRaceNameReservation(context.Background(), store.client, canonicalKey(record.RaceName))
require.NoError(t, err)
require.Equal(t, record.UserID, reservation.UserID)
require.Equal(t, record.RaceName, reservation.RaceName)
}
func TestBlockedEmailStoreUpsertAndGet(t *testing.T) {
t.Parallel()
store := newTestStore(t)
blockedEmailStore := store.BlockedEmails()
record := authblock.BlockedEmailSubject{
Email: common.Email("blocked@example.com"),
ReasonCode: common.ReasonCode("policy_blocked"),
BlockedAt: time.Unix(1_775_240_100, 0).UTC(),
ResolvedUserID: common.UserID("user-123"),
}
require.NoError(t, blockedEmailStore.Upsert(context.Background(), record))
got, err := blockedEmailStore.GetByEmail(context.Background(), record.Email)
require.NoError(t, err)
require.Equal(t, record, got)
}
func TestEnsureResolveAndBlockFlows(t *testing.T) {
t.Parallel()
store := newTestStore(t)
now := time.Unix(1_775_240_000, 0).UTC()
accountRecord := validAccountRecord()
entitlementSnapshot := validEntitlementSnapshot(accountRecord.UserID, now)
created, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
Email: accountRecord.Email,
Account: accountRecord,
Entitlement: entitlementSnapshot,
EntitlementRecord: validEntitlementRecord(accountRecord.UserID, now),
Reservation: raceNameReservation(accountRecord.UserID, accountRecord.RaceName, accountRecord.UpdatedAt),
})
require.NoError(t, err)
require.Equal(t, ports.EnsureByEmailOutcomeCreated, created.Outcome)
reservation, err := store.loadRaceNameReservation(context.Background(), store.client, canonicalKey(accountRecord.RaceName))
require.NoError(t, err)
require.Equal(t, accountRecord.UserID, reservation.UserID)
entitlementHistory, err := store.ListEntitlementRecordsByUserID(context.Background(), accountRecord.UserID)
require.NoError(t, err)
require.Len(t, entitlementHistory, 1)
require.Equal(t, validEntitlementRecord(accountRecord.UserID, now), entitlementHistory[0])
resolved, err := store.ResolveByEmail(context.Background(), accountRecord.Email)
require.NoError(t, err)
require.Equal(t, ports.AuthResolutionKindExisting, resolved.Kind)
blockedByUserID, err := store.BlockByUserID(context.Background(), ports.BlockByUserIDInput{
UserID: accountRecord.UserID,
ReasonCode: common.ReasonCode("policy_blocked"),
BlockedAt: now.Add(time.Minute),
})
require.NoError(t, err)
require.Equal(t, ports.AuthBlockOutcomeBlocked, blockedByUserID.Outcome)
repeatedBlock, err := store.BlockByEmail(context.Background(), ports.BlockByEmailInput{
Email: accountRecord.Email,
ReasonCode: common.ReasonCode("policy_blocked"),
BlockedAt: now.Add(2 * time.Minute),
})
require.NoError(t, err)
require.Equal(t, ports.AuthBlockOutcomeAlreadyBlocked, repeatedBlock.Outcome)
require.Equal(t, accountRecord.UserID, repeatedBlock.UserID)
blockedResolution, err := store.ResolveByEmail(context.Background(), accountRecord.Email)
require.NoError(t, err)
require.Equal(t, ports.AuthResolutionKindBlocked, blockedResolution.Kind)
ensureBlocked, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
Email: accountRecord.Email,
Account: accountRecord,
Entitlement: entitlementSnapshot,
EntitlementRecord: validEntitlementRecord(accountRecord.UserID, now),
Reservation: raceNameReservation(accountRecord.UserID, accountRecord.RaceName, accountRecord.UpdatedAt),
})
require.NoError(t, err)
require.Equal(t, ports.EnsureByEmailOutcomeBlocked, ensureBlocked.Outcome)
}
func TestBlockedEmailWithoutUserPreventsEnsureCreate(t *testing.T) {
t.Parallel()
store := newTestStore(t)
now := time.Unix(1_775_240_000, 0).UTC()
accountRecord := validAccountRecord()
entitlementSnapshot := validEntitlementSnapshot(accountRecord.UserID, now)
blocked, err := store.BlockByEmail(context.Background(), ports.BlockByEmailInput{
Email: accountRecord.Email,
ReasonCode: common.ReasonCode("policy_blocked"),
BlockedAt: now,
})
require.NoError(t, err)
require.Equal(t, ports.AuthBlockOutcomeBlocked, blocked.Outcome)
require.True(t, blocked.UserID.IsZero())
resolved, err := store.ResolveByEmail(context.Background(), accountRecord.Email)
require.NoError(t, err)
require.Equal(t, ports.AuthResolutionKindBlocked, resolved.Kind)
ensured, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
Email: accountRecord.Email,
Account: accountRecord,
Entitlement: entitlementSnapshot,
EntitlementRecord: validEntitlementRecord(accountRecord.UserID, now),
Reservation: raceNameReservation(accountRecord.UserID, accountRecord.RaceName, accountRecord.UpdatedAt),
})
require.NoError(t, err)
require.Equal(t, ports.EnsureByEmailOutcomeBlocked, ensured.Outcome)
exists, err := store.ExistsByUserID(context.Background(), accountRecord.UserID)
require.NoError(t, err)
require.False(t, exists)
}
func TestEnsureByEmailExistingDoesNotOverwriteStoredSettings(t *testing.T) {
t.Parallel()
store := newTestStore(t)
createdAt := time.Unix(1_775_240_000, 0).UTC()
existingAccount := account.UserAccount{
UserID: common.UserID("user-existing"),
Email: common.Email("pilot@example.com"),
RaceName: common.RaceName("Pilot Nova"),
PreferredLanguage: common.LanguageTag("en"),
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
CreatedAt: createdAt,
UpdatedAt: createdAt,
}
require.NoError(t, store.Create(context.Background(), createAccountInput(existingAccount)))
result, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
Email: existingAccount.Email,
Account: account.UserAccount{
UserID: common.UserID("user-created"),
Email: existingAccount.Email,
RaceName: common.RaceName("player-new123"),
PreferredLanguage: common.LanguageTag("fr-FR"),
TimeZone: common.TimeZoneName("UTC"),
CreatedAt: createdAt.Add(time.Minute),
UpdatedAt: createdAt.Add(time.Minute),
},
Entitlement: validEntitlementSnapshot(common.UserID("user-created"), createdAt.Add(time.Minute)),
EntitlementRecord: validEntitlementRecord(common.UserID("user-created"), createdAt.Add(time.Minute)),
Reservation: raceNameReservation(common.UserID("user-created"), common.RaceName("player-new123"), createdAt.Add(time.Minute)),
})
require.NoError(t, err)
require.Equal(t, ports.EnsureByEmailOutcomeExisting, result.Outcome)
require.Equal(t, existingAccount.UserID, result.UserID)
storedAccount, err := store.GetByEmail(context.Background(), existingAccount.Email)
require.NoError(t, err)
require.Equal(t, existingAccount, storedAccount)
}
func TestAccountStoreRenameRaceNameSwapsLookupAtomically(t *testing.T) {
t.Parallel()
store := newTestStore(t)
accountStore := store.Accounts()
record := validAccountRecord()
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record)))
updatedAt := record.UpdatedAt.Add(time.Minute)
require.NoError(t, accountStore.RenameRaceName(context.Background(), renameRaceNameInput(record, common.RaceName("Nova Prime"), updatedAt)))
stored, err := accountStore.GetByUserID(context.Background(), record.UserID)
require.NoError(t, err)
require.Equal(t, common.RaceName("Nova Prime"), stored.RaceName)
require.True(t, stored.UpdatedAt.Equal(updatedAt))
_, err = accountStore.GetByRaceName(context.Background(), record.RaceName)
require.ErrorIs(t, err, ports.ErrNotFound)
renamed, err := accountStore.GetByRaceName(context.Background(), common.RaceName("Nova Prime"))
require.NoError(t, err)
require.Equal(t, record.UserID, renamed.UserID)
_, err = store.loadRaceNameReservation(context.Background(), store.client, canonicalKey(record.RaceName))
require.ErrorIs(t, err, ports.ErrNotFound)
reservation, err := store.loadRaceNameReservation(context.Background(), store.client, canonicalKey(common.RaceName("Nova Prime")))
require.NoError(t, err)
require.Equal(t, common.RaceName("Nova Prime"), reservation.RaceName)
}
func TestAccountStoreRenameRaceNameAllowsSameOwnerCanonicalSlot(t *testing.T) {
t.Parallel()
store := newTestStore(t)
accountStore := store.Accounts()
record := validAccountRecord()
record.RaceName = common.RaceName("Pilot Nova")
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record)))
updatedAt := record.UpdatedAt.Add(time.Minute)
require.NoError(t, accountStore.RenameRaceName(context.Background(), renameRaceNameInput(record, common.RaceName("P1lot Nova"), updatedAt)))
reservation, err := store.loadRaceNameReservation(context.Background(), store.client, canonicalKey(common.RaceName("P1lot Nova")))
require.NoError(t, err)
require.Equal(t, common.RaceName("P1lot Nova"), reservation.RaceName)
}
func TestAccountStoreRenameRaceNameReturnsConflictWhenTargetExists(t *testing.T) {
t.Parallel()
store := newTestStore(t)
accountStore := store.Accounts()
first := validAccountRecord()
second := validAccountRecord()
second.UserID = common.UserID("user-456")
second.Email = common.Email("other@example.com")
second.RaceName = common.RaceName("Taken Name")
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(first)))
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(second)))
err := accountStore.RenameRaceName(context.Background(), renameRaceNameInput(first, second.RaceName, first.UpdatedAt.Add(time.Minute)))
require.ErrorIs(t, err, ports.ErrConflict)
stored, err := accountStore.GetByUserID(context.Background(), first.UserID)
require.NoError(t, err)
require.Equal(t, first.RaceName, stored.RaceName)
}
func TestAccountStoreUpdateDeclaredCountryPreservesLookups(t *testing.T) {
t.Parallel()
store := newTestStore(t)
accountStore := store.Accounts()
record := validAccountRecord()
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record)))
updated := record
updated.DeclaredCountry = common.CountryCode("FR")
updated.UpdatedAt = record.UpdatedAt.Add(time.Minute)
require.NoError(t, accountStore.Update(context.Background(), updated))
byUserID, err := accountStore.GetByUserID(context.Background(), record.UserID)
require.NoError(t, err)
require.Equal(t, updated, byUserID)
byEmail, err := accountStore.GetByEmail(context.Background(), record.Email)
require.NoError(t, err)
require.Equal(t, updated, byEmail)
byRaceName, err := accountStore.GetByRaceName(context.Background(), record.RaceName)
require.NoError(t, err)
require.Equal(t, updated, byRaceName)
}
func TestAccountStoreCreateReturnsConflictWhenCanonicalReservationExists(t *testing.T) {
t.Parallel()
store := newTestStore(t)
accountStore := store.Accounts()
first := validAccountRecord()
second := validAccountRecord()
second.UserID = common.UserID("user-456")
second.Email = common.Email("other@example.com")
second.RaceName = common.RaceName("P1lot Nova")
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(first)))
err := accountStore.Create(context.Background(), createAccountInput(second))
require.ErrorIs(t, err, ports.ErrConflict)
}
func TestBlockByUserIDRepeatedCallsStayIdempotent(t *testing.T) {
t.Parallel()
store := newTestStore(t)
now := time.Unix(1_775_240_000, 0).UTC()
accountRecord := validAccountRecord()
require.NoError(t, store.Create(context.Background(), createAccountInput(accountRecord)))
first, err := store.BlockByUserID(context.Background(), ports.BlockByUserIDInput{
UserID: accountRecord.UserID,
ReasonCode: common.ReasonCode("policy_blocked"),
BlockedAt: now,
})
require.NoError(t, err)
require.Equal(t, ports.AuthBlockOutcomeBlocked, first.Outcome)
second, err := store.BlockByUserID(context.Background(), ports.BlockByUserIDInput{
UserID: accountRecord.UserID,
ReasonCode: common.ReasonCode("policy_blocked"),
BlockedAt: now.Add(time.Minute),
})
require.NoError(t, err)
require.Equal(t, ports.AuthBlockOutcomeAlreadyBlocked, second.Outcome)
require.Equal(t, accountRecord.UserID, second.UserID)
}
func TestBlockByUserIDUnknownUserReturnsNotFound(t *testing.T) {
t.Parallel()
store := newTestStore(t)
_, err := store.BlockByUserID(context.Background(), ports.BlockByUserIDInput{
UserID: common.UserID("user-missing"),
ReasonCode: common.ReasonCode("policy_blocked"),
BlockedAt: time.Unix(1_775_240_000, 0).UTC(),
})
require.ErrorIs(t, err, ports.ErrNotFound)
}
func TestSanctionAndLimitStoresRoundTrip(t *testing.T) {
t.Parallel()
store := newTestStore(t)
sanctionStore := store.Sanctions()
limitStore := store.Limits()
now := time.Unix(1_775_240_000, 0).UTC()
sanctionRecord := policy.SanctionRecord{
RecordID: policy.SanctionRecordID("sanction-1"),
UserID: common.UserID("user-123"),
SanctionCode: policy.SanctionCodeLoginBlock,
Scope: common.Scope("self_service"),
ReasonCode: common.ReasonCode("policy_enforced"),
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
AppliedAt: now,
}
require.NoError(t, sanctionStore.Create(context.Background(), sanctionRecord))
gotSanction, err := sanctionStore.GetByRecordID(context.Background(), sanctionRecord.RecordID)
require.NoError(t, err)
require.Equal(t, sanctionRecord, gotSanction)
sanctions, err := sanctionStore.ListByUserID(context.Background(), sanctionRecord.UserID)
require.NoError(t, err)
require.Len(t, sanctions, 1)
expiresAt := now.Add(time.Hour)
sanctionRecord.ExpiresAt = &expiresAt
require.NoError(t, sanctionStore.Update(context.Background(), sanctionRecord))
gotSanction, err = sanctionStore.GetByRecordID(context.Background(), sanctionRecord.RecordID)
require.NoError(t, err)
require.Equal(t, sanctionRecord.RecordID, gotSanction.RecordID)
require.Equal(t, sanctionRecord.UserID, gotSanction.UserID)
require.Equal(t, sanctionRecord.SanctionCode, gotSanction.SanctionCode)
require.Equal(t, sanctionRecord.Scope, gotSanction.Scope)
require.Equal(t, sanctionRecord.ReasonCode, gotSanction.ReasonCode)
require.Equal(t, sanctionRecord.Actor, gotSanction.Actor)
require.True(t, gotSanction.AppliedAt.Equal(sanctionRecord.AppliedAt))
require.NotNil(t, gotSanction.ExpiresAt)
require.True(t, gotSanction.ExpiresAt.Equal(*sanctionRecord.ExpiresAt))
limitRecord := policy.LimitRecord{
RecordID: policy.LimitRecordID("limit-1"),
UserID: common.UserID("user-123"),
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
Value: 3,
ReasonCode: common.ReasonCode("policy_enforced"),
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
AppliedAt: now,
}
require.NoError(t, limitStore.Create(context.Background(), limitRecord))
gotLimit, err := limitStore.GetByRecordID(context.Background(), limitRecord.RecordID)
require.NoError(t, err)
require.Equal(t, limitRecord, gotLimit)
limits, err := limitStore.ListByUserID(context.Background(), limitRecord.UserID)
require.NoError(t, err)
require.Len(t, limits, 1)
limitRecord.Value = 5
require.NoError(t, limitStore.Update(context.Background(), limitRecord))
gotLimit, err = limitStore.GetByRecordID(context.Background(), limitRecord.RecordID)
require.NoError(t, err)
require.Equal(t, limitRecord, gotLimit)
}
func TestPolicyLifecycleApplyAndRemoveSanction(t *testing.T) {
t.Parallel()
store := newTestStore(t)
lifecycleStore := store.PolicyLifecycle()
sanctionStore := store.Sanctions()
snapshotStore := store.EntitlementSnapshots()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
require.NoError(t, snapshotStore.Put(context.Background(), validEntitlementSnapshot(userID, now)))
record := policy.SanctionRecord{
RecordID: policy.SanctionRecordID("sanction-1"),
UserID: userID,
SanctionCode: policy.SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("manual_block"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now,
}
require.NoError(t, lifecycleStore.ApplySanction(context.Background(), ports.ApplySanctionInput{
NewRecord: record,
}))
activeRecordID, err := store.loadActiveSanctionRecordID(
context.Background(),
store.client,
store.keyspace.ActiveSanction(userID, policy.SanctionCodeLoginBlock),
)
require.NoError(t, err)
require.Equal(t, record.RecordID, activeRecordID)
err = lifecycleStore.ApplySanction(context.Background(), ports.ApplySanctionInput{
NewRecord: policy.SanctionRecord{
RecordID: policy.SanctionRecordID("sanction-2"),
UserID: userID,
SanctionCode: policy.SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("manual_block"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")},
AppliedAt: now.Add(time.Minute),
},
})
require.ErrorIs(t, err, ports.ErrConflict)
removed := record
removedAt := now.Add(30 * time.Minute)
removed.RemovedAt = &removedAt
removed.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")}
removed.RemovedReasonCode = common.ReasonCode("manual_remove")
require.NoError(t, lifecycleStore.RemoveSanction(context.Background(), ports.RemoveSanctionInput{
ExpectedActiveRecord: record,
UpdatedRecord: removed,
}))
stored, err := sanctionStore.GetByRecordID(context.Background(), record.RecordID)
require.NoError(t, err)
require.Equal(t, removed, stored)
_, err = store.loadActiveSanctionRecordID(
context.Background(),
store.client,
store.keyspace.ActiveSanction(userID, policy.SanctionCodeLoginBlock),
)
require.ErrorIs(t, err, ports.ErrNotFound)
}
func TestPolicyLifecycleSetAndRemoveLimit(t *testing.T) {
t.Parallel()
store := newTestStore(t)
lifecycleStore := store.PolicyLifecycle()
limitStore := store.Limits()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
first := policy.LimitRecord{
RecordID: policy.LimitRecordID("limit-1"),
UserID: userID,
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
Value: 3,
ReasonCode: common.ReasonCode("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now,
}
require.NoError(t, lifecycleStore.SetLimit(context.Background(), ports.SetLimitInput{
NewRecord: first,
}))
activeRecordID, err := store.loadActiveLimitRecordID(
context.Background(),
store.client,
store.keyspace.ActiveLimit(userID, policy.LimitCodeMaxOwnedPrivateGames),
)
require.NoError(t, err)
require.Equal(t, first.RecordID, activeRecordID)
second := policy.LimitRecord{
RecordID: policy.LimitRecordID("limit-2"),
UserID: userID,
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
Value: 5,
ReasonCode: common.ReasonCode("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")},
AppliedAt: now.Add(time.Hour),
}
updatedFirst := first
removedAt := second.AppliedAt
updatedFirst.RemovedAt = &removedAt
updatedFirst.RemovedBy = second.Actor
updatedFirst.RemovedReasonCode = second.ReasonCode
require.NoError(t, lifecycleStore.SetLimit(context.Background(), ports.SetLimitInput{
ExpectedActiveRecord: &first,
UpdatedActiveRecord: &updatedFirst,
NewRecord: second,
}))
storedFirst, err := limitStore.GetByRecordID(context.Background(), first.RecordID)
require.NoError(t, err)
require.Equal(t, updatedFirst, storedFirst)
activeRecordID, err = store.loadActiveLimitRecordID(
context.Background(),
store.client,
store.keyspace.ActiveLimit(userID, policy.LimitCodeMaxOwnedPrivateGames),
)
require.NoError(t, err)
require.Equal(t, second.RecordID, activeRecordID)
removedSecond := second
removeAt := now.Add(90 * time.Minute)
removedSecond.RemovedAt = &removeAt
removedSecond.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-3")}
removedSecond.RemovedReasonCode = common.ReasonCode("manual_remove")
require.NoError(t, lifecycleStore.RemoveLimit(context.Background(), ports.RemoveLimitInput{
ExpectedActiveRecord: second,
UpdatedRecord: removedSecond,
}))
storedSecond, err := limitStore.GetByRecordID(context.Background(), second.RecordID)
require.NoError(t, err)
require.Equal(t, removedSecond, storedSecond)
_, err = store.loadActiveLimitRecordID(
context.Background(),
store.client,
store.keyspace.ActiveLimit(userID, policy.LimitCodeMaxOwnedPrivateGames),
)
require.ErrorIs(t, err, ports.ErrNotFound)
}
func TestEntitlementLifecycleTransitions(t *testing.T) {
t.Parallel()
store := newTestStore(t)
historyStore := store.EntitlementHistory()
snapshotStore := store.EntitlementSnapshots()
lifecycleStore := store.EntitlementLifecycle()
userID := common.UserID("user-123")
startedFreeAt := time.Unix(1_775_240_000, 0).UTC()
freeRecord := validEntitlementRecord(userID, startedFreeAt)
freeSnapshot := validEntitlementSnapshot(userID, startedFreeAt)
require.NoError(t, historyStore.Create(context.Background(), freeRecord))
require.NoError(t, snapshotStore.Put(context.Background(), freeSnapshot))
grantStartsAt := startedFreeAt.Add(24 * time.Hour)
grantEndsAt := grantStartsAt.Add(30 * 24 * time.Hour)
grantedRecord := paidEntitlementRecord(
entitlement.EntitlementRecordID("entitlement-paid-1"),
userID,
entitlement.PlanCodePaidMonthly,
grantStartsAt,
grantEndsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
grantedSnapshot := paidEntitlementSnapshot(
userID,
entitlement.PlanCodePaidMonthly,
grantStartsAt,
grantEndsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
closedFreeRecord := freeRecord
closedFreeRecord.ClosedAt = timePointer(grantStartsAt)
closedFreeRecord.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
closedFreeRecord.ClosedReasonCode = common.ReasonCode("manual_grant")
require.NoError(t, lifecycleStore.Grant(context.Background(), ports.GrantEntitlementInput{
ExpectedCurrentSnapshot: freeSnapshot,
ExpectedCurrentRecord: freeRecord,
UpdatedCurrentRecord: closedFreeRecord,
NewRecord: grantedRecord,
NewSnapshot: grantedSnapshot,
}))
storedSnapshot, err := snapshotStore.GetByUserID(context.Background(), userID)
require.NoError(t, err)
require.Equal(t, grantedSnapshot, storedSnapshot)
storedFreeRecord, err := historyStore.GetByRecordID(context.Background(), freeRecord.RecordID)
require.NoError(t, err)
require.Equal(t, closedFreeRecord, storedFreeRecord)
extendedEndsAt := grantEndsAt.Add(30 * 24 * time.Hour)
extensionRecord := paidEntitlementRecord(
entitlement.EntitlementRecordID("entitlement-paid-2"),
userID,
entitlement.PlanCodePaidMonthly,
grantEndsAt,
extendedEndsAt,
common.Source("admin"),
common.ReasonCode("manual_extend"),
)
extendedSnapshot := paidEntitlementSnapshot(
userID,
entitlement.PlanCodePaidMonthly,
grantStartsAt,
extendedEndsAt,
common.Source("admin"),
common.ReasonCode("manual_extend"),
)
require.NoError(t, lifecycleStore.Extend(context.Background(), ports.ExtendEntitlementInput{
ExpectedCurrentSnapshot: grantedSnapshot,
NewRecord: extensionRecord,
NewSnapshot: extendedSnapshot,
}))
storedSnapshot, err = snapshotStore.GetByUserID(context.Background(), userID)
require.NoError(t, err)
require.Equal(t, extendedSnapshot, storedSnapshot)
revokeAt := grantEndsAt.Add(12 * time.Hour)
revokedCurrentRecord := extensionRecord
revokedCurrentRecord.ClosedAt = timePointer(revokeAt)
revokedCurrentRecord.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
revokedCurrentRecord.ClosedReasonCode = common.ReasonCode("manual_revoke")
freeAfterRevokeRecord := entitlement.PeriodRecord{
RecordID: entitlement.EntitlementRecordID("entitlement-free-2"),
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_revoke"),
StartsAt: revokeAt,
CreatedAt: revokeAt,
}
freeAfterRevokeSnapshot := entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
IsPaid: false,
StartsAt: revokeAt,
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_revoke"),
UpdatedAt: revokeAt,
}
require.NoError(t, lifecycleStore.Revoke(context.Background(), ports.RevokeEntitlementInput{
ExpectedCurrentSnapshot: extendedSnapshot,
ExpectedCurrentRecord: extensionRecord,
UpdatedCurrentRecord: revokedCurrentRecord,
NewRecord: freeAfterRevokeRecord,
NewSnapshot: freeAfterRevokeSnapshot,
}))
storedSnapshot, err = snapshotStore.GetByUserID(context.Background(), userID)
require.NoError(t, err)
require.Equal(t, freeAfterRevokeSnapshot, storedSnapshot)
historyRecords, err := historyStore.ListByUserID(context.Background(), userID)
require.NoError(t, err)
require.Len(t, historyRecords, 4)
}
func TestRepairExpiredEntitlementMaterializesFreeSnapshot(t *testing.T) {
t.Parallel()
store := newTestStore(t)
historyStore := store.EntitlementHistory()
snapshotStore := store.EntitlementSnapshots()
lifecycleStore := store.EntitlementLifecycle()
userID := common.UserID("user-123")
startsAt := time.Unix(1_775_240_000, 0).UTC()
endsAt := startsAt.Add(24 * time.Hour)
expiredSnapshot := paidEntitlementSnapshot(
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
endsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
expiredSnapshot.UpdatedAt = endsAt.Add(24 * time.Hour)
expiredRecord := paidEntitlementRecord(
entitlement.EntitlementRecordID("entitlement-paid-1"),
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
endsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
require.NoError(t, historyStore.Create(context.Background(), expiredRecord))
require.NoError(t, snapshotStore.Put(context.Background(), expiredSnapshot))
repairedAt := endsAt.Add(2 * time.Hour)
freeRecord := entitlement.PeriodRecord{
RecordID: entitlement.EntitlementRecordID("entitlement-free-after-expiry"),
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
Source: common.Source("entitlement_expiry_repair"),
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
ReasonCode: common.ReasonCode("paid_entitlement_expired"),
StartsAt: endsAt,
CreatedAt: repairedAt,
}
freeSnapshot := entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
IsPaid: false,
StartsAt: endsAt,
Source: common.Source("entitlement_expiry_repair"),
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
ReasonCode: common.ReasonCode("paid_entitlement_expired"),
UpdatedAt: repairedAt,
}
require.NoError(t, lifecycleStore.RepairExpired(context.Background(), ports.RepairExpiredEntitlementInput{
ExpectedExpiredSnapshot: expiredSnapshot,
NewRecord: freeRecord,
NewSnapshot: freeSnapshot,
}))
storedSnapshot, err := snapshotStore.GetByUserID(context.Background(), userID)
require.NoError(t, err)
require.Equal(t, freeSnapshot, storedSnapshot)
historyRecords, err := historyStore.ListByUserID(context.Background(), userID)
require.NoError(t, err)
require.Len(t, historyRecords, 2)
require.Equal(t, freeRecord, historyRecords[1])
}
func newTestStore(t *testing.T) *Store {
t.Helper()
server := miniredis.RunT(t)
store, err := New(Config{
Addr: server.Addr(),
DB: 0,
KeyspacePrefix: "user:test:",
OperationTimeout: 250 * time.Millisecond,
})
require.NoError(t, err)
t.Cleanup(func() {
_ = store.Close()
})
return store
}
func validAccountRecord() account.UserAccount {
createdAt := time.Unix(1_775_240_000, 0).UTC()
return account.UserAccount{
UserID: common.UserID("user-123"),
Email: common.Email("pilot@example.com"),
RaceName: common.RaceName("Pilot Nova"),
PreferredLanguage: common.LanguageTag("en"),
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
CreatedAt: createdAt,
UpdatedAt: createdAt,
}
}
func validEntitlementSnapshot(userID common.UserID, now time.Time) entitlement.CurrentSnapshot {
return entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
IsPaid: false,
StartsAt: now,
Source: common.Source("auth_registration"),
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
ReasonCode: common.ReasonCode("initial_free_entitlement"),
UpdatedAt: now,
}
}
func validEntitlementRecord(userID common.UserID, now time.Time) entitlement.PeriodRecord {
return entitlement.PeriodRecord{
RecordID: entitlement.EntitlementRecordID("entitlement-" + userID.String()),
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
Source: common.Source("auth_registration"),
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
ReasonCode: common.ReasonCode("initial_free_entitlement"),
StartsAt: now,
CreatedAt: now,
}
}
func paidEntitlementRecord(
recordID entitlement.EntitlementRecordID,
userID common.UserID,
planCode entitlement.PlanCode,
startsAt time.Time,
endsAt time.Time,
source common.Source,
reasonCode common.ReasonCode,
) entitlement.PeriodRecord {
return entitlement.PeriodRecord{
RecordID: recordID,
UserID: userID,
PlanCode: planCode,
Source: source,
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: reasonCode,
StartsAt: startsAt,
EndsAt: timePointer(endsAt),
CreatedAt: startsAt,
}
}
func paidEntitlementSnapshot(
userID common.UserID,
planCode entitlement.PlanCode,
startsAt time.Time,
endsAt time.Time,
source common.Source,
reasonCode common.ReasonCode,
) entitlement.CurrentSnapshot {
return entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: planCode,
IsPaid: true,
StartsAt: startsAt,
EndsAt: timePointer(endsAt),
Source: source,
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: reasonCode,
UpdatedAt: startsAt,
}
}
func timePointer(value time.Time) *time.Time {
utcValue := value.UTC()
return &utcValue
}
func createAccountInput(record account.UserAccount) ports.CreateAccountInput {
return ports.CreateAccountInput{
Account: record,
Reservation: raceNameReservation(record.UserID, record.RaceName, record.UpdatedAt),
}
}
func renameRaceNameInput(
record account.UserAccount,
newRaceName common.RaceName,
updatedAt time.Time,
) ports.RenameRaceNameInput {
return ports.RenameRaceNameInput{
UserID: record.UserID,
CurrentCanonicalKey: canonicalKey(record.RaceName),
NewRaceName: newRaceName,
NewReservation: raceNameReservation(record.UserID, newRaceName, updatedAt),
UpdatedAt: updatedAt,
}
}
func raceNameReservation(
userID common.UserID,
raceName common.RaceName,
reservedAt time.Time,
) account.RaceNameReservation {
return account.RaceNameReservation{
CanonicalKey: canonicalKey(raceName),
UserID: userID,
RaceName: raceName,
ReservedAt: reservedAt.UTC(),
}
}
func canonicalKey(raceName common.RaceName) account.RaceNameCanonicalKey {
return account.RaceNameCanonicalKey(strings.NewReplacer(
"1", "i",
"0", "o",
"8", "b",
).Replace(strings.ToLower(raceName.String())))
}
@@ -0,0 +1,200 @@
// Package redisstate defines the frozen Redis logical keyspace and pagination
// helpers used by future User Service storage adapters.
package redisstate
import (
"encoding/base64"
"fmt"
"strings"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
)
const defaultPrefix = "user:"
// Keyspace builds the frozen Redis logical keys used by future storage
// adapters. The package intentionally exposes key construction only and does
// not depend on any Redis client.
type Keyspace struct {
// Prefix stores the namespace prefix applied to every key. The zero value
// uses `user:`.
Prefix string
}
// Account returns the primary user-account key for userID.
func (k Keyspace) Account(userID common.UserID) string {
return k.prefix() + "account:" + encodeKeyComponent(userID.String())
}
// EmailLookup returns the exact normalized e-mail lookup key.
func (k Keyspace) EmailLookup(email common.Email) string {
return k.prefix() + "lookup:email:" + encodeKeyComponent(email.String())
}
// RaceNameLookup returns the exact stored race-name lookup key.
func (k Keyspace) RaceNameLookup(raceName common.RaceName) string {
return k.prefix() + "lookup:race-name:" + encodeKeyComponent(raceName.String())
}
// RaceNameReservation returns the replaceable canonical race-name reservation
// key.
func (k Keyspace) RaceNameReservation(key account.RaceNameCanonicalKey) string {
return k.prefix() + "reservation:race-name:" + encodeKeyComponent(key.String())
}
// BlockedEmailSubject returns the dedicated blocked-email-subject key.
func (k Keyspace) BlockedEmailSubject(email common.Email) string {
return k.prefix() + "blocked-email:" + encodeKeyComponent(email.String())
}
// EntitlementRecord returns the primary entitlement history-record key.
func (k Keyspace) EntitlementRecord(recordID entitlement.EntitlementRecordID) string {
return k.prefix() + "entitlement:record:" + encodeKeyComponent(recordID.String())
}
// EntitlementHistory returns the per-user entitlement-history index key.
func (k Keyspace) EntitlementHistory(userID common.UserID) string {
return k.prefix() + "entitlement:history:" + encodeKeyComponent(userID.String())
}
// EntitlementSnapshot returns the current entitlement-snapshot key.
func (k Keyspace) EntitlementSnapshot(userID common.UserID) string {
return k.prefix() + "entitlement:snapshot:" + encodeKeyComponent(userID.String())
}
// SanctionRecord returns the primary sanction history-record key.
func (k Keyspace) SanctionRecord(recordID policy.SanctionRecordID) string {
return k.prefix() + "sanction:record:" + encodeKeyComponent(recordID.String())
}
// SanctionHistory returns the per-user sanction-history index key.
func (k Keyspace) SanctionHistory(userID common.UserID) string {
return k.prefix() + "sanction:history:" + encodeKeyComponent(userID.String())
}
// ActiveSanction returns the per-user active-sanction slot for one sanction
// code. The slot guarantees at most one active sanction per `user_id +
// sanction_code`.
func (k Keyspace) ActiveSanction(userID common.UserID, code policy.SanctionCode) string {
return k.prefix() + "sanction:active:" + encodeKeyComponent(userID.String()) + ":" + encodeKeyComponent(string(code))
}
// LimitRecord returns the primary limit history-record key.
func (k Keyspace) LimitRecord(recordID policy.LimitRecordID) string {
return k.prefix() + "limit:record:" + encodeKeyComponent(recordID.String())
}
// LimitHistory returns the per-user limit-history index key.
func (k Keyspace) LimitHistory(userID common.UserID) string {
return k.prefix() + "limit:history:" + encodeKeyComponent(userID.String())
}
// ActiveLimit returns the per-user active-limit slot for one limit code. The
// slot guarantees at most one active limit per `user_id + limit_code`.
func (k Keyspace) ActiveLimit(userID common.UserID, code policy.LimitCode) string {
return k.prefix() + "limit:active:" + encodeKeyComponent(userID.String()) + ":" + encodeKeyComponent(string(code))
}
// CreatedAtIndex returns the deterministic newest-first user-ordering index.
func (k Keyspace) CreatedAtIndex() string {
return k.prefix() + "index:created-at"
}
// PaidStateIndex returns the coarse free-versus-paid index key.
func (k Keyspace) PaidStateIndex(state entitlement.PaidState) string {
return k.prefix() + "index:paid-state:" + encodeKeyComponent(string(state))
}
// FinitePaidExpiryIndex returns the finite paid-expiry index key. Lifetime
// plans intentionally do not participate in this index.
func (k Keyspace) FinitePaidExpiryIndex() string {
return k.prefix() + "index:paid-expiry:finite"
}
// DeclaredCountryIndex returns the current declared-country reverse-lookup
// index key.
func (k Keyspace) DeclaredCountryIndex(code common.CountryCode) string {
return k.prefix() + "index:declared-country:" + encodeKeyComponent(code.String())
}
// ActiveSanctionCodeIndex returns the reverse-lookup index key for users with
// an active sanction code.
func (k Keyspace) ActiveSanctionCodeIndex(code policy.SanctionCode) string {
return k.prefix() + "index:active-sanction:" + encodeKeyComponent(string(code))
}
// ActiveLimitCodeIndex returns the reverse-lookup index key for users with an
// active limit code.
func (k Keyspace) ActiveLimitCodeIndex(code policy.LimitCode) string {
return k.prefix() + "index:active-limit:" + encodeKeyComponent(string(code))
}
// EligibilityMarkerIndex returns the reverse-lookup index key for one derived
// eligibility marker boolean.
func (k Keyspace) EligibilityMarkerIndex(marker policy.EligibilityMarker, value bool) string {
return fmt.Sprintf("%sindex:eligibility:%s:%t", k.prefix(), encodeKeyComponent(string(marker)), value)
}
// CreatedAtScore returns the frozen ZSET score representation for created-at
// ordering and deterministic pagination.
func CreatedAtScore(createdAt time.Time) float64 {
return float64(createdAt.UTC().UnixMicro())
}
// ExpiryScore returns the frozen ZSET score representation for finite paid
// expiry ordering.
func ExpiryScore(expiresAt time.Time) float64 {
return float64(expiresAt.UTC().UnixMicro())
}
// PageCursor identifies the last seen `(created_at, user_id)` tuple used by
// deterministic newest-first pagination.
type PageCursor struct {
// CreatedAt stores the created-at component of the last seen row.
CreatedAt time.Time
// UserID stores the user-id tiebreaker component of the last seen row.
UserID common.UserID
}
// Validate reports whether PageCursor contains a complete cursor tuple.
func (cursor PageCursor) Validate() error {
if err := common.ValidateTimestamp("page cursor created at", cursor.CreatedAt); err != nil {
return err
}
if err := cursor.UserID.Validate(); err != nil {
return fmt.Errorf("page cursor user id: %w", err)
}
return nil
}
// ComparePageOrder compares two listing positions using the frozen ordering:
// `created_at desc`, then `user_id desc`.
func ComparePageOrder(left PageCursor, right PageCursor) int {
switch {
case left.CreatedAt.After(right.CreatedAt):
return -1
case left.CreatedAt.Before(right.CreatedAt):
return 1
default:
return -strings.Compare(left.UserID.String(), right.UserID.String())
}
}
func (k Keyspace) prefix() string {
prefix := strings.TrimSpace(k.Prefix)
if prefix == "" {
return defaultPrefix
}
return prefix
}
func encodeKeyComponent(value string) string {
return base64.RawURLEncoding.EncodeToString([]byte(value))
}
@@ -0,0 +1,59 @@
package redisstate
import (
"testing"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"github.com/stretchr/testify/require"
)
func TestKeyspaceBuildsStableKeys(t *testing.T) {
t.Parallel()
keyspace := Keyspace{Prefix: "custom:"}
require.Equal(t, "custom:account:dXNlci0xMjM", keyspace.Account(common.UserID("user-123")))
require.Equal(t, "custom:lookup:email:cGlsb3RAZXhhbXBsZS5jb20", keyspace.EmailLookup(common.Email("pilot@example.com")))
require.Equal(t, "custom:lookup:race-name:UGlsb3QgTm92YQ", keyspace.RaceNameLookup(common.RaceName("Pilot Nova")))
require.Equal(t, "custom:reservation:race-name:cGlsb3Qtbm92YQ", keyspace.RaceNameReservation(account.RaceNameCanonicalKey("pilot-nova")))
require.Equal(t, "custom:blocked-email:cGlsb3RAZXhhbXBsZS5jb20", keyspace.BlockedEmailSubject(common.Email("pilot@example.com")))
require.Equal(t, "custom:entitlement:record:ZW50aXRsZW1lbnQtMTIz", keyspace.EntitlementRecord(entitlement.EntitlementRecordID("entitlement-123")))
require.Equal(t, "custom:sanction:record:c2FuY3Rpb24tMQ", keyspace.SanctionRecord(policy.SanctionRecordID("sanction-1")))
require.Equal(t, "custom:limit:record:bGltaXQtMQ", keyspace.LimitRecord(policy.LimitRecordID("limit-1")))
require.Equal(t, "custom:sanction:active:dXNlci0xMjM:bG9naW5fYmxvY2s", keyspace.ActiveSanction(common.UserID("user-123"), policy.SanctionCodeLoginBlock))
require.Equal(t, "custom:limit:active:dXNlci0xMjM:bWF4X293bmVkX3ByaXZhdGVfZ2FtZXM", keyspace.ActiveLimit(common.UserID("user-123"), policy.LimitCodeMaxOwnedPrivateGames))
require.Equal(t, "custom:index:created-at", keyspace.CreatedAtIndex())
require.Equal(t, "custom:index:paid-state:cGFpZA", keyspace.PaidStateIndex(entitlement.PaidStatePaid))
require.Equal(t, "custom:index:paid-expiry:finite", keyspace.FinitePaidExpiryIndex())
require.Equal(t, "custom:index:declared-country:REU", keyspace.DeclaredCountryIndex(common.CountryCode("DE")))
require.Equal(t, "custom:index:active-sanction:bG9naW5fYmxvY2s", keyspace.ActiveSanctionCodeIndex(policy.SanctionCodeLoginBlock))
require.Equal(t, "custom:index:active-limit:bWF4X293bmVkX3ByaXZhdGVfZ2FtZXM", keyspace.ActiveLimitCodeIndex(policy.LimitCodeMaxOwnedPrivateGames))
require.Equal(t, "custom:index:eligibility:Y2FuX2xvZ2lu:true", keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanLogin, true))
}
func TestComparePageOrder(t *testing.T) {
t.Parallel()
newer := PageCursor{CreatedAt: time.Unix(20, 0).UTC(), UserID: common.UserID("user-200")}
older := PageCursor{CreatedAt: time.Unix(10, 0).UTC(), UserID: common.UserID("user-100")}
sameTimeHigherUserID := PageCursor{CreatedAt: time.Unix(20, 0).UTC(), UserID: common.UserID("user-300")}
require.Negative(t, ComparePageOrder(newer, older))
require.Positive(t, ComparePageOrder(older, newer))
require.Negative(t, ComparePageOrder(sameTimeHigherUserID, newer))
}
func TestScoresUseUnixMicro(t *testing.T) {
t.Parallel()
value := time.Unix(1_775_240_000, 123_000).UTC()
want := float64(value.UnixMicro())
require.Equal(t, want, CreatedAtScore(value))
require.Equal(t, want, ExpiryScore(value))
}
@@ -0,0 +1,191 @@
package redisstate
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
)
var (
// ErrPageTokenFiltersMismatch reports that a supplied page token was created
// for a different normalized filter set.
ErrPageTokenFiltersMismatch = errors.New("page token filters do not match current filters")
)
// UserListFilters stores the frozen admin-listing filter set that becomes part
// of the opaque page token fingerprint.
type UserListFilters struct {
// PaidState stores the coarse free-versus-paid filter.
PaidState entitlement.PaidState
// PaidExpiresBefore stores the optional finite-paid expiry upper bound.
PaidExpiresBefore *time.Time
// PaidExpiresAfter stores the optional finite-paid expiry lower bound.
PaidExpiresAfter *time.Time
// DeclaredCountry stores the optional declared-country filter.
DeclaredCountry common.CountryCode
// SanctionCode stores the optional active-sanction filter.
SanctionCode policy.SanctionCode
// LimitCode stores the optional active-limit filter.
LimitCode policy.LimitCode
// CanLogin stores the optional login-eligibility filter.
CanLogin *bool
// CanCreatePrivateGame stores the optional private-game-create eligibility
// filter.
CanCreatePrivateGame *bool
// CanJoinGame stores the optional join-game eligibility filter.
CanJoinGame *bool
}
// Validate reports whether UserListFilters is structurally valid.
func (filters UserListFilters) Validate() error {
if !filters.PaidState.IsKnown() {
return fmt.Errorf("paid state %q is unsupported", filters.PaidState)
}
if filters.PaidExpiresBefore != nil && filters.PaidExpiresBefore.IsZero() {
return fmt.Errorf("paid expires before must not be zero")
}
if filters.PaidExpiresAfter != nil && filters.PaidExpiresAfter.IsZero() {
return fmt.Errorf("paid expires after must not be zero")
}
if !filters.DeclaredCountry.IsZero() {
if err := filters.DeclaredCountry.Validate(); err != nil {
return fmt.Errorf("declared country: %w", err)
}
}
if filters.SanctionCode != "" && !filters.SanctionCode.IsKnown() {
return fmt.Errorf("sanction code %q is unsupported", filters.SanctionCode)
}
if filters.LimitCode != "" && !filters.LimitCode.IsKnown() {
return fmt.Errorf("limit code %q is unsupported", filters.LimitCode)
}
return nil
}
// EncodePageToken encodes cursor and filters into the frozen opaque page token
// format.
func EncodePageToken(cursor PageCursor, filters UserListFilters) (string, error) {
if err := cursor.Validate(); err != nil {
return "", fmt.Errorf("encode page token: %w", err)
}
fingerprint, err := normalizeFilters(filters)
if err != nil {
return "", fmt.Errorf("encode page token: %w", err)
}
payload, err := json.Marshal(pageTokenPayload{
CreatedAt: cursor.CreatedAt.UTC().Format(time.RFC3339Nano),
UserID: cursor.UserID.String(),
Filters: fingerprint,
})
if err != nil {
return "", fmt.Errorf("encode page token: %w", err)
}
return base64.RawURLEncoding.EncodeToString(payload), nil
}
// DecodePageToken decodes raw into the frozen page cursor and verifies that
// the embedded normalized filter set matches expectedFilters.
func DecodePageToken(raw string, expectedFilters UserListFilters) (PageCursor, error) {
fingerprint, err := normalizeFilters(expectedFilters)
if err != nil {
return PageCursor{}, fmt.Errorf("decode page token: %w", err)
}
payload, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return PageCursor{}, fmt.Errorf("decode page token: %w", err)
}
var token pageTokenPayload
if err := json.Unmarshal(payload, &token); err != nil {
return PageCursor{}, fmt.Errorf("decode page token: %w", err)
}
if token.Filters != fingerprint {
return PageCursor{}, ErrPageTokenFiltersMismatch
}
createdAt, err := time.Parse(time.RFC3339Nano, token.CreatedAt)
if err != nil {
return PageCursor{}, fmt.Errorf("decode page token: parse created_at: %w", err)
}
cursor := PageCursor{
CreatedAt: createdAt.UTC(),
UserID: common.UserID(token.UserID),
}
if err := cursor.Validate(); err != nil {
return PageCursor{}, fmt.Errorf("decode page token: %w", err)
}
return cursor, nil
}
type pageTokenPayload struct {
CreatedAt string `json:"created_at"`
UserID string `json:"user_id"`
Filters normalizedFilterPayload `json:"filters"`
}
type normalizedFilterPayload struct {
PaidState string `json:"paid_state,omitempty"`
PaidExpiresBeforeUTC string `json:"paid_expires_before_utc,omitempty"`
PaidExpiresAfterUTC string `json:"paid_expires_after_utc,omitempty"`
DeclaredCountry string `json:"declared_country,omitempty"`
SanctionCode string `json:"sanction_code,omitempty"`
LimitCode string `json:"limit_code,omitempty"`
CanLogin string `json:"can_login,omitempty"`
CanCreatePrivateGame string `json:"can_create_private_game,omitempty"`
CanJoinGame string `json:"can_join_game,omitempty"`
}
func normalizeFilters(filters UserListFilters) (normalizedFilterPayload, error) {
if err := filters.Validate(); err != nil {
return normalizedFilterPayload{}, err
}
return normalizedFilterPayload{
PaidState: string(filters.PaidState),
PaidExpiresBeforeUTC: formatOptionalTime(filters.PaidExpiresBefore),
PaidExpiresAfterUTC: formatOptionalTime(filters.PaidExpiresAfter),
DeclaredCountry: filters.DeclaredCountry.String(),
SanctionCode: string(filters.SanctionCode),
LimitCode: string(filters.LimitCode),
CanLogin: formatOptionalBool(filters.CanLogin),
CanCreatePrivateGame: formatOptionalBool(filters.CanCreatePrivateGame),
CanJoinGame: formatOptionalBool(filters.CanJoinGame),
}, nil
}
func formatOptionalTime(value *time.Time) string {
if value == nil {
return ""
}
return value.UTC().Format(time.RFC3339Nano)
}
func formatOptionalBool(value *bool) string {
if value == nil {
return ""
}
if *value {
return "true"
}
return "false"
}
@@ -0,0 +1,70 @@
package redisstate
import (
"testing"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"github.com/stretchr/testify/require"
)
func TestEncodeDecodePageToken(t *testing.T) {
t.Parallel()
before := time.Unix(1_775_250_000, 0).UTC()
after := time.Unix(1_775_240_000, 0).UTC()
canLogin := true
canCreate := false
canJoin := true
filters := UserListFilters{
PaidState: entitlement.PaidStatePaid,
PaidExpiresBefore: &before,
PaidExpiresAfter: &after,
DeclaredCountry: common.CountryCode("DE"),
SanctionCode: policy.SanctionCodeLoginBlock,
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
CanLogin: &canLogin,
CanCreatePrivateGame: &canCreate,
CanJoinGame: &canJoin,
}
cursor := PageCursor{
CreatedAt: time.Unix(1_775_240_100, 987_000_000).UTC(),
UserID: common.UserID("user-123"),
}
token, err := EncodePageToken(cursor, filters)
require.NoError(t, err)
decoded, err := DecodePageToken(token, filters)
require.NoError(t, err)
require.Equal(t, cursor, decoded)
}
func TestDecodePageTokenFilterMismatch(t *testing.T) {
t.Parallel()
cursor := PageCursor{
CreatedAt: time.Unix(1_775_240_100, 0).UTC(),
UserID: common.UserID("user-123"),
}
filters := UserListFilters{
PaidState: entitlement.PaidStatePaid,
}
token, err := EncodePageToken(cursor, filters)
require.NoError(t, err)
_, err = DecodePageToken(token, UserListFilters{PaidState: entitlement.PaidStateFree})
require.ErrorIs(t, err, ErrPageTokenFiltersMismatch)
}
func TestDecodePageTokenRejectsInvalidInput(t *testing.T) {
t.Parallel()
_, err := DecodePageToken("%%%not-base64%%%", UserListFilters{})
require.Error(t, err)
}
+133
View File
@@ -0,0 +1,133 @@
// Package adminapi exposes the optional private admin HTTP listener used for
// operational endpoints such as Prometheus metrics.
package adminapi
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"sync"
"galaxy/user/internal/config"
)
// Server owns the optional admin HTTP listener exposed by the user service.
type Server struct {
cfg config.AdminHTTPConfig
handler http.Handler
logger *slog.Logger
stateMu sync.RWMutex
server *http.Server
listener net.Listener
}
// NewServer constructs an admin HTTP server for cfg and handler.
func NewServer(cfg config.AdminHTTPConfig, handler http.Handler, logger *slog.Logger) *Server {
if handler == nil {
handler = http.NotFoundHandler()
}
if logger == nil {
logger = slog.Default()
}
mux := http.NewServeMux()
mux.Handle("GET /metrics", handler)
return &Server{
cfg: cfg,
handler: mux,
logger: logger.With("component", "admin_http"),
}
}
// Enabled reports whether the admin listener should run.
func (server *Server) Enabled() bool {
return server != nil && server.cfg.Addr != ""
}
// Run binds the configured listener and serves the admin HTTP surface until
// Shutdown closes the server. A disabled admin server returns when ctx is
// canceled.
func (server *Server) Run(ctx context.Context) error {
if ctx == nil {
return errors.New("run admin HTTP server: nil context")
}
if err := ctx.Err(); err != nil {
return err
}
if !server.Enabled() {
<-ctx.Done()
return nil
}
listener, err := net.Listen("tcp", server.cfg.Addr)
if err != nil {
return fmt.Errorf("run admin HTTP server: listen on %q: %w", server.cfg.Addr, err)
}
httpServer := &http.Server{
Handler: server.handler,
ReadHeaderTimeout: server.cfg.ReadHeaderTimeout,
ReadTimeout: server.cfg.ReadTimeout,
IdleTimeout: server.cfg.IdleTimeout,
}
server.stateMu.Lock()
server.server = httpServer
server.listener = listener
server.stateMu.Unlock()
server.logger.Info("admin HTTP server started", "addr", listener.Addr().String())
shutdownDone := make(chan struct{})
go func() {
defer close(shutdownDone)
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), server.cfg.ReadTimeout)
defer cancel()
_ = server.Shutdown(shutdownCtx)
}()
defer func() {
server.stateMu.Lock()
server.server = nil
server.listener = nil
server.stateMu.Unlock()
<-shutdownDone
}()
err = httpServer.Serve(listener)
switch {
case err == nil:
return nil
case errors.Is(err, http.ErrServerClosed):
server.logger.Info("admin HTTP server stopped")
return nil
default:
return fmt.Errorf("run admin HTTP server: serve on %q: %w", server.cfg.Addr, err)
}
}
// Shutdown gracefully stops the admin HTTP server within ctx.
func (server *Server) Shutdown(ctx context.Context) error {
if ctx == nil {
return errors.New("shutdown admin HTTP server: nil context")
}
server.stateMu.RLock()
httpServer := server.server
server.stateMu.RUnlock()
if httpServer == nil {
return nil
}
if err := httpServer.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("shutdown admin HTTP server: %w", err)
}
return nil
}
+98
View File
@@ -0,0 +1,98 @@
package adminapi
import (
"context"
"net/http"
"testing"
"time"
"galaxy/user/internal/config"
"github.com/stretchr/testify/require"
)
func TestServerRunDisabledWaitsForContext(t *testing.T) {
t.Parallel()
server := NewServer(config.AdminHTTPConfig{}, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("disabled admin server must not serve requests")
}), nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- server.Run(ctx)
}()
cancel()
select {
case err := <-errCh:
require.ErrorIs(t, err, context.Canceled)
case <-time.After(2 * time.Second):
t.Fatal("disabled admin server did not stop after context cancellation")
}
}
func TestServerRunServesMetricsOnly(t *testing.T) {
t.Parallel()
server := NewServer(config.AdminHTTPConfig{
Addr: "127.0.0.1:0",
ReadHeaderTimeout: 2 * time.Second,
ReadTimeout: 10 * time.Second,
IdleTimeout: time.Minute,
}, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("sample_metric 1\n"))
}), nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- server.Run(ctx)
}()
addr := waitForListener(t, server)
metricsResponse, err := http.Get("http://" + addr + "/metrics")
require.NoError(t, err)
t.Cleanup(func() { _ = metricsResponse.Body.Close() })
require.Equal(t, http.StatusOK, metricsResponse.StatusCode)
rootResponse, err := http.Get("http://" + addr + "/")
require.NoError(t, err)
t.Cleanup(func() { _ = rootResponse.Body.Close() })
require.Equal(t, http.StatusNotFound, rootResponse.StatusCode)
cancel()
select {
case err := <-errCh:
require.NoError(t, err)
case <-time.After(2 * time.Second):
t.Fatal("admin server did not stop after context cancellation")
}
}
func waitForListener(t *testing.T, server *Server) string {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
server.stateMu.RLock()
listener := server.listener
server.stateMu.RUnlock()
if listener != nil {
return listener.Addr().String()
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("admin server listener did not start")
return ""
}
@@ -0,0 +1,205 @@
package internalhttp
import (
"context"
"net/http"
"strconv"
"strings"
"time"
"galaxy/user/internal/service/adminusers"
"galaxy/user/internal/service/shared"
"github.com/gin-gonic/gin"
)
type getUserByEmailRequest struct {
Email string `json:"email"`
}
type getUserByRaceNameRequest struct {
RaceName string `json:"race_name"`
}
func handleGetUserByID(useCase GetUserByIDUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, adminusers.GetUserByIDInput{
UserID: c.Param("user_id"),
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, result)
}
}
func handleGetUserByEmail(useCase GetUserByEmailUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request getUserByEmailRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, adminusers.GetUserByEmailInput{
Email: request.Email,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, result)
}
}
func handleGetUserByRaceName(useCase GetUserByRaceNameUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request getUserByRaceNameRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, adminusers.GetUserByRaceNameInput{
RaceName: request.RaceName,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, result)
}
}
func handleListUsers(useCase ListUsersUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
input, err := buildListUsersInput(c)
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, input)
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, result)
}
}
func buildListUsersInput(c *gin.Context) (adminusers.ListUsersInput, error) {
pageSize, err := parseOptionalPageSize(c, "page_size")
if err != nil {
return adminusers.ListUsersInput{}, err
}
pageToken, err := parseOptionalPageToken(c, "page_token")
if err != nil {
return adminusers.ListUsersInput{}, err
}
paidExpiresBefore, err := parseOptionalRFC3339Query(c, "paid_expires_before")
if err != nil {
return adminusers.ListUsersInput{}, err
}
paidExpiresAfter, err := parseOptionalRFC3339Query(c, "paid_expires_after")
if err != nil {
return adminusers.ListUsersInput{}, err
}
canLogin, err := parseOptionalBoolQuery(c, "can_login")
if err != nil {
return adminusers.ListUsersInput{}, err
}
canCreatePrivateGame, err := parseOptionalBoolQuery(c, "can_create_private_game")
if err != nil {
return adminusers.ListUsersInput{}, err
}
canJoinGame, err := parseOptionalBoolQuery(c, "can_join_game")
if err != nil {
return adminusers.ListUsersInput{}, err
}
return adminusers.ListUsersInput{
PageSize: pageSize,
PageToken: pageToken,
PaidState: c.Query("paid_state"),
PaidExpiresBefore: paidExpiresBefore,
PaidExpiresAfter: paidExpiresAfter,
DeclaredCountry: c.Query("declared_country"),
SanctionCode: c.Query("sanction_code"),
LimitCode: c.Query("limit_code"),
CanLogin: canLogin,
CanCreatePrivateGame: canCreatePrivateGame,
CanJoinGame: canJoinGame,
}, nil
}
func parseOptionalPageSize(c *gin.Context, name string) (int, error) {
raw, present := c.GetQuery(name)
if !present {
return 0, nil
}
value, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || value < 1 || value > 200 {
return 0, shared.InvalidRequest("page_size must be between 1 and 200")
}
return value, nil
}
func parseOptionalPageToken(c *gin.Context, name string) (string, error) {
raw, present := c.GetQuery(name)
if !present {
return "", nil
}
if strings.TrimSpace(raw) != raw {
return "", shared.InvalidRequest("page_token must not contain surrounding whitespace")
}
return raw, nil
}
func parseOptionalRFC3339Query(c *gin.Context, name string) (*time.Time, error) {
raw, present := c.GetQuery(name)
if !present {
return nil, nil
}
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(raw))
if err != nil {
return nil, shared.InvalidRequest(name + " must be a valid RFC 3339 timestamp")
}
return &parsed, nil
}
func parseOptionalBoolQuery(c *gin.Context, name string) (*bool, error) {
raw, present := c.GetQuery(name)
if !present {
return nil, nil
}
parsed, err := strconv.ParseBool(strings.TrimSpace(raw))
if err != nil {
return nil, shared.InvalidRequest(name + " must be a valid boolean")
}
return &parsed, nil
}
@@ -0,0 +1,233 @@
package internalhttp
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"galaxy/user/internal/service/accountview"
"galaxy/user/internal/service/adminusers"
"galaxy/user/internal/service/shared"
"github.com/stretchr/testify/require"
)
func TestAdminReadHandlersSuccessCases(t *testing.T) {
t.Parallel()
handler := mustNewHandler(t, Dependencies{
GetUserByID: getUserByIDFunc(func(_ context.Context, input adminusers.GetUserByIDInput) (adminusers.LookupResult, error) {
require.Equal(t, "user-123", input.UserID)
return adminusers.LookupResult{User: sampleAccountView()}, nil
}),
GetUserByEmail: getUserByEmailFunc(func(_ context.Context, input adminusers.GetUserByEmailInput) (adminusers.LookupResult, error) {
require.Equal(t, "pilot@example.com", input.Email)
return adminusers.LookupResult{User: sampleAccountView()}, nil
}),
GetUserByRaceName: getUserByRaceNameFunc(func(_ context.Context, input adminusers.GetUserByRaceNameInput) (adminusers.LookupResult, error) {
require.Equal(t, "Pilot Nova", input.RaceName)
return adminusers.LookupResult{User: sampleAccountView()}, nil
}),
ListUsers: listUsersFunc(func(_ context.Context, input adminusers.ListUsersInput) (adminusers.ListUsersResult, error) {
require.Equal(t, 2, input.PageSize)
require.Equal(t, "cursor-1", input.PageToken)
require.Equal(t, "paid", input.PaidState)
require.Equal(t, "DE", input.DeclaredCountry)
require.Equal(t, "login_block", input.SanctionCode)
require.Equal(t, "max_owned_private_games", input.LimitCode)
require.NotNil(t, input.PaidExpiresBefore)
require.NotNil(t, input.PaidExpiresAfter)
require.NotNil(t, input.CanLogin)
require.NotNil(t, input.CanCreatePrivateGame)
require.NotNil(t, input.CanJoinGame)
require.False(t, *input.CanLogin)
require.True(t, *input.CanCreatePrivateGame)
require.True(t, *input.CanJoinGame)
require.Equal(t, time.Date(2026, time.April, 10, 12, 0, 0, 0, time.UTC), input.PaidExpiresBefore.UTC())
require.Equal(t, time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC), input.PaidExpiresAfter.UTC())
other := sampleAccountView()
other.UserID = "user-234"
other.Email = "second@example.com"
other.RaceName = "Second Pilot"
return adminusers.ListUsersResult{
Items: []accountview.AccountView{sampleAccountView(), other},
NextPageToken: "cursor-2",
}, nil
}),
})
tests := []struct {
name string
method string
path string
body string
wantStatus int
wantBody string
}{
{
name: "get user by id",
method: http.MethodGet,
path: "/api/v1/internal/users/user-123",
wantStatus: http.StatusOK,
wantBody: `{"user":{"user_id":"user-123","email":"pilot@example.com","race_name":"Pilot Nova","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}}`,
},
{
name: "get user by email",
method: http.MethodPost,
path: "/api/v1/internal/user-lookups/by-email",
body: `{"email":"pilot@example.com"}`,
wantStatus: http.StatusOK,
wantBody: `{"user":{"user_id":"user-123","email":"pilot@example.com","race_name":"Pilot Nova","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}}`,
},
{
name: "get user by race name",
method: http.MethodPost,
path: "/api/v1/internal/user-lookups/by-race-name",
body: `{"race_name":"Pilot Nova"}`,
wantStatus: http.StatusOK,
wantBody: `{"user":{"user_id":"user-123","email":"pilot@example.com","race_name":"Pilot Nova","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}}`,
},
{
name: "list users",
method: http.MethodGet,
path: "/api/v1/internal/users?page_size=2&page_token=cursor-1&paid_state=paid&paid_expires_before=2026-04-10T12:00:00Z&paid_expires_after=2026-04-01T12:00:00Z&declared_country=DE&sanction_code=login_block&limit_code=max_owned_private_games&can_login=false&can_create_private_game=true&can_join_game=true",
wantStatus: http.StatusOK,
wantBody: `{"items":[{"user_id":"user-123","email":"pilot@example.com","race_name":"Pilot Nova","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},{"user_id":"user-234","email":"second@example.com","race_name":"Second Pilot","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}],"next_page_token":"cursor-2"}`,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var body *bytes.Buffer
if tt.body != "" {
body = bytes.NewBufferString(tt.body)
} else {
body = &bytes.Buffer{}
}
request := httptest.NewRequest(tt.method, tt.path, body)
if tt.body != "" {
request.Header.Set("Content-Type", "application/json")
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
require.Equal(t, tt.wantStatus, recorder.Code)
assertJSONEq(t, recorder.Body.String(), tt.wantBody)
})
}
}
func TestAdminReadHandlersErrorCases(t *testing.T) {
t.Parallel()
handler := mustNewHandler(t, Dependencies{
GetUserByID: getUserByIDFunc(func(context.Context, adminusers.GetUserByIDInput) (adminusers.LookupResult, error) {
return adminusers.LookupResult{}, shared.SubjectNotFound()
}),
GetUserByEmail: getUserByEmailFunc(func(context.Context, adminusers.GetUserByEmailInput) (adminusers.LookupResult, error) {
return adminusers.LookupResult{}, shared.SubjectNotFound()
}),
GetUserByRaceName: getUserByRaceNameFunc(func(context.Context, adminusers.GetUserByRaceNameInput) (adminusers.LookupResult, error) {
return adminusers.LookupResult{}, shared.SubjectNotFound()
}),
ListUsers: listUsersFunc(func(context.Context, adminusers.ListUsersInput) (adminusers.ListUsersResult, error) {
return adminusers.ListUsersResult{}, shared.InvalidRequest("page_token is invalid or does not match current filters")
}),
})
tests := []struct {
name string
method string
path string
body string
wantStatus int
wantBody string
}{
{
name: "get user by id not found",
method: http.MethodGet,
path: "/api/v1/internal/users/user-missing",
wantStatus: http.StatusNotFound,
wantBody: `{"error":{"code":"subject_not_found","message":"subject not found"}}`,
},
{
name: "get user by email malformed json",
method: http.MethodPost,
path: "/api/v1/internal/user-lookups/by-email",
body: `{"email":"pilot@example.com","extra":true}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"request body contains unknown field \"extra\""}}`,
},
{
name: "get user by race name not found",
method: http.MethodPost,
path: "/api/v1/internal/user-lookups/by-race-name",
body: `{"race_name":"Missing Pilot"}`,
wantStatus: http.StatusNotFound,
wantBody: `{"error":{"code":"subject_not_found","message":"subject not found"}}`,
},
{
name: "list users invalid page size",
method: http.MethodGet,
path: "/api/v1/internal/users?page_size=201",
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"page_size must be between 1 and 200"}}`,
},
{
name: "list users invalid timestamp",
method: http.MethodGet,
path: "/api/v1/internal/users?paid_expires_before=not-a-time",
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"paid_expires_before must be a valid RFC 3339 timestamp"}}`,
},
{
name: "list users invalid boolean",
method: http.MethodGet,
path: "/api/v1/internal/users?can_login=maybe",
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"can_login must be a valid boolean"}}`,
},
{
name: "list users invalid page token",
method: http.MethodGet,
path: "/api/v1/internal/users?page_token=cursor-1",
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"page_token is invalid or does not match current filters"}}`,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var body *bytes.Buffer
if tt.body != "" {
body = bytes.NewBufferString(tt.body)
} else {
body = &bytes.Buffer{}
}
request := httptest.NewRequest(tt.method, tt.path, body)
if tt.body != "" {
request.Header.Set("Content-Type", "application/json")
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
require.Equal(t, tt.wantStatus, recorder.Code)
assertJSONEq(t, recorder.Body.String(), tt.wantBody)
})
}
}
+841
View File
@@ -0,0 +1,841 @@
package internalhttp
import (
"context"
"fmt"
"log/slog"
"net/http"
"time"
"galaxy/user/internal/logging"
"galaxy/user/internal/service/authdirectory"
"galaxy/user/internal/service/entitlementsvc"
"galaxy/user/internal/service/geosync"
"galaxy/user/internal/service/lobbyeligibility"
"galaxy/user/internal/service/policysvc"
"galaxy/user/internal/service/selfservice"
"galaxy/user/internal/service/shared"
"galaxy/user/internal/telemetry"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
"go.opentelemetry.io/otel/attribute"
)
const internalHTTPServiceName = "galaxy-user-internal"
type errorResponse struct {
Error errorBody `json:"error"`
}
type errorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
type resolveByEmailRequest struct {
Email string `json:"email"`
}
type resolveByEmailResponse struct {
Kind string `json:"kind"`
UserID string `json:"user_id,omitempty"`
BlockReasonCode string `json:"block_reason_code,omitempty"`
}
type existsByUserIDResponse struct {
Exists bool `json:"exists"`
}
type ensureByEmailRequest struct {
Email string `json:"email"`
RegistrationContext *ensureRegistrationContextDTO `json:"registration_context"`
}
type ensureRegistrationContextDTO struct {
PreferredLanguage string `json:"preferred_language"`
TimeZone string `json:"time_zone"`
}
type ensureByEmailResponse struct {
Outcome string `json:"outcome"`
UserID string `json:"user_id,omitempty"`
BlockReasonCode string `json:"block_reason_code,omitempty"`
}
type blockByUserIDRequest struct {
ReasonCode string `json:"reason_code"`
}
type blockByEmailRequest struct {
Email string `json:"email"`
ReasonCode string `json:"reason_code"`
}
type blockResponse struct {
Outcome string `json:"outcome"`
UserID string `json:"user_id,omitempty"`
}
type getMyAccountResponse struct {
Account selfservice.AccountView `json:"account"`
}
type updateMyProfileRequest struct {
RaceName string `json:"race_name"`
}
type updateMySettingsRequest struct {
PreferredLanguage string `json:"preferred_language"`
TimeZone string `json:"time_zone"`
}
type syncDeclaredCountryRequest struct {
DeclaredCountry string `json:"declared_country"`
}
type syncDeclaredCountryResponse struct {
UserID string `json:"user_id"`
DeclaredCountry string `json:"declared_country"`
UpdatedAt time.Time `json:"updated_at"`
}
type actorDTO struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
}
type grantEntitlementRequest struct {
PlanCode string `json:"plan_code"`
Source string `json:"source"`
ReasonCode string `json:"reason_code"`
Actor actorDTO `json:"actor"`
StartsAt string `json:"starts_at"`
EndsAt string `json:"ends_at,omitempty"`
}
type extendEntitlementRequest struct {
Source string `json:"source"`
ReasonCode string `json:"reason_code"`
Actor actorDTO `json:"actor"`
EndsAt string `json:"ends_at"`
}
type revokeEntitlementRequest struct {
Source string `json:"source"`
ReasonCode string `json:"reason_code"`
Actor actorDTO `json:"actor"`
}
type applySanctionRequest struct {
SanctionCode string `json:"sanction_code"`
Scope string `json:"scope"`
ReasonCode string `json:"reason_code"`
Actor actorDTO `json:"actor"`
AppliedAt string `json:"applied_at"`
ExpiresAt string `json:"expires_at,omitempty"`
}
type removeSanctionRequest struct {
SanctionCode string `json:"sanction_code"`
ReasonCode string `json:"reason_code"`
Actor actorDTO `json:"actor"`
}
type setLimitRequest struct {
LimitCode string `json:"limit_code"`
Value int `json:"value"`
ReasonCode string `json:"reason_code"`
Actor actorDTO `json:"actor"`
AppliedAt string `json:"applied_at"`
ExpiresAt string `json:"expires_at,omitempty"`
}
type removeLimitRequest struct {
LimitCode string `json:"limit_code"`
ReasonCode string `json:"reason_code"`
Actor actorDTO `json:"actor"`
}
type entitlementSnapshotResponse struct {
PlanCode string `json:"plan_code"`
IsPaid bool `json:"is_paid"`
Source string `json:"source"`
Actor actorDTO `json:"actor"`
ReasonCode string `json:"reason_code"`
StartsAt time.Time `json:"starts_at"`
EndsAt *time.Time `json:"ends_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
type entitlementCommandResponse struct {
UserID string `json:"user_id"`
Entitlement entitlementSnapshotResponse `json:"entitlement"`
}
func newHandlerWithConfig(cfg Config, deps Dependencies) (http.Handler, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
normalizedDeps, err := normalizeDependencies(deps)
if err != nil {
return nil, err
}
configureGinModeOnce.Do(func() {
gin.SetMode(gin.ReleaseMode)
})
engine := gin.New()
engine.Use(newOTelMiddleware(normalizedDeps.Telemetry))
engine.Use(withObservability(normalizedDeps.Logger, normalizedDeps.Telemetry))
engine.POST("/api/v1/internal/user-resolutions/by-email", handleResolveByEmail(normalizedDeps.ResolveByEmail, cfg.RequestTimeout))
engine.GET("/api/v1/internal/users/:user_id/exists", handleExistsByUserID(normalizedDeps.ExistsByUserID, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/ensure-by-email", handleEnsureByEmail(normalizedDeps.EnsureByEmail, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/block", handleBlockByUserID(normalizedDeps.BlockByUserID, cfg.RequestTimeout))
engine.POST("/api/v1/internal/user-blocks/by-email", handleBlockByEmail(normalizedDeps.BlockByEmail, cfg.RequestTimeout))
engine.GET("/api/v1/internal/users/:user_id/account", handleGetMyAccount(normalizedDeps.GetMyAccount, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/profile", handleUpdateMyProfile(normalizedDeps.UpdateMyProfile, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/settings", handleUpdateMySettings(normalizedDeps.UpdateMySettings, cfg.RequestTimeout))
engine.GET("/api/v1/internal/users/:user_id", handleGetUserByID(normalizedDeps.GetUserByID, cfg.RequestTimeout))
engine.POST("/api/v1/internal/user-lookups/by-email", handleGetUserByEmail(normalizedDeps.GetUserByEmail, cfg.RequestTimeout))
engine.POST("/api/v1/internal/user-lookups/by-race-name", handleGetUserByRaceName(normalizedDeps.GetUserByRaceName, cfg.RequestTimeout))
engine.GET("/api/v1/internal/users", handleListUsers(normalizedDeps.ListUsers, cfg.RequestTimeout))
engine.GET("/api/v1/internal/users/:user_id/eligibility", handleGetUserEligibility(normalizedDeps.GetUserEligibility, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/declared-country/sync", handleSyncDeclaredCountry(normalizedDeps.SyncDeclaredCountry, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/entitlements/grant", handleGrantEntitlement(normalizedDeps.GrantEntitlement, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/entitlements/extend", handleExtendEntitlement(normalizedDeps.ExtendEntitlement, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/entitlements/revoke", handleRevokeEntitlement(normalizedDeps.RevokeEntitlement, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/sanctions/apply", handleApplySanction(normalizedDeps.ApplySanction, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/sanctions/remove", handleRemoveSanction(normalizedDeps.RemoveSanction, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/limits/set", handleSetLimit(normalizedDeps.SetLimit, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/limits/remove", handleRemoveLimit(normalizedDeps.RemoveLimit, cfg.RequestTimeout))
return engine, nil
}
func handleResolveByEmail(useCase ResolveByEmailUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request resolveByEmailRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, authdirectory.ResolveByEmailInput{
Email: request.Email,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, resolveByEmailResponse{
Kind: result.Kind,
UserID: result.UserID,
BlockReasonCode: result.BlockReasonCode,
})
}
}
func handleExistsByUserID(useCase ExistsByUserIDUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, authdirectory.ExistsByUserIDInput{
UserID: c.Param("user_id"),
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, existsByUserIDResponse{Exists: result.Exists})
}
}
func handleEnsureByEmail(useCase EnsureByEmailUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request ensureByEmailRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
if request.RegistrationContext == nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest("registration_context must be present")))
return
}
var registrationContext *authdirectory.RegistrationContext
registrationContext = &authdirectory.RegistrationContext{
PreferredLanguage: request.RegistrationContext.PreferredLanguage,
TimeZone: request.RegistrationContext.TimeZone,
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, authdirectory.EnsureByEmailInput{
Email: request.Email,
RegistrationContext: registrationContext,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, ensureByEmailResponse{
Outcome: result.Outcome,
UserID: result.UserID,
BlockReasonCode: result.BlockReasonCode,
})
}
}
func handleBlockByUserID(useCase BlockByUserIDUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request blockByUserIDRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, authdirectory.BlockByUserIDInput{
UserID: c.Param("user_id"),
ReasonCode: request.ReasonCode,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, blockResponse{
Outcome: result.Outcome,
UserID: result.UserID,
})
}
}
func handleBlockByEmail(useCase BlockByEmailUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request blockByEmailRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, authdirectory.BlockByEmailInput{
Email: request.Email,
ReasonCode: request.ReasonCode,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, blockResponse{
Outcome: result.Outcome,
UserID: result.UserID,
})
}
}
func handleGetMyAccount(useCase GetMyAccountUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, selfservice.GetMyAccountInput{
UserID: c.Param("user_id"),
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, getMyAccountResponse{
Account: result.Account,
})
}
}
func handleUpdateMyProfile(useCase UpdateMyProfileUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request updateMyProfileRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, selfservice.UpdateMyProfileInput{
UserID: c.Param("user_id"),
RaceName: request.RaceName,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, getMyAccountResponse{
Account: result.Account,
})
}
}
func handleUpdateMySettings(useCase UpdateMySettingsUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request updateMySettingsRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, selfservice.UpdateMySettingsInput{
UserID: c.Param("user_id"),
PreferredLanguage: request.PreferredLanguage,
TimeZone: request.TimeZone,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, getMyAccountResponse{
Account: result.Account,
})
}
}
func handleGetUserEligibility(useCase GetUserEligibilityUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, lobbyeligibility.GetUserEligibilityInput{
UserID: c.Param("user_id"),
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, result)
}
}
func handleSyncDeclaredCountry(useCase SyncDeclaredCountryUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request syncDeclaredCountryRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, geosync.SyncDeclaredCountryInput{
UserID: c.Param("user_id"),
DeclaredCountry: request.DeclaredCountry,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, syncDeclaredCountryResponse{
UserID: result.UserID,
DeclaredCountry: result.DeclaredCountry,
UpdatedAt: result.UpdatedAt.UTC(),
})
}
}
func handleGrantEntitlement(useCase GrantEntitlementUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request grantEntitlementRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, entitlementsvc.GrantInput{
UserID: c.Param("user_id"),
PlanCode: request.PlanCode,
Source: request.Source,
ReasonCode: request.ReasonCode,
Actor: entitlementsvc.ActorInput{
Type: request.Actor.Type,
ID: request.Actor.ID,
},
StartsAt: request.StartsAt,
EndsAt: request.EndsAt,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, entitlementCommandResponseFromResult(result))
}
}
func handleExtendEntitlement(useCase ExtendEntitlementUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request extendEntitlementRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, entitlementsvc.ExtendInput{
UserID: c.Param("user_id"),
Source: request.Source,
ReasonCode: request.ReasonCode,
Actor: entitlementsvc.ActorInput{
Type: request.Actor.Type,
ID: request.Actor.ID,
},
EndsAt: request.EndsAt,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, entitlementCommandResponseFromResult(result))
}
}
func handleRevokeEntitlement(useCase RevokeEntitlementUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request revokeEntitlementRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, entitlementsvc.RevokeInput{
UserID: c.Param("user_id"),
Source: request.Source,
ReasonCode: request.ReasonCode,
Actor: entitlementsvc.ActorInput{
Type: request.Actor.Type,
ID: request.Actor.ID,
},
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, entitlementCommandResponseFromResult(result))
}
}
func handleApplySanction(useCase ApplySanctionUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request applySanctionRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, policysvc.ApplySanctionInput{
UserID: c.Param("user_id"),
SanctionCode: request.SanctionCode,
Scope: request.Scope,
ReasonCode: request.ReasonCode,
Actor: policysvc.ActorInput{
Type: request.Actor.Type,
ID: request.Actor.ID,
},
AppliedAt: request.AppliedAt,
ExpiresAt: request.ExpiresAt,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, result)
}
}
func handleRemoveSanction(useCase RemoveSanctionUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request removeSanctionRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, policysvc.RemoveSanctionInput{
UserID: c.Param("user_id"),
SanctionCode: request.SanctionCode,
ReasonCode: request.ReasonCode,
Actor: policysvc.ActorInput{
Type: request.Actor.Type,
ID: request.Actor.ID,
},
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, result)
}
}
func handleSetLimit(useCase SetLimitUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request setLimitRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, policysvc.SetLimitInput{
UserID: c.Param("user_id"),
LimitCode: request.LimitCode,
Value: request.Value,
ReasonCode: request.ReasonCode,
Actor: policysvc.ActorInput{
Type: request.Actor.Type,
ID: request.Actor.ID,
},
AppliedAt: request.AppliedAt,
ExpiresAt: request.ExpiresAt,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, result)
}
}
func handleRemoveLimit(useCase RemoveLimitUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request removeLimitRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, policysvc.RemoveLimitInput{
UserID: c.Param("user_id"),
LimitCode: request.LimitCode,
ReasonCode: request.ReasonCode,
Actor: policysvc.ActorInput{
Type: request.Actor.Type,
ID: request.Actor.ID,
},
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, result)
}
}
func normalizeDependencies(deps Dependencies) (Dependencies, error) {
switch {
case deps.ResolveByEmail == nil:
return Dependencies{}, fmt.Errorf("resolve-by-email use case must not be nil")
case deps.EnsureByEmail == nil:
return Dependencies{}, fmt.Errorf("ensure-by-email use case must not be nil")
case deps.ExistsByUserID == nil:
return Dependencies{}, fmt.Errorf("exists-by-user-id use case must not be nil")
case deps.BlockByUserID == nil:
return Dependencies{}, fmt.Errorf("block-by-user-id use case must not be nil")
case deps.BlockByEmail == nil:
return Dependencies{}, fmt.Errorf("block-by-email use case must not be nil")
case deps.GetMyAccount == nil:
return Dependencies{}, fmt.Errorf("get-my-account use case must not be nil")
case deps.UpdateMyProfile == nil:
return Dependencies{}, fmt.Errorf("update-my-profile use case must not be nil")
case deps.UpdateMySettings == nil:
return Dependencies{}, fmt.Errorf("update-my-settings use case must not be nil")
case deps.GetUserByID == nil:
return Dependencies{}, fmt.Errorf("get-user-by-id use case must not be nil")
case deps.GetUserByEmail == nil:
return Dependencies{}, fmt.Errorf("get-user-by-email use case must not be nil")
case deps.GetUserByRaceName == nil:
return Dependencies{}, fmt.Errorf("get-user-by-race-name use case must not be nil")
case deps.ListUsers == nil:
return Dependencies{}, fmt.Errorf("list-users use case must not be nil")
case deps.GetUserEligibility == nil:
return Dependencies{}, fmt.Errorf("get-user-eligibility use case must not be nil")
case deps.SyncDeclaredCountry == nil:
return Dependencies{}, fmt.Errorf("sync-declared-country use case must not be nil")
case deps.GrantEntitlement == nil:
return Dependencies{}, fmt.Errorf("grant-entitlement use case must not be nil")
case deps.ExtendEntitlement == nil:
return Dependencies{}, fmt.Errorf("extend-entitlement use case must not be nil")
case deps.RevokeEntitlement == nil:
return Dependencies{}, fmt.Errorf("revoke-entitlement use case must not be nil")
case deps.ApplySanction == nil:
return Dependencies{}, fmt.Errorf("apply-sanction use case must not be nil")
case deps.RemoveSanction == nil:
return Dependencies{}, fmt.Errorf("remove-sanction use case must not be nil")
case deps.SetLimit == nil:
return Dependencies{}, fmt.Errorf("set-limit use case must not be nil")
case deps.RemoveLimit == nil:
return Dependencies{}, fmt.Errorf("remove-limit use case must not be nil")
default:
if deps.Logger == nil {
deps.Logger = slog.Default()
}
return deps, nil
}
}
func entitlementCommandResponseFromResult(result entitlementsvc.CommandResult) entitlementCommandResponse {
response := entitlementCommandResponse{
UserID: result.UserID,
Entitlement: entitlementSnapshotResponse{
PlanCode: string(result.Entitlement.PlanCode),
IsPaid: result.Entitlement.IsPaid,
Source: result.Entitlement.Source.String(),
Actor: actorDTO{Type: result.Entitlement.Actor.Type.String(), ID: result.Entitlement.Actor.ID.String()},
ReasonCode: result.Entitlement.ReasonCode.String(),
StartsAt: result.Entitlement.StartsAt.UTC(),
UpdatedAt: result.Entitlement.UpdatedAt.UTC(),
},
}
if result.Entitlement.EndsAt != nil {
value := result.Entitlement.EndsAt.UTC()
response.Entitlement.EndsAt = &value
}
return response
}
func newOTelMiddleware(runtime *telemetry.Runtime) gin.HandlerFunc {
options := []otelgin.Option{}
if runtime != nil {
options = append(
options,
otelgin.WithTracerProvider(runtime.TracerProvider()),
otelgin.WithMeterProvider(runtime.MeterProvider()),
)
}
return otelgin.Middleware(internalHTTPServiceName, options...)
}
func withObservability(logger *slog.Logger, metrics *telemetry.Runtime) gin.HandlerFunc {
if logger == nil {
logger = slog.Default()
}
return func(c *gin.Context) {
startedAt := time.Now()
c.Next()
statusCode := c.Writer.Status()
route := c.FullPath()
if route == "" {
route = "unmatched"
}
errorCode, _ := c.Get(internalErrorCodeContextKey)
errorCodeValue, _ := errorCode.(string)
outcome := outcomeFromStatusCode(statusCode)
duration := time.Since(startedAt)
attrs := []any{
"transport", "http",
"route", route,
"method", c.Request.Method,
"status_code", statusCode,
"duration_ms", float64(duration.Microseconds()) / 1000,
"edge_outcome", string(outcome),
}
if errorCodeValue != "" {
attrs = append(attrs, "error_code", errorCodeValue)
}
attrs = append(attrs, logging.TraceAttrsFromContext(c.Request.Context())...)
metricAttrs := []attribute.KeyValue{
attribute.String("route", route),
attribute.String("method", c.Request.Method),
attribute.String("edge_outcome", string(outcome)),
}
if errorCodeValue != "" {
metricAttrs = append(metricAttrs, attribute.String("error_code", errorCodeValue))
}
metrics.RecordInternalHTTPRequest(c.Request.Context(), metricAttrs, duration)
switch outcome {
case edgeOutcomeSuccess:
logger.InfoContext(c.Request.Context(), "internal request completed", attrs...)
case edgeOutcomeFailed:
logger.ErrorContext(c.Request.Context(), "internal request failed", attrs...)
default:
logger.WarnContext(c.Request.Context(), "internal request rejected", attrs...)
}
}
}
type edgeOutcome string
const (
edgeOutcomeSuccess edgeOutcome = "success"
edgeOutcomeRejected edgeOutcome = "rejected"
edgeOutcomeFailed edgeOutcome = "failed"
)
func outcomeFromStatusCode(statusCode int) edgeOutcome {
switch {
case statusCode >= 500:
return edgeOutcomeFailed
case statusCode >= 400:
return edgeOutcomeRejected
default:
return edgeOutcomeSuccess
}
}
File diff suppressed because it is too large Load Diff
+88
View File
@@ -0,0 +1,88 @@
package internalhttp
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"galaxy/user/internal/service/shared"
"github.com/gin-gonic/gin"
)
const internalErrorCodeContextKey = "internal_error_code"
type malformedJSONRequestError struct {
message string
}
func (err *malformedJSONRequestError) Error() string {
if err == nil {
return ""
}
return err.message
}
func decodeJSONRequest(request *http.Request, target any) error {
if request == nil || request.Body == nil {
return &malformedJSONRequestError{message: "request body must not be empty"}
}
decoder := json.NewDecoder(request.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return describeJSONDecodeError(err)
}
if err := decoder.Decode(&struct{}{}); err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return &malformedJSONRequestError{message: "request body must contain a single JSON object"}
}
return &malformedJSONRequestError{message: "request body must contain a single JSON object"}
}
func describeJSONDecodeError(err error) error {
var syntaxErr *json.SyntaxError
var typeErr *json.UnmarshalTypeError
switch {
case errors.Is(err, io.EOF):
return &malformedJSONRequestError{message: "request body must not be empty"}
case errors.As(err, &syntaxErr):
return &malformedJSONRequestError{message: "request body contains malformed JSON"}
case errors.Is(err, io.ErrUnexpectedEOF):
return &malformedJSONRequestError{message: "request body contains malformed JSON"}
case errors.As(err, &typeErr):
if strings.TrimSpace(typeErr.Field) != "" {
return &malformedJSONRequestError{
message: fmt.Sprintf("request body contains an invalid value for %q", typeErr.Field),
}
}
return &malformedJSONRequestError{message: "request body contains an invalid JSON value"}
case strings.HasPrefix(err.Error(), "json: unknown field "):
return &malformedJSONRequestError{
message: fmt.Sprintf("request body contains unknown field %s", strings.TrimPrefix(err.Error(), "json: unknown field ")),
}
default:
return &malformedJSONRequestError{message: "request body contains invalid JSON"}
}
}
func abortWithProjection(c *gin.Context, projection shared.InternalErrorProjection) {
c.Set(internalErrorCodeContextKey, projection.Code)
c.AbortWithStatusJSON(projection.StatusCode, errorResponse{
Error: errorBody{
Code: projection.Code,
Message: projection.Message,
},
})
}
@@ -0,0 +1,112 @@
package internalhttp
import (
"bytes"
"context"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"galaxy/user/internal/service/authdirectory"
usertelemetry "galaxy/user/internal/telemetry"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/attribute"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
)
func TestInternalHandlerEmitsTraceFieldsAndMetrics(t *testing.T) {
t.Parallel()
logger, buffer := newObservedLogger()
telemetryRuntime, reader, recorder := newObservedInternalTelemetryRuntime(t)
handler := mustNewHandler(t, Dependencies{
Logger: logger,
Telemetry: telemetryRuntime,
ExistsByUserID: existsByUserIDFunc(func(context.Context, authdirectory.ExistsByUserIDInput) (authdirectory.ExistsByUserIDResult, error) {
return authdirectory.ExistsByUserIDResult{Exists: true}, nil
}),
})
recorderHTTP := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/v1/internal/users/user-123/exists", nil)
request.Header.Set("traceparent", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")
handler.ServeHTTP(recorderHTTP, request)
require.Equal(t, http.StatusOK, recorderHTTP.Code)
require.NotEmpty(t, recorder.Ended())
assert.Contains(t, buffer.String(), "otel_trace_id")
assert.Contains(t, buffer.String(), "otel_span_id")
assertMetricCount(t, reader, "user.internal_http.requests", map[string]string{
"route": "/api/v1/internal/users/:user_id/exists",
"method": http.MethodGet,
"edge_outcome": "success",
}, 1)
}
func newObservedInternalTelemetryRuntime(t *testing.T) (*usertelemetry.Runtime, *sdkmetric.ManualReader, *tracetest.SpanRecorder) {
t.Helper()
reader := sdkmetric.NewManualReader()
meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
recorder := tracetest.NewSpanRecorder()
tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
runtime, err := usertelemetry.NewWithProviders(meterProvider, tracerProvider)
require.NoError(t, err)
return runtime, reader, recorder
}
func newObservedLogger() (*slog.Logger, *bytes.Buffer) {
buffer := &bytes.Buffer{}
return slog.New(slog.NewJSONHandler(buffer, &slog.HandlerOptions{Level: slog.LevelDebug})), buffer
}
func assertMetricCount(t *testing.T, reader *sdkmetric.ManualReader, metricName string, wantAttrs map[string]string, wantValue int64) {
t.Helper()
var resourceMetrics metricdata.ResourceMetrics
require.NoError(t, reader.Collect(context.Background(), &resourceMetrics))
for _, scopeMetrics := range resourceMetrics.ScopeMetrics {
for _, metric := range scopeMetrics.Metrics {
if metric.Name != metricName {
continue
}
sum, ok := metric.Data.(metricdata.Sum[int64])
require.True(t, ok)
for _, point := range sum.DataPoints {
if hasMetricAttributes(point.Attributes.ToSlice(), wantAttrs) {
assert.Equal(t, wantValue, point.Value)
return
}
}
}
}
require.Failf(t, "test failed", "metric %q with attrs %v not found", metricName, wantAttrs)
}
func hasMetricAttributes(values []attribute.KeyValue, want map[string]string) bool {
if len(values) != len(want) {
return false
}
for _, value := range values {
if want[string(value.Key)] != value.Value.AsString() {
return false
}
}
return true
}
+411
View File
@@ -0,0 +1,411 @@
// Package internalhttp exposes the trusted internal HTTP API used by auth,
// gateway self-service, and internal administrative workflows.
package internalhttp
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"sync"
"time"
"galaxy/user/internal/service/adminusers"
"galaxy/user/internal/service/authdirectory"
"galaxy/user/internal/service/entitlementsvc"
"galaxy/user/internal/service/geosync"
"galaxy/user/internal/service/lobbyeligibility"
"galaxy/user/internal/service/policysvc"
"galaxy/user/internal/service/selfservice"
"galaxy/user/internal/telemetry"
)
const jsonContentType = "application/json; charset=utf-8"
var configureGinModeOnce sync.Once
// ResolveByEmailUseCase describes the auth-facing resolve-by-email service
// consumed by the HTTP transport layer.
type ResolveByEmailUseCase interface {
// Execute resolves one e-mail subject without creating any account.
Execute(ctx context.Context, input authdirectory.ResolveByEmailInput) (authdirectory.ResolveByEmailResult, error)
}
// EnsureByEmailUseCase describes the auth-facing ensure-by-email service
// consumed by the HTTP transport layer.
type EnsureByEmailUseCase interface {
// Execute returns an existing user, creates a new one, or reports a blocked
// outcome for one e-mail subject.
Execute(ctx context.Context, input authdirectory.EnsureByEmailInput) (authdirectory.EnsureByEmailResult, error)
}
// ExistsByUserIDUseCase describes the auth-facing exists-by-user-id service
// consumed by the HTTP transport layer.
type ExistsByUserIDUseCase interface {
// Execute reports whether one stable user identifier exists.
Execute(ctx context.Context, input authdirectory.ExistsByUserIDInput) (authdirectory.ExistsByUserIDResult, error)
}
// BlockByUserIDUseCase describes the auth-facing block-by-user-id service
// consumed by the HTTP transport layer.
type BlockByUserIDUseCase interface {
// Execute blocks one account addressed by stable user identifier.
Execute(ctx context.Context, input authdirectory.BlockByUserIDInput) (authdirectory.BlockResult, error)
}
// BlockByEmailUseCase describes the auth-facing block-by-email service
// consumed by the HTTP transport layer.
type BlockByEmailUseCase interface {
// Execute blocks one exact normalized e-mail subject.
Execute(ctx context.Context, input authdirectory.BlockByEmailInput) (authdirectory.BlockResult, error)
}
// GetMyAccountUseCase describes the self-service account-read use case
// consumed by the HTTP transport layer.
type GetMyAccountUseCase interface {
// Execute returns the authenticated account aggregate for one user.
Execute(ctx context.Context, input selfservice.GetMyAccountInput) (selfservice.GetMyAccountResult, error)
}
// UpdateMyProfileUseCase describes the self-service profile-mutation use case
// consumed by the HTTP transport layer.
type UpdateMyProfileUseCase interface {
// Execute updates the allowed self-service profile fields for one user.
Execute(ctx context.Context, input selfservice.UpdateMyProfileInput) (selfservice.UpdateMyProfileResult, error)
}
// UpdateMySettingsUseCase describes the self-service settings-mutation use
// case consumed by the HTTP transport layer.
type UpdateMySettingsUseCase interface {
// Execute updates the allowed self-service settings fields for one user.
Execute(ctx context.Context, input selfservice.UpdateMySettingsInput) (selfservice.UpdateMySettingsResult, error)
}
// GetUserByIDUseCase describes the trusted admin exact-read by stable user id
// consumed by the HTTP transport layer.
type GetUserByIDUseCase interface {
// Execute returns the full current account aggregate for one user id.
Execute(ctx context.Context, input adminusers.GetUserByIDInput) (adminusers.LookupResult, error)
}
// GetUserByEmailUseCase describes the trusted admin exact-read by normalized
// e-mail consumed by the HTTP transport layer.
type GetUserByEmailUseCase interface {
// Execute returns the full current account aggregate for one normalized
// e-mail address.
Execute(ctx context.Context, input adminusers.GetUserByEmailInput) (adminusers.LookupResult, error)
}
// GetUserByRaceNameUseCase describes the trusted admin exact-read by exact
// stored race name consumed by the HTTP transport layer.
type GetUserByRaceNameUseCase interface {
// Execute returns the full current account aggregate for one exact race
// name.
Execute(ctx context.Context, input adminusers.GetUserByRaceNameInput) (adminusers.LookupResult, error)
}
// ListUsersUseCase describes the trusted admin paginated listing use case
// consumed by the HTTP transport layer.
type ListUsersUseCase interface {
// Execute returns one deterministic filtered page of full account
// aggregates.
Execute(ctx context.Context, input adminusers.ListUsersInput) (adminusers.ListUsersResult, error)
}
// GetUserEligibilityUseCase describes the trusted lobby-facing eligibility
// snapshot use case consumed by the HTTP transport layer.
type GetUserEligibilityUseCase interface {
// Execute returns one read-optimized lobby eligibility snapshot for one
// user.
Execute(ctx context.Context, input lobbyeligibility.GetUserEligibilityInput) (lobbyeligibility.GetUserEligibilityResult, error)
}
// SyncDeclaredCountryUseCase describes the trusted geo-facing declared-country
// sync use case consumed by the HTTP transport layer.
type SyncDeclaredCountryUseCase interface {
// Execute synchronizes the current effective declared country for one user.
Execute(ctx context.Context, input geosync.SyncDeclaredCountryInput) (geosync.SyncDeclaredCountryResult, error)
}
// GrantEntitlementUseCase describes the trusted entitlement-grant use case
// consumed by the HTTP transport layer.
type GrantEntitlementUseCase interface {
// Execute grants a new current paid entitlement for one user.
Execute(ctx context.Context, input entitlementsvc.GrantInput) (entitlementsvc.CommandResult, error)
}
// ExtendEntitlementUseCase describes the trusted entitlement-extend use case
// consumed by the HTTP transport layer.
type ExtendEntitlementUseCase interface {
// Execute extends the current finite paid entitlement for one user.
Execute(ctx context.Context, input entitlementsvc.ExtendInput) (entitlementsvc.CommandResult, error)
}
// RevokeEntitlementUseCase describes the trusted entitlement-revoke use case
// consumed by the HTTP transport layer.
type RevokeEntitlementUseCase interface {
// Execute revokes the current paid entitlement for one user.
Execute(ctx context.Context, input entitlementsvc.RevokeInput) (entitlementsvc.CommandResult, error)
}
// ApplySanctionUseCase describes the trusted sanction-apply use case consumed
// by the HTTP transport layer.
type ApplySanctionUseCase interface {
// Execute applies one new active sanction record.
Execute(ctx context.Context, input policysvc.ApplySanctionInput) (policysvc.SanctionCommandResult, error)
}
// RemoveSanctionUseCase describes the trusted sanction-remove use case
// consumed by the HTTP transport layer.
type RemoveSanctionUseCase interface {
// Execute removes one current active sanction record by code.
Execute(ctx context.Context, input policysvc.RemoveSanctionInput) (policysvc.SanctionCommandResult, error)
}
// SetLimitUseCase describes the trusted limit-set use case consumed by the
// HTTP transport layer.
type SetLimitUseCase interface {
// Execute creates or replaces one current active limit record.
Execute(ctx context.Context, input policysvc.SetLimitInput) (policysvc.LimitCommandResult, error)
}
// RemoveLimitUseCase describes the trusted limit-remove use case consumed by
// the HTTP transport layer.
type RemoveLimitUseCase interface {
// Execute removes one current active limit record by code.
Execute(ctx context.Context, input policysvc.RemoveLimitInput) (policysvc.LimitCommandResult, error)
}
// Config describes the trusted internal HTTP listener owned by the user
// service.
type Config struct {
// Addr stores the TCP listen address.
Addr string
// ReadHeaderTimeout bounds how long the listener may spend reading request
// headers before rejecting the connection.
ReadHeaderTimeout time.Duration
// ReadTimeout bounds how long the listener may spend reading one request.
ReadTimeout time.Duration
// IdleTimeout bounds how long keep-alive connections stay open.
IdleTimeout time.Duration
// RequestTimeout bounds one application-layer request execution.
RequestTimeout time.Duration
}
// Validate reports whether cfg contains a usable internal HTTP listener
// configuration.
func (cfg Config) Validate() error {
switch {
case cfg.Addr == "":
return errors.New("internal HTTP addr must not be empty")
case cfg.ReadHeaderTimeout <= 0:
return errors.New("internal HTTP read header timeout must be positive")
case cfg.ReadTimeout <= 0:
return errors.New("internal HTTP read timeout must be positive")
case cfg.IdleTimeout <= 0:
return errors.New("internal HTTP idle timeout must be positive")
case cfg.RequestTimeout <= 0:
return errors.New("internal HTTP request timeout must be positive")
default:
return nil
}
}
// Dependencies describes the collaborators used by the trusted internal HTTP
// transport layer.
type Dependencies struct {
// ResolveByEmail executes the auth-facing resolve-by-email use case.
ResolveByEmail ResolveByEmailUseCase
// EnsureByEmail executes the auth-facing ensure-by-email use case.
EnsureByEmail EnsureByEmailUseCase
// ExistsByUserID executes the auth-facing exists-by-user-id use case.
ExistsByUserID ExistsByUserIDUseCase
// BlockByUserID executes the auth-facing block-by-user-id use case.
BlockByUserID BlockByUserIDUseCase
// BlockByEmail executes the auth-facing block-by-email use case.
BlockByEmail BlockByEmailUseCase
// GetMyAccount executes the self-service authenticated account-read use
// case.
GetMyAccount GetMyAccountUseCase
// UpdateMyProfile executes the self-service profile-mutation use case.
UpdateMyProfile UpdateMyProfileUseCase
// UpdateMySettings executes the self-service settings-mutation use case.
UpdateMySettings UpdateMySettingsUseCase
// GetUserByID executes the trusted admin exact-read by stable user id.
GetUserByID GetUserByIDUseCase
// GetUserByEmail executes the trusted admin exact-read by normalized
// e-mail.
GetUserByEmail GetUserByEmailUseCase
// GetUserByRaceName executes the trusted admin exact-read by exact stored
// race name.
GetUserByRaceName GetUserByRaceNameUseCase
// ListUsers executes the trusted admin paginated filtered listing use case.
ListUsers ListUsersUseCase
// GetUserEligibility executes the trusted lobby-facing eligibility snapshot
// read.
GetUserEligibility GetUserEligibilityUseCase
// SyncDeclaredCountry executes the trusted geo-facing declared-country sync
// command.
SyncDeclaredCountry SyncDeclaredCountryUseCase
// GrantEntitlement executes the trusted entitlement-grant use case.
GrantEntitlement GrantEntitlementUseCase
// ExtendEntitlement executes the trusted entitlement-extend use case.
ExtendEntitlement ExtendEntitlementUseCase
// RevokeEntitlement executes the trusted entitlement-revoke use case.
RevokeEntitlement RevokeEntitlementUseCase
// ApplySanction executes the trusted sanction-apply use case.
ApplySanction ApplySanctionUseCase
// RemoveSanction executes the trusted sanction-remove use case.
RemoveSanction RemoveSanctionUseCase
// SetLimit executes the trusted limit-set use case.
SetLimit SetLimitUseCase
// RemoveLimit executes the trusted limit-remove use case.
RemoveLimit RemoveLimitUseCase
// Logger writes structured transport logs. When nil, the default logger is
// used.
Logger *slog.Logger
// Telemetry records OpenTelemetry spans and low-cardinality HTTP metrics.
Telemetry *telemetry.Runtime
}
// Server owns the trusted internal HTTP listener exposed by the user service.
type Server struct {
cfg Config
handler http.Handler
logger *slog.Logger
stateMu sync.RWMutex
server *http.Server
listener net.Listener
}
// NewServer constructs one trusted internal HTTP server for cfg and deps.
func NewServer(cfg Config, deps Dependencies) (*Server, error) {
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("new internal HTTP server: %w", err)
}
handler, err := newHandlerWithConfig(cfg, deps)
if err != nil {
return nil, fmt.Errorf("new internal HTTP server: %w", err)
}
logger := deps.Logger
if logger == nil {
logger = slog.Default()
}
return &Server{
cfg: cfg,
handler: handler,
logger: logger,
}, nil
}
// Run binds the configured listener and serves the trusted internal HTTP
// surface until ctx is cancelled or Shutdown closes the server.
func (server *Server) Run(ctx context.Context) error {
if ctx == nil {
return errors.New("run internal HTTP server: nil context")
}
if err := ctx.Err(); err != nil {
return err
}
listener, err := net.Listen("tcp", server.cfg.Addr)
if err != nil {
return fmt.Errorf("run internal HTTP server: listen on %q: %w", server.cfg.Addr, err)
}
httpServer := &http.Server{
Handler: server.handler,
ReadHeaderTimeout: server.cfg.ReadHeaderTimeout,
ReadTimeout: server.cfg.ReadTimeout,
IdleTimeout: server.cfg.IdleTimeout,
}
server.stateMu.Lock()
server.server = httpServer
server.listener = listener
server.stateMu.Unlock()
server.logger.Info("internal HTTP server started", "addr", listener.Addr().String())
shutdownDone := make(chan struct{})
go func() {
defer close(shutdownDone)
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), server.cfg.RequestTimeout)
defer cancel()
_ = server.Shutdown(shutdownCtx)
}()
defer func() {
server.stateMu.Lock()
server.server = nil
server.listener = nil
server.stateMu.Unlock()
<-shutdownDone
}()
err = httpServer.Serve(listener)
switch {
case err == nil:
return nil
case errors.Is(err, http.ErrServerClosed):
server.logger.Info("internal HTTP server stopped")
return nil
default:
return fmt.Errorf("run internal HTTP server: serve on %q: %w", server.cfg.Addr, err)
}
}
// Shutdown gracefully stops the internal HTTP server within ctx.
func (server *Server) Shutdown(ctx context.Context) error {
if ctx == nil {
return errors.New("shutdown internal HTTP server: nil context")
}
server.stateMu.RLock()
httpServer := server.server
server.stateMu.RUnlock()
if httpServer == nil {
return nil
}
if err := httpServer.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("shutdown internal HTTP server: %w", err)
}
return nil
}
+493
View File
@@ -0,0 +1,493 @@
// Package app wires the runnable user-service process.
package app
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"galaxy/user/internal/adapters/local"
"galaxy/user/internal/adapters/redis/domainevents"
"galaxy/user/internal/adapters/redis/userstore"
"galaxy/user/internal/adminapi"
"galaxy/user/internal/api/internalhttp"
"galaxy/user/internal/config"
"galaxy/user/internal/service/adminusers"
"galaxy/user/internal/service/authdirectory"
"galaxy/user/internal/service/entitlementsvc"
"galaxy/user/internal/service/geosync"
"galaxy/user/internal/service/lobbyeligibility"
"galaxy/user/internal/service/policysvc"
"galaxy/user/internal/service/selfservice"
"galaxy/user/internal/telemetry"
)
type pinger interface {
Ping(context.Context) error
}
type closer interface {
Close() error
}
// Runtime owns the runnable user-service process plus the cleanup functions
// that release runtime resources after shutdown.
type Runtime struct {
cfg config.Config
logger *slog.Logger
// Server owns the internal HTTP listener exposed by the user service.
Server *internalhttp.Server
// AdminServer owns the optional private admin HTTP listener.
AdminServer *adminapi.Server
// Telemetry owns the process-wide OpenTelemetry providers and Prometheus
// handler.
Telemetry *telemetry.Runtime
cleanupFns []func() error
}
// NewRuntime constructs the runnable user-service process from cfg.
func NewRuntime(ctx context.Context, cfg config.Config, logger *slog.Logger) (*Runtime, error) {
if ctx == nil {
return nil, fmt.Errorf("new user-service runtime: nil context")
}
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("new user-service runtime: %w", err)
}
if logger == nil {
logger = slog.Default()
}
runtime := &Runtime{
cfg: cfg,
logger: logger,
}
cleanupOnError := func(err error) (*Runtime, error) {
return nil, fmt.Errorf("%w; cleanup: %w", err, runtime.Close())
}
telemetryRuntime, err := telemetry.NewProcess(ctx, telemetry.ProcessConfig{
ServiceName: cfg.Telemetry.ServiceName,
TracesExporter: cfg.Telemetry.TracesExporter,
MetricsExporter: cfg.Telemetry.MetricsExporter,
TracesProtocol: cfg.Telemetry.TracesProtocol,
MetricsProtocol: cfg.Telemetry.MetricsProtocol,
StdoutTracesEnabled: cfg.Telemetry.StdoutTracesEnabled,
StdoutMetricsEnabled: cfg.Telemetry.StdoutMetricsEnabled,
}, logger.With("component", "telemetry"))
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: telemetry runtime: %w", err))
}
runtime.Telemetry = telemetryRuntime
runtime.cleanupFns = append(runtime.cleanupFns, func() error {
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
defer cancel()
return telemetryRuntime.Shutdown(shutdownCtx)
})
store, err := userstore.New(userstore.Config{
Addr: cfg.Redis.Addr,
Username: cfg.Redis.Username,
Password: cfg.Redis.Password,
DB: cfg.Redis.DB,
TLSEnabled: cfg.Redis.TLSEnabled,
KeyspacePrefix: cfg.Redis.KeyspacePrefix,
OperationTimeout: cfg.Redis.OperationTimeout,
})
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: redis user store: %w", err))
}
runtime.cleanupFns = append(runtime.cleanupFns, store.Close)
if err := pingDependency(ctx, "redis user store", store); err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: %w", err))
}
domainEventPublisher, err := domainevents.New(domainevents.Config{
Addr: cfg.Redis.Addr,
Username: cfg.Redis.Username,
Password: cfg.Redis.Password,
DB: cfg.Redis.DB,
TLSEnabled: cfg.Redis.TLSEnabled,
Stream: cfg.Redis.DomainEventsStream,
StreamMaxLen: cfg.Redis.DomainEventsStreamMaxLen,
OperationTimeout: cfg.Redis.OperationTimeout,
})
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: redis domain-event publisher: %w", err))
}
runtime.cleanupFns = append(runtime.cleanupFns, domainEventPublisher.Close)
if err := pingDependency(ctx, "redis domain-event publisher", domainEventPublisher); err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: %w", err))
}
clock := local.Clock{}
idGenerator := local.IDGenerator{}
raceNamePolicy, err := local.NewRaceNamePolicy()
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: race-name policy: %w", err))
}
componentLogger := func(component string) *slog.Logger {
return logger.With("component", component)
}
resolver, err := authdirectory.NewResolverWithObservability(store, componentLogger("authdirectory"), telemetryRuntime)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: resolver: %w", err))
}
ensurer, err := authdirectory.NewEnsurerWithObservability(
store,
clock,
idGenerator,
raceNamePolicy,
componentLogger("authdirectory"),
telemetryRuntime,
domainEventPublisher,
domainEventPublisher,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: ensurer: %w", err))
}
existenceChecker, err := authdirectory.NewExistenceChecker(store)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: existence checker: %w", err))
}
blockByUserID, err := authdirectory.NewBlockByUserIDService(store, clock)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: block-by-user-id service: %w", err))
}
blockByEmail, err := authdirectory.NewBlockByEmailService(store, clock)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: block-by-email service: %w", err))
}
entitlementReader, err := entitlementsvc.NewReaderWithObservability(
store.EntitlementSnapshots(),
store.EntitlementLifecycle(),
clock,
idGenerator,
componentLogger("entitlementsvc"),
telemetryRuntime,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: entitlement reader: %w", err))
}
grantEntitlement, err := entitlementsvc.NewGrantServiceWithObservability(
store.Accounts(),
store.EntitlementHistory(),
entitlementReader,
store.EntitlementLifecycle(),
clock,
idGenerator,
componentLogger("entitlementsvc"),
telemetryRuntime,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: grant entitlement service: %w", err))
}
extendEntitlement, err := entitlementsvc.NewExtendServiceWithObservability(
store.Accounts(),
store.EntitlementHistory(),
entitlementReader,
store.EntitlementLifecycle(),
clock,
idGenerator,
componentLogger("entitlementsvc"),
telemetryRuntime,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: extend entitlement service: %w", err))
}
revokeEntitlement, err := entitlementsvc.NewRevokeServiceWithObservability(
store.Accounts(),
store.EntitlementHistory(),
entitlementReader,
store.EntitlementLifecycle(),
clock,
idGenerator,
componentLogger("entitlementsvc"),
telemetryRuntime,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: revoke entitlement service: %w", err))
}
accountGetter, err := selfservice.NewAccountGetter(store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), clock)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: account getter: %w", err))
}
profileUpdater, err := selfservice.NewProfileUpdaterWithObservability(
store.Accounts(),
entitlementReader,
store.Sanctions(),
store.Limits(),
clock,
raceNamePolicy,
componentLogger("selfservice"),
telemetryRuntime,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: profile updater: %w", err))
}
settingsUpdater, err := selfservice.NewSettingsUpdaterWithObservability(
store.Accounts(),
entitlementReader,
store.Sanctions(),
store.Limits(),
clock,
componentLogger("selfservice"),
telemetryRuntime,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: settings updater: %w", err))
}
getUserByID, err := adminusers.NewByIDGetter(store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), clock)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: admin get-user-by-id: %w", err))
}
getUserByEmail, err := adminusers.NewByEmailGetter(store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), clock)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: admin get-user-by-email: %w", err))
}
getUserByRaceName, err := adminusers.NewByRaceNameGetter(store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), clock)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: admin get-user-by-race-name: %w", err))
}
listUsers, err := adminusers.NewLister(store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), clock, store)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: admin list-users: %w", err))
}
userEligibility, err := lobbyeligibility.NewSnapshotReader(store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), clock)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: lobby eligibility snapshot reader: %w", err))
}
syncDeclaredCountry, err := geosync.NewSyncServiceWithObservability(
store.Accounts(),
clock,
domainEventPublisher,
componentLogger("geosync"),
telemetryRuntime,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: geo declared-country sync service: %w", err))
}
applySanction, err := policysvc.NewApplySanctionServiceWithObservability(
store.Accounts(),
store.Sanctions(),
store.Limits(),
store.PolicyLifecycle(),
clock,
idGenerator,
componentLogger("policysvc"),
telemetryRuntime,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: apply sanction service: %w", err))
}
removeSanction, err := policysvc.NewRemoveSanctionServiceWithObservability(
store.Accounts(),
store.Sanctions(),
store.Limits(),
store.PolicyLifecycle(),
clock,
idGenerator,
componentLogger("policysvc"),
telemetryRuntime,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: remove sanction service: %w", err))
}
setLimit, err := policysvc.NewSetLimitServiceWithObservability(
store.Accounts(),
store.Sanctions(),
store.Limits(),
store.PolicyLifecycle(),
clock,
idGenerator,
componentLogger("policysvc"),
telemetryRuntime,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: set limit service: %w", err))
}
removeLimit, err := policysvc.NewRemoveLimitServiceWithObservability(
store.Accounts(),
store.Sanctions(),
store.Limits(),
store.PolicyLifecycle(),
clock,
idGenerator,
componentLogger("policysvc"),
telemetryRuntime,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: remove limit service: %w", err))
}
server, err := internalhttp.NewServer(internalhttp.Config{
Addr: cfg.InternalHTTP.Addr,
ReadHeaderTimeout: cfg.InternalHTTP.ReadHeaderTimeout,
ReadTimeout: cfg.InternalHTTP.ReadTimeout,
IdleTimeout: cfg.InternalHTTP.IdleTimeout,
RequestTimeout: cfg.InternalHTTP.RequestTimeout,
}, internalhttp.Dependencies{
ResolveByEmail: resolver,
EnsureByEmail: ensurer,
ExistsByUserID: existenceChecker,
BlockByUserID: blockByUserID,
BlockByEmail: blockByEmail,
GetMyAccount: accountGetter,
UpdateMyProfile: profileUpdater,
UpdateMySettings: settingsUpdater,
GetUserByID: getUserByID,
GetUserByEmail: getUserByEmail,
GetUserByRaceName: getUserByRaceName,
ListUsers: listUsers,
GetUserEligibility: userEligibility,
SyncDeclaredCountry: syncDeclaredCountry,
GrantEntitlement: grantEntitlement,
ExtendEntitlement: extendEntitlement,
RevokeEntitlement: revokeEntitlement,
ApplySanction: applySanction,
RemoveSanction: removeSanction,
SetLimit: setLimit,
RemoveLimit: removeLimit,
Logger: logger.With("component", "internal_http"),
Telemetry: telemetryRuntime,
})
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: internal HTTP server: %w", err))
}
adminServer := adminapi.NewServer(cfg.AdminHTTP, telemetryRuntime.Handler(), logger)
runtime.Server = server
runtime.AdminServer = adminServer
return runtime, nil
}
// Run serves the internal and admin HTTP listeners until ctx is canceled or a
// listener fails.
func (runtime *Runtime) Run(ctx context.Context) error {
if ctx == nil {
return errors.New("run user-service runtime: nil context")
}
if runtime == nil {
return errors.New("run user-service runtime: nil runtime")
}
if runtime.Server == nil {
return errors.New("run user-service runtime: nil internal HTTP server")
}
if runtime.AdminServer == nil {
return errors.New("run user-service runtime: nil admin HTTP server")
}
runCtx, cancel := context.WithCancel(ctx)
defer cancel()
var (
wg sync.WaitGroup
shutdownMu sync.Mutex
shutdownDone bool
shutdownErr error
)
shutdownServers := func() {
shutdownMu.Lock()
defer shutdownMu.Unlock()
if shutdownDone {
return
}
shutdownDone = true
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), runtime.cfg.ShutdownTimeout)
defer shutdownCancel()
shutdownErr = errors.Join(
runtime.Server.Shutdown(shutdownCtx),
runtime.AdminServer.Shutdown(shutdownCtx),
)
}
errCh := make(chan error, 2)
runServer := func(name string, serve func(context.Context) error) {
wg.Add(1)
go func() {
defer wg.Done()
if err := serve(runCtx); err != nil {
select {
case errCh <- fmt.Errorf("%s: %w", name, err):
default:
}
cancel()
}
}()
}
runServer("internal HTTP server", runtime.Server.Run)
runServer("admin HTTP server", runtime.AdminServer.Run)
done := make(chan struct{})
go func() {
defer close(done)
<-runCtx.Done()
shutdownServers()
wg.Wait()
}()
var runErr error
select {
case runErr = <-errCh:
cancel()
case <-ctx.Done():
cancel()
case <-done:
}
<-done
return errors.Join(runErr, shutdownErr)
}
// Close releases every runtime dependency in reverse construction order.
func (runtime *Runtime) Close() error {
if runtime == nil {
return nil
}
var messages []string
for index := len(runtime.cleanupFns) - 1; index >= 0; index-- {
if err := runtime.cleanupFns[index](); err != nil {
messages = append(messages, err.Error())
}
}
if len(messages) == 0 {
return nil
}
return errors.New(strings.Join(messages, "; "))
}
func pingDependency(ctx context.Context, name string, dependency pinger) error {
if err := dependency.Ping(ctx); err != nil {
return fmt.Errorf("ping %s: %w", name, err)
}
return nil
}
var _ closer = (*userstore.Store)(nil)
+551
View File
@@ -0,0 +1,551 @@
// Package config loads the user-service process configuration from environment
// variables.
package config
import (
"crypto/tls"
"fmt"
"net"
"os"
"strconv"
"strings"
"time"
)
const (
shutdownTimeoutEnvVar = "USERSERVICE_SHUTDOWN_TIMEOUT"
logLevelEnvVar = "USERSERVICE_LOG_LEVEL"
internalHTTPAddrEnvVar = "USERSERVICE_INTERNAL_HTTP_ADDR"
internalHTTPReadHeaderTimeoutEnvVar = "USERSERVICE_INTERNAL_HTTP_READ_HEADER_TIMEOUT"
internalHTTPReadTimeoutEnvVar = "USERSERVICE_INTERNAL_HTTP_READ_TIMEOUT"
internalHTTPIdleTimeoutEnvVar = "USERSERVICE_INTERNAL_HTTP_IDLE_TIMEOUT"
internalHTTPRequestTimeoutEnvVar = "USERSERVICE_INTERNAL_HTTP_REQUEST_TIMEOUT"
adminHTTPAddrEnvVar = "USERSERVICE_ADMIN_HTTP_ADDR"
adminHTTPReadHeaderTimeoutEnvVar = "USERSERVICE_ADMIN_HTTP_READ_HEADER_TIMEOUT"
adminHTTPReadTimeoutEnvVar = "USERSERVICE_ADMIN_HTTP_READ_TIMEOUT"
adminHTTPIdleTimeoutEnvVar = "USERSERVICE_ADMIN_HTTP_IDLE_TIMEOUT"
redisAddrEnvVar = "USERSERVICE_REDIS_ADDR"
redisUsernameEnvVar = "USERSERVICE_REDIS_USERNAME"
redisPasswordEnvVar = "USERSERVICE_REDIS_PASSWORD"
redisDBEnvVar = "USERSERVICE_REDIS_DB"
redisTLSEnabledEnvVar = "USERSERVICE_REDIS_TLS_ENABLED"
redisOperationTimeoutEnvVar = "USERSERVICE_REDIS_OPERATION_TIMEOUT"
redisKeyspacePrefixEnvVar = "USERSERVICE_REDIS_KEYSPACE_PREFIX"
redisDomainEventsStreamEnvVar = "USERSERVICE_REDIS_DOMAIN_EVENTS_STREAM"
redisDomainEventsStreamMaxLenEnvVar = "USERSERVICE_REDIS_DOMAIN_EVENTS_STREAM_MAX_LEN"
otelServiceNameEnvVar = "OTEL_SERVICE_NAME"
otelTracesExporterEnvVar = "OTEL_TRACES_EXPORTER"
otelMetricsExporterEnvVar = "OTEL_METRICS_EXPORTER"
otelExporterOTLPProtocolEnvVar = "OTEL_EXPORTER_OTLP_PROTOCOL"
otelExporterOTLPTracesProtocolEnvVar = "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL"
otelExporterOTLPMetricsProtocolEnvVar = "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL"
otelStdoutTracesEnabledEnvVar = "USERSERVICE_OTEL_STDOUT_TRACES_ENABLED"
otelStdoutMetricsEnabledEnvVar = "USERSERVICE_OTEL_STDOUT_METRICS_ENABLED"
defaultShutdownTimeout = 5 * time.Second
defaultLogLevel = "info"
defaultInternalHTTPAddr = ":8091"
defaultAdminHTTPAddr = ""
defaultReadHeaderTimeout = 2 * time.Second
defaultReadTimeout = 10 * time.Second
defaultIdleTimeout = time.Minute
defaultRequestTimeout = 3 * time.Second
defaultRedisDB = 0
defaultRedisOperationTimeout = 250 * time.Millisecond
defaultRedisKeyspacePrefix = "user:"
defaultDomainEventsStream = "user:domain_events"
defaultDomainEventsStreamMaxLen = 1024
defaultOTelServiceName = "galaxy-user"
otelExporterNone = "none"
otelExporterOTLP = "otlp"
otelProtocolHTTPProtobuf = "http/protobuf"
otelProtocolGRPC = "grpc"
)
// Config stores the full user-service process configuration.
type Config struct {
// ShutdownTimeout bounds graceful shutdown of the long-lived listeners and
// runtime resources.
ShutdownTimeout time.Duration
// Logging configures the process-wide logger.
Logging LoggingConfig
// InternalHTTP configures the trusted internal HTTP listener.
InternalHTTP InternalHTTPConfig
// AdminHTTP configures the optional private admin HTTP listener.
AdminHTTP AdminHTTPConfig
// Redis configures the Redis-backed user store and domain-event publisher.
Redis RedisConfig
// Telemetry configures the process-wide OpenTelemetry runtime.
Telemetry TelemetryConfig
}
// LoggingConfig configures the process-wide logger.
type LoggingConfig struct {
// Level stores the process log level.
Level string
}
// InternalHTTPConfig configures the internal HTTP listener.
type InternalHTTPConfig struct {
// Addr stores the TCP listen address.
Addr string
// ReadHeaderTimeout bounds request-header reading.
ReadHeaderTimeout time.Duration
// ReadTimeout bounds reading one request.
ReadTimeout time.Duration
// IdleTimeout bounds how long keep-alive connections stay open.
IdleTimeout time.Duration
// RequestTimeout bounds one application-layer request execution.
RequestTimeout time.Duration
}
// Validate reports whether cfg stores a usable internal HTTP listener
// configuration.
func (cfg InternalHTTPConfig) Validate() error {
switch {
case strings.TrimSpace(cfg.Addr) == "":
return fmt.Errorf("internal HTTP addr must not be empty")
case cfg.ReadHeaderTimeout <= 0:
return fmt.Errorf("internal HTTP read header timeout must be positive")
case cfg.ReadTimeout <= 0:
return fmt.Errorf("internal HTTP read timeout must be positive")
case cfg.IdleTimeout <= 0:
return fmt.Errorf("internal HTTP idle timeout must be positive")
case cfg.RequestTimeout <= 0:
return fmt.Errorf("internal HTTP request timeout must be positive")
default:
return nil
}
}
// AdminHTTPConfig describes the private operational HTTP listener used for
// Prometheus metrics exposure. The listener remains disabled when Addr is
// empty.
type AdminHTTPConfig struct {
// Addr stores the TCP listen address used by the admin HTTP server.
Addr string
// ReadHeaderTimeout bounds request-header reading.
ReadHeaderTimeout time.Duration
// ReadTimeout bounds reading one request.
ReadTimeout time.Duration
// IdleTimeout bounds how long keep-alive connections stay open.
IdleTimeout time.Duration
}
// Validate reports whether cfg stores a usable optional admin HTTP listener
// configuration.
func (cfg AdminHTTPConfig) Validate() error {
if strings.TrimSpace(cfg.Addr) == "" {
return nil
}
switch {
case cfg.ReadHeaderTimeout <= 0:
return fmt.Errorf("admin HTTP read header timeout must be positive")
case cfg.ReadTimeout <= 0:
return fmt.Errorf("admin HTTP read timeout must be positive")
case cfg.IdleTimeout <= 0:
return fmt.Errorf("admin HTTP idle timeout must be positive")
default:
return nil
}
}
// RedisConfig configures the Redis-backed store and domain-event publisher.
type RedisConfig struct {
// Addr stores the Redis network address.
Addr string
// Username stores the optional Redis ACL username.
Username string
// Password stores the optional Redis ACL password.
Password string
// DB stores the Redis logical database index.
DB int
// TLSEnabled reports whether TLS must be used for Redis connections.
TLSEnabled bool
// OperationTimeout bounds one Redis round trip.
OperationTimeout time.Duration
// KeyspacePrefix stores the root prefix of the service-owned Redis keyspace.
KeyspacePrefix string
// DomainEventsStream stores the Redis Stream key used for auxiliary
// post-commit domain events.
DomainEventsStream string
// DomainEventsStreamMaxLen bounds the domain-events Redis Stream with
// approximate trimming.
DomainEventsStreamMaxLen int64
}
// TLSConfig returns the conservative TLS configuration used by Redis adapters
// when TLSEnabled is true.
func (cfg RedisConfig) TLSConfig() *tls.Config {
if !cfg.TLSEnabled {
return nil
}
return &tls.Config{MinVersion: tls.VersionTLS12}
}
// Validate reports whether cfg stores a usable Redis configuration.
func (cfg RedisConfig) Validate() error {
switch {
case strings.TrimSpace(cfg.Addr) == "":
return fmt.Errorf("redis addr must not be empty")
case cfg.DB < 0:
return fmt.Errorf("redis db must not be negative")
case cfg.OperationTimeout <= 0:
return fmt.Errorf("redis operation timeout must be positive")
case strings.TrimSpace(cfg.KeyspacePrefix) == "":
return fmt.Errorf("redis keyspace prefix must not be empty")
case strings.TrimSpace(cfg.DomainEventsStream) == "":
return fmt.Errorf("redis domain events stream must not be empty")
case cfg.DomainEventsStreamMaxLen <= 0:
return fmt.Errorf("redis domain events stream max len must be positive")
default:
return nil
}
}
// TelemetryConfig configures the user-service OpenTelemetry runtime.
type TelemetryConfig struct {
// ServiceName overrides the default OpenTelemetry service name.
ServiceName string
// TracesExporter selects the external traces exporter. Supported values are
// `none` and `otlp`.
TracesExporter string
// MetricsExporter selects the external metrics exporter. Supported values
// are `none` and `otlp`.
MetricsExporter string
// TracesProtocol selects the OTLP traces protocol when TracesExporter is
// `otlp`.
TracesProtocol string
// MetricsProtocol selects the OTLP metrics protocol when MetricsExporter is
// `otlp`.
MetricsProtocol string
// StdoutTracesEnabled enables the additional stdout trace exporter used for
// local development and debugging.
StdoutTracesEnabled bool
// StdoutMetricsEnabled enables the additional stdout metric exporter used
// for local development and debugging.
StdoutMetricsEnabled bool
}
// Validate reports whether cfg contains a supported OpenTelemetry exporter
// configuration.
func (cfg TelemetryConfig) Validate() error {
switch cfg.TracesExporter {
case otelExporterNone, otelExporterOTLP:
default:
return fmt.Errorf("%s %q is unsupported", otelTracesExporterEnvVar, cfg.TracesExporter)
}
switch cfg.MetricsExporter {
case otelExporterNone, otelExporterOTLP:
default:
return fmt.Errorf("%s %q is unsupported", otelMetricsExporterEnvVar, cfg.MetricsExporter)
}
if cfg.TracesProtocol != "" && cfg.TracesProtocol != otelProtocolHTTPProtobuf && cfg.TracesProtocol != otelProtocolGRPC {
return fmt.Errorf("%s %q is unsupported", otelExporterOTLPTracesProtocolEnvVar, cfg.TracesProtocol)
}
if cfg.MetricsProtocol != "" && cfg.MetricsProtocol != otelProtocolHTTPProtobuf && cfg.MetricsProtocol != otelProtocolGRPC {
return fmt.Errorf("%s %q is unsupported", otelExporterOTLPMetricsProtocolEnvVar, cfg.MetricsProtocol)
}
return nil
}
// DefaultAdminHTTPConfig returns the default settings for the optional private
// admin HTTP listener.
func DefaultAdminHTTPConfig() AdminHTTPConfig {
return AdminHTTPConfig{
Addr: defaultAdminHTTPAddr,
ReadHeaderTimeout: defaultReadHeaderTimeout,
ReadTimeout: defaultReadTimeout,
IdleTimeout: defaultIdleTimeout,
}
}
// DefaultConfig returns the default process configuration with all optional
// values filled.
func DefaultConfig() Config {
return Config{
ShutdownTimeout: defaultShutdownTimeout,
Logging: LoggingConfig{
Level: defaultLogLevel,
},
InternalHTTP: InternalHTTPConfig{
Addr: defaultInternalHTTPAddr,
ReadHeaderTimeout: defaultReadHeaderTimeout,
ReadTimeout: defaultReadTimeout,
IdleTimeout: defaultIdleTimeout,
RequestTimeout: defaultRequestTimeout,
},
AdminHTTP: DefaultAdminHTTPConfig(),
Redis: RedisConfig{
DB: defaultRedisDB,
OperationTimeout: defaultRedisOperationTimeout,
KeyspacePrefix: defaultRedisKeyspacePrefix,
DomainEventsStream: defaultDomainEventsStream,
DomainEventsStreamMaxLen: defaultDomainEventsStreamMaxLen,
},
Telemetry: TelemetryConfig{
ServiceName: defaultOTelServiceName,
TracesExporter: otelExporterNone,
MetricsExporter: otelExporterNone,
},
}
}
// Validate reports whether cfg is process-ready.
func (cfg Config) Validate() error {
switch {
case cfg.ShutdownTimeout <= 0:
return fmt.Errorf("shutdown timeout must be positive")
}
if err := cfg.InternalHTTP.Validate(); err != nil {
return fmt.Errorf("internal HTTP config: %w", err)
}
if err := cfg.AdminHTTP.Validate(); err != nil {
return fmt.Errorf("admin HTTP config: %w", err)
}
if err := cfg.Redis.Validate(); err != nil {
return fmt.Errorf("redis config: %w", err)
}
if _, err := parseLogLevel(cfg.Logging.Level); err != nil {
return fmt.Errorf("logging config: %w", err)
}
if err := cfg.Telemetry.Validate(); err != nil {
return fmt.Errorf("telemetry config: %w", err)
}
return nil
}
// LoadFromEnv loads Config from the process environment.
func LoadFromEnv() (Config, error) {
cfg := DefaultConfig()
var err error
cfg.ShutdownTimeout, err = loadDuration(shutdownTimeoutEnvVar, cfg.ShutdownTimeout)
if err != nil {
return Config{}, err
}
cfg.Logging.Level = loadString(logLevelEnvVar, cfg.Logging.Level)
cfg.InternalHTTP.Addr = loadString(internalHTTPAddrEnvVar, cfg.InternalHTTP.Addr)
cfg.InternalHTTP.ReadHeaderTimeout, err = loadDuration(internalHTTPReadHeaderTimeoutEnvVar, cfg.InternalHTTP.ReadHeaderTimeout)
if err != nil {
return Config{}, err
}
cfg.InternalHTTP.ReadTimeout, err = loadDuration(internalHTTPReadTimeoutEnvVar, cfg.InternalHTTP.ReadTimeout)
if err != nil {
return Config{}, err
}
cfg.InternalHTTP.IdleTimeout, err = loadDuration(internalHTTPIdleTimeoutEnvVar, cfg.InternalHTTP.IdleTimeout)
if err != nil {
return Config{}, err
}
cfg.InternalHTTP.RequestTimeout, err = loadDuration(internalHTTPRequestTimeoutEnvVar, cfg.InternalHTTP.RequestTimeout)
if err != nil {
return Config{}, err
}
cfg.AdminHTTP.Addr = loadString(adminHTTPAddrEnvVar, cfg.AdminHTTP.Addr)
cfg.AdminHTTP.ReadHeaderTimeout, err = loadDuration(adminHTTPReadHeaderTimeoutEnvVar, cfg.AdminHTTP.ReadHeaderTimeout)
if err != nil {
return Config{}, err
}
cfg.AdminHTTP.ReadTimeout, err = loadDuration(adminHTTPReadTimeoutEnvVar, cfg.AdminHTTP.ReadTimeout)
if err != nil {
return Config{}, err
}
cfg.AdminHTTP.IdleTimeout, err = loadDuration(adminHTTPIdleTimeoutEnvVar, cfg.AdminHTTP.IdleTimeout)
if err != nil {
return Config{}, err
}
cfg.Redis.Addr = loadString(redisAddrEnvVar, cfg.Redis.Addr)
cfg.Redis.Username = loadString(redisUsernameEnvVar, cfg.Redis.Username)
cfg.Redis.Password = loadString(redisPasswordEnvVar, cfg.Redis.Password)
cfg.Redis.DB, err = loadInt(redisDBEnvVar, cfg.Redis.DB)
if err != nil {
return Config{}, err
}
cfg.Redis.TLSEnabled, err = loadBool(redisTLSEnabledEnvVar, cfg.Redis.TLSEnabled)
if err != nil {
return Config{}, err
}
cfg.Redis.OperationTimeout, err = loadDuration(redisOperationTimeoutEnvVar, cfg.Redis.OperationTimeout)
if err != nil {
return Config{}, err
}
cfg.Redis.KeyspacePrefix = loadString(redisKeyspacePrefixEnvVar, cfg.Redis.KeyspacePrefix)
cfg.Redis.DomainEventsStream = loadString(redisDomainEventsStreamEnvVar, cfg.Redis.DomainEventsStream)
cfg.Redis.DomainEventsStreamMaxLen, err = loadInt64(redisDomainEventsStreamMaxLenEnvVar, cfg.Redis.DomainEventsStreamMaxLen)
if err != nil {
return Config{}, err
}
cfg.Telemetry.ServiceName = loadString(otelServiceNameEnvVar, cfg.Telemetry.ServiceName)
cfg.Telemetry.TracesExporter = normalizeExporterValue(loadString(otelTracesExporterEnvVar, cfg.Telemetry.TracesExporter))
cfg.Telemetry.MetricsExporter = normalizeExporterValue(loadString(otelMetricsExporterEnvVar, cfg.Telemetry.MetricsExporter))
cfg.Telemetry.TracesProtocol = loadOTLPProtocol(
os.Getenv(otelExporterOTLPTracesProtocolEnvVar),
os.Getenv(otelExporterOTLPProtocolEnvVar),
cfg.Telemetry.TracesExporter,
)
cfg.Telemetry.MetricsProtocol = loadOTLPProtocol(
os.Getenv(otelExporterOTLPMetricsProtocolEnvVar),
os.Getenv(otelExporterOTLPProtocolEnvVar),
cfg.Telemetry.MetricsExporter,
)
cfg.Telemetry.StdoutTracesEnabled, err = loadBool(otelStdoutTracesEnabledEnvVar, cfg.Telemetry.StdoutTracesEnabled)
if err != nil {
return Config{}, err
}
cfg.Telemetry.StdoutMetricsEnabled, err = loadBool(otelStdoutMetricsEnabledEnvVar, cfg.Telemetry.StdoutMetricsEnabled)
if err != nil {
return Config{}, err
}
if err := cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
func loadString(envName string, defaultValue string) string {
value, ok := os.LookupEnv(envName)
if !ok {
return defaultValue
}
return strings.TrimSpace(value)
}
func loadDuration(envName string, defaultValue time.Duration) (time.Duration, error) {
value, ok := os.LookupEnv(envName)
if !ok {
return defaultValue, nil
}
duration, err := time.ParseDuration(strings.TrimSpace(value))
if err != nil {
return 0, fmt.Errorf("%s: parse duration: %w", envName, err)
}
return duration, nil
}
func loadInt(envName string, defaultValue int) (int, error) {
value, ok := os.LookupEnv(envName)
if !ok {
return defaultValue, nil
}
parsedValue, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
return 0, fmt.Errorf("%s: parse int: %w", envName, err)
}
return parsedValue, nil
}
func loadInt64(envName string, defaultValue int64) (int64, error) {
value, ok := os.LookupEnv(envName)
if !ok {
return defaultValue, nil
}
parsedValue, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
if err != nil {
return 0, fmt.Errorf("%s: parse int64: %w", envName, err)
}
return parsedValue, nil
}
func loadBool(envName string, defaultValue bool) (bool, error) {
value, ok := os.LookupEnv(envName)
if !ok {
return defaultValue, nil
}
parsedValue, err := strconv.ParseBool(strings.TrimSpace(value))
if err != nil {
return false, fmt.Errorf("%s: parse bool: %w", envName, err)
}
return parsedValue, nil
}
func parseLogLevel(value string) (string, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "debug", "info", "warn", "error":
return value, nil
default:
return "", fmt.Errorf("unsupported log level %q", value)
}
}
func normalizeExporterValue(value string) string {
switch strings.TrimSpace(value) {
case "", otelExporterNone:
return otelExporterNone
default:
return strings.TrimSpace(value)
}
}
func loadOTLPProtocol(primary string, fallback string, exporter string) string {
protocol := strings.TrimSpace(primary)
if protocol == "" {
protocol = strings.TrimSpace(fallback)
}
if protocol == "" && exporter == otelExporterOTLP {
return otelProtocolHTTPProtobuf
}
return protocol
}
// ListenAddress returns the resolved listen address used by tests and process
// startup.
func (cfg InternalHTTPConfig) ListenAddress() string {
if strings.HasPrefix(cfg.Addr, ":") {
return net.JoinHostPort("", strings.TrimPrefix(cfg.Addr, ":"))
}
return cfg.Addr
}
+106
View File
@@ -0,0 +1,106 @@
package config
import (
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestLoadFromEnvUsesDefaults(t *testing.T) {
t.Setenv(redisAddrEnvVar, "127.0.0.1:6379")
cfg, err := LoadFromEnv()
require.NoError(t, err)
defaults := DefaultConfig()
require.Equal(t, defaults.ShutdownTimeout, cfg.ShutdownTimeout)
require.Equal(t, defaults.Logging.Level, cfg.Logging.Level)
require.Equal(t, defaults.InternalHTTP, cfg.InternalHTTP)
require.Equal(t, defaults.AdminHTTP, cfg.AdminHTTP)
require.Equal(t, "127.0.0.1:6379", cfg.Redis.Addr)
require.Equal(t, defaults.Redis.DB, cfg.Redis.DB)
require.Equal(t, defaults.Redis.DomainEventsStream, cfg.Redis.DomainEventsStream)
require.Equal(t, defaults.Redis.DomainEventsStreamMaxLen, cfg.Redis.DomainEventsStreamMaxLen)
require.Equal(t, defaults.Telemetry, cfg.Telemetry)
}
func TestLoadFromEnvAppliesOverrides(t *testing.T) {
t.Setenv(shutdownTimeoutEnvVar, "9s")
t.Setenv(logLevelEnvVar, "debug")
t.Setenv(internalHTTPAddrEnvVar, "127.0.0.1:18091")
t.Setenv(internalHTTPReadHeaderTimeoutEnvVar, "3s")
t.Setenv(internalHTTPRequestTimeoutEnvVar, "750ms")
t.Setenv(adminHTTPAddrEnvVar, "127.0.0.1:19091")
t.Setenv(adminHTTPIdleTimeoutEnvVar, "90s")
t.Setenv(redisAddrEnvVar, "127.0.0.1:6380")
t.Setenv(redisUsernameEnvVar, "alice")
t.Setenv(redisPasswordEnvVar, "secret")
t.Setenv(redisDBEnvVar, "3")
t.Setenv(redisTLSEnabledEnvVar, "true")
t.Setenv(redisOperationTimeoutEnvVar, "900ms")
t.Setenv(redisKeyspacePrefixEnvVar, "user:custom:")
t.Setenv(redisDomainEventsStreamEnvVar, "user:test_events")
t.Setenv(redisDomainEventsStreamMaxLenEnvVar, "2048")
t.Setenv(otelServiceNameEnvVar, "galaxy-user-stage12")
t.Setenv(otelTracesExporterEnvVar, "otlp")
t.Setenv(otelMetricsExporterEnvVar, "otlp")
t.Setenv(otelExporterOTLPTracesProtocolEnvVar, "grpc")
t.Setenv(otelExporterOTLPMetricsProtocolEnvVar, "http/protobuf")
t.Setenv(otelStdoutTracesEnabledEnvVar, "true")
t.Setenv(otelStdoutMetricsEnabledEnvVar, "true")
cfg, err := LoadFromEnv()
require.NoError(t, err)
require.Equal(t, 9*time.Second, cfg.ShutdownTimeout)
require.Equal(t, "debug", cfg.Logging.Level)
require.Equal(t, "127.0.0.1:18091", cfg.InternalHTTP.Addr)
require.Equal(t, 3*time.Second, cfg.InternalHTTP.ReadHeaderTimeout)
require.Equal(t, 750*time.Millisecond, cfg.InternalHTTP.RequestTimeout)
require.Equal(t, "127.0.0.1:19091", cfg.AdminHTTP.Addr)
require.Equal(t, 90*time.Second, cfg.AdminHTTP.IdleTimeout)
require.Equal(t, "127.0.0.1:6380", cfg.Redis.Addr)
require.Equal(t, "alice", cfg.Redis.Username)
require.Equal(t, "secret", cfg.Redis.Password)
require.Equal(t, 3, cfg.Redis.DB)
require.True(t, cfg.Redis.TLSEnabled)
require.Equal(t, 900*time.Millisecond, cfg.Redis.OperationTimeout)
require.Equal(t, "user:custom:", cfg.Redis.KeyspacePrefix)
require.Equal(t, "user:test_events", cfg.Redis.DomainEventsStream)
require.Equal(t, int64(2048), cfg.Redis.DomainEventsStreamMaxLen)
require.Equal(t, "galaxy-user-stage12", cfg.Telemetry.ServiceName)
require.Equal(t, "otlp", cfg.Telemetry.TracesExporter)
require.Equal(t, "otlp", cfg.Telemetry.MetricsExporter)
require.Equal(t, "grpc", cfg.Telemetry.TracesProtocol)
require.Equal(t, "http/protobuf", cfg.Telemetry.MetricsProtocol)
require.True(t, cfg.Telemetry.StdoutTracesEnabled)
require.True(t, cfg.Telemetry.StdoutMetricsEnabled)
}
func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
tests := []struct {
name string
envName string
envVal string
}{
{name: "invalid duration", envName: shutdownTimeoutEnvVar, envVal: "later"},
{name: "invalid bool", envName: redisTLSEnabledEnvVar, envVal: "sometimes"},
{name: "invalid log level", envName: logLevelEnvVar, envVal: "verbose"},
{name: "invalid int", envName: redisDBEnvVar, envVal: "db-three"},
{name: "invalid stream max len", envName: redisDomainEventsStreamMaxLenEnvVar, envVal: "many"},
{name: "invalid traces exporter", envName: otelTracesExporterEnvVar, envVal: "zipkin"},
{name: "invalid metrics protocol", envName: otelExporterOTLPMetricsProtocolEnvVar, envVal: "udp"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Setenv(redisAddrEnvVar, "127.0.0.1:6379")
t.Setenv(tt.envName, tt.envVal)
_, err := LoadFromEnv()
require.Error(t, err)
})
}
}
+136
View File
@@ -0,0 +1,136 @@
// Package account defines the logical user-account entities owned directly by
// User Service.
package account
import (
"fmt"
"strings"
"time"
"galaxy/user/internal/domain/common"
)
// RaceNameCanonicalKey stores the policy-produced reservation key used to
// enforce replaceable race-name uniqueness.
type RaceNameCanonicalKey string
// String returns RaceNameCanonicalKey as its stored canonical string.
func (key RaceNameCanonicalKey) String() string {
return string(key)
}
// IsZero reports whether RaceNameCanonicalKey does not contain a usable value.
func (key RaceNameCanonicalKey) IsZero() bool {
return strings.TrimSpace(string(key)) == ""
}
// Validate reports whether RaceNameCanonicalKey is non-empty and trimmed.
func (key RaceNameCanonicalKey) Validate() error {
switch {
case key.IsZero():
return fmt.Errorf("race name canonical key must not be empty")
case strings.TrimSpace(string(key)) != string(key):
return fmt.Errorf("race name canonical key must not contain surrounding whitespace")
default:
return nil
}
}
// UserAccount stores the current editable account state of one regular user.
type UserAccount struct {
// UserID identifies the durable regular-user account.
UserID common.UserID
// Email stores the normalized login/contact address of the account.
Email common.Email
// RaceName stores the original-casing user-facing race name.
RaceName common.RaceName
// PreferredLanguage stores the current declared language tag.
PreferredLanguage common.LanguageTag
// TimeZone stores the current declared time-zone name.
TimeZone common.TimeZoneName
// DeclaredCountry stores the latest effective declared-country value. The
// zero value means the geo workflow has not synchronized any country yet.
DeclaredCountry common.CountryCode
// CreatedAt stores the account creation timestamp.
CreatedAt time.Time
// UpdatedAt stores the last account mutation timestamp.
UpdatedAt time.Time
}
// Validate reports whether UserAccount satisfies the frozen Stage 02
// structural invariants.
func (record UserAccount) Validate() error {
if err := record.UserID.Validate(); err != nil {
return fmt.Errorf("user account user id: %w", err)
}
if err := record.Email.Validate(); err != nil {
return fmt.Errorf("user account email: %w", err)
}
if err := record.RaceName.Validate(); err != nil {
return fmt.Errorf("user account race name: %w", err)
}
if err := record.PreferredLanguage.Validate(); err != nil {
return fmt.Errorf("user account preferred language: %w", err)
}
if err := record.TimeZone.Validate(); err != nil {
return fmt.Errorf("user account time zone: %w", err)
}
if !record.DeclaredCountry.IsZero() {
if err := record.DeclaredCountry.Validate(); err != nil {
return fmt.Errorf("user account declared country: %w", err)
}
}
if err := common.ValidateTimestamp("user account created at", record.CreatedAt); err != nil {
return err
}
if err := common.ValidateTimestamp("user account updated at", record.UpdatedAt); err != nil {
return err
}
if record.UpdatedAt.Before(record.CreatedAt) {
return fmt.Errorf("user account updated at must not be before created at")
}
return nil
}
// RaceNameReservation stores the current uniqueness reservation for one
// canonicalized race-name key.
type RaceNameReservation struct {
// CanonicalKey stores the policy-produced uniqueness key.
CanonicalKey RaceNameCanonicalKey
// UserID identifies the account that owns the reservation.
UserID common.UserID
// RaceName stores the original-casing name linked to the reservation.
RaceName common.RaceName
// ReservedAt stores when the reservation was acquired.
ReservedAt time.Time
}
// Validate reports whether RaceNameReservation satisfies the frozen Stage 02
// structural invariants.
func (record RaceNameReservation) Validate() error {
if err := record.CanonicalKey.Validate(); err != nil {
return fmt.Errorf("race name reservation canonical key: %w", err)
}
if err := record.UserID.Validate(); err != nil {
return fmt.Errorf("race name reservation user id: %w", err)
}
if err := record.RaceName.Validate(); err != nil {
return fmt.Errorf("race name reservation race name: %w", err)
}
if err := common.ValidateTimestamp("race name reservation reserved at", record.ReservedAt); err != nil {
return err
}
return nil
}
+119
View File
@@ -0,0 +1,119 @@
package account
import (
"testing"
"time"
"galaxy/user/internal/domain/common"
"github.com/stretchr/testify/require"
)
func TestUserAccountValidate(t *testing.T) {
t.Parallel()
createdAt := time.Unix(1_775_240_000, 0).UTC()
updatedAt := createdAt.Add(2 * time.Hour)
tests := []struct {
name string
record UserAccount
wantErr bool
}{
{
name: "valid without declared country",
record: UserAccount{
UserID: common.UserID("user-123"),
Email: common.Email("pilot@example.com"),
RaceName: common.RaceName("Pilot Nova"),
PreferredLanguage: common.LanguageTag("en"),
TimeZone: common.TimeZoneName("Europe/Berlin"),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
},
},
{
name: "valid with declared country",
record: UserAccount{
UserID: common.UserID("user-123"),
Email: common.Email("pilot@example.com"),
RaceName: common.RaceName("Pilot Nova"),
PreferredLanguage: common.LanguageTag("en"),
TimeZone: common.TimeZoneName("Europe/Berlin"),
DeclaredCountry: common.CountryCode("DE"),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
},
},
{
name: "updated before created",
record: UserAccount{
UserID: common.UserID("user-123"),
Email: common.Email("pilot@example.com"),
RaceName: common.RaceName("Pilot Nova"),
PreferredLanguage: common.LanguageTag("en"),
TimeZone: common.TimeZoneName("Europe/Berlin"),
CreatedAt: createdAt,
UpdatedAt: createdAt.Add(-time.Second),
},
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.record.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestRaceNameReservationValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
record RaceNameReservation
wantErr bool
}{
{
name: "valid",
record: RaceNameReservation{
CanonicalKey: RaceNameCanonicalKey("pilot-nova"),
UserID: common.UserID("user-123"),
RaceName: common.RaceName("Pilot Nova"),
ReservedAt: time.Unix(1_775_240_100, 0).UTC(),
},
},
{
name: "empty canonical key",
record: RaceNameReservation{
UserID: common.UserID("user-123"),
RaceName: common.RaceName("Pilot Nova"),
ReservedAt: time.Unix(1_775_240_100, 0).UTC(),
},
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.record.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
+56
View File
@@ -0,0 +1,56 @@
// Package authblock defines the dedicated pre-user auth-block entity stored by
// User Service.
package authblock
import (
"fmt"
"time"
"galaxy/user/internal/domain/common"
)
// BlockedEmailSubject stores a blocked e-mail subject that may exist before
// any user account exists.
type BlockedEmailSubject struct {
// Email stores the normalized blocked e-mail subject.
Email common.Email
// ReasonCode stores the machine-readable reason for the block.
ReasonCode common.ReasonCode
// BlockedAt stores when the block became effective.
BlockedAt time.Time
// Actor stores optional audit metadata for the block initiator.
Actor common.ActorRef
// ResolvedUserID stores the linked user when the blocked e-mail already
// belongs to an existing account.
ResolvedUserID common.UserID
}
// Validate reports whether BlockedEmailSubject satisfies the frozen Stage 02
// structural invariants.
func (record BlockedEmailSubject) Validate() error {
if err := record.Email.Validate(); err != nil {
return fmt.Errorf("blocked email subject email: %w", err)
}
if err := record.ReasonCode.Validate(); err != nil {
return fmt.Errorf("blocked email subject reason code: %w", err)
}
if err := common.ValidateTimestamp("blocked email subject blocked at", record.BlockedAt); err != nil {
return err
}
if !record.Actor.IsZero() {
if err := record.Actor.Validate(); err != nil {
return fmt.Errorf("blocked email subject actor: %w", err)
}
}
if !record.ResolvedUserID.IsZero() {
if err := record.ResolvedUserID.Validate(); err != nil {
return fmt.Errorf("blocked email subject resolved user id: %w", err)
}
}
return nil
}
@@ -0,0 +1,61 @@
package authblock
import (
"testing"
"time"
"galaxy/user/internal/domain/common"
"github.com/stretchr/testify/require"
)
func TestBlockedEmailSubjectValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
record BlockedEmailSubject
wantErr bool
}{
{
name: "valid without actor or user",
record: BlockedEmailSubject{
Email: common.Email("pilot@example.com"),
ReasonCode: common.ReasonCode("policy_blocked"),
BlockedAt: time.Unix(1_775_240_000, 0).UTC(),
},
},
{
name: "valid with actor and user",
record: BlockedEmailSubject{
Email: common.Email("pilot@example.com"),
ReasonCode: common.ReasonCode("policy_blocked"),
BlockedAt: time.Unix(1_775_240_000, 0).UTC(),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ResolvedUserID: common.UserID("user-123"),
},
},
{
name: "missing blocked at",
record: BlockedEmailSubject{
Email: common.Email("pilot@example.com"),
ReasonCode: common.ReasonCode("policy_blocked"),
},
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.record.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
+338
View File
@@ -0,0 +1,338 @@
// Package common defines shared value objects used across the user-service
// domain model.
package common
import (
"errors"
"fmt"
"net/mail"
"strings"
"time"
)
const (
maxRaceNameLength = 64
maxLanguageTagLength = 32
maxTimeZoneNameLength = 128
)
// UserID identifies one regular-platform user owned by User Service.
type UserID string
// String returns UserID as its stored identifier string.
func (id UserID) String() string {
return string(id)
}
// IsZero reports whether UserID does not contain a usable identifier.
func (id UserID) IsZero() bool {
return strings.TrimSpace(string(id)) == ""
}
// Validate reports whether UserID is non-empty, normalized, and uses the
// frozen Stage 02 prefix.
func (id UserID) Validate() error {
return validatePrefixedToken("user id", string(id), "user-")
}
// Email stores one normalized user-login e-mail address.
type Email string
// String returns Email as its stored canonical string.
func (email Email) String() string {
return string(email)
}
// IsZero reports whether Email does not contain a usable address.
func (email Email) IsZero() bool {
return strings.TrimSpace(string(email)) == ""
}
// Validate reports whether Email is non-empty, trimmed, and matches the same
// single-address syntax expected by internal REST contracts.
func (email Email) Validate() error {
raw := string(email)
if err := validateToken("email", raw); err != nil {
return err
}
parsedAddress, err := mail.ParseAddress(raw)
if err != nil || parsedAddress.Name != "" || parsedAddress.Address != raw {
return fmt.Errorf("email %q must be a single valid email address", raw)
}
return nil
}
// RaceName stores one original-casing race name selected for the user
// account.
type RaceName string
// String returns RaceName as its stored value.
func (name RaceName) String() string {
return string(name)
}
// IsZero reports whether RaceName does not contain a usable value.
func (name RaceName) IsZero() bool {
return strings.TrimSpace(string(name)) == ""
}
// Validate reports whether RaceName is non-empty, trimmed, and within the
// frozen OpenAPI length bound.
func (name RaceName) Validate() error {
raw := string(name)
if err := validateToken("race name", raw); err != nil {
return err
}
if len(raw) > maxRaceNameLength {
return fmt.Errorf("race name must be at most %d bytes", maxRaceNameLength)
}
return nil
}
// LanguageTag stores one declared BCP 47 language-tag string.
type LanguageTag string
// String returns LanguageTag as its stored value.
func (tag LanguageTag) String() string {
return string(tag)
}
// IsZero reports whether LanguageTag does not contain a usable value.
func (tag LanguageTag) IsZero() bool {
return strings.TrimSpace(string(tag)) == ""
}
// Validate reports whether LanguageTag is non-empty, trimmed, and within the
// frozen OpenAPI length bound. Stage 02 intentionally freezes the storage
// shape and not the later boundary-level BCP 47 parser choice.
func (tag LanguageTag) Validate() error {
raw := string(tag)
if err := validateToken("language tag", raw); err != nil {
return err
}
if len(raw) > maxLanguageTagLength {
return fmt.Errorf("language tag must be at most %d bytes", maxLanguageTagLength)
}
return nil
}
// TimeZoneName stores one declared IANA time-zone name.
type TimeZoneName string
// String returns TimeZoneName as its stored value.
func (name TimeZoneName) String() string {
return string(name)
}
// IsZero reports whether TimeZoneName does not contain a usable value.
func (name TimeZoneName) IsZero() bool {
return strings.TrimSpace(string(name)) == ""
}
// Validate reports whether TimeZoneName is non-empty, trimmed, and within the
// frozen OpenAPI length bound. Later application stages may tighten
// boundary-level validation further.
func (name TimeZoneName) Validate() error {
raw := string(name)
if err := validateToken("time zone name", raw); err != nil {
return err
}
if len(raw) > maxTimeZoneNameLength {
return fmt.Errorf("time zone name must be at most %d bytes", maxTimeZoneNameLength)
}
return nil
}
// CountryCode stores one ISO 3166-1 alpha-2 code.
type CountryCode string
// String returns CountryCode as its stored value.
func (code CountryCode) String() string {
return string(code)
}
// IsZero reports whether CountryCode does not contain a usable value.
func (code CountryCode) IsZero() bool {
return strings.TrimSpace(string(code)) == ""
}
// Validate reports whether CountryCode is an uppercase ISO 3166-1 alpha-2
// code.
func (code CountryCode) Validate() error {
raw := string(code)
if len(raw) != 2 {
return fmt.Errorf("country code %q must contain exactly two letters", raw)
}
for idx := 0; idx < len(raw); idx++ {
if raw[idx] < 'A' || raw[idx] > 'Z' {
return fmt.Errorf("country code %q must contain only uppercase ASCII letters", raw)
}
}
return nil
}
// ActorType stores one machine-readable actor type for audit metadata.
type ActorType string
// String returns ActorType as its stored value.
func (actorType ActorType) String() string {
return string(actorType)
}
// IsZero reports whether ActorType does not contain a usable value.
func (actorType ActorType) IsZero() bool {
return strings.TrimSpace(string(actorType)) == ""
}
// Validate reports whether ActorType is non-empty and trimmed.
func (actorType ActorType) Validate() error {
return validateToken("actor type", string(actorType))
}
// ActorID stores one optional stable actor identifier.
type ActorID string
// String returns ActorID as its stored value.
func (actorID ActorID) String() string {
return string(actorID)
}
// IsZero reports whether ActorID does not contain a usable value.
func (actorID ActorID) IsZero() bool {
return strings.TrimSpace(string(actorID)) == ""
}
// Validate reports whether ActorID is trimmed when present.
func (actorID ActorID) Validate() error {
if actorID.IsZero() {
return nil
}
return validateToken("actor id", string(actorID))
}
// ActorRef stores actor metadata captured on trusted mutations.
type ActorRef struct {
// Type identifies the machine-readable actor class such as `admin`,
// `service`, or `billing`.
Type ActorType
// ID stores the optional stable actor identifier.
ID ActorID
}
// IsZero reports whether ActorRef does not contain any audit actor metadata.
func (ref ActorRef) IsZero() bool {
return ref.Type.IsZero() && ref.ID.IsZero()
}
// Validate reports whether ActorRef contains a required type and an optional
// trimmed identifier.
func (ref ActorRef) Validate() error {
if err := ref.Type.Validate(); err != nil {
return fmt.Errorf("actor ref type: %w", err)
}
if err := ref.ID.Validate(); err != nil {
return fmt.Errorf("actor ref id: %w", err)
}
return nil
}
// ReasonCode stores one machine-readable reason code.
type ReasonCode string
// String returns ReasonCode as its stored value.
func (code ReasonCode) String() string {
return string(code)
}
// IsZero reports whether ReasonCode does not contain a usable value.
func (code ReasonCode) IsZero() bool {
return strings.TrimSpace(string(code)) == ""
}
// Validate reports whether ReasonCode is non-empty and trimmed.
func (code ReasonCode) Validate() error {
return validateToken("reason code", string(code))
}
// Source stores one machine-readable mutation source.
type Source string
// String returns Source as its stored value.
func (source Source) String() string {
return string(source)
}
// IsZero reports whether Source does not contain a usable value.
func (source Source) IsZero() bool {
return strings.TrimSpace(string(source)) == ""
}
// Validate reports whether Source is non-empty and trimmed.
func (source Source) Validate() error {
return validateToken("source", string(source))
}
// Scope stores one machine-readable sanction scope.
type Scope string
// String returns Scope as its stored value.
func (scope Scope) String() string {
return string(scope)
}
// IsZero reports whether Scope does not contain a usable value.
func (scope Scope) IsZero() bool {
return strings.TrimSpace(string(scope)) == ""
}
// Validate reports whether Scope is non-empty and trimmed.
func (scope Scope) Validate() error {
return validateToken("scope", string(scope))
}
// ValidateTimestamp reports whether value is set.
func ValidateTimestamp(name string, value time.Time) error {
if value.IsZero() {
return fmt.Errorf("%s must not be zero", name)
}
return nil
}
func validateToken(name string, value string) error {
switch {
case strings.TrimSpace(value) == "":
return fmt.Errorf("%s must not be empty", name)
case strings.TrimSpace(value) != value:
return fmt.Errorf("%s must not contain surrounding whitespace", name)
default:
return nil
}
}
func validatePrefixedToken(name string, value string, prefix string) error {
if err := validateToken(name, value); err != nil {
return err
}
if !strings.HasPrefix(value, prefix) {
return fmt.Errorf("%s must start with %q", name, prefix)
}
if len(value) == len(prefix) {
return fmt.Errorf("%s must contain opaque data after %q", name, prefix)
}
return nil
}
// ErrInvertedTimeRange reports that the logical end of a range is not after
// its start.
var ErrInvertedTimeRange = errors.New("time range end must be after start")
+207
View File
@@ -0,0 +1,207 @@
package common
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestUserIDValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value UserID
wantErr bool
}{
{name: "valid", value: UserID("user-abc123")},
{name: "empty", value: UserID(""), wantErr: true},
{name: "surrounding whitespace", value: UserID(" user-abc123 "), wantErr: true},
{name: "wrong prefix", value: UserID("account-abc123"), wantErr: true},
{name: "prefix only", value: UserID("user-"), wantErr: true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.value.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestEmailValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value Email
wantErr bool
}{
{name: "valid", value: Email("pilot@example.com")},
{name: "empty", value: Email(""), wantErr: true},
{name: "display name", value: Email("Pilot <pilot@example.com>"), wantErr: true},
{name: "invalid", value: Email("not-an-email"), wantErr: true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.value.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestRaceNameValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value RaceName
wantErr bool
}{
{name: "valid", value: RaceName("Admiral Nova")},
{name: "empty", value: RaceName(""), wantErr: true},
{name: "too long", value: RaceName("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmn"), wantErr: true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.value.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestLanguageTagValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value LanguageTag
wantErr bool
}{
{name: "valid", value: LanguageTag("en-US")},
{name: "empty", value: LanguageTag(""), wantErr: true},
{name: "surrounding whitespace", value: LanguageTag(" en "), wantErr: true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.value.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestTimeZoneNameValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value TimeZoneName
wantErr bool
}{
{name: "valid", value: TimeZoneName("Europe/Berlin")},
{name: "empty", value: TimeZoneName(""), wantErr: true},
{name: "surrounding whitespace", value: TimeZoneName(" UTC "), wantErr: true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.value.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestCountryCodeValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value CountryCode
wantErr bool
}{
{name: "valid", value: CountryCode("DE")},
{name: "lowercase", value: CountryCode("de"), wantErr: true},
{name: "wrong length", value: CountryCode("DEU"), wantErr: true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.value.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestActorRefValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value ActorRef
wantErr bool
}{
{name: "valid without id", value: ActorRef{Type: ActorType("service")}},
{name: "valid with id", value: ActorRef{Type: ActorType("admin"), ID: ActorID("admin-1")}},
{name: "missing type", value: ActorRef{ID: ActorID("admin-1")}, wantErr: true},
{name: "invalid id whitespace", value: ActorRef{Type: ActorType("admin"), ID: ActorID(" admin-1 ")}, wantErr: true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.value.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
+325
View File
@@ -0,0 +1,325 @@
// Package entitlement defines the logical entitlement entities owned by User
// Service.
package entitlement
import (
"fmt"
"strings"
"time"
"galaxy/user/internal/domain/common"
)
// PlanCode identifies one supported entitlement plan.
type PlanCode string
const (
// PlanCodeFree reports the free default entitlement.
PlanCodeFree PlanCode = "free"
// PlanCodePaidMonthly reports a finite monthly paid entitlement.
PlanCodePaidMonthly PlanCode = "paid_monthly"
// PlanCodePaidYearly reports a finite yearly paid entitlement.
PlanCodePaidYearly PlanCode = "paid_yearly"
// PlanCodePaidLifetime reports a non-expiring paid entitlement.
PlanCodePaidLifetime PlanCode = "paid_lifetime"
)
// IsKnown reports whether PlanCode belongs to the frozen v1 catalog.
func (code PlanCode) IsKnown() bool {
switch code {
case PlanCodeFree, PlanCodePaidMonthly, PlanCodePaidYearly, PlanCodePaidLifetime:
return true
default:
return false
}
}
// IsPaid reports whether PlanCode represents a paid entitlement state.
func (code PlanCode) IsPaid() bool {
switch code {
case PlanCodePaidMonthly, PlanCodePaidYearly, PlanCodePaidLifetime:
return true
default:
return false
}
}
// HasFiniteExpiry reports whether PlanCode requires a bounded `ends_at`
// value in the Stage 07 entitlement timeline model.
func (code PlanCode) HasFiniteExpiry() bool {
switch code {
case PlanCodePaidMonthly, PlanCodePaidYearly:
return true
default:
return false
}
}
// EntitlementRecordID identifies one immutable entitlement history record.
type EntitlementRecordID string
// String returns EntitlementRecordID as its stored identifier string.
func (id EntitlementRecordID) String() string {
return string(id)
}
// IsZero reports whether EntitlementRecordID does not contain a usable value.
func (id EntitlementRecordID) IsZero() bool {
return strings.TrimSpace(string(id)) == ""
}
// Validate reports whether EntitlementRecordID is non-empty, normalized, and
// uses the frozen Stage 02 prefix.
func (id EntitlementRecordID) Validate() error {
switch {
case id.IsZero():
return fmt.Errorf("entitlement record id must not be empty")
case strings.TrimSpace(string(id)) != string(id):
return fmt.Errorf("entitlement record id must not contain surrounding whitespace")
case !strings.HasPrefix(string(id), "entitlement-"):
return fmt.Errorf("entitlement record id must start with %q", "entitlement-")
case len(string(id)) == len("entitlement-"):
return fmt.Errorf("entitlement record id must contain opaque data after %q", "entitlement-")
default:
return nil
}
}
// PeriodRecord stores one entitlement-period history record.
type PeriodRecord struct {
// RecordID identifies the immutable history record.
RecordID EntitlementRecordID
// UserID identifies the account that owns the entitlement record.
UserID common.UserID
// PlanCode stores the effective plan for the recorded period.
PlanCode PlanCode
// Source stores the machine-readable mutation source.
Source common.Source
// Actor stores the audit actor metadata captured for the mutation.
Actor common.ActorRef
// ReasonCode stores the machine-readable reason for the mutation.
ReasonCode common.ReasonCode
// StartsAt stores when the period becomes effective.
StartsAt time.Time
// EndsAt stores the optional planned end of the period.
EndsAt *time.Time
// CreatedAt stores when the history record was created.
CreatedAt time.Time
// ClosedAt stores when the period was later closed early by another trusted
// mutation.
ClosedAt *time.Time
// ClosedBy stores optional audit actor metadata for the close mutation.
ClosedBy common.ActorRef
// ClosedReasonCode stores the reason for closing the period early.
ClosedReasonCode common.ReasonCode
}
// Validate reports whether PeriodRecord satisfies the frozen Stage 02
// structural invariants.
func (record PeriodRecord) Validate() error {
if err := record.RecordID.Validate(); err != nil {
return fmt.Errorf("entitlement period record id: %w", err)
}
if err := record.UserID.Validate(); err != nil {
return fmt.Errorf("entitlement period user id: %w", err)
}
if !record.PlanCode.IsKnown() {
return fmt.Errorf("entitlement period plan code %q is unsupported", record.PlanCode)
}
if err := record.Source.Validate(); err != nil {
return fmt.Errorf("entitlement period source: %w", err)
}
if err := record.Actor.Validate(); err != nil {
return fmt.Errorf("entitlement period actor: %w", err)
}
if err := record.ReasonCode.Validate(); err != nil {
return fmt.Errorf("entitlement period reason code: %w", err)
}
if err := common.ValidateTimestamp("entitlement period starts at", record.StartsAt); err != nil {
return err
}
if err := validatePlanBounds("entitlement period", record.PlanCode, record.StartsAt, record.EndsAt); err != nil {
return err
}
if err := common.ValidateTimestamp("entitlement period created at", record.CreatedAt); err != nil {
return err
}
if record.ClosedAt == nil {
if !record.ClosedBy.IsZero() {
return fmt.Errorf("entitlement period closed by must be empty when closed at is absent")
}
if !record.ClosedReasonCode.IsZero() {
return fmt.Errorf("entitlement period closed reason code must be empty when closed at is absent")
}
return nil
}
if record.ClosedAt.Before(record.StartsAt) {
return fmt.Errorf("entitlement period closed at must not be before starts at")
}
if record.EndsAt != nil && record.ClosedAt.After(*record.EndsAt) {
return fmt.Errorf("entitlement period closed at must not be after ends at")
}
if record.ClosedAt.Before(record.CreatedAt) {
return fmt.Errorf("entitlement period closed at must not be before created at")
}
if err := record.ClosedBy.Validate(); err != nil {
return fmt.Errorf("entitlement period closed by: %w", err)
}
if err := record.ClosedReasonCode.Validate(); err != nil {
return fmt.Errorf("entitlement period closed reason code: %w", err)
}
return nil
}
// IsEffectiveAt reports whether PeriodRecord is the currently effective
// segment at the supplied timestamp.
func (record PeriodRecord) IsEffectiveAt(now time.Time) bool {
if record.ClosedAt != nil {
return false
}
if record.StartsAt.After(now) {
return false
}
if record.EndsAt != nil && !record.EndsAt.After(now) {
return false
}
return true
}
// CurrentSnapshot stores the read-optimized current entitlement state of one
// user account.
type CurrentSnapshot struct {
// UserID identifies the account that owns the current entitlement.
UserID common.UserID
// PlanCode stores the current effective plan code.
PlanCode PlanCode
// IsPaid stores the materialized paid/free state used on hot read paths.
IsPaid bool
// StartsAt stores when the current effective state started.
StartsAt time.Time
// EndsAt stores the optional end of the current finite entitlement.
EndsAt *time.Time
// Source stores the machine-readable source of the current state.
Source common.Source
// Actor stores the actor metadata attached to the last successful mutation.
Actor common.ActorRef
// ReasonCode stores the machine-readable reason attached to the last
// successful mutation.
ReasonCode common.ReasonCode
// UpdatedAt stores when the snapshot was last recomputed.
UpdatedAt time.Time
}
// Validate reports whether CurrentSnapshot satisfies the frozen Stage 02
// structural invariants.
func (record CurrentSnapshot) Validate() error {
if err := record.UserID.Validate(); err != nil {
return fmt.Errorf("entitlement snapshot user id: %w", err)
}
if !record.PlanCode.IsKnown() {
return fmt.Errorf("entitlement snapshot plan code %q is unsupported", record.PlanCode)
}
if record.IsPaid != record.PlanCode.IsPaid() {
return fmt.Errorf("entitlement snapshot paid flag must match plan code %q", record.PlanCode)
}
if err := common.ValidateTimestamp("entitlement snapshot starts at", record.StartsAt); err != nil {
return err
}
if err := validatePlanBounds("entitlement snapshot", record.PlanCode, record.StartsAt, record.EndsAt); err != nil {
return err
}
if err := record.Source.Validate(); err != nil {
return fmt.Errorf("entitlement snapshot source: %w", err)
}
if err := record.Actor.Validate(); err != nil {
return fmt.Errorf("entitlement snapshot actor: %w", err)
}
if err := record.ReasonCode.Validate(); err != nil {
return fmt.Errorf("entitlement snapshot reason code: %w", err)
}
if err := common.ValidateTimestamp("entitlement snapshot updated at", record.UpdatedAt); err != nil {
return err
}
return nil
}
// HasFiniteExpiry reports whether CurrentSnapshot participates in the finite
// paid-expiry index.
func (record CurrentSnapshot) HasFiniteExpiry() bool {
return record.IsPaid && record.EndsAt != nil
}
// IsExpiredAt reports whether CurrentSnapshot represents a finite paid state
// that has already reached its stored expiry.
func (record CurrentSnapshot) IsExpiredAt(now time.Time) bool {
return record.HasFiniteExpiry() && !record.EndsAt.After(now)
}
// PaidState identifies the coarse free-versus-paid filter used by admin
// listing.
type PaidState string
const (
// PaidStateFree filters accounts whose current entitlement is free.
PaidStateFree PaidState = "free"
// PaidStatePaid filters accounts whose current entitlement is paid.
PaidStatePaid PaidState = "paid"
)
// IsKnown reports whether PaidState belongs to the frozen Stage 02 filter
// vocabulary.
func (state PaidState) IsKnown() bool {
switch state {
case "", PaidStateFree, PaidStatePaid:
return true
default:
return false
}
}
func validatePlanBounds(
name string,
planCode PlanCode,
startsAt time.Time,
endsAt *time.Time,
) error {
switch {
case planCode.HasFiniteExpiry():
if endsAt == nil {
return fmt.Errorf("%s ends at must be present for plan code %q", name, planCode)
}
if !endsAt.After(startsAt) {
return common.ErrInvertedTimeRange
}
case endsAt != nil:
return fmt.Errorf("%s ends at must be empty for plan code %q", name, planCode)
}
return nil
}
@@ -0,0 +1,159 @@
package entitlement
import (
"testing"
"time"
"galaxy/user/internal/domain/common"
"github.com/stretchr/testify/require"
)
func TestPeriodRecordValidate(t *testing.T) {
t.Parallel()
startsAt := time.Unix(1_775_240_000, 0).UTC()
endsAt := startsAt.Add(30 * 24 * time.Hour)
createdAt := startsAt.Add(-time.Hour)
closedAt := startsAt.Add(12 * time.Hour)
tests := []struct {
name string
record PeriodRecord
wantErr bool
}{
{
name: "valid open record",
record: PeriodRecord{
RecordID: EntitlementRecordID("entitlement-123"),
UserID: common.UserID("user-123"),
PlanCode: PlanCodePaidMonthly,
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_grant"),
StartsAt: startsAt,
EndsAt: &endsAt,
CreatedAt: createdAt,
},
},
{
name: "valid closed record",
record: PeriodRecord{
RecordID: EntitlementRecordID("entitlement-123"),
UserID: common.UserID("user-123"),
PlanCode: PlanCodePaidMonthly,
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_grant"),
StartsAt: startsAt,
EndsAt: &endsAt,
CreatedAt: createdAt,
ClosedAt: &closedAt,
ClosedBy: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")},
ClosedReasonCode: common.ReasonCode("manual_revoke"),
},
},
{
name: "close metadata without closed at",
record: PeriodRecord{
RecordID: EntitlementRecordID("entitlement-123"),
UserID: common.UserID("user-123"),
PlanCode: PlanCodePaidMonthly,
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_grant"),
StartsAt: startsAt,
CreatedAt: createdAt,
ClosedReasonCode: common.ReasonCode("manual_revoke"),
},
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.record.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestCurrentSnapshotValidate(t *testing.T) {
t.Parallel()
startsAt := time.Unix(1_775_240_000, 0).UTC()
endsAt := startsAt.Add(30 * 24 * time.Hour)
updatedAt := startsAt.Add(2 * time.Hour)
tests := []struct {
name string
record CurrentSnapshot
wantErr bool
wantFinite bool
}{
{
name: "valid finite paid snapshot",
record: CurrentSnapshot{
UserID: common.UserID("user-123"),
PlanCode: PlanCodePaidMonthly,
IsPaid: true,
StartsAt: startsAt,
EndsAt: &endsAt,
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_grant"),
UpdatedAt: updatedAt,
},
wantFinite: true,
},
{
name: "valid free snapshot",
record: CurrentSnapshot{
UserID: common.UserID("user-123"),
PlanCode: PlanCodeFree,
IsPaid: false,
StartsAt: startsAt,
Source: common.Source("system"),
Actor: common.ActorRef{Type: common.ActorType("service")},
ReasonCode: common.ReasonCode("default_free_plan"),
UpdatedAt: updatedAt,
},
},
{
name: "paid flag mismatch",
record: CurrentSnapshot{
UserID: common.UserID("user-123"),
PlanCode: PlanCodeFree,
IsPaid: true,
StartsAt: startsAt,
Source: common.Source("system"),
Actor: common.ActorRef{Type: common.ActorType("service")},
ReasonCode: common.ReasonCode("default_free_plan"),
UpdatedAt: updatedAt,
},
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.record.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tt.wantFinite, tt.record.HasFiniteExpiry())
})
}
}
+511
View File
@@ -0,0 +1,511 @@
// Package policy defines sanction, limit, and eligibility-domain entities used
// by User Service.
package policy
import (
"fmt"
"slices"
"strings"
"time"
"galaxy/user/internal/domain/common"
)
// SanctionCode identifies one supported sanction in the v1 policy catalog.
type SanctionCode string
const (
// SanctionCodeLoginBlock denies login.
SanctionCodeLoginBlock SanctionCode = "login_block"
// SanctionCodePrivateGameCreateBlock denies private-game creation.
SanctionCodePrivateGameCreateBlock SanctionCode = "private_game_create_block"
// SanctionCodePrivateGameManageBlock denies private-game management.
SanctionCodePrivateGameManageBlock SanctionCode = "private_game_manage_block"
// SanctionCodeGameJoinBlock denies game joining.
SanctionCodeGameJoinBlock SanctionCode = "game_join_block"
// SanctionCodeProfileUpdateBlock denies self-service profile/settings
// mutations.
SanctionCodeProfileUpdateBlock SanctionCode = "profile_update_block"
)
// IsKnown reports whether SanctionCode belongs to the frozen v1 catalog.
func (code SanctionCode) IsKnown() bool {
switch code {
case SanctionCodeLoginBlock,
SanctionCodePrivateGameCreateBlock,
SanctionCodePrivateGameManageBlock,
SanctionCodeGameJoinBlock,
SanctionCodeProfileUpdateBlock:
return true
default:
return false
}
}
// LimitCode identifies one user-specific limit code recognized by User
// Service.
type LimitCode string
const (
// LimitCodeMaxOwnedPrivateGames limits how many private games the user may
// own while the current entitlement is paid.
LimitCodeMaxOwnedPrivateGames LimitCode = "max_owned_private_games"
// LimitCodeMaxPendingPublicApplications stores the total public-games budget
// consumed together with current active public memberships when Game Lobby
// derives remaining pending application headroom.
LimitCodeMaxPendingPublicApplications LimitCode = "max_pending_public_applications"
// LimitCodeMaxActiveGameMemberships limits how many active public-game
// memberships the user may hold at once.
LimitCodeMaxActiveGameMemberships LimitCode = "max_active_game_memberships"
)
const (
// LimitCodeMaxActivePrivateGames is a retired legacy code recognized only
// so old stored records do not break current reads.
LimitCodeMaxActivePrivateGames LimitCode = "max_active_private_games"
// LimitCodeMaxPendingPrivateJoinRequests is a retired legacy code
// recognized only so old stored records do not break current reads.
LimitCodeMaxPendingPrivateJoinRequests LimitCode = "max_pending_private_join_requests"
// LimitCodeMaxPendingPrivateInvitesSent is a retired legacy code
// recognized only so old stored records do not break current reads.
LimitCodeMaxPendingPrivateInvitesSent LimitCode = "max_pending_private_invites_sent"
)
// IsKnown reports whether LimitCode belongs to the current supported write/API
// catalog.
func (code LimitCode) IsKnown() bool {
return code.IsSupported()
}
// IsSupported reports whether LimitCode belongs to the current supported
// write/API catalog.
func (code LimitCode) IsSupported() bool {
switch code {
case LimitCodeMaxOwnedPrivateGames,
LimitCodeMaxPendingPublicApplications,
LimitCodeMaxActiveGameMemberships:
return true
default:
return false
}
}
// IsRetired reports whether LimitCode is a retired legacy code recognized
// only for read compatibility with already stored history records.
func (code LimitCode) IsRetired() bool {
switch code {
case LimitCodeMaxActivePrivateGames,
LimitCodeMaxPendingPrivateJoinRequests,
LimitCodeMaxPendingPrivateInvitesSent:
return true
default:
return false
}
}
// IsRecognized reports whether LimitCode is either currently supported or
// retired-but-recognized for read compatibility.
func (code LimitCode) IsRecognized() bool {
return code.IsSupported() || code.IsRetired()
}
// EligibilityMarker identifies one derived eligibility boolean that may be
// indexed for admin listing.
type EligibilityMarker string
const (
// EligibilityMarkerCanLogin tracks whether the user may currently log in.
EligibilityMarkerCanLogin EligibilityMarker = "can_login"
// EligibilityMarkerCanCreatePrivateGame tracks whether the user may create
// a private game.
EligibilityMarkerCanCreatePrivateGame EligibilityMarker = "can_create_private_game"
// EligibilityMarkerCanManagePrivateGame tracks whether the user may manage
// a private game.
EligibilityMarkerCanManagePrivateGame EligibilityMarker = "can_manage_private_game"
// EligibilityMarkerCanJoinGame tracks whether the user may join a game.
EligibilityMarkerCanJoinGame EligibilityMarker = "can_join_game"
// EligibilityMarkerCanUpdateProfile tracks whether the user may update
// self-service profile/settings fields.
EligibilityMarkerCanUpdateProfile EligibilityMarker = "can_update_profile"
)
// IsKnown reports whether EligibilityMarker belongs to the frozen v1 set.
func (marker EligibilityMarker) IsKnown() bool {
switch marker {
case EligibilityMarkerCanLogin,
EligibilityMarkerCanCreatePrivateGame,
EligibilityMarkerCanManagePrivateGame,
EligibilityMarkerCanJoinGame,
EligibilityMarkerCanUpdateProfile:
return true
default:
return false
}
}
// SanctionRecordID identifies one sanction history record.
type SanctionRecordID string
// String returns SanctionRecordID as its stored identifier string.
func (id SanctionRecordID) String() string {
return string(id)
}
// IsZero reports whether SanctionRecordID does not contain a usable value.
func (id SanctionRecordID) IsZero() bool {
return strings.TrimSpace(string(id)) == ""
}
// Validate reports whether SanctionRecordID is non-empty, normalized, and
// uses the frozen Stage 02 prefix.
func (id SanctionRecordID) Validate() error {
return validatePrefixedRecordID("sanction record id", string(id), "sanction-")
}
// LimitRecordID identifies one limit history record.
type LimitRecordID string
// String returns LimitRecordID as its stored identifier string.
func (id LimitRecordID) String() string {
return string(id)
}
// IsZero reports whether LimitRecordID does not contain a usable value.
func (id LimitRecordID) IsZero() bool {
return strings.TrimSpace(string(id)) == ""
}
// Validate reports whether LimitRecordID is non-empty, normalized, and uses
// the frozen Stage 02 prefix.
func (id LimitRecordID) Validate() error {
return validatePrefixedRecordID("limit record id", string(id), "limit-")
}
// SanctionRecord stores one sanction history record.
type SanctionRecord struct {
// RecordID identifies the sanction history record.
RecordID SanctionRecordID
// UserID identifies the account that owns the sanction.
UserID common.UserID
// SanctionCode stores the sanction applied to the account.
SanctionCode SanctionCode
// Scope stores the machine-readable scope attached to the sanction.
Scope common.Scope
// ReasonCode stores the reason for the sanction mutation.
ReasonCode common.ReasonCode
// Actor stores the audit actor metadata for the apply mutation.
Actor common.ActorRef
// AppliedAt stores when the sanction becomes effective.
AppliedAt time.Time
// ExpiresAt stores the optional planned expiry of the sanction.
ExpiresAt *time.Time
// RemovedAt stores when the sanction was later removed explicitly.
RemovedAt *time.Time
// RemovedBy stores the audit actor metadata for the remove mutation.
RemovedBy common.ActorRef
// RemovedReasonCode stores the reason for the remove mutation.
RemovedReasonCode common.ReasonCode
}
// Validate reports whether SanctionRecord satisfies the frozen structural
// invariants that do not depend on a caller-supplied clock.
func (record SanctionRecord) Validate() error {
if err := record.RecordID.Validate(); err != nil {
return fmt.Errorf("sanction record id: %w", err)
}
if err := record.UserID.Validate(); err != nil {
return fmt.Errorf("sanction user id: %w", err)
}
if !record.SanctionCode.IsKnown() {
return fmt.Errorf("sanction code %q is unsupported", record.SanctionCode)
}
if err := record.Scope.Validate(); err != nil {
return fmt.Errorf("sanction scope: %w", err)
}
if err := record.ReasonCode.Validate(); err != nil {
return fmt.Errorf("sanction reason code: %w", err)
}
if err := record.Actor.Validate(); err != nil {
return fmt.Errorf("sanction actor: %w", err)
}
if err := common.ValidateTimestamp("sanction applied at", record.AppliedAt); err != nil {
return err
}
if record.ExpiresAt != nil && !record.ExpiresAt.After(record.AppliedAt) {
return common.ErrInvertedTimeRange
}
if record.RemovedAt == nil {
if !record.RemovedBy.IsZero() {
return fmt.Errorf("sanction removed by must be empty when removed at is absent")
}
if !record.RemovedReasonCode.IsZero() {
return fmt.Errorf("sanction removed reason code must be empty when removed at is absent")
}
return nil
}
if record.RemovedAt.Before(record.AppliedAt) {
return fmt.Errorf("sanction removed at must not be before applied at")
}
if err := record.RemovedBy.Validate(); err != nil {
return fmt.Errorf("sanction removed by: %w", err)
}
if err := record.RemovedReasonCode.Validate(); err != nil {
return fmt.Errorf("sanction removed reason code: %w", err)
}
return nil
}
// ValidateAt reports whether SanctionRecord also satisfies the current-time
// Stage 02 invariant that `applied_at` must not be in the future.
func (record SanctionRecord) ValidateAt(now time.Time) error {
if err := record.Validate(); err != nil {
return err
}
if now.IsZero() {
return fmt.Errorf("sanction validation time must not be zero")
}
if record.AppliedAt.After(now.UTC()) {
return fmt.Errorf("sanction applied at must not be in the future")
}
return nil
}
// IsActiveAt reports whether SanctionRecord is active at now according to the
// frozen Stage 02 rules.
func (record SanctionRecord) IsActiveAt(now time.Time) bool {
now = now.UTC()
switch {
case now.IsZero():
return false
case record.AppliedAt.After(now):
return false
case record.RemovedAt != nil:
return false
case record.ExpiresAt != nil && !record.ExpiresAt.After(now):
return false
default:
return true
}
}
// LimitRecord stores one user-specific limit history record.
type LimitRecord struct {
// RecordID identifies the limit history record.
RecordID LimitRecordID
// UserID identifies the account that owns the limit.
UserID common.UserID
// LimitCode stores which count-based limit is overridden.
LimitCode LimitCode
// Value stores the override value.
Value int
// ReasonCode stores the reason for the limit mutation.
ReasonCode common.ReasonCode
// Actor stores the audit actor metadata for the set mutation.
Actor common.ActorRef
// AppliedAt stores when the limit becomes effective.
AppliedAt time.Time
// ExpiresAt stores the optional planned expiry of the limit.
ExpiresAt *time.Time
// RemovedAt stores when the limit was later removed explicitly.
RemovedAt *time.Time
// RemovedBy stores the audit actor metadata for the remove mutation.
RemovedBy common.ActorRef
// RemovedReasonCode stores the reason for the remove mutation.
RemovedReasonCode common.ReasonCode
}
// Validate reports whether LimitRecord satisfies the structural invariants
// that do not depend on a caller-supplied clock. Retired legacy limit codes
// remain recognized here so already stored records still decode safely.
func (record LimitRecord) Validate() error {
if err := record.RecordID.Validate(); err != nil {
return fmt.Errorf("limit record id: %w", err)
}
if err := record.UserID.Validate(); err != nil {
return fmt.Errorf("limit user id: %w", err)
}
if !record.LimitCode.IsRecognized() {
return fmt.Errorf("limit code %q is unsupported", record.LimitCode)
}
if record.Value < 0 {
return fmt.Errorf("limit value must not be negative")
}
if err := record.ReasonCode.Validate(); err != nil {
return fmt.Errorf("limit reason code: %w", err)
}
if err := record.Actor.Validate(); err != nil {
return fmt.Errorf("limit actor: %w", err)
}
if err := common.ValidateTimestamp("limit applied at", record.AppliedAt); err != nil {
return err
}
if record.ExpiresAt != nil && !record.ExpiresAt.After(record.AppliedAt) {
return common.ErrInvertedTimeRange
}
if record.RemovedAt == nil {
if !record.RemovedBy.IsZero() {
return fmt.Errorf("limit removed by must be empty when removed at is absent")
}
if !record.RemovedReasonCode.IsZero() {
return fmt.Errorf("limit removed reason code must be empty when removed at is absent")
}
return nil
}
if record.RemovedAt.Before(record.AppliedAt) {
return fmt.Errorf("limit removed at must not be before applied at")
}
if err := record.RemovedBy.Validate(); err != nil {
return fmt.Errorf("limit removed by: %w", err)
}
if err := record.RemovedReasonCode.Validate(); err != nil {
return fmt.Errorf("limit removed reason code: %w", err)
}
return nil
}
// ValidateAt reports whether LimitRecord also satisfies the current-time Stage
// 02 invariant that `applied_at` must not be in the future.
func (record LimitRecord) ValidateAt(now time.Time) error {
if err := record.Validate(); err != nil {
return err
}
if now.IsZero() {
return fmt.Errorf("limit validation time must not be zero")
}
if record.AppliedAt.After(now.UTC()) {
return fmt.Errorf("limit applied at must not be in the future")
}
return nil
}
// IsActiveAt reports whether LimitRecord is active at now according to the
// frozen Stage 02 rules.
func (record LimitRecord) IsActiveAt(now time.Time) bool {
now = now.UTC()
switch {
case now.IsZero():
return false
case record.AppliedAt.After(now):
return false
case record.RemovedAt != nil:
return false
case record.ExpiresAt != nil && !record.ExpiresAt.After(now):
return false
default:
return true
}
}
// ActiveSanctionsAt returns the active sanctions at now, sorted
// deterministically by `sanction_code`. The function returns an error when the
// input contains structurally invalid records or more than one active sanction
// for the same `user_id + sanction_code`.
func ActiveSanctionsAt(records []SanctionRecord, now time.Time) ([]SanctionRecord, error) {
active := make([]SanctionRecord, 0, len(records))
seen := make(map[SanctionCode]struct{}, len(records))
for _, record := range records {
if err := record.ValidateAt(now); err != nil {
return nil, err
}
if !record.IsActiveAt(now) {
continue
}
if _, ok := seen[record.SanctionCode]; ok {
return nil, fmt.Errorf("multiple active sanctions for code %q", record.SanctionCode)
}
seen[record.SanctionCode] = struct{}{}
active = append(active, record)
}
slices.SortFunc(active, func(left SanctionRecord, right SanctionRecord) int {
return strings.Compare(string(left.SanctionCode), string(right.SanctionCode))
})
return active, nil
}
// ActiveLimitsAt returns the active limits at now, sorted deterministically by
// `limit_code`. Retired legacy limit codes are ignored so historical records
// stored under the old catalog do not affect current effective reads. The
// function returns an error when the input contains structurally invalid
// records or more than one active current limit for the same
// `user_id + limit_code`.
func ActiveLimitsAt(records []LimitRecord, now time.Time) ([]LimitRecord, error) {
active := make([]LimitRecord, 0, len(records))
seen := make(map[LimitCode]struct{}, len(records))
for _, record := range records {
if err := record.ValidateAt(now); err != nil {
return nil, err
}
if !record.IsActiveAt(now) {
continue
}
if !record.LimitCode.IsSupported() {
continue
}
if _, ok := seen[record.LimitCode]; ok {
return nil, fmt.Errorf("multiple active limits for code %q", record.LimitCode)
}
seen[record.LimitCode] = struct{}{}
active = append(active, record)
}
slices.SortFunc(active, func(left LimitRecord, right LimitRecord) int {
return strings.Compare(string(left.LimitCode), string(right.LimitCode))
})
return active, nil
}
func validatePrefixedRecordID(name string, value string, prefix string) error {
switch {
case strings.TrimSpace(value) == "":
return fmt.Errorf("%s must not be empty", name)
case strings.TrimSpace(value) != value:
return fmt.Errorf("%s must not contain surrounding whitespace", name)
case !strings.HasPrefix(value, prefix):
return fmt.Errorf("%s must start with %q", name, prefix)
case len(value) == len(prefix):
return fmt.Errorf("%s must contain opaque data after %q", name, prefix)
default:
return nil
}
}
+236
View File
@@ -0,0 +1,236 @@
package policy
import (
"testing"
"time"
"galaxy/user/internal/domain/common"
"github.com/stretchr/testify/require"
)
func TestSanctionRecordValidateAt(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
expiresAt := now.Add(time.Hour)
removedAt := now.Add(30 * time.Minute)
tests := []struct {
name string
record SanctionRecord
wantErr bool
wantActive bool
}{
{
name: "active",
record: SanctionRecord{
RecordID: SanctionRecordID("sanction-1"),
UserID: common.UserID("user-123"),
SanctionCode: SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("policy_blocked"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(-time.Minute),
ExpiresAt: &expiresAt,
},
wantActive: true,
},
{
name: "expired",
record: SanctionRecord{
RecordID: SanctionRecordID("sanction-1"),
UserID: common.UserID("user-123"),
SanctionCode: SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("policy_blocked"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(-2 * time.Hour),
ExpiresAt: ptrTime(now.Add(-time.Minute)),
},
},
{
name: "removed",
record: SanctionRecord{
RecordID: SanctionRecordID("sanction-1"),
UserID: common.UserID("user-123"),
SanctionCode: SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("policy_blocked"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(-time.Hour),
RemovedAt: &removedAt,
RemovedBy: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")},
RemovedReasonCode: common.ReasonCode("manual_remove"),
},
},
{
name: "future applied at",
record: SanctionRecord{
RecordID: SanctionRecordID("sanction-1"),
UserID: common.UserID("user-123"),
SanctionCode: SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("policy_blocked"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(time.Minute),
},
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.record.ValidateAt(now)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tt.wantActive, tt.record.IsActiveAt(now))
})
}
}
func TestActiveSanctionsAt(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
records := []SanctionRecord{
{
RecordID: SanctionRecordID("sanction-1"),
UserID: common.UserID("user-123"),
SanctionCode: SanctionCodeProfileUpdateBlock,
Scope: common.Scope("profile"),
ReasonCode: common.ReasonCode("moderation"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(-time.Hour),
},
{
RecordID: SanctionRecordID("sanction-2"),
UserID: common.UserID("user-123"),
SanctionCode: SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("policy"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")},
AppliedAt: now.Add(-2 * time.Hour),
ExpiresAt: ptrTime(now.Add(-time.Minute)),
},
}
active, err := ActiveSanctionsAt(records, now)
require.NoError(t, err)
require.Len(t, active, 1)
require.Equal(t, SanctionCodeProfileUpdateBlock, active[0].SanctionCode)
}
func TestActiveSanctionsAtDuplicateActiveCode(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
_, err := ActiveSanctionsAt([]SanctionRecord{
{
RecordID: SanctionRecordID("sanction-1"),
UserID: common.UserID("user-123"),
SanctionCode: SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("policy"),
Actor: common.ActorRef{Type: common.ActorType("admin")},
AppliedAt: now.Add(-time.Hour),
},
{
RecordID: SanctionRecordID("sanction-2"),
UserID: common.UserID("user-123"),
SanctionCode: SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("policy"),
Actor: common.ActorRef{Type: common.ActorType("admin")},
AppliedAt: now.Add(-2 * time.Hour),
},
}, now)
require.Error(t, err)
}
func TestLimitRecordValidateAtAndActiveLimits(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
record := LimitRecord{
RecordID: LimitRecordID("limit-1"),
UserID: common.UserID("user-123"),
LimitCode: LimitCodeMaxOwnedPrivateGames,
Value: 3,
ReasonCode: common.ReasonCode("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(-time.Minute),
}
require.NoError(t, record.ValidateAt(now))
require.True(t, record.IsActiveAt(now))
active, err := ActiveLimitsAt([]LimitRecord{
record,
{
RecordID: LimitRecordID("limit-2"),
UserID: common.UserID("user-123"),
LimitCode: LimitCodeMaxActivePrivateGames,
Value: 7,
ReasonCode: common.ReasonCode("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin")},
AppliedAt: now.Add(-time.Hour),
},
}, now)
require.NoError(t, err)
require.Len(t, active, 1)
require.Equal(t, LimitCodeMaxOwnedPrivateGames, active[0].LimitCode)
}
func TestLimitCodeSupportAndRetiredRecognition(t *testing.T) {
t.Parallel()
require.True(t, LimitCodeMaxOwnedPrivateGames.IsSupported())
require.True(t, LimitCodeMaxPendingPublicApplications.IsSupported())
require.True(t, LimitCodeMaxActiveGameMemberships.IsSupported())
require.True(t, LimitCodeMaxActivePrivateGames.IsRetired())
require.True(t, LimitCodeMaxPendingPrivateJoinRequests.IsRetired())
require.True(t, LimitCodeMaxPendingPrivateInvitesSent.IsRetired())
require.True(t, LimitCodeMaxActivePrivateGames.IsRecognized())
require.False(t, LimitCode("unknown_limit").IsRecognized())
require.False(t, LimitCodeMaxActivePrivateGames.IsKnown())
}
func TestActiveLimitsAtDuplicateActiveCode(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
_, err := ActiveLimitsAt([]LimitRecord{
{
RecordID: LimitRecordID("limit-1"),
UserID: common.UserID("user-123"),
LimitCode: LimitCodeMaxOwnedPrivateGames,
Value: 2,
ReasonCode: common.ReasonCode("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin")},
AppliedAt: now.Add(-time.Hour),
},
{
RecordID: LimitRecordID("limit-2"),
UserID: common.UserID("user-123"),
LimitCode: LimitCodeMaxOwnedPrivateGames,
Value: 5,
ReasonCode: common.ReasonCode("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin")},
AppliedAt: now.Add(-2 * time.Hour),
},
}, now)
require.Error(t, err)
}
func ptrTime(value time.Time) *time.Time {
return &value
}
+43
View File
@@ -0,0 +1,43 @@
// Package logging configures the user-service process logger and provides
// context-aware helpers for attaching OpenTelemetry trace identifiers.
package logging
import (
"context"
"fmt"
"log/slog"
"os"
"strings"
"go.opentelemetry.io/otel/trace"
)
// New constructs the process-wide JSON logger from level.
func New(level string) (*slog.Logger, error) {
var slogLevel slog.Level
if err := slogLevel.UnmarshalText([]byte(strings.TrimSpace(level))); err != nil {
return nil, fmt.Errorf("build logger: %w", err)
}
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slogLevel,
})), nil
}
// TraceAttrsFromContext returns slog key-value pairs for the active
// OpenTelemetry span when ctx carries a valid span context.
func TraceAttrsFromContext(ctx context.Context) []any {
if ctx == nil {
return nil
}
spanContext := trace.SpanContextFromContext(ctx)
if !spanContext.IsValid() {
return nil
}
return []any{
"otel_trace_id", spanContext.TraceID().String(),
"otel_span_id", spanContext.SpanID().String(),
}
}
+130
View File
@@ -0,0 +1,130 @@
package ports
import (
"context"
"fmt"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
)
// CreateAccountInput stores the atomic account-create state that must commit
// together.
type CreateAccountInput struct {
// Account stores the durable user-account state.
Account account.UserAccount
// Reservation stores the canonical race-name reservation linked to Account.
Reservation account.RaceNameReservation
}
// Validate reports whether CreateAccountInput is structurally complete.
func (input CreateAccountInput) Validate() error {
if err := input.Account.Validate(); err != nil {
return fmt.Errorf("create account input account: %w", err)
}
if err := input.Reservation.Validate(); err != nil {
return fmt.Errorf("create account input reservation: %w", err)
}
if input.Account.UserID != input.Reservation.UserID {
return fmt.Errorf("create account input reservation user id must match account user id")
}
if input.Account.RaceName != input.Reservation.RaceName {
return fmt.Errorf("create account input reservation race name must match account race name")
}
return nil
}
// RenameRaceNameInput stores the atomic state required to replace one stored
// race name and its canonical reservation.
type RenameRaceNameInput struct {
// UserID identifies the account that must be updated.
UserID common.UserID
// CurrentCanonicalKey stores the currently owned canonical reservation key.
CurrentCanonicalKey account.RaceNameCanonicalKey
// NewRaceName stores the replacement exact stored race name.
NewRaceName common.RaceName
// NewReservation stores the replacement canonical reservation.
NewReservation account.RaceNameReservation
// UpdatedAt stores the account mutation timestamp.
UpdatedAt time.Time
}
// Validate reports whether RenameRaceNameInput is structurally complete.
func (input RenameRaceNameInput) Validate() error {
if err := input.UserID.Validate(); err != nil {
return fmt.Errorf("rename race name input user id: %w", err)
}
if err := input.CurrentCanonicalKey.Validate(); err != nil {
return fmt.Errorf("rename race name input current canonical key: %w", err)
}
if err := input.NewRaceName.Validate(); err != nil {
return fmt.Errorf("rename race name input race name: %w", err)
}
if err := input.NewReservation.Validate(); err != nil {
return fmt.Errorf("rename race name input reservation: %w", err)
}
if err := common.ValidateTimestamp("rename race name input updated at", input.UpdatedAt); err != nil {
return err
}
if input.NewReservation.UserID != input.UserID {
return fmt.Errorf("rename race name input reservation user id must match user id")
}
if input.NewReservation.RaceName != input.NewRaceName {
return fmt.Errorf("rename race name input reservation race name must match new race name")
}
return nil
}
// UserAccountStore persists source-of-truth user-account records and their
// exact lookup mappings.
type UserAccountStore interface {
// Create stores one new account record. Implementations must wrap
// ErrConflict when the user id, e-mail, or exact race-name lookup already
// exists.
Create(ctx context.Context, input CreateAccountInput) error
// GetByUserID returns the stored account identified by userID.
GetByUserID(ctx context.Context, userID common.UserID) (account.UserAccount, error)
// GetByEmail returns the stored account identified by the normalized e-mail
// address.
GetByEmail(ctx context.Context, email common.Email) (account.UserAccount, error)
// GetByRaceName returns the stored account identified by the exact stored
// race name.
GetByRaceName(ctx context.Context, raceName common.RaceName) (account.UserAccount, error)
// ExistsByUserID reports whether userID currently identifies a stored
// account.
ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error)
// RenameRaceName replaces the stored race name of userID and swaps the
// exact race-name lookup atomically. Implementations must wrap ErrConflict
// when newRaceName is already owned by another account.
RenameRaceName(ctx context.Context, input RenameRaceNameInput) error
// Update replaces the stored account state for record.UserID.
Update(ctx context.Context, record account.UserAccount) error
}
// RaceNameReservationStore persists source-of-truth race-name reservations.
type RaceNameReservationStore interface {
// Create stores one new race-name reservation keyed by its canonical
// uniqueness key. Implementations must wrap ErrConflict when the canonical
// key is already reserved.
Create(ctx context.Context, record account.RaceNameReservation) error
// GetByCanonicalKey returns the stored reservation identified by key.
GetByCanonicalKey(ctx context.Context, key account.RaceNameCanonicalKey) (account.RaceNameReservation, error)
// DeleteByCanonicalKey removes the reservation identified by key.
DeleteByCanonicalKey(ctx context.Context, key account.RaceNameCanonicalKey) error
}
+369
View File
@@ -0,0 +1,369 @@
package ports
import (
"context"
"fmt"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
)
// AuthResolutionKind identifies the coarse auth-facing resolution state of one
// e-mail subject.
type AuthResolutionKind string
const (
// AuthResolutionKindExisting reports that the e-mail belongs to an existing
// account.
AuthResolutionKindExisting AuthResolutionKind = "existing"
// AuthResolutionKindCreatable reports that the e-mail is not blocked and no
// account exists yet.
AuthResolutionKindCreatable AuthResolutionKind = "creatable"
// AuthResolutionKindBlocked reports that the e-mail subject is blocked.
AuthResolutionKindBlocked AuthResolutionKind = "blocked"
)
// IsKnown reports whether AuthResolutionKind belongs to the supported
// auth-facing vocabulary.
func (kind AuthResolutionKind) IsKnown() bool {
switch kind {
case AuthResolutionKindExisting, AuthResolutionKindCreatable, AuthResolutionKindBlocked:
return true
default:
return false
}
}
// ResolveByEmailResult stores the coarse auth-facing state of one e-mail
// subject.
type ResolveByEmailResult struct {
// Kind stores the coarse resolution state.
Kind AuthResolutionKind
// UserID is present only when Kind is AuthResolutionKindExisting.
UserID common.UserID
// BlockReasonCode is present only when Kind is AuthResolutionKindBlocked.
BlockReasonCode common.ReasonCode
}
// Validate reports whether ResolveByEmailResult satisfies the auth-facing
// invariant set.
func (result ResolveByEmailResult) Validate() error {
if !result.Kind.IsKnown() {
return fmt.Errorf("resolve-by-email result kind %q is unsupported", result.Kind)
}
switch result.Kind {
case AuthResolutionKindExisting:
if err := result.UserID.Validate(); err != nil {
return fmt.Errorf("resolve-by-email result user id: %w", err)
}
if !result.BlockReasonCode.IsZero() {
return fmt.Errorf("resolve-by-email result block reason code must be empty for existing outcome")
}
case AuthResolutionKindCreatable:
if !result.UserID.IsZero() {
return fmt.Errorf("resolve-by-email result user id must be empty for creatable outcome")
}
if !result.BlockReasonCode.IsZero() {
return fmt.Errorf("resolve-by-email result block reason code must be empty for creatable outcome")
}
case AuthResolutionKindBlocked:
if !result.UserID.IsZero() {
return fmt.Errorf("resolve-by-email result user id must be empty for blocked outcome")
}
if err := result.BlockReasonCode.Validate(); err != nil {
return fmt.Errorf("resolve-by-email result block reason code: %w", err)
}
}
return nil
}
// EnsureByEmailOutcome identifies the coarse auth-facing ensure result.
type EnsureByEmailOutcome string
const (
// EnsureByEmailOutcomeExisting reports that the e-mail already belongs to an
// existing account.
EnsureByEmailOutcomeExisting EnsureByEmailOutcome = "existing"
// EnsureByEmailOutcomeCreated reports that a new account was created.
EnsureByEmailOutcomeCreated EnsureByEmailOutcome = "created"
// EnsureByEmailOutcomeBlocked reports that creation or reuse is blocked by
// policy.
EnsureByEmailOutcomeBlocked EnsureByEmailOutcome = "blocked"
)
// IsKnown reports whether EnsureByEmailOutcome belongs to the supported
// auth-facing vocabulary.
func (outcome EnsureByEmailOutcome) IsKnown() bool {
switch outcome {
case EnsureByEmailOutcomeExisting, EnsureByEmailOutcomeCreated, EnsureByEmailOutcomeBlocked:
return true
default:
return false
}
}
// EnsureByEmailInput stores the complete create payload required for atomic
// ensure-by-email behavior.
type EnsureByEmailInput struct {
// Email stores the exact normalized e-mail subject addressed by the ensure
// call.
Email common.Email
// Account stores the fully initialized account that should be persisted when
// the e-mail does not yet exist and is not blocked.
Account account.UserAccount
// Entitlement stores the initial current entitlement snapshot for the new
// account.
Entitlement entitlement.CurrentSnapshot
// EntitlementRecord stores the initial entitlement history record that must
// be created atomically with Entitlement.
EntitlementRecord entitlement.PeriodRecord
// Reservation stores the canonical race-name reservation for Account.
Reservation account.RaceNameReservation
}
// Validate reports whether EnsureByEmailInput is structurally complete.
func (input EnsureByEmailInput) Validate() error {
if err := input.Email.Validate(); err != nil {
return fmt.Errorf("ensure-by-email input email: %w", err)
}
if err := input.Account.Validate(); err != nil {
return fmt.Errorf("ensure-by-email input account: %w", err)
}
if err := input.Entitlement.Validate(); err != nil {
return fmt.Errorf("ensure-by-email input entitlement snapshot: %w", err)
}
if err := input.EntitlementRecord.Validate(); err != nil {
return fmt.Errorf("ensure-by-email input entitlement record: %w", err)
}
if err := input.Reservation.Validate(); err != nil {
return fmt.Errorf("ensure-by-email input reservation: %w", err)
}
if input.Account.Email != input.Email {
return fmt.Errorf("ensure-by-email input account email must match request email")
}
if input.Account.UserID != input.Entitlement.UserID {
return fmt.Errorf("ensure-by-email input account user id must match entitlement user id")
}
if input.Account.UserID != input.EntitlementRecord.UserID {
return fmt.Errorf("ensure-by-email input account user id must match entitlement record user id")
}
if input.Account.UserID != input.Reservation.UserID {
return fmt.Errorf("ensure-by-email input account user id must match reservation user id")
}
if input.Account.RaceName != input.Reservation.RaceName {
return fmt.Errorf("ensure-by-email input account race name must match reservation race name")
}
if input.EntitlementRecord.PlanCode != input.Entitlement.PlanCode {
return fmt.Errorf("ensure-by-email input entitlement record plan code must match entitlement snapshot plan code")
}
if input.EntitlementRecord.Source != input.Entitlement.Source {
return fmt.Errorf("ensure-by-email input entitlement record source must match entitlement snapshot source")
}
if input.EntitlementRecord.Actor != input.Entitlement.Actor {
return fmt.Errorf("ensure-by-email input entitlement record actor must match entitlement snapshot actor")
}
if input.EntitlementRecord.ReasonCode != input.Entitlement.ReasonCode {
return fmt.Errorf("ensure-by-email input entitlement record reason code must match entitlement snapshot reason code")
}
if !input.EntitlementRecord.StartsAt.Equal(input.Entitlement.StartsAt) {
return fmt.Errorf("ensure-by-email input entitlement record starts at must match entitlement snapshot starts at")
}
if !equalOptionalTimes(input.EntitlementRecord.EndsAt, input.Entitlement.EndsAt) {
return fmt.Errorf("ensure-by-email input entitlement record ends at must match entitlement snapshot ends at")
}
return nil
}
// EnsureByEmailResult stores the coarse auth-facing outcome of an atomic
// ensure-by-email call.
type EnsureByEmailResult struct {
// Outcome stores the coarse ensure result.
Outcome EnsureByEmailOutcome
// UserID is present only for existing or created outcomes.
UserID common.UserID
// BlockReasonCode is present only for the blocked outcome.
BlockReasonCode common.ReasonCode
}
// Validate reports whether EnsureByEmailResult satisfies the auth-facing
// invariant set.
func (result EnsureByEmailResult) Validate() error {
if !result.Outcome.IsKnown() {
return fmt.Errorf("ensure-by-email result outcome %q is unsupported", result.Outcome)
}
switch result.Outcome {
case EnsureByEmailOutcomeExisting, EnsureByEmailOutcomeCreated:
if err := result.UserID.Validate(); err != nil {
return fmt.Errorf("ensure-by-email result user id: %w", err)
}
if !result.BlockReasonCode.IsZero() {
return fmt.Errorf("ensure-by-email result block reason code must be empty for existing or created outcome")
}
case EnsureByEmailOutcomeBlocked:
if !result.UserID.IsZero() {
return fmt.Errorf("ensure-by-email result user id must be empty for blocked outcome")
}
if err := result.BlockReasonCode.Validate(); err != nil {
return fmt.Errorf("ensure-by-email result block reason code: %w", err)
}
}
return nil
}
// AuthBlockOutcome identifies the coarse result of blocking one auth subject.
type AuthBlockOutcome string
const (
// AuthBlockOutcomeBlocked reports that the current mutation created a new
// block record.
AuthBlockOutcomeBlocked AuthBlockOutcome = "blocked"
// AuthBlockOutcomeAlreadyBlocked reports that the block already existed.
AuthBlockOutcomeAlreadyBlocked AuthBlockOutcome = "already_blocked"
)
// IsKnown reports whether AuthBlockOutcome belongs to the supported
// auth-facing vocabulary.
func (outcome AuthBlockOutcome) IsKnown() bool {
switch outcome {
case AuthBlockOutcomeBlocked, AuthBlockOutcomeAlreadyBlocked:
return true
default:
return false
}
}
// BlockByUserIDInput stores one auth-facing block request addressed by stable
// user identifier.
type BlockByUserIDInput struct {
// UserID identifies the account that must be blocked.
UserID common.UserID
// ReasonCode stores the machine-readable block reason.
ReasonCode common.ReasonCode
// BlockedAt stores the timestamp applied to the blocked e-mail subject
// record when a new block is created.
BlockedAt time.Time
}
// Validate reports whether BlockByUserIDInput is structurally complete.
func (input BlockByUserIDInput) Validate() error {
if err := input.UserID.Validate(); err != nil {
return fmt.Errorf("block-by-user-id input user id: %w", err)
}
if err := input.ReasonCode.Validate(); err != nil {
return fmt.Errorf("block-by-user-id input reason code: %w", err)
}
if err := common.ValidateTimestamp("block-by-user-id input blocked at", input.BlockedAt); err != nil {
return err
}
return nil
}
// BlockByEmailInput stores one auth-facing block request addressed by exact
// normalized e-mail subject.
type BlockByEmailInput struct {
// Email identifies the e-mail subject that must be blocked.
Email common.Email
// ReasonCode stores the machine-readable block reason.
ReasonCode common.ReasonCode
// BlockedAt stores the timestamp applied to the blocked e-mail subject
// record when a new block is created.
BlockedAt time.Time
}
// Validate reports whether BlockByEmailInput is structurally complete.
func (input BlockByEmailInput) Validate() error {
if err := input.Email.Validate(); err != nil {
return fmt.Errorf("block-by-email input email: %w", err)
}
if err := input.ReasonCode.Validate(); err != nil {
return fmt.Errorf("block-by-email input reason code: %w", err)
}
if err := common.ValidateTimestamp("block-by-email input blocked at", input.BlockedAt); err != nil {
return err
}
return nil
}
// BlockResult stores the coarse auth-facing result of a block mutation.
type BlockResult struct {
// Outcome reports whether a new block was applied or already existed.
Outcome AuthBlockOutcome
// UserID stores the resolved account when the blocked subject belongs to one
// existing user.
UserID common.UserID
}
// Validate reports whether BlockResult satisfies the auth-facing invariant
// set.
func (result BlockResult) Validate() error {
if !result.Outcome.IsKnown() {
return fmt.Errorf("block result outcome %q is unsupported", result.Outcome)
}
if !result.UserID.IsZero() {
if err := result.UserID.Validate(); err != nil {
return fmt.Errorf("block result user id: %w", err)
}
}
return nil
}
// AuthDirectoryStore performs the narrow set of atomic auth-facing reads and
// mutations that must not observe inconsistent cross-key Redis state.
type AuthDirectoryStore interface {
// ResolveByEmail returns the current coarse auth-facing resolution state for
// email.
ResolveByEmail(ctx context.Context, email common.Email) (ResolveByEmailResult, error)
// ExistsByUserID reports whether userID currently identifies a stored
// account.
ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error)
// EnsureByEmail returns an existing user, creates a new one, or reports a
// blocked outcome atomically for one e-mail subject.
EnsureByEmail(ctx context.Context, input EnsureByEmailInput) (EnsureByEmailResult, error)
// BlockByUserID applies a block to the account identified by userID.
BlockByUserID(ctx context.Context, input BlockByUserIDInput) (BlockResult, error)
// BlockByEmail applies a block to email even when no account exists yet.
BlockByEmail(ctx context.Context, input BlockByEmailInput) (BlockResult, error)
}
func equalOptionalTimes(left *time.Time, right *time.Time) bool {
switch {
case left == nil && right == nil:
return true
case left == nil || right == nil:
return false
default:
return left.Equal(*right)
}
}
+18
View File
@@ -0,0 +1,18 @@
package ports
import (
"context"
"galaxy/user/internal/domain/authblock"
"galaxy/user/internal/domain/common"
)
// BlockedEmailStore persists the dedicated blocked-email-subject model used by
// auth-facing flows.
type BlockedEmailStore interface {
// GetByEmail returns the blocked-email subject for email.
GetByEmail(ctx context.Context, email common.Email) (authblock.BlockedEmailSubject, error)
// Upsert stores or replaces the blocked-email subject for record.Email.
Upsert(ctx context.Context, record authblock.BlockedEmailSubject) error
}
+9
View File
@@ -0,0 +1,9 @@
package ports
import "time"
// Clock returns the current wall-clock time used by timestamped mutations.
type Clock interface {
// Now returns the current time.
Now() time.Time
}
@@ -0,0 +1,55 @@
package ports
import (
"context"
"fmt"
"time"
"galaxy/user/internal/domain/common"
)
const (
// DeclaredCountryChangedEventType identifies declared-country change events
// in the shared auxiliary event stream.
DeclaredCountryChangedEventType = "user.declared_country.changed"
)
// DeclaredCountryChangedEvent stores one auxiliary declared-country change
// notification emitted after a successful source-of-truth update.
type DeclaredCountryChangedEvent struct {
// UserID identifies the user whose current declared country changed.
UserID common.UserID
// TraceID stores the optional OpenTelemetry trace identifier propagated
// from the current request context.
TraceID string
// DeclaredCountry stores the latest effective declared country.
DeclaredCountry common.CountryCode
// UpdatedAt stores the persisted account mutation timestamp.
UpdatedAt time.Time
// Source stores the machine-readable upstream mutation source.
Source common.Source
}
// Validate reports whether event is structurally complete.
func (event DeclaredCountryChangedEvent) Validate() error {
if err := validateEventEnvelope("declared-country changed event", event.UserID, event.UpdatedAt, event.Source, event.TraceID); err != nil {
return err
}
if err := event.DeclaredCountry.Validate(); err != nil {
return fmt.Errorf("declared-country changed event declared country: %w", err)
}
return nil
}
// DeclaredCountryChangedPublisher publishes auxiliary declared-country change
// notifications after source-of-truth account updates.
type DeclaredCountryChangedPublisher interface {
// PublishDeclaredCountryChanged propagates one committed declared-country
// change event.
PublishDeclaredCountryChanged(ctx context.Context, event DeclaredCountryChangedEvent) error
}
@@ -0,0 +1,537 @@
package ports
import (
"context"
"fmt"
"strings"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
)
const (
// ProfileChangedEventType identifies profile-change events in the shared
// auxiliary event stream.
ProfileChangedEventType = "user.profile.changed"
// SettingsChangedEventType identifies settings-change events in the shared
// auxiliary event stream.
SettingsChangedEventType = "user.settings.changed"
// EntitlementChangedEventType identifies entitlement-change events in the
// shared auxiliary event stream.
EntitlementChangedEventType = "user.entitlement.changed"
// SanctionChangedEventType identifies sanction-change events in the shared
// auxiliary event stream.
SanctionChangedEventType = "user.sanction.changed"
// LimitChangedEventType identifies limit-change events in the shared
// auxiliary event stream.
LimitChangedEventType = "user.limit.changed"
)
// ProfileChangedOperation identifies one profile-change event kind.
type ProfileChangedOperation string
const (
// ProfileChangedOperationInitialized reports the initial account
// materialization performed during auth-driven user creation.
ProfileChangedOperationInitialized ProfileChangedOperation = "initialized"
// ProfileChangedOperationUpdated reports a later self-service profile
// update.
ProfileChangedOperationUpdated ProfileChangedOperation = "updated"
)
// IsKnown reports whether operation belongs to the frozen profile-change
// event vocabulary.
func (operation ProfileChangedOperation) IsKnown() bool {
switch operation {
case ProfileChangedOperationInitialized, ProfileChangedOperationUpdated:
return true
default:
return false
}
}
// SettingsChangedOperation identifies one settings-change event kind.
type SettingsChangedOperation string
const (
// SettingsChangedOperationInitialized reports the initial account settings
// materialization performed during auth-driven user creation.
SettingsChangedOperationInitialized SettingsChangedOperation = "initialized"
// SettingsChangedOperationUpdated reports a later self-service settings
// update.
SettingsChangedOperationUpdated SettingsChangedOperation = "updated"
)
// IsKnown reports whether operation belongs to the frozen settings-change
// event vocabulary.
func (operation SettingsChangedOperation) IsKnown() bool {
switch operation {
case SettingsChangedOperationInitialized, SettingsChangedOperationUpdated:
return true
default:
return false
}
}
// EntitlementChangedOperation identifies one entitlement-change event kind.
type EntitlementChangedOperation string
const (
// EntitlementChangedOperationInitialized reports the initial free snapshot
// created for a new user.
EntitlementChangedOperationInitialized EntitlementChangedOperation = "initialized"
// EntitlementChangedOperationGranted reports an explicit paid grant.
EntitlementChangedOperationGranted EntitlementChangedOperation = "granted"
// EntitlementChangedOperationExtended reports an explicit paid extension.
EntitlementChangedOperationExtended EntitlementChangedOperation = "extended"
// EntitlementChangedOperationRevoked reports an explicit paid revoke.
EntitlementChangedOperationRevoked EntitlementChangedOperation = "revoked"
// EntitlementChangedOperationExpiredRepaired reports lazy repair of a
// naturally expired finite paid snapshot.
EntitlementChangedOperationExpiredRepaired EntitlementChangedOperation = "expired_repaired"
)
// IsKnown reports whether operation belongs to the frozen entitlement-change
// event vocabulary.
func (operation EntitlementChangedOperation) IsKnown() bool {
switch operation {
case EntitlementChangedOperationInitialized,
EntitlementChangedOperationGranted,
EntitlementChangedOperationExtended,
EntitlementChangedOperationRevoked,
EntitlementChangedOperationExpiredRepaired:
return true
default:
return false
}
}
// SanctionChangedOperation identifies one sanction-change event kind.
type SanctionChangedOperation string
const (
// SanctionChangedOperationApplied reports a new active sanction.
SanctionChangedOperationApplied SanctionChangedOperation = "applied"
// SanctionChangedOperationRemoved reports explicit removal of an active
// sanction.
SanctionChangedOperationRemoved SanctionChangedOperation = "removed"
)
// IsKnown reports whether operation belongs to the frozen sanction-change
// event vocabulary.
func (operation SanctionChangedOperation) IsKnown() bool {
switch operation {
case SanctionChangedOperationApplied, SanctionChangedOperationRemoved:
return true
default:
return false
}
}
// LimitChangedOperation identifies one limit-change event kind.
type LimitChangedOperation string
const (
// LimitChangedOperationSet reports a new or replacement active limit.
LimitChangedOperationSet LimitChangedOperation = "set"
// LimitChangedOperationRemoved reports explicit removal of an active limit.
LimitChangedOperationRemoved LimitChangedOperation = "removed"
)
// IsKnown reports whether operation belongs to the frozen limit-change event
// vocabulary.
func (operation LimitChangedOperation) IsKnown() bool {
switch operation {
case LimitChangedOperationSet, LimitChangedOperationRemoved:
return true
default:
return false
}
}
// ProfileChangedEvent stores one post-commit auxiliary profile-change event.
type ProfileChangedEvent struct {
// UserID identifies the changed user.
UserID common.UserID
// OccurredAt stores the mutation timestamp emitted into the shared event
// envelope.
OccurredAt time.Time
// Source stores the machine-readable mutation source.
Source common.Source
// TraceID stores the optional OpenTelemetry trace identifier propagated
// from the current request context.
TraceID string
// Operation stores the profile-change event kind.
Operation ProfileChangedOperation
// RaceName stores the latest exact race name after the commit.
RaceName common.RaceName
}
// Validate reports whether event is structurally complete.
func (event ProfileChangedEvent) Validate() error {
if err := validateEventEnvelope("profile changed event", event.UserID, event.OccurredAt, event.Source, event.TraceID); err != nil {
return err
}
if !event.Operation.IsKnown() {
return fmt.Errorf("profile changed event operation %q is unsupported", event.Operation)
}
if err := event.RaceName.Validate(); err != nil {
return fmt.Errorf("profile changed event race name: %w", err)
}
return nil
}
// SettingsChangedEvent stores one post-commit auxiliary settings-change event.
type SettingsChangedEvent struct {
// UserID identifies the changed user.
UserID common.UserID
// OccurredAt stores the mutation timestamp emitted into the shared event
// envelope.
OccurredAt time.Time
// Source stores the machine-readable mutation source.
Source common.Source
// TraceID stores the optional OpenTelemetry trace identifier propagated
// from the current request context.
TraceID string
// Operation stores the settings-change event kind.
Operation SettingsChangedOperation
// PreferredLanguage stores the latest preferred language after the commit.
PreferredLanguage common.LanguageTag
// TimeZone stores the latest time-zone name after the commit.
TimeZone common.TimeZoneName
}
// Validate reports whether event is structurally complete.
func (event SettingsChangedEvent) Validate() error {
if err := validateEventEnvelope("settings changed event", event.UserID, event.OccurredAt, event.Source, event.TraceID); err != nil {
return err
}
if !event.Operation.IsKnown() {
return fmt.Errorf("settings changed event operation %q is unsupported", event.Operation)
}
if err := event.PreferredLanguage.Validate(); err != nil {
return fmt.Errorf("settings changed event preferred language: %w", err)
}
if err := event.TimeZone.Validate(); err != nil {
return fmt.Errorf("settings changed event time zone: %w", err)
}
return nil
}
// EntitlementChangedEvent stores one post-commit auxiliary entitlement-change
// event.
type EntitlementChangedEvent struct {
// UserID identifies the changed user.
UserID common.UserID
// OccurredAt stores the mutation timestamp emitted into the shared event
// envelope.
OccurredAt time.Time
// Source stores the machine-readable mutation source.
Source common.Source
// TraceID stores the optional OpenTelemetry trace identifier propagated
// from the current request context.
TraceID string
// Operation stores the entitlement-change event kind.
Operation EntitlementChangedOperation
// PlanCode stores the effective plan after the commit.
PlanCode entitlement.PlanCode
// IsPaid stores the effective paid/free flag after the commit.
IsPaid bool
// StartsAt stores when the effective entitlement state started.
StartsAt time.Time
// EndsAt stores the optional finite paid expiry.
EndsAt *time.Time
// ReasonCode stores the mutation reason.
ReasonCode common.ReasonCode
// Actor stores the audit actor metadata attached to the mutation.
Actor common.ActorRef
// UpdatedAt stores when the current entitlement snapshot was recomputed.
UpdatedAt time.Time
}
// Validate reports whether event is structurally complete.
func (event EntitlementChangedEvent) Validate() error {
if err := validateEventEnvelope("entitlement changed event", event.UserID, event.OccurredAt, event.Source, event.TraceID); err != nil {
return err
}
if !event.Operation.IsKnown() {
return fmt.Errorf("entitlement changed event operation %q is unsupported", event.Operation)
}
if !event.PlanCode.IsKnown() {
return fmt.Errorf("entitlement changed event plan code %q is unsupported", event.PlanCode)
}
if event.IsPaid != event.PlanCode.IsPaid() {
return fmt.Errorf("entitlement changed event paid flag must match plan code %q", event.PlanCode)
}
if err := common.ValidateTimestamp("entitlement changed event starts at", event.StartsAt); err != nil {
return err
}
if event.PlanCode.HasFiniteExpiry() {
if event.EndsAt == nil {
return fmt.Errorf("entitlement changed event ends at must be present for plan code %q", event.PlanCode)
}
if !event.EndsAt.After(event.StartsAt) {
return common.ErrInvertedTimeRange
}
} else if event.EndsAt != nil {
return fmt.Errorf("entitlement changed event ends at must be empty for plan code %q", event.PlanCode)
}
if err := event.ReasonCode.Validate(); err != nil {
return fmt.Errorf("entitlement changed event reason code: %w", err)
}
if err := event.Actor.Validate(); err != nil {
return fmt.Errorf("entitlement changed event actor: %w", err)
}
if err := common.ValidateTimestamp("entitlement changed event updated at", event.UpdatedAt); err != nil {
return err
}
return nil
}
// SanctionChangedEvent stores one post-commit auxiliary sanction-change event.
type SanctionChangedEvent struct {
// UserID identifies the changed user.
UserID common.UserID
// OccurredAt stores the mutation timestamp emitted into the shared event
// envelope.
OccurredAt time.Time
// Source stores the machine-readable mutation source.
Source common.Source
// TraceID stores the optional OpenTelemetry trace identifier propagated
// from the current request context.
TraceID string
// Operation stores the sanction-change event kind.
Operation SanctionChangedOperation
// SanctionCode stores the affected sanction code.
SanctionCode policy.SanctionCode
// Scope stores the machine-readable sanction scope.
Scope common.Scope
// ReasonCode stores the mutation reason.
ReasonCode common.ReasonCode
// Actor stores the audit actor metadata attached to the mutation.
Actor common.ActorRef
// AppliedAt stores when the sanction became effective.
AppliedAt time.Time
// ExpiresAt stores the optional planned sanction expiry.
ExpiresAt *time.Time
// RemovedAt stores the optional sanction removal timestamp.
RemovedAt *time.Time
}
// Validate reports whether event is structurally complete.
func (event SanctionChangedEvent) Validate() error {
if err := validateEventEnvelope("sanction changed event", event.UserID, event.OccurredAt, event.Source, event.TraceID); err != nil {
return err
}
if !event.Operation.IsKnown() {
return fmt.Errorf("sanction changed event operation %q is unsupported", event.Operation)
}
if !event.SanctionCode.IsKnown() {
return fmt.Errorf("sanction changed event sanction code %q is unsupported", event.SanctionCode)
}
if err := event.Scope.Validate(); err != nil {
return fmt.Errorf("sanction changed event scope: %w", err)
}
if err := event.ReasonCode.Validate(); err != nil {
return fmt.Errorf("sanction changed event reason code: %w", err)
}
if err := event.Actor.Validate(); err != nil {
return fmt.Errorf("sanction changed event actor: %w", err)
}
if err := common.ValidateTimestamp("sanction changed event applied at", event.AppliedAt); err != nil {
return err
}
if event.ExpiresAt != nil && !event.ExpiresAt.After(event.AppliedAt) {
return common.ErrInvertedTimeRange
}
if event.RemovedAt != nil && event.RemovedAt.Before(event.AppliedAt) {
return fmt.Errorf("sanction changed event removed at must not be before applied at")
}
return nil
}
// LimitChangedEvent stores one post-commit auxiliary limit-change event.
type LimitChangedEvent struct {
// UserID identifies the changed user.
UserID common.UserID
// OccurredAt stores the mutation timestamp emitted into the shared event
// envelope.
OccurredAt time.Time
// Source stores the machine-readable mutation source.
Source common.Source
// TraceID stores the optional OpenTelemetry trace identifier propagated
// from the current request context.
TraceID string
// Operation stores the limit-change event kind.
Operation LimitChangedOperation
// LimitCode stores the affected limit code.
LimitCode policy.LimitCode
// Value stores the active limit value when the operation is `set`.
Value *int
// ReasonCode stores the mutation reason.
ReasonCode common.ReasonCode
// Actor stores the audit actor metadata attached to the mutation.
Actor common.ActorRef
// AppliedAt stores when the limit became effective.
AppliedAt time.Time
// ExpiresAt stores the optional planned limit expiry.
ExpiresAt *time.Time
// RemovedAt stores the optional explicit limit removal timestamp.
RemovedAt *time.Time
}
// Validate reports whether event is structurally complete.
func (event LimitChangedEvent) Validate() error {
if err := validateEventEnvelope("limit changed event", event.UserID, event.OccurredAt, event.Source, event.TraceID); err != nil {
return err
}
if !event.Operation.IsKnown() {
return fmt.Errorf("limit changed event operation %q is unsupported", event.Operation)
}
if !event.LimitCode.IsSupported() {
return fmt.Errorf("limit changed event limit code %q is unsupported", event.LimitCode)
}
switch event.Operation {
case LimitChangedOperationSet:
if event.Value == nil {
return fmt.Errorf("limit changed event value must be present for operation %q", event.Operation)
}
if *event.Value < 0 {
return fmt.Errorf("limit changed event value must not be negative")
}
case LimitChangedOperationRemoved:
if event.Value != nil && *event.Value < 0 {
return fmt.Errorf("limit changed event value must not be negative")
}
}
if err := event.ReasonCode.Validate(); err != nil {
return fmt.Errorf("limit changed event reason code: %w", err)
}
if err := event.Actor.Validate(); err != nil {
return fmt.Errorf("limit changed event actor: %w", err)
}
if err := common.ValidateTimestamp("limit changed event applied at", event.AppliedAt); err != nil {
return err
}
if event.ExpiresAt != nil && !event.ExpiresAt.After(event.AppliedAt) {
return common.ErrInvertedTimeRange
}
if event.RemovedAt != nil && event.RemovedAt.Before(event.AppliedAt) {
return fmt.Errorf("limit changed event removed at must not be before applied at")
}
return nil
}
// ProfileChangedPublisher publishes auxiliary profile-change notifications.
type ProfileChangedPublisher interface {
// PublishProfileChanged propagates one committed profile-change event.
PublishProfileChanged(ctx context.Context, event ProfileChangedEvent) error
}
// SettingsChangedPublisher publishes auxiliary settings-change notifications.
type SettingsChangedPublisher interface {
// PublishSettingsChanged propagates one committed settings-change event.
PublishSettingsChanged(ctx context.Context, event SettingsChangedEvent) error
}
// EntitlementChangedPublisher publishes auxiliary entitlement-change
// notifications.
type EntitlementChangedPublisher interface {
// PublishEntitlementChanged propagates one committed entitlement-change
// event.
PublishEntitlementChanged(ctx context.Context, event EntitlementChangedEvent) error
}
// SanctionChangedPublisher publishes auxiliary sanction-change notifications.
type SanctionChangedPublisher interface {
// PublishSanctionChanged propagates one committed sanction-change event.
PublishSanctionChanged(ctx context.Context, event SanctionChangedEvent) error
}
// LimitChangedPublisher publishes auxiliary limit-change notifications.
type LimitChangedPublisher interface {
// PublishLimitChanged propagates one committed limit-change event.
PublishLimitChanged(ctx context.Context, event LimitChangedEvent) error
}
func validateEventEnvelope(name string, userID common.UserID, occurredAt time.Time, source common.Source, traceID string) error {
if err := userID.Validate(); err != nil {
return fmt.Errorf("%s user id: %w", name, err)
}
if err := common.ValidateTimestamp(name+" occurred at", occurredAt); err != nil {
return err
}
if err := source.Validate(); err != nil {
return fmt.Errorf("%s source: %w", name, err)
}
if traceID != "" {
if strings.TrimSpace(traceID) != traceID {
return fmt.Errorf("%s trace id must not contain surrounding whitespace", name)
}
}
return nil
}
+230
View File
@@ -0,0 +1,230 @@
package ports
import (
"context"
"fmt"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
)
// EntitlementHistoryStore persists immutable entitlement period records and
// later close-state updates.
type EntitlementHistoryStore interface {
// Create stores one new entitlement period history record. Implementations
// must wrap ErrConflict when record.RecordID already exists.
Create(ctx context.Context, record entitlement.PeriodRecord) error
// GetByRecordID returns the entitlement period history record identified by
// recordID.
GetByRecordID(ctx context.Context, recordID entitlement.EntitlementRecordID) (entitlement.PeriodRecord, error)
// ListByUserID returns every entitlement period history record owned by
// userID.
ListByUserID(ctx context.Context, userID common.UserID) ([]entitlement.PeriodRecord, error)
// Update replaces one stored entitlement period history record.
Update(ctx context.Context, record entitlement.PeriodRecord) error
}
// EntitlementSnapshotStore persists the read-optimized current entitlement
// snapshot.
type EntitlementSnapshotStore interface {
// GetByUserID returns the current entitlement snapshot for userID.
GetByUserID(ctx context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error)
// Put stores the current entitlement snapshot for record.UserID.
Put(ctx context.Context, record entitlement.CurrentSnapshot) error
}
// GrantEntitlementInput stores one atomic transition from a current free
// entitlement state to a current paid state.
type GrantEntitlementInput struct {
// ExpectedCurrentSnapshot stores the exact snapshot that must still be
// current before the mutation commits.
ExpectedCurrentSnapshot entitlement.CurrentSnapshot
// ExpectedCurrentRecord stores the current effective free period that must
// still be current before the mutation commits.
ExpectedCurrentRecord entitlement.PeriodRecord
// UpdatedCurrentRecord stores ExpectedCurrentRecord after the close metadata
// is applied.
UpdatedCurrentRecord entitlement.PeriodRecord
// NewRecord stores the new paid entitlement history segment.
NewRecord entitlement.PeriodRecord
// NewSnapshot stores the new current effective entitlement snapshot.
NewSnapshot entitlement.CurrentSnapshot
}
// Validate reports whether GrantEntitlementInput is structurally complete.
func (input GrantEntitlementInput) Validate() error {
if err := input.ExpectedCurrentSnapshot.Validate(); err != nil {
return fmt.Errorf("grant entitlement input expected current snapshot: %w", err)
}
if err := input.ExpectedCurrentRecord.Validate(); err != nil {
return fmt.Errorf("grant entitlement input expected current record: %w", err)
}
if err := input.UpdatedCurrentRecord.Validate(); err != nil {
return fmt.Errorf("grant entitlement input updated current record: %w", err)
}
if err := input.NewRecord.Validate(); err != nil {
return fmt.Errorf("grant entitlement input new record: %w", err)
}
if err := input.NewSnapshot.Validate(); err != nil {
return fmt.Errorf("grant entitlement input new snapshot: %w", err)
}
if input.ExpectedCurrentSnapshot.UserID != input.ExpectedCurrentRecord.UserID ||
input.ExpectedCurrentSnapshot.UserID != input.UpdatedCurrentRecord.UserID ||
input.ExpectedCurrentSnapshot.UserID != input.NewRecord.UserID ||
input.ExpectedCurrentSnapshot.UserID != input.NewSnapshot.UserID {
return fmt.Errorf("grant entitlement input all records must belong to the same user id")
}
if input.ExpectedCurrentRecord.RecordID != input.UpdatedCurrentRecord.RecordID {
return fmt.Errorf("grant entitlement input updated current record must preserve record id")
}
return nil
}
// ExtendEntitlementInput stores one atomic extension of a current finite paid
// entitlement state.
type ExtendEntitlementInput struct {
// ExpectedCurrentSnapshot stores the exact snapshot that must still be
// current before the mutation commits.
ExpectedCurrentSnapshot entitlement.CurrentSnapshot
// NewRecord stores the appended entitlement history segment that extends the
// current paid state.
NewRecord entitlement.PeriodRecord
// NewSnapshot stores the replacement current effective entitlement snapshot.
NewSnapshot entitlement.CurrentSnapshot
}
// Validate reports whether ExtendEntitlementInput is structurally complete.
func (input ExtendEntitlementInput) Validate() error {
if err := input.ExpectedCurrentSnapshot.Validate(); err != nil {
return fmt.Errorf("extend entitlement input expected current snapshot: %w", err)
}
if err := input.NewRecord.Validate(); err != nil {
return fmt.Errorf("extend entitlement input new record: %w", err)
}
if err := input.NewSnapshot.Validate(); err != nil {
return fmt.Errorf("extend entitlement input new snapshot: %w", err)
}
if input.ExpectedCurrentSnapshot.UserID != input.NewRecord.UserID ||
input.ExpectedCurrentSnapshot.UserID != input.NewSnapshot.UserID {
return fmt.Errorf("extend entitlement input all records must belong to the same user id")
}
return nil
}
// RevokeEntitlementInput stores one atomic transition from a current paid
// entitlement state to a new free state.
type RevokeEntitlementInput struct {
// ExpectedCurrentSnapshot stores the exact snapshot that must still be
// current before the mutation commits.
ExpectedCurrentSnapshot entitlement.CurrentSnapshot
// ExpectedCurrentRecord stores the current effective paid period that must
// still be current before the mutation commits.
ExpectedCurrentRecord entitlement.PeriodRecord
// UpdatedCurrentRecord stores ExpectedCurrentRecord after the close metadata
// is applied.
UpdatedCurrentRecord entitlement.PeriodRecord
// NewRecord stores the newly created free entitlement period.
NewRecord entitlement.PeriodRecord
// NewSnapshot stores the replacement current effective free snapshot.
NewSnapshot entitlement.CurrentSnapshot
}
// Validate reports whether RevokeEntitlementInput is structurally complete.
func (input RevokeEntitlementInput) Validate() error {
if err := input.ExpectedCurrentSnapshot.Validate(); err != nil {
return fmt.Errorf("revoke entitlement input expected current snapshot: %w", err)
}
if err := input.ExpectedCurrentRecord.Validate(); err != nil {
return fmt.Errorf("revoke entitlement input expected current record: %w", err)
}
if err := input.UpdatedCurrentRecord.Validate(); err != nil {
return fmt.Errorf("revoke entitlement input updated current record: %w", err)
}
if err := input.NewRecord.Validate(); err != nil {
return fmt.Errorf("revoke entitlement input new record: %w", err)
}
if err := input.NewSnapshot.Validate(); err != nil {
return fmt.Errorf("revoke entitlement input new snapshot: %w", err)
}
if input.ExpectedCurrentSnapshot.UserID != input.ExpectedCurrentRecord.UserID ||
input.ExpectedCurrentSnapshot.UserID != input.UpdatedCurrentRecord.UserID ||
input.ExpectedCurrentSnapshot.UserID != input.NewRecord.UserID ||
input.ExpectedCurrentSnapshot.UserID != input.NewSnapshot.UserID {
return fmt.Errorf("revoke entitlement input all records must belong to the same user id")
}
if input.ExpectedCurrentRecord.RecordID != input.UpdatedCurrentRecord.RecordID {
return fmt.Errorf("revoke entitlement input updated current record must preserve record id")
}
return nil
}
// RepairExpiredEntitlementInput stores one atomic lazy-repair transition from
// an expired finite paid snapshot to a materialized free state.
type RepairExpiredEntitlementInput struct {
// ExpectedExpiredSnapshot stores the exact expired snapshot that must still
// be current before the repair commits.
ExpectedExpiredSnapshot entitlement.CurrentSnapshot
// NewRecord stores the newly created free entitlement period.
NewRecord entitlement.PeriodRecord
// NewSnapshot stores the replacement current effective free snapshot.
NewSnapshot entitlement.CurrentSnapshot
}
// Validate reports whether RepairExpiredEntitlementInput is structurally
// complete.
func (input RepairExpiredEntitlementInput) Validate() error {
if err := input.ExpectedExpiredSnapshot.Validate(); err != nil {
return fmt.Errorf("repair expired entitlement input expected expired snapshot: %w", err)
}
if err := input.NewRecord.Validate(); err != nil {
return fmt.Errorf("repair expired entitlement input new record: %w", err)
}
if err := input.NewSnapshot.Validate(); err != nil {
return fmt.Errorf("repair expired entitlement input new snapshot: %w", err)
}
if input.ExpectedExpiredSnapshot.UserID != input.NewRecord.UserID ||
input.ExpectedExpiredSnapshot.UserID != input.NewSnapshot.UserID {
return fmt.Errorf("repair expired entitlement input all records must belong to the same user id")
}
return nil
}
// EntitlementLifecycleStore persists atomic entitlement timeline transitions
// that must keep history and current snapshot consistent.
type EntitlementLifecycleStore interface {
// Grant atomically closes the current free period, creates a new paid
// period, and replaces the current snapshot.
Grant(ctx context.Context, input GrantEntitlementInput) error
// Extend atomically appends one paid-history segment and replaces the
// current snapshot.
Extend(ctx context.Context, input ExtendEntitlementInput) error
// Revoke atomically closes the current paid period, creates a new free
// period, and replaces the current snapshot.
Revoke(ctx context.Context, input RevokeEntitlementInput) error
// RepairExpired atomically replaces one expired finite paid snapshot with a
// materialized free state.
RepairExpired(ctx context.Context, input RepairExpiredEntitlementInput) error
}
+31
View File
@@ -0,0 +1,31 @@
// Package ports defines the storage-agnostic boundaries used by the user
// service.
package ports
import (
"errors"
"fmt"
)
var (
// ErrNotFound reports that a requested source-of-truth record does not
// exist in the dependency behind the port.
ErrNotFound = errors.New("ports: record not found")
// ErrConflict reports that a create or update cannot be applied because the
// dependency state conflicts with the requested mutation.
ErrConflict = errors.New("ports: conflict")
// ErrInvalidPageToken reports that a supplied pagination token cannot be
// decoded or does not match the expected filter set.
ErrInvalidPageToken = errors.New("ports: invalid page token")
)
var (
// ErrRaceNameConflict reports that a mutation specifically failed because a
// race-name lookup or canonical reservation is already owned by another
// user. The sentinel still matches ErrConflict via errors.Is so callers can
// preserve the stable public conflict semantics while collecting more
// precise observability.
ErrRaceNameConflict = fmt.Errorf("%w: race name conflict", ErrConflict)
)
+29
View File
@@ -0,0 +1,29 @@
package ports
import (
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
)
// IDGenerator creates new user identifiers and generated initial race names.
type IDGenerator interface {
// NewUserID returns one newly generated stable user identifier.
NewUserID() (common.UserID, error)
// NewInitialRaceName returns one generated initial race name in the
// `player-<shortid>` form.
NewInitialRaceName() (common.RaceName, error)
// NewEntitlementRecordID returns one newly generated entitlement history
// record identifier.
NewEntitlementRecordID() (entitlement.EntitlementRecordID, error)
// NewSanctionRecordID returns one newly generated sanction history record
// identifier.
NewSanctionRecordID() (policy.SanctionRecordID, error)
// NewLimitRecordID returns one newly generated limit history record
// identifier.
NewLimitRecordID() (policy.LimitRecordID, error)
}
+188
View File
@@ -0,0 +1,188 @@
package ports
import (
"context"
"fmt"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/policy"
)
// SanctionStore persists sanction history records and later remove-state
// updates.
type SanctionStore interface {
// Create stores one new sanction history record. Implementations must wrap
// ErrConflict when record.RecordID already exists.
Create(ctx context.Context, record policy.SanctionRecord) error
// GetByRecordID returns the sanction history record identified by recordID.
GetByRecordID(ctx context.Context, recordID policy.SanctionRecordID) (policy.SanctionRecord, error)
// ListByUserID returns every sanction history record owned by userID.
ListByUserID(ctx context.Context, userID common.UserID) ([]policy.SanctionRecord, error)
// Update replaces one stored sanction history record.
Update(ctx context.Context, record policy.SanctionRecord) error
}
// LimitStore persists user-specific limit history records and later
// remove-state updates.
type LimitStore interface {
// Create stores one new limit history record. Implementations must wrap
// ErrConflict when record.RecordID already exists.
Create(ctx context.Context, record policy.LimitRecord) error
// GetByRecordID returns the limit history record identified by recordID.
GetByRecordID(ctx context.Context, recordID policy.LimitRecordID) (policy.LimitRecord, error)
// ListByUserID returns every limit history record owned by userID.
ListByUserID(ctx context.Context, userID common.UserID) ([]policy.LimitRecord, error)
// Update replaces one stored limit history record.
Update(ctx context.Context, record policy.LimitRecord) error
}
// ApplySanctionInput stores one atomic creation of a new active sanction.
type ApplySanctionInput struct {
// NewRecord stores the sanction history record that must become active.
NewRecord policy.SanctionRecord
}
// Validate reports whether ApplySanctionInput is structurally complete.
func (input ApplySanctionInput) Validate() error {
if err := input.NewRecord.Validate(); err != nil {
return fmt.Errorf("apply sanction input new record: %w", err)
}
return nil
}
// RemoveSanctionInput stores one atomic removal of the current active
// sanction for one `user_id + sanction_code`.
type RemoveSanctionInput struct {
// ExpectedActiveRecord stores the exact sanction record that must still be
// active before the mutation commits.
ExpectedActiveRecord policy.SanctionRecord
// UpdatedRecord stores ExpectedActiveRecord after remove metadata is
// applied.
UpdatedRecord policy.SanctionRecord
}
// Validate reports whether RemoveSanctionInput is structurally complete.
func (input RemoveSanctionInput) Validate() error {
if err := input.ExpectedActiveRecord.Validate(); err != nil {
return fmt.Errorf("remove sanction input expected active record: %w", err)
}
if err := input.UpdatedRecord.Validate(); err != nil {
return fmt.Errorf("remove sanction input updated record: %w", err)
}
if input.ExpectedActiveRecord.RecordID != input.UpdatedRecord.RecordID {
return fmt.Errorf("remove sanction input updated record must preserve record id")
}
if input.ExpectedActiveRecord.UserID != input.UpdatedRecord.UserID {
return fmt.Errorf("remove sanction input records must belong to the same user id")
}
if input.ExpectedActiveRecord.SanctionCode != input.UpdatedRecord.SanctionCode {
return fmt.Errorf("remove sanction input records must preserve sanction code")
}
return nil
}
// SetLimitInput stores one atomic creation or replacement of the current
// active limit for one `user_id + limit_code`.
type SetLimitInput struct {
// ExpectedActiveRecord stores the currently active limit that must still be
// active before replacement commits. It stays nil when no active limit
// exists yet.
ExpectedActiveRecord *policy.LimitRecord
// UpdatedActiveRecord stores ExpectedActiveRecord after remove metadata is
// applied. It stays nil when no active limit exists yet.
UpdatedActiveRecord *policy.LimitRecord
// NewRecord stores the limit history record that must become active.
NewRecord policy.LimitRecord
}
// Validate reports whether SetLimitInput is structurally complete.
func (input SetLimitInput) Validate() error {
if err := input.NewRecord.Validate(); err != nil {
return fmt.Errorf("set limit input new record: %w", err)
}
switch {
case input.ExpectedActiveRecord == nil && input.UpdatedActiveRecord == nil:
return nil
case input.ExpectedActiveRecord == nil || input.UpdatedActiveRecord == nil:
return fmt.Errorf("set limit input active replacement records must both be present or absent")
}
if err := input.ExpectedActiveRecord.Validate(); err != nil {
return fmt.Errorf("set limit input expected active record: %w", err)
}
if err := input.UpdatedActiveRecord.Validate(); err != nil {
return fmt.Errorf("set limit input updated active record: %w", err)
}
if input.ExpectedActiveRecord.RecordID != input.UpdatedActiveRecord.RecordID {
return fmt.Errorf("set limit input updated active record must preserve record id")
}
if input.ExpectedActiveRecord.UserID != input.UpdatedActiveRecord.UserID ||
input.ExpectedActiveRecord.UserID != input.NewRecord.UserID {
return fmt.Errorf("set limit input records must belong to the same user id")
}
if input.ExpectedActiveRecord.LimitCode != input.UpdatedActiveRecord.LimitCode ||
input.ExpectedActiveRecord.LimitCode != input.NewRecord.LimitCode {
return fmt.Errorf("set limit input records must preserve limit code")
}
return nil
}
// RemoveLimitInput stores one atomic removal of the current active limit for
// one `user_id + limit_code`.
type RemoveLimitInput struct {
// ExpectedActiveRecord stores the exact limit record that must still be
// active before the mutation commits.
ExpectedActiveRecord policy.LimitRecord
// UpdatedRecord stores ExpectedActiveRecord after remove metadata is
// applied.
UpdatedRecord policy.LimitRecord
}
// Validate reports whether RemoveLimitInput is structurally complete.
func (input RemoveLimitInput) Validate() error {
if err := input.ExpectedActiveRecord.Validate(); err != nil {
return fmt.Errorf("remove limit input expected active record: %w", err)
}
if err := input.UpdatedRecord.Validate(); err != nil {
return fmt.Errorf("remove limit input updated record: %w", err)
}
if input.ExpectedActiveRecord.RecordID != input.UpdatedRecord.RecordID {
return fmt.Errorf("remove limit input updated record must preserve record id")
}
if input.ExpectedActiveRecord.UserID != input.UpdatedRecord.UserID {
return fmt.Errorf("remove limit input records must belong to the same user id")
}
if input.ExpectedActiveRecord.LimitCode != input.UpdatedRecord.LimitCode {
return fmt.Errorf("remove limit input records must preserve limit code")
}
return nil
}
// PolicyLifecycleStore persists atomic sanction and limit transitions that
// must keep history and active-slot state consistent.
type PolicyLifecycleStore interface {
// ApplySanction atomically creates one new active sanction record.
ApplySanction(ctx context.Context, input ApplySanctionInput) error
// RemoveSanction atomically removes one active sanction record.
RemoveSanction(ctx context.Context, input RemoveSanctionInput) error
// SetLimit atomically creates or replaces one active limit record.
SetLimit(ctx context.Context, input SetLimitInput) error
// RemoveLimit atomically removes one active limit record.
RemoveLimit(ctx context.Context, input RemoveLimitInput) error
}
+14
View File
@@ -0,0 +1,14 @@
package ports
import (
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
)
// RaceNamePolicy produces the canonical uniqueness key used to reserve one
// replaceable race-name slot.
type RaceNamePolicy interface {
// CanonicalKey returns the stable reservation key for raceName. Callers are
// expected to pass a validated raceName value.
CanonicalKey(raceName common.RaceName) (account.RaceNameCanonicalKey, error)
}
+129
View File
@@ -0,0 +1,129 @@
package ports
import (
"context"
"fmt"
"strings"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
)
const (
// DefaultUserListPageSize stores the frozen default page size used by the
// trusted admin listing surface when the caller omits `page_size`.
DefaultUserListPageSize = 50
// MaxUserListPageSize stores the frozen maximum page size accepted by the
// trusted admin listing surface.
MaxUserListPageSize = 200
)
// UserListFilters stores the frozen admin-listing filter set.
type UserListFilters struct {
// PaidState stores the optional coarse free-versus-paid filter.
PaidState entitlement.PaidState
// PaidExpiresBefore stores the optional strict upper bound for finite paid
// expiry.
PaidExpiresBefore *time.Time
// PaidExpiresAfter stores the optional strict lower bound for finite paid
// expiry.
PaidExpiresAfter *time.Time
// DeclaredCountry stores the optional current declared-country filter.
DeclaredCountry common.CountryCode
// SanctionCode stores the optional active-sanction filter.
SanctionCode policy.SanctionCode
// LimitCode stores the optional active user-specific limit filter.
LimitCode policy.LimitCode
// CanLogin stores the optional derived login-eligibility filter.
CanLogin *bool
// CanCreatePrivateGame stores the optional derived private-game-create
// eligibility filter.
CanCreatePrivateGame *bool
// CanJoinGame stores the optional derived game-join eligibility filter.
CanJoinGame *bool
}
// Validate reports whether filters is structurally valid.
func (filters UserListFilters) Validate() error {
if !filters.PaidState.IsKnown() {
return fmt.Errorf("paid state %q is unsupported", filters.PaidState)
}
if filters.PaidExpiresBefore != nil && filters.PaidExpiresBefore.IsZero() {
return fmt.Errorf("paid expires before must not be zero")
}
if filters.PaidExpiresAfter != nil && filters.PaidExpiresAfter.IsZero() {
return fmt.Errorf("paid expires after must not be zero")
}
if !filters.DeclaredCountry.IsZero() {
if err := filters.DeclaredCountry.Validate(); err != nil {
return fmt.Errorf("declared country: %w", err)
}
}
if filters.SanctionCode != "" && !filters.SanctionCode.IsKnown() {
return fmt.Errorf("sanction code %q is unsupported", filters.SanctionCode)
}
if filters.LimitCode != "" && !filters.LimitCode.IsKnown() {
return fmt.Errorf("limit code %q is unsupported", filters.LimitCode)
}
return nil
}
// ListUsersInput stores one trusted admin-listing read request.
type ListUsersInput struct {
// PageSize stores the maximum number of ordered user identifiers returned
// in one storage page.
PageSize int
// PageToken stores the optional opaque continuation cursor.
PageToken string
// Filters stores the normalized filter set bound into PageToken.
Filters UserListFilters
}
// Validate reports whether input is structurally complete.
func (input ListUsersInput) Validate() error {
switch {
case input.PageSize < 1:
return fmt.Errorf("page size must be at least 1")
case input.PageSize > MaxUserListPageSize:
return fmt.Errorf("page size must be at most %d", MaxUserListPageSize)
case strings.TrimSpace(input.PageToken) != input.PageToken:
return fmt.Errorf("page token must not contain surrounding whitespace")
}
if err := input.Filters.Validate(); err != nil {
return fmt.Errorf("filters: %w", err)
}
return nil
}
// ListUsersResult stores one deterministic ordered storage page of user ids.
type ListUsersResult struct {
// UserIDs stores the ordered user identifiers returned for the requested
// page.
UserIDs []common.UserID
// NextPageToken stores the optional opaque continuation cursor for the next
// page.
NextPageToken string
}
// UserListStore provides deterministic ordered admin-listing pagination over
// stored user identifiers.
type UserListStore interface {
// ListUserIDs returns one deterministic storage page of user identifiers.
ListUserIDs(ctx context.Context, input ListUsersInput) (ListUsersResult, error)
}
@@ -0,0 +1,336 @@
// Package accountview materializes the shared account aggregate view used by
// self-service and trusted administrative reads.
package accountview
import (
"context"
"errors"
"fmt"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/shared"
)
// ActorRefView stores transport-ready audit actor metadata.
type ActorRefView struct {
// Type stores the machine-readable actor type.
Type string `json:"type"`
// ID stores the optional stable actor identifier.
ID string `json:"id,omitempty"`
}
// EntitlementSnapshotView stores the transport-ready current entitlement
// snapshot of one account.
type EntitlementSnapshotView struct {
// PlanCode stores the effective entitlement plan code.
PlanCode string `json:"plan_code"`
// IsPaid reports whether the effective plan is paid.
IsPaid bool `json:"is_paid"`
// Source stores the machine-readable mutation source.
Source string `json:"source"`
// Actor stores the audit actor metadata attached to the snapshot.
Actor ActorRefView `json:"actor"`
// ReasonCode stores the machine-readable reason attached to the snapshot.
ReasonCode string `json:"reason_code"`
// StartsAt stores when the effective state started.
StartsAt time.Time `json:"starts_at"`
// EndsAt stores the optional finite effective expiry.
EndsAt *time.Time `json:"ends_at,omitempty"`
// UpdatedAt stores when the snapshot was last recomputed.
UpdatedAt time.Time `json:"updated_at"`
}
// ActiveSanctionView stores one transport-ready active sanction.
type ActiveSanctionView struct {
// SanctionCode stores the active sanction code.
SanctionCode string `json:"sanction_code"`
// Scope stores the machine-readable sanction scope.
Scope string `json:"scope"`
// ReasonCode stores the machine-readable sanction reason.
ReasonCode string `json:"reason_code"`
// Actor stores the audit actor metadata attached to the sanction.
Actor ActorRefView `json:"actor"`
// AppliedAt stores when the sanction became active.
AppliedAt time.Time `json:"applied_at"`
// ExpiresAt stores the optional planned sanction expiry.
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// ActiveLimitView stores one transport-ready active user-specific limit.
type ActiveLimitView struct {
// LimitCode stores the active limit code.
LimitCode string `json:"limit_code"`
// Value stores the current override value.
Value int `json:"value"`
// ReasonCode stores the machine-readable limit reason.
ReasonCode string `json:"reason_code"`
// Actor stores the audit actor metadata attached to the limit.
Actor ActorRefView `json:"actor"`
// AppliedAt stores when the limit became active.
AppliedAt time.Time `json:"applied_at"`
// ExpiresAt stores the optional planned limit expiry.
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// AccountView stores the transport-ready account aggregate shared by
// self-service and admin reads.
type AccountView struct {
// UserID stores the durable regular-user identifier.
UserID string `json:"user_id"`
// Email stores the exact normalized login e-mail address.
Email string `json:"email"`
// RaceName stores the current user-facing race name.
RaceName string `json:"race_name"`
// PreferredLanguage stores the current BCP 47 preferred language.
PreferredLanguage string `json:"preferred_language"`
// TimeZone stores the current IANA time-zone name.
TimeZone string `json:"time_zone"`
// DeclaredCountry stores the optional latest effective declared country.
DeclaredCountry string `json:"declared_country,omitempty"`
// Entitlement stores the current entitlement snapshot.
Entitlement EntitlementSnapshotView `json:"entitlement"`
// ActiveSanctions stores the current active sanctions sorted by code.
ActiveSanctions []ActiveSanctionView `json:"active_sanctions"`
// ActiveLimits stores the current active user-specific limits sorted by
// code.
ActiveLimits []ActiveLimitView `json:"active_limits"`
// CreatedAt stores when the account was created.
CreatedAt time.Time `json:"created_at"`
// UpdatedAt stores when the account was last mutated.
UpdatedAt time.Time `json:"updated_at"`
}
// Aggregate stores the raw domain state that backs one shared account view.
type Aggregate struct {
// AccountRecord stores the current editable account record.
AccountRecord account.UserAccount
// EntitlementSnapshot stores the current effective entitlement snapshot.
EntitlementSnapshot entitlement.CurrentSnapshot
// ActiveSanctions stores the active sanctions sorted by code.
ActiveSanctions []policy.SanctionRecord
// ActiveLimits stores the active user-specific limits sorted by code.
ActiveLimits []policy.LimitRecord
}
// HasActiveSanction reports whether aggregate currently contains code in its
// active sanction set.
func (aggregate Aggregate) HasActiveSanction(code policy.SanctionCode) bool {
for _, record := range aggregate.ActiveSanctions {
if record.SanctionCode == code {
return true
}
}
return false
}
// HasActiveLimit reports whether aggregate currently contains code in its
// active user-specific limit set.
func (aggregate Aggregate) HasActiveLimit(code policy.LimitCode) bool {
for _, record := range aggregate.ActiveLimits {
if record.LimitCode == code {
return true
}
}
return false
}
// View materializes Aggregate into the shared transport-ready account view.
func (aggregate Aggregate) View() AccountView {
view := AccountView{
UserID: aggregate.AccountRecord.UserID.String(),
Email: aggregate.AccountRecord.Email.String(),
RaceName: aggregate.AccountRecord.RaceName.String(),
PreferredLanguage: aggregate.AccountRecord.PreferredLanguage.String(),
TimeZone: aggregate.AccountRecord.TimeZone.String(),
Entitlement: EntitlementSnapshotView{
PlanCode: string(aggregate.EntitlementSnapshot.PlanCode),
IsPaid: aggregate.EntitlementSnapshot.IsPaid,
Source: aggregate.EntitlementSnapshot.Source.String(),
Actor: actorRefView(aggregate.EntitlementSnapshot.Actor),
ReasonCode: aggregate.EntitlementSnapshot.ReasonCode.String(),
StartsAt: aggregate.EntitlementSnapshot.StartsAt.UTC(),
EndsAt: cloneOptionalTime(aggregate.EntitlementSnapshot.EndsAt),
UpdatedAt: aggregate.EntitlementSnapshot.UpdatedAt.UTC(),
},
ActiveSanctions: make([]ActiveSanctionView, 0, len(aggregate.ActiveSanctions)),
ActiveLimits: make([]ActiveLimitView, 0, len(aggregate.ActiveLimits)),
CreatedAt: aggregate.AccountRecord.CreatedAt.UTC(),
UpdatedAt: aggregate.AccountRecord.UpdatedAt.UTC(),
}
if !aggregate.AccountRecord.DeclaredCountry.IsZero() {
view.DeclaredCountry = aggregate.AccountRecord.DeclaredCountry.String()
}
for _, sanctionRecord := range aggregate.ActiveSanctions {
view.ActiveSanctions = append(view.ActiveSanctions, ActiveSanctionView{
SanctionCode: string(sanctionRecord.SanctionCode),
Scope: sanctionRecord.Scope.String(),
ReasonCode: sanctionRecord.ReasonCode.String(),
Actor: actorRefView(sanctionRecord.Actor),
AppliedAt: sanctionRecord.AppliedAt.UTC(),
ExpiresAt: cloneOptionalTime(sanctionRecord.ExpiresAt),
})
}
for _, limitRecord := range aggregate.ActiveLimits {
view.ActiveLimits = append(view.ActiveLimits, ActiveLimitView{
LimitCode: string(limitRecord.LimitCode),
Value: limitRecord.Value,
ReasonCode: limitRecord.ReasonCode.String(),
Actor: actorRefView(limitRecord.Actor),
AppliedAt: limitRecord.AppliedAt.UTC(),
ExpiresAt: cloneOptionalTime(limitRecord.ExpiresAt),
})
}
return view
}
type entitlementReader interface {
GetByUserID(ctx context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error)
}
// Loader materializes the shared current account aggregate for one user id.
type Loader struct {
accounts ports.UserAccountStore
entitlements entitlementReader
sanctions ports.SanctionStore
limits ports.LimitStore
clock ports.Clock
}
// NewLoader constructs one shared account-aggregate loader.
func NewLoader(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
) (*Loader, error) {
switch {
case accounts == nil:
return nil, fmt.Errorf("account view loader: user account store must not be nil")
case entitlements == nil:
return nil, fmt.Errorf("account view loader: entitlement reader must not be nil")
case sanctions == nil:
return nil, fmt.Errorf("account view loader: sanction store must not be nil")
case limits == nil:
return nil, fmt.Errorf("account view loader: limit store must not be nil")
case clock == nil:
return nil, fmt.Errorf("account view loader: clock must not be nil")
default:
return &Loader{
accounts: accounts,
entitlements: entitlements,
sanctions: sanctions,
limits: limits,
clock: clock,
}, nil
}
}
// Load materializes the shared account aggregate identified by userID.
func (loader *Loader) Load(ctx context.Context, userID common.UserID) (Aggregate, error) {
if loader == nil {
return Aggregate{}, shared.InternalError(fmt.Errorf("account view loader must not be nil"))
}
accountRecord, err := loader.accounts.GetByUserID(ctx, userID)
switch {
case err == nil:
case errors.Is(err, ports.ErrNotFound):
return Aggregate{}, shared.SubjectNotFound()
default:
return Aggregate{}, shared.ServiceUnavailable(err)
}
entitlementSnapshot, err := loader.entitlements.GetByUserID(ctx, userID)
switch {
case err == nil:
case errors.Is(err, ports.ErrNotFound):
return Aggregate{}, shared.InternalError(fmt.Errorf("user %q is missing entitlement snapshot", userID))
default:
return Aggregate{}, shared.ServiceUnavailable(err)
}
sanctionRecords, err := loader.sanctions.ListByUserID(ctx, userID)
if err != nil {
return Aggregate{}, shared.ServiceUnavailable(err)
}
limitRecords, err := loader.limits.ListByUserID(ctx, userID)
if err != nil {
return Aggregate{}, shared.ServiceUnavailable(err)
}
now := loader.clock.Now().UTC()
activeSanctions, err := policy.ActiveSanctionsAt(sanctionRecords, now)
if err != nil {
return Aggregate{}, shared.InternalError(fmt.Errorf("evaluate active sanctions for user %q: %w", userID, err))
}
activeLimits, err := policy.ActiveLimitsAt(limitRecords, now)
if err != nil {
return Aggregate{}, shared.InternalError(fmt.Errorf("evaluate active limits for user %q: %w", userID, err))
}
return Aggregate{
AccountRecord: accountRecord,
EntitlementSnapshot: entitlementSnapshot,
ActiveSanctions: activeSanctions,
ActiveLimits: activeLimits,
}, nil
}
func actorRefView(ref common.ActorRef) ActorRefView {
return ActorRefView{
Type: ref.Type.String(),
ID: ref.ID.String(),
}
}
func cloneOptionalTime(value *time.Time) *time.Time {
if value == nil {
return nil
}
cloned := value.UTC()
return &cloned
}
+504
View File
@@ -0,0 +1,504 @@
// Package adminusers implements the trusted administrative user-read surface
// owned by User Service.
package adminusers
import (
"context"
"errors"
"fmt"
"strings"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/accountview"
"galaxy/user/internal/service/shared"
)
// LookupResult stores one exact trusted admin user lookup result.
type LookupResult struct {
// User stores the shared account aggregate of the resolved user.
User accountview.AccountView `json:"user"`
}
// GetUserByIDInput stores one exact trusted lookup by stable user identifier.
type GetUserByIDInput struct {
// UserID stores the stable regular-user identifier to resolve.
UserID string
}
// GetUserByEmailInput stores one exact trusted lookup by normalized e-mail.
type GetUserByEmailInput struct {
// Email stores the normalized login/contact e-mail to resolve.
Email string
}
// GetUserByRaceNameInput stores one exact trusted lookup by exact stored race
// name.
type GetUserByRaceNameInput struct {
// RaceName stores the exact current race name to resolve.
RaceName string
}
// ListUsersInput stores one trusted administrative user-list request.
type ListUsersInput struct {
// PageSize stores the requested maximum number of returned users. The zero
// value selects the frozen default page size.
PageSize int
// PageToken stores the optional opaque continuation cursor.
PageToken string
// PaidState stores the optional coarse free-versus-paid filter.
PaidState string
// PaidExpiresBefore stores the optional strict finite paid-expiry upper
// bound.
PaidExpiresBefore *time.Time
// PaidExpiresAfter stores the optional strict finite paid-expiry lower
// bound.
PaidExpiresAfter *time.Time
// DeclaredCountry stores the optional current declared-country filter.
DeclaredCountry string
// SanctionCode stores the optional active-sanction filter.
SanctionCode string
// LimitCode stores the optional active user-specific limit filter.
LimitCode string
// CanLogin stores the optional derived login-eligibility filter.
CanLogin *bool
// CanCreatePrivateGame stores the optional derived private-game-create
// eligibility filter.
CanCreatePrivateGame *bool
// CanJoinGame stores the optional derived game-join eligibility filter.
CanJoinGame *bool
}
// ListUsersResult stores one trusted administrative page of user aggregates.
type ListUsersResult struct {
// Items stores the returned user aggregates in deterministic order.
Items []accountview.AccountView `json:"items"`
// NextPageToken stores the optional continuation cursor for the next page.
NextPageToken string `json:"next_page_token,omitempty"`
}
type entitlementReader interface {
GetByUserID(ctx context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error)
}
type readSupport struct {
accounts ports.UserAccountStore
loader *accountview.Loader
}
func newReadSupport(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
) (readSupport, error) {
loader, err := accountview.NewLoader(accounts, entitlements, sanctions, limits, clock)
if err != nil {
return readSupport{}, fmt.Errorf("account view loader: %w", err)
}
return readSupport{
accounts: accounts,
loader: loader,
}, nil
}
// ByIDGetter executes exact trusted lookups by stable user identifier.
type ByIDGetter struct {
support readSupport
}
// NewByIDGetter constructs one exact admin lookup by user id.
func NewByIDGetter(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
) (*ByIDGetter, error) {
support, err := newReadSupport(accounts, entitlements, sanctions, limits, clock)
if err != nil {
return nil, fmt.Errorf("admin users by-id getter: %w", err)
}
return &ByIDGetter{support: support}, nil
}
// Execute resolves one exact user by stable user identifier.
func (service *ByIDGetter) Execute(ctx context.Context, input GetUserByIDInput) (LookupResult, error) {
if ctx == nil {
return LookupResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
return LookupResult{}, err
}
aggregate, err := service.support.loader.Load(ctx, userID)
if err != nil {
return LookupResult{}, err
}
return LookupResult{User: aggregate.View()}, nil
}
// ByEmailGetter executes exact trusted lookups by normalized e-mail.
type ByEmailGetter struct {
support readSupport
}
// NewByEmailGetter constructs one exact admin lookup by normalized e-mail.
func NewByEmailGetter(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
) (*ByEmailGetter, error) {
support, err := newReadSupport(accounts, entitlements, sanctions, limits, clock)
if err != nil {
return nil, fmt.Errorf("admin users by-email getter: %w", err)
}
return &ByEmailGetter{support: support}, nil
}
// Execute resolves one exact user by normalized e-mail.
func (service *ByEmailGetter) Execute(ctx context.Context, input GetUserByEmailInput) (LookupResult, error) {
if ctx == nil {
return LookupResult{}, shared.InvalidRequest("context must not be nil")
}
email, err := shared.ParseEmail(input.Email)
if err != nil {
return LookupResult{}, err
}
record, err := service.support.accounts.GetByEmail(ctx, email)
switch {
case err == nil:
case errors.Is(err, ports.ErrNotFound):
return LookupResult{}, shared.SubjectNotFound()
default:
return LookupResult{}, shared.ServiceUnavailable(err)
}
aggregate, err := service.support.loader.Load(ctx, record.UserID)
if err != nil {
return LookupResult{}, err
}
return LookupResult{User: aggregate.View()}, nil
}
// ByRaceNameGetter executes exact trusted lookups by exact stored race name.
type ByRaceNameGetter struct {
support readSupport
}
// NewByRaceNameGetter constructs one exact admin lookup by exact stored race
// name.
func NewByRaceNameGetter(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
) (*ByRaceNameGetter, error) {
support, err := newReadSupport(accounts, entitlements, sanctions, limits, clock)
if err != nil {
return nil, fmt.Errorf("admin users by-race-name getter: %w", err)
}
return &ByRaceNameGetter{support: support}, nil
}
// Execute resolves one exact user by exact stored race name.
func (service *ByRaceNameGetter) Execute(ctx context.Context, input GetUserByRaceNameInput) (LookupResult, error) {
if ctx == nil {
return LookupResult{}, shared.InvalidRequest("context must not be nil")
}
raceName, err := shared.ParseRaceName(input.RaceName)
if err != nil {
return LookupResult{}, err
}
record, err := service.support.accounts.GetByRaceName(ctx, raceName)
switch {
case err == nil:
case errors.Is(err, ports.ErrNotFound):
return LookupResult{}, shared.SubjectNotFound()
default:
return LookupResult{}, shared.ServiceUnavailable(err)
}
aggregate, err := service.support.loader.Load(ctx, record.UserID)
if err != nil {
return LookupResult{}, err
}
return LookupResult{User: aggregate.View()}, nil
}
// Lister executes the trusted administrative filtered user listing.
type Lister struct {
support readSupport
listStore ports.UserListStore
}
// NewLister constructs one trusted administrative filtered user lister.
func NewLister(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
listStore ports.UserListStore,
) (*Lister, error) {
if listStore == nil {
return nil, fmt.Errorf("admin users lister: user list store must not be nil")
}
support, err := newReadSupport(accounts, entitlements, sanctions, limits, clock)
if err != nil {
return nil, fmt.Errorf("admin users lister: %w", err)
}
return &Lister{
support: support,
listStore: listStore,
}, nil
}
// Execute lists users in deterministic newest-first order and combines all
// supplied filters with logical AND semantics.
func (service *Lister) Execute(ctx context.Context, input ListUsersInput) (ListUsersResult, error) {
if ctx == nil {
return ListUsersResult{}, shared.InvalidRequest("context must not be nil")
}
if strings.TrimSpace(input.PageToken) != input.PageToken {
return ListUsersResult{}, shared.InvalidRequest("page_token must not contain surrounding whitespace")
}
pageSize, err := normalizePageSize(input.PageSize)
if err != nil {
return ListUsersResult{}, err
}
filters, err := parseListFilters(input)
if err != nil {
return ListUsersResult{}, err
}
result := ListUsersResult{
Items: make([]accountview.AccountView, 0, pageSize),
}
currentToken := input.PageToken
for len(result.Items) < pageSize {
candidatePage, err := service.listStore.ListUserIDs(ctx, ports.ListUsersInput{
PageSize: 1,
PageToken: currentToken,
Filters: filters,
})
switch {
case err == nil:
case errors.Is(err, ports.ErrInvalidPageToken):
return ListUsersResult{}, shared.InvalidRequest("page_token is invalid or does not match current filters")
default:
return ListUsersResult{}, shared.ServiceUnavailable(err)
}
if len(candidatePage.UserIDs) == 0 {
result.NextPageToken = ""
return result, nil
}
nextToken := candidatePage.NextPageToken
candidateID := candidatePage.UserIDs[0]
aggregate, err := service.support.loader.Load(ctx, candidateID)
if err != nil {
return ListUsersResult{}, err
}
if matchesFilters(aggregate, filters) {
result.Items = append(result.Items, aggregate.View())
result.NextPageToken = nextToken
}
if nextToken == "" {
result.NextPageToken = ""
return result, nil
}
currentToken = nextToken
}
return result, nil
}
func normalizePageSize(value int) (int, error) {
switch {
case value == 0:
return ports.DefaultUserListPageSize, nil
case value < 0:
return 0, shared.InvalidRequest("page_size must be between 1 and 200")
case value > ports.MaxUserListPageSize:
return 0, shared.InvalidRequest("page_size must be between 1 and 200")
default:
return value, nil
}
}
func parseListFilters(input ListUsersInput) (ports.UserListFilters, error) {
paidState, err := parsePaidState(input.PaidState)
if err != nil {
return ports.UserListFilters{}, err
}
declaredCountry, err := parseCountryCode(input.DeclaredCountry)
if err != nil {
return ports.UserListFilters{}, err
}
sanctionCode, err := parseSanctionCode(input.SanctionCode)
if err != nil {
return ports.UserListFilters{}, err
}
limitCode, err := parseLimitCode(input.LimitCode)
if err != nil {
return ports.UserListFilters{}, err
}
filters := ports.UserListFilters{
PaidState: paidState,
PaidExpiresBefore: input.PaidExpiresBefore,
PaidExpiresAfter: input.PaidExpiresAfter,
DeclaredCountry: declaredCountry,
SanctionCode: sanctionCode,
LimitCode: limitCode,
CanLogin: input.CanLogin,
CanCreatePrivateGame: input.CanCreatePrivateGame,
CanJoinGame: input.CanJoinGame,
}
if err := filters.Validate(); err != nil {
return ports.UserListFilters{}, shared.InvalidRequest(err.Error())
}
return filters, nil
}
func parsePaidState(value string) (entitlement.PaidState, error) {
state := entitlement.PaidState(shared.NormalizeString(value))
if !state.IsKnown() {
return "", shared.InvalidRequest(fmt.Sprintf("paid_state %q is unsupported", state))
}
return state, nil
}
func parseCountryCode(value string) (common.CountryCode, error) {
code := common.CountryCode(shared.NormalizeString(value))
if code.IsZero() {
return "", nil
}
if err := code.Validate(); err != nil {
return "", shared.InvalidRequest(fmt.Sprintf("declared_country: %s", err.Error()))
}
return code, nil
}
func parseSanctionCode(value string) (policy.SanctionCode, error) {
code := policy.SanctionCode(shared.NormalizeString(value))
if code == "" {
return "", nil
}
if !code.IsKnown() {
return "", shared.InvalidRequest(fmt.Sprintf("sanction_code %q is unsupported", code))
}
return code, nil
}
func parseLimitCode(value string) (policy.LimitCode, error) {
code := policy.LimitCode(shared.NormalizeString(value))
if code == "" {
return "", nil
}
if !code.IsKnown() {
return "", shared.InvalidRequest(fmt.Sprintf("limit_code %q is unsupported", code))
}
return code, nil
}
func matchesFilters(aggregate accountview.Aggregate, filters ports.UserListFilters) bool {
switch filters.PaidState {
case entitlement.PaidStateFree:
if aggregate.EntitlementSnapshot.IsPaid {
return false
}
case entitlement.PaidStatePaid:
if !aggregate.EntitlementSnapshot.IsPaid {
return false
}
}
if filters.PaidExpiresBefore != nil {
if !aggregate.EntitlementSnapshot.HasFiniteExpiry() || !aggregate.EntitlementSnapshot.EndsAt.Before(filters.PaidExpiresBefore.UTC()) {
return false
}
}
if filters.PaidExpiresAfter != nil {
if !aggregate.EntitlementSnapshot.HasFiniteExpiry() || !aggregate.EntitlementSnapshot.EndsAt.After(filters.PaidExpiresAfter.UTC()) {
return false
}
}
if !filters.DeclaredCountry.IsZero() && aggregate.AccountRecord.DeclaredCountry != filters.DeclaredCountry {
return false
}
if filters.SanctionCode != "" && !aggregate.HasActiveSanction(filters.SanctionCode) {
return false
}
if filters.LimitCode != "" && !aggregate.HasActiveLimit(filters.LimitCode) {
return false
}
canLogin, canCreatePrivateGame, canJoinGame := deriveFilterEligibility(aggregate)
if filters.CanLogin != nil && canLogin != *filters.CanLogin {
return false
}
if filters.CanCreatePrivateGame != nil && canCreatePrivateGame != *filters.CanCreatePrivateGame {
return false
}
if filters.CanJoinGame != nil && canJoinGame != *filters.CanJoinGame {
return false
}
return true
}
func deriveFilterEligibility(aggregate accountview.Aggregate) (bool, bool, bool) {
canLogin := !aggregate.HasActiveSanction(policy.SanctionCodeLoginBlock)
canCreatePrivateGame := canLogin &&
aggregate.EntitlementSnapshot.IsPaid &&
!aggregate.HasActiveSanction(policy.SanctionCodePrivateGameCreateBlock)
canJoinGame := canLogin &&
!aggregate.HasActiveSanction(policy.SanctionCodeGameJoinBlock)
return canLogin, canCreatePrivateGame, canJoinGame
}
@@ -0,0 +1,623 @@
package adminusers
import (
"context"
"errors"
"fmt"
"testing"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/entitlementsvc"
"galaxy/user/internal/service/shared"
"github.com/stretchr/testify/require"
)
func TestByIDGetterExecuteReturnsAggregate(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
service, err := NewByIDGetter(
newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "Pilot Nova", now)),
&fakeAdminEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validAdminFreeSnapshot(common.UserID("user-123"), now),
},
},
fakeAdminSanctionStore{
byUserID: map[common.UserID][]policy.SanctionRecord{
common.UserID("user-123"): {
validAdminActiveSanction(common.UserID("user-123"), policy.SanctionCodeLoginBlock, now.Add(-time.Hour)),
expiredAdminSanction(common.UserID("user-123"), policy.SanctionCodeGameJoinBlock, now.Add(-2*time.Hour)),
},
},
},
fakeAdminLimitStore{
byUserID: map[common.UserID][]policy.LimitRecord{
common.UserID("user-123"): {
validAdminActiveLimit(common.UserID("user-123"), policy.LimitCodeMaxOwnedPrivateGames, 3, now.Add(-time.Hour)),
},
},
},
adminFixedClock{now: now},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), GetUserByIDInput{UserID: " user-123 "})
require.NoError(t, err)
require.Equal(t, "user-123", result.User.UserID)
require.Equal(t, "pilot@example.com", result.User.Email)
require.Len(t, result.User.ActiveSanctions, 1)
require.Equal(t, string(policy.SanctionCodeLoginBlock), result.User.ActiveSanctions[0].SanctionCode)
require.Len(t, result.User.ActiveLimits, 1)
require.Equal(t, string(policy.LimitCodeMaxOwnedPrivateGames), result.User.ActiveLimits[0].LimitCode)
}
func TestByEmailGetterExecuteUnknownUserReturnsNotFound(t *testing.T) {
t.Parallel()
service, err := NewByEmailGetter(
newFakeAdminAccountStore(),
&fakeAdminEntitlementSnapshotStore{},
fakeAdminSanctionStore{},
fakeAdminLimitStore{},
adminFixedClock{now: time.Unix(1_775_240_500, 0).UTC()},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), GetUserByEmailInput{Email: "missing@example.com"})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err))
}
func TestByRaceNameGetterExecuteReturnsAggregate(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
service, err := NewByRaceNameGetter(
newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "Pilot Nova", now)),
&fakeAdminEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validAdminFreeSnapshot(common.UserID("user-123"), now),
},
},
fakeAdminSanctionStore{},
fakeAdminLimitStore{},
adminFixedClock{now: now},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), GetUserByRaceNameInput{RaceName: " Pilot Nova "})
require.NoError(t, err)
require.Equal(t, "user-123", result.User.UserID)
require.Equal(t, "Pilot Nova", result.User.RaceName)
}
func TestListerExecuteAppliesCombinedFiltersWithLogicalAND(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
firstExpiry := now.Add(48 * time.Hour)
secondExpiry := now.Add(72 * time.Hour)
before := now.Add(96 * time.Hour)
after := now.Add(24 * time.Hour)
canLogin := false
canCreatePrivateGame := false
canJoinGame := false
accountStore := newFakeAdminAccountStore(
validAdminUserAccount("user-300", "u300@example.com", "User 300", now),
validAdminUserAccount("user-200", "u200@example.com", "User 200", now),
validAdminUserAccount("user-100", "u100@example.com", "User 100", now),
)
snapshotStore := &fakeAdminEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-300"): validAdminPaidSnapshot(common.UserID("user-300"), now, firstExpiry),
common.UserID("user-200"): validAdminPaidSnapshot(common.UserID("user-200"), now, secondExpiry),
common.UserID("user-100"): validAdminPaidSnapshot(common.UserID("user-100"), now, secondExpiry),
},
}
sanctionStore := fakeAdminSanctionStore{
byUserID: map[common.UserID][]policy.SanctionRecord{
common.UserID("user-300"): {
validAdminActiveSanction(common.UserID("user-300"), policy.SanctionCodeLoginBlock, now.Add(-time.Hour)),
},
common.UserID("user-200"): {
validAdminActiveSanction(common.UserID("user-200"), policy.SanctionCodeLoginBlock, now.Add(-time.Hour)),
},
common.UserID("user-100"): {
validAdminActiveSanction(common.UserID("user-100"), policy.SanctionCodeLoginBlock, now.Add(-time.Hour)),
},
},
}
limitStore := fakeAdminLimitStore{
byUserID: map[common.UserID][]policy.LimitRecord{
common.UserID("user-300"): {
validAdminActiveLimit(common.UserID("user-300"), policy.LimitCodeMaxOwnedPrivateGames, 3, now.Add(-time.Hour)),
},
common.UserID("user-100"): {
validAdminActiveLimit(common.UserID("user-100"), policy.LimitCodeMaxOwnedPrivateGames, 3, now.Add(-time.Hour)),
},
},
}
listStore := &fakeAdminListStore{
pages: map[string]ports.ListUsersResult{
"": {
UserIDs: []common.UserID{common.UserID("user-300")},
NextPageToken: "cursor-1",
},
"cursor-1": {
UserIDs: []common.UserID{common.UserID("user-200")},
NextPageToken: "cursor-2",
},
"cursor-2": {
UserIDs: []common.UserID{common.UserID("user-100")},
NextPageToken: "",
},
},
}
service, err := NewLister(accountStore, snapshotStore, sanctionStore, limitStore, adminFixedClock{now: now}, listStore)
require.NoError(t, err)
result, err := service.Execute(context.Background(), ListUsersInput{
PageSize: 2,
PaidState: "paid",
PaidExpiresBefore: &before,
PaidExpiresAfter: &after,
DeclaredCountry: "DE",
SanctionCode: "login_block",
LimitCode: "max_owned_private_games",
CanLogin: &canLogin,
CanCreatePrivateGame: &canCreatePrivateGame,
CanJoinGame: &canJoinGame,
})
require.NoError(t, err)
require.Len(t, result.Items, 2)
require.Equal(t, "user-300", result.Items[0].UserID)
require.Equal(t, "user-100", result.Items[1].UserID)
require.Equal(t, "", result.NextPageToken)
require.Len(t, listStore.calls, 3)
for _, call := range listStore.calls {
require.Equal(t, 1, call.PageSize)
require.Equal(t, entitlement.PaidStatePaid, call.Filters.PaidState)
require.Equal(t, common.CountryCode("DE"), call.Filters.DeclaredCountry)
require.Equal(t, policy.SanctionCodeLoginBlock, call.Filters.SanctionCode)
require.Equal(t, policy.LimitCodeMaxOwnedPrivateGames, call.Filters.LimitCode)
}
}
func TestListerExecuteDefaultAndMaximumPageSize(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
accountStore := newFakeAdminAccountStore(
validAdminUserAccount("user-300", "u300@example.com", "User 300", now),
validAdminUserAccount("user-200", "u200@example.com", "User 200", now),
validAdminUserAccount("user-100", "u100@example.com", "User 100", now),
)
snapshotStore := &fakeAdminEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-300"): validAdminFreeSnapshot(common.UserID("user-300"), now),
common.UserID("user-200"): validAdminFreeSnapshot(common.UserID("user-200"), now),
common.UserID("user-100"): validAdminFreeSnapshot(common.UserID("user-100"), now),
},
}
t.Run("default page size", func(t *testing.T) {
t.Parallel()
listStore := &fakeAdminListStore{
pages: map[string]ports.ListUsersResult{
"": {
UserIDs: []common.UserID{common.UserID("user-300")},
NextPageToken: "cursor-1",
},
"cursor-1": {
UserIDs: []common.UserID{common.UserID("user-200")},
NextPageToken: "cursor-2",
},
"cursor-2": {
UserIDs: []common.UserID{common.UserID("user-100")},
NextPageToken: "",
},
},
}
service, err := NewLister(accountStore, snapshotStore, fakeAdminSanctionStore{}, fakeAdminLimitStore{}, adminFixedClock{now: now}, listStore)
require.NoError(t, err)
result, err := service.Execute(context.Background(), ListUsersInput{})
require.NoError(t, err)
require.Len(t, result.Items, 3)
})
t.Run("maximum page size", func(t *testing.T) {
t.Parallel()
listStore := &fakeAdminListStore{
pages: map[string]ports.ListUsersResult{
"": {
UserIDs: []common.UserID{common.UserID("user-300")},
NextPageToken: "",
},
},
}
service, err := NewLister(accountStore, snapshotStore, fakeAdminSanctionStore{}, fakeAdminLimitStore{}, adminFixedClock{now: now}, listStore)
require.NoError(t, err)
result, err := service.Execute(context.Background(), ListUsersInput{PageSize: ports.MaxUserListPageSize})
require.NoError(t, err)
require.Len(t, result.Items, 1)
})
t.Run("above maximum is rejected", func(t *testing.T) {
t.Parallel()
service, err := NewLister(accountStore, snapshotStore, fakeAdminSanctionStore{}, fakeAdminLimitStore{}, adminFixedClock{now: now}, &fakeAdminListStore{})
require.NoError(t, err)
_, err = service.Execute(context.Background(), ListUsersInput{PageSize: ports.MaxUserListPageSize + 1})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
require.Equal(t, "page_size must be between 1 and 200", err.Error())
})
}
func TestListerExecuteInvalidPageTokenReturnsInvalidRequest(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
service, err := NewLister(
newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "Pilot Nova", now)),
&fakeAdminEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validAdminFreeSnapshot(common.UserID("user-123"), now),
},
},
fakeAdminSanctionStore{},
fakeAdminLimitStore{},
adminFixedClock{now: now},
&fakeAdminListStore{err: fmt.Errorf("wrapped: %w", ports.ErrInvalidPageToken)},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), ListUsersInput{PageToken: "bad-token"})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
require.Equal(t, "page_token is invalid or does not match current filters", err.Error())
}
func TestListerExecuteRepairsExpiredPaidSnapshotBeforeFiltering(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
expiredAt := now.Add(-time.Hour)
accountStore := newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "Pilot Nova", now))
snapshotStore := &fakeAdminEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): {
UserID: common.UserID("user-123"),
PlanCode: entitlement.PlanCodePaidMonthly,
IsPaid: true,
StartsAt: now.Add(-30 * 24 * time.Hour),
EndsAt: adminTimePointer(expiredAt),
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_grant"),
UpdatedAt: expiredAt,
},
},
}
reader, err := entitlementsvc.NewReader(
snapshotStore,
&fakeAdminEntitlementLifecycleStore{snapshotStore: snapshotStore},
adminFixedClock{now: now},
adminReaderIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-repair-free-record")},
)
require.NoError(t, err)
listStore := &fakeAdminListStore{
pages: map[string]ports.ListUsersResult{
"": {
UserIDs: []common.UserID{common.UserID("user-123")},
NextPageToken: "",
},
},
}
service, err := NewLister(accountStore, reader, fakeAdminSanctionStore{}, fakeAdminLimitStore{}, adminFixedClock{now: now}, listStore)
require.NoError(t, err)
result, err := service.Execute(context.Background(), ListUsersInput{PaidState: "free"})
require.NoError(t, err)
require.Len(t, result.Items, 1)
require.Equal(t, "free", result.Items[0].Entitlement.PlanCode)
require.False(t, result.Items[0].Entitlement.IsPaid)
storedSnapshot, err := snapshotStore.GetByUserID(context.Background(), common.UserID("user-123"))
require.NoError(t, err)
require.Equal(t, entitlement.PlanCodeFree, storedSnapshot.PlanCode)
require.False(t, storedSnapshot.IsPaid)
require.Equal(t, expiredAt, storedSnapshot.StartsAt)
}
type adminFixedClock struct {
now time.Time
}
func (clock adminFixedClock) Now() time.Time {
return clock.now
}
type adminReaderIDGenerator struct {
recordID entitlement.EntitlementRecordID
}
func (generator adminReaderIDGenerator) NewUserID() (common.UserID, error) {
return "", errors.New("unexpected NewUserID call")
}
func (generator adminReaderIDGenerator) NewInitialRaceName() (common.RaceName, error) {
return "", errors.New("unexpected NewInitialRaceName call")
}
func (generator adminReaderIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
return generator.recordID, nil
}
func (generator adminReaderIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
return "", errors.New("unexpected NewSanctionRecordID call")
}
func (generator adminReaderIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
return "", errors.New("unexpected NewLimitRecordID call")
}
type fakeAdminAccountStore struct {
byUserID map[common.UserID]account.UserAccount
byEmail map[common.Email]common.UserID
byRaceName map[common.RaceName]common.UserID
updateErr error
renameErr error
createErr error
existsByID map[common.UserID]bool
}
func newFakeAdminAccountStore(records ...account.UserAccount) *fakeAdminAccountStore {
store := &fakeAdminAccountStore{
byUserID: make(map[common.UserID]account.UserAccount, len(records)),
byEmail: make(map[common.Email]common.UserID, len(records)),
byRaceName: make(map[common.RaceName]common.UserID, len(records)),
existsByID: make(map[common.UserID]bool, len(records)),
}
for _, record := range records {
store.byUserID[record.UserID] = record
store.byEmail[record.Email] = record.UserID
store.byRaceName[record.RaceName] = record.UserID
store.existsByID[record.UserID] = true
}
return store
}
func (store *fakeAdminAccountStore) Create(context.Context, ports.CreateAccountInput) error {
return store.createErr
}
func (store *fakeAdminAccountStore) GetByUserID(_ context.Context, userID common.UserID) (account.UserAccount, error) {
record, ok := store.byUserID[userID]
if !ok {
return account.UserAccount{}, ports.ErrNotFound
}
return record, nil
}
func (store *fakeAdminAccountStore) GetByEmail(_ context.Context, email common.Email) (account.UserAccount, error) {
userID, ok := store.byEmail[email]
if !ok {
return account.UserAccount{}, ports.ErrNotFound
}
return store.byUserID[userID], nil
}
func (store *fakeAdminAccountStore) GetByRaceName(_ context.Context, raceName common.RaceName) (account.UserAccount, error) {
userID, ok := store.byRaceName[raceName]
if !ok {
return account.UserAccount{}, ports.ErrNotFound
}
return store.byUserID[userID], nil
}
func (store *fakeAdminAccountStore) ExistsByUserID(_ context.Context, userID common.UserID) (bool, error) {
return store.existsByID[userID], nil
}
func (store *fakeAdminAccountStore) RenameRaceName(context.Context, ports.RenameRaceNameInput) error {
return store.renameErr
}
func (store *fakeAdminAccountStore) Update(context.Context, account.UserAccount) error {
return store.updateErr
}
type fakeAdminEntitlementSnapshotStore struct {
byUserID map[common.UserID]entitlement.CurrentSnapshot
}
func (store *fakeAdminEntitlementSnapshotStore) GetByUserID(_ context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error) {
record, ok := store.byUserID[userID]
if !ok {
return entitlement.CurrentSnapshot{}, ports.ErrNotFound
}
return record, nil
}
func (store *fakeAdminEntitlementSnapshotStore) Put(_ context.Context, record entitlement.CurrentSnapshot) error {
if store.byUserID == nil {
store.byUserID = make(map[common.UserID]entitlement.CurrentSnapshot)
}
store.byUserID[record.UserID] = record
return nil
}
type fakeAdminEntitlementLifecycleStore struct {
snapshotStore *fakeAdminEntitlementSnapshotStore
}
func (store *fakeAdminEntitlementLifecycleStore) Grant(context.Context, ports.GrantEntitlementInput) error {
return errors.New("unexpected Grant call")
}
func (store *fakeAdminEntitlementLifecycleStore) Extend(context.Context, ports.ExtendEntitlementInput) error {
return errors.New("unexpected Extend call")
}
func (store *fakeAdminEntitlementLifecycleStore) Revoke(context.Context, ports.RevokeEntitlementInput) error {
return errors.New("unexpected Revoke call")
}
func (store *fakeAdminEntitlementLifecycleStore) RepairExpired(ctx context.Context, input ports.RepairExpiredEntitlementInput) error {
return store.snapshotStore.Put(ctx, input.NewSnapshot)
}
type fakeAdminSanctionStore struct {
byUserID map[common.UserID][]policy.SanctionRecord
}
func (store fakeAdminSanctionStore) Create(context.Context, policy.SanctionRecord) error {
return nil
}
func (store fakeAdminSanctionStore) GetByRecordID(context.Context, policy.SanctionRecordID) (policy.SanctionRecord, error) {
return policy.SanctionRecord{}, ports.ErrNotFound
}
func (store fakeAdminSanctionStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.SanctionRecord, error) {
return append([]policy.SanctionRecord(nil), store.byUserID[userID]...), nil
}
func (store fakeAdminSanctionStore) Update(context.Context, policy.SanctionRecord) error {
return nil
}
type fakeAdminLimitStore struct {
byUserID map[common.UserID][]policy.LimitRecord
}
func (store fakeAdminLimitStore) Create(context.Context, policy.LimitRecord) error {
return nil
}
func (store fakeAdminLimitStore) GetByRecordID(context.Context, policy.LimitRecordID) (policy.LimitRecord, error) {
return policy.LimitRecord{}, ports.ErrNotFound
}
func (store fakeAdminLimitStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.LimitRecord, error) {
return append([]policy.LimitRecord(nil), store.byUserID[userID]...), nil
}
func (store fakeAdminLimitStore) Update(context.Context, policy.LimitRecord) error {
return nil
}
type fakeAdminListStore struct {
pages map[string]ports.ListUsersResult
err error
calls []ports.ListUsersInput
}
func (store *fakeAdminListStore) ListUserIDs(_ context.Context, input ports.ListUsersInput) (ports.ListUsersResult, error) {
store.calls = append(store.calls, input)
if store.err != nil {
return ports.ListUsersResult{}, store.err
}
result, ok := store.pages[input.PageToken]
if !ok {
return ports.ListUsersResult{}, nil
}
return result, nil
}
func validAdminUserAccount(userID string, email string, raceName string, now time.Time) account.UserAccount {
return account.UserAccount{
UserID: common.UserID(userID),
Email: common.Email(email),
RaceName: common.RaceName(raceName),
PreferredLanguage: common.LanguageTag("en"),
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
DeclaredCountry: common.CountryCode("DE"),
CreatedAt: now,
UpdatedAt: now,
}
}
func validAdminFreeSnapshot(userID common.UserID, now time.Time) entitlement.CurrentSnapshot {
return entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
IsPaid: false,
StartsAt: now,
Source: common.Source("auth_registration"),
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
ReasonCode: common.ReasonCode("initial_free_entitlement"),
UpdatedAt: now,
}
}
func validAdminPaidSnapshot(userID common.UserID, now time.Time, endsAt time.Time) entitlement.CurrentSnapshot {
return entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodePaidMonthly,
IsPaid: true,
StartsAt: now.Add(-24 * time.Hour),
EndsAt: adminTimePointer(endsAt),
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_grant"),
UpdatedAt: now,
}
}
func validAdminActiveSanction(userID common.UserID, code policy.SanctionCode, appliedAt time.Time) policy.SanctionRecord {
return policy.SanctionRecord{
RecordID: policy.SanctionRecordID("sanction-" + string(code) + "-" + userID.String()),
UserID: userID,
SanctionCode: code,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("manual_block"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: appliedAt,
}
}
func expiredAdminSanction(userID common.UserID, code policy.SanctionCode, appliedAt time.Time) policy.SanctionRecord {
record := validAdminActiveSanction(userID, code, appliedAt)
record.ExpiresAt = adminTimePointer(appliedAt.Add(30 * time.Minute))
return record
}
func validAdminActiveLimit(userID common.UserID, code policy.LimitCode, value int, appliedAt time.Time) policy.LimitRecord {
return policy.LimitRecord{
RecordID: policy.LimitRecordID("limit-" + string(code) + "-" + userID.String()),
UserID: userID,
LimitCode: code,
Value: value,
ReasonCode: common.ReasonCode("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: appliedAt,
}
}
func adminTimePointer(value time.Time) *time.Time {
copied := value.UTC()
return &copied
}
@@ -0,0 +1,614 @@
// Package authdirectory implements the auth-facing user-resolution, ensure,
// existence, and block use cases owned by the user service.
package authdirectory
import (
"context"
"errors"
"fmt"
"log/slog"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/shared"
"galaxy/user/internal/telemetry"
)
const (
initialEntitlementSource common.Source = "auth_registration"
initialEntitlementReasonCode common.ReasonCode = "initial_free_entitlement"
initialEntitlementActorType common.ActorType = "service"
initialEntitlementActorID common.ActorID = "user-service"
ensureCreateRetryLimit = 8
)
// ResolveByEmailInput stores one auth-facing resolve-by-email request.
type ResolveByEmailInput struct {
// Email stores the caller-supplied e-mail subject.
Email string
}
// ResolveByEmailResult stores one auth-facing resolve-by-email response.
type ResolveByEmailResult struct {
// Kind stores the coarse user-resolution outcome.
Kind string
// UserID is present only when Kind is `existing`.
UserID string
// BlockReasonCode is present only when Kind is `blocked`.
BlockReasonCode string
}
// Resolver executes the auth-facing resolve-by-email use case.
type Resolver struct {
store ports.AuthDirectoryStore
logger *slog.Logger
telemetry *telemetry.Runtime
}
// NewResolver returns one resolve-by-email use case instance.
func NewResolver(store ports.AuthDirectoryStore) (*Resolver, error) {
return NewResolverWithObservability(store, nil, nil)
}
// NewResolverWithObservability returns one resolve-by-email use case instance
// with optional structured logging and metrics hooks.
func NewResolverWithObservability(
store ports.AuthDirectoryStore,
logger *slog.Logger,
telemetryRuntime *telemetry.Runtime,
) (*Resolver, error) {
if store == nil {
return nil, fmt.Errorf("authdirectory resolver: auth directory store must not be nil")
}
return &Resolver{
store: store,
logger: logger,
telemetry: telemetryRuntime,
}, nil
}
// Execute resolves one e-mail subject without creating any account.
func (service *Resolver) Execute(ctx context.Context, input ResolveByEmailInput) (result ResolveByEmailResult, err error) {
outcome := "failed"
defer func() {
if service.telemetry != nil {
service.telemetry.RecordAuthResolutionOutcome(ctx, "resolve_by_email", outcome)
}
if err != nil {
shared.LogServiceOutcome(service.logger, ctx, "auth resolution failed", err,
"use_case", "resolve_by_email",
"outcome", outcome,
)
}
}()
if ctx == nil {
return ResolveByEmailResult{}, shared.InvalidRequest("context must not be nil")
}
email, err := shared.ParseEmail(input.Email)
if err != nil {
return ResolveByEmailResult{}, err
}
resolution, err := service.store.ResolveByEmail(ctx, email)
if err != nil {
return ResolveByEmailResult{}, shared.ServiceUnavailable(err)
}
if err := resolution.Validate(); err != nil {
return ResolveByEmailResult{}, shared.InternalError(err)
}
result = ResolveByEmailResult{
Kind: string(resolution.Kind),
}
if !resolution.UserID.IsZero() {
result.UserID = resolution.UserID.String()
}
if !resolution.BlockReasonCode.IsZero() {
result.BlockReasonCode = resolution.BlockReasonCode.String()
}
outcome = result.Kind
return result, nil
}
// RegistrationContext stores the create-only auth-facing initialization
// context forwarded by authsession.
type RegistrationContext struct {
// PreferredLanguage stores the initial preferred language.
PreferredLanguage string
// TimeZone stores the initial declared time-zone name.
TimeZone string
}
// EnsureByEmailInput stores one auth-facing ensure-by-email request.
type EnsureByEmailInput struct {
// Email stores the caller-supplied e-mail subject.
Email string
// RegistrationContext stores the required create-only registration context.
RegistrationContext *RegistrationContext
}
// EnsureByEmailResult stores one auth-facing ensure-by-email response.
type EnsureByEmailResult struct {
// Outcome stores the coarse ensure outcome.
Outcome string
// UserID is present only for `existing` and `created`.
UserID string
// BlockReasonCode is present only for `blocked`.
BlockReasonCode string
}
// Ensurer executes the auth-facing ensure-by-email use case.
type Ensurer struct {
store ports.AuthDirectoryStore
clock ports.Clock
idGenerator ports.IDGenerator
policy ports.RaceNamePolicy
logger *slog.Logger
telemetry *telemetry.Runtime
profilePublisher ports.ProfileChangedPublisher
settingsPublisher ports.SettingsChangedPublisher
entitlementPublisher ports.EntitlementChangedPublisher
}
// NewEnsurer returns one ensure-by-email use case instance.
func NewEnsurer(
store ports.AuthDirectoryStore,
clock ports.Clock,
idGenerator ports.IDGenerator,
policy ports.RaceNamePolicy,
) (*Ensurer, error) {
return NewEnsurerWithObservability(store, clock, idGenerator, policy, nil, nil, nil, nil, nil)
}
// NewEnsurerWithObservability returns one ensure-by-email use case instance
// with optional structured logging, metrics, and post-commit event
// publication hooks.
func NewEnsurerWithObservability(
store ports.AuthDirectoryStore,
clock ports.Clock,
idGenerator ports.IDGenerator,
policy ports.RaceNamePolicy,
logger *slog.Logger,
telemetryRuntime *telemetry.Runtime,
profilePublisher ports.ProfileChangedPublisher,
settingsPublisher ports.SettingsChangedPublisher,
entitlementPublisher ports.EntitlementChangedPublisher,
) (*Ensurer, error) {
switch {
case store == nil:
return nil, fmt.Errorf("authdirectory ensurer: auth directory store must not be nil")
case clock == nil:
return nil, fmt.Errorf("authdirectory ensurer: clock must not be nil")
case idGenerator == nil:
return nil, fmt.Errorf("authdirectory ensurer: id generator must not be nil")
case policy == nil:
return nil, fmt.Errorf("authdirectory ensurer: race-name policy must not be nil")
default:
return &Ensurer{
store: store,
clock: clock,
idGenerator: idGenerator,
policy: policy,
logger: logger,
telemetry: telemetryRuntime,
profilePublisher: profilePublisher,
settingsPublisher: settingsPublisher,
entitlementPublisher: entitlementPublisher,
}, nil
}
}
// Execute ensures that one e-mail subject maps to an existing user, a newly
// created user, or a blocked outcome.
func (service *Ensurer) Execute(ctx context.Context, input EnsureByEmailInput) (result EnsureByEmailResult, err error) {
outcome := "failed"
userIDString := ""
defer func() {
if service.telemetry != nil {
service.telemetry.RecordUserCreationOutcome(ctx, outcome)
}
shared.LogServiceOutcome(service.logger, ctx, "ensure by email completed", err,
"use_case", "ensure_by_email",
"outcome", outcome,
"user_id", userIDString,
"source", initialEntitlementSource.String(),
)
}()
if ctx == nil {
return EnsureByEmailResult{}, shared.InvalidRequest("context must not be nil")
}
email, err := shared.ParseEmail(input.Email)
if err != nil {
return EnsureByEmailResult{}, err
}
if input.RegistrationContext == nil {
return EnsureByEmailResult{}, shared.InvalidRequest("registration_context must be present")
}
preferredLanguage, err := shared.ParseRegistrationPreferredLanguage(input.RegistrationContext.PreferredLanguage)
if err != nil {
return EnsureByEmailResult{}, err
}
timeZone, err := shared.ParseRegistrationTimeZoneName(input.RegistrationContext.TimeZone)
if err != nil {
return EnsureByEmailResult{}, err
}
now := service.clock.Now().UTC()
for attempt := 0; attempt < ensureCreateRetryLimit; attempt++ {
userID, err := service.idGenerator.NewUserID()
if err != nil {
return EnsureByEmailResult{}, shared.ServiceUnavailable(err)
}
raceName, err := service.idGenerator.NewInitialRaceName()
if err != nil {
return EnsureByEmailResult{}, shared.ServiceUnavailable(err)
}
accountRecord := account.UserAccount{
UserID: userID,
Email: email,
RaceName: raceName,
PreferredLanguage: preferredLanguage,
TimeZone: timeZone,
CreatedAt: now,
UpdatedAt: now,
}
entitlementSnapshot := entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
IsPaid: false,
StartsAt: now,
Source: initialEntitlementSource,
Actor: common.ActorRef{Type: initialEntitlementActorType, ID: initialEntitlementActorID},
ReasonCode: initialEntitlementReasonCode,
UpdatedAt: now,
}
entitlementRecordID, err := service.idGenerator.NewEntitlementRecordID()
if err != nil {
return EnsureByEmailResult{}, shared.ServiceUnavailable(err)
}
entitlementRecord := entitlement.PeriodRecord{
RecordID: entitlementRecordID,
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
Source: initialEntitlementSource,
Actor: common.ActorRef{Type: initialEntitlementActorType, ID: initialEntitlementActorID},
ReasonCode: initialEntitlementReasonCode,
StartsAt: now,
CreatedAt: now,
}
reservation, err := shared.BuildRaceNameReservation(service.policy, userID, raceName, now)
if err != nil {
return EnsureByEmailResult{}, shared.ServiceUnavailable(err)
}
ensureResult, err := service.store.EnsureByEmail(ctx, ports.EnsureByEmailInput{
Email: email,
Account: accountRecord,
Entitlement: entitlementSnapshot,
EntitlementRecord: entitlementRecord,
Reservation: reservation,
})
if err != nil {
if errors.Is(err, ports.ErrRaceNameConflict) && service.telemetry != nil {
service.telemetry.RecordRaceNameReservationConflict(ctx, "ensure_by_email")
}
if errors.Is(err, ports.ErrConflict) {
continue
}
return EnsureByEmailResult{}, shared.ServiceUnavailable(err)
}
if err := ensureResult.Validate(); err != nil {
return EnsureByEmailResult{}, shared.InternalError(err)
}
result = EnsureByEmailResult{
Outcome: string(ensureResult.Outcome),
}
if !ensureResult.UserID.IsZero() {
result.UserID = ensureResult.UserID.String()
userIDString = result.UserID
}
if !ensureResult.BlockReasonCode.IsZero() {
result.BlockReasonCode = ensureResult.BlockReasonCode.String()
}
outcome = result.Outcome
if result.Outcome == string(ports.EnsureByEmailOutcomeCreated) {
service.publishInitializedEvents(ctx, accountRecord, entitlementSnapshot)
}
return result, nil
}
return EnsureByEmailResult{}, shared.ServiceUnavailable(fmt.Errorf("ensure-by-email conflict retry limit exceeded"))
}
func (service *Ensurer) publishInitializedEvents(
ctx context.Context,
accountRecord account.UserAccount,
entitlementSnapshot entitlement.CurrentSnapshot,
) {
occurredAt := accountRecord.UpdatedAt.UTC()
service.publishProfileChanged(ctx, ports.ProfileChangedEvent{
UserID: accountRecord.UserID,
OccurredAt: occurredAt,
Source: initialEntitlementSource,
Operation: ports.ProfileChangedOperationInitialized,
RaceName: accountRecord.RaceName,
})
service.publishSettingsChanged(ctx, ports.SettingsChangedEvent{
UserID: accountRecord.UserID,
OccurredAt: occurredAt,
Source: initialEntitlementSource,
Operation: ports.SettingsChangedOperationInitialized,
PreferredLanguage: accountRecord.PreferredLanguage,
TimeZone: accountRecord.TimeZone,
})
service.publishEntitlementChanged(ctx, ports.EntitlementChangedEvent{
UserID: entitlementSnapshot.UserID,
OccurredAt: occurredAt,
Source: initialEntitlementSource,
Operation: ports.EntitlementChangedOperationInitialized,
PlanCode: entitlementSnapshot.PlanCode,
IsPaid: entitlementSnapshot.IsPaid,
StartsAt: entitlementSnapshot.StartsAt,
EndsAt: entitlementSnapshot.EndsAt,
ReasonCode: entitlementSnapshot.ReasonCode,
Actor: entitlementSnapshot.Actor,
UpdatedAt: entitlementSnapshot.UpdatedAt,
})
}
func (service *Ensurer) publishProfileChanged(ctx context.Context, event ports.ProfileChangedEvent) {
if service.profilePublisher == nil {
return
}
if err := service.profilePublisher.PublishProfileChanged(ctx, event); err != nil {
if service.telemetry != nil {
service.telemetry.RecordEventPublicationFailure(ctx, ports.ProfileChangedEventType)
}
shared.LogEventPublicationFailure(service.logger, ctx, ports.ProfileChangedEventType, err,
"use_case", "ensure_by_email",
"user_id", event.UserID.String(),
"source", event.Source.String(),
)
}
}
func (service *Ensurer) publishSettingsChanged(ctx context.Context, event ports.SettingsChangedEvent) {
if service.settingsPublisher == nil {
return
}
if err := service.settingsPublisher.PublishSettingsChanged(ctx, event); err != nil {
if service.telemetry != nil {
service.telemetry.RecordEventPublicationFailure(ctx, ports.SettingsChangedEventType)
}
shared.LogEventPublicationFailure(service.logger, ctx, ports.SettingsChangedEventType, err,
"use_case", "ensure_by_email",
"user_id", event.UserID.String(),
"source", event.Source.String(),
)
}
}
func (service *Ensurer) publishEntitlementChanged(ctx context.Context, event ports.EntitlementChangedEvent) {
if service.entitlementPublisher == nil {
return
}
if err := service.entitlementPublisher.PublishEntitlementChanged(ctx, event); err != nil {
if service.telemetry != nil {
service.telemetry.RecordEventPublicationFailure(ctx, ports.EntitlementChangedEventType)
}
shared.LogEventPublicationFailure(service.logger, ctx, ports.EntitlementChangedEventType, err,
"use_case", "ensure_by_email",
"user_id", event.UserID.String(),
"source", event.Source.String(),
"reason_code", event.ReasonCode.String(),
"actor_type", event.Actor.Type.String(),
"actor_id", event.Actor.ID.String(),
)
}
}
// ExistsByUserIDInput stores one auth-facing existence check request.
type ExistsByUserIDInput struct {
// UserID stores the caller-supplied stable user identifier.
UserID string
}
// ExistsByUserIDResult stores one auth-facing existence check response.
type ExistsByUserIDResult struct {
// Exists reports whether the supplied user identifier currently exists.
Exists bool
}
// ExistenceChecker executes the auth-facing exists-by-user-id use case.
type ExistenceChecker struct {
store ports.AuthDirectoryStore
}
// NewExistenceChecker returns one exists-by-user-id use case instance.
func NewExistenceChecker(store ports.AuthDirectoryStore) (*ExistenceChecker, error) {
if store == nil {
return nil, fmt.Errorf("authdirectory existence checker: auth directory store must not be nil")
}
return &ExistenceChecker{store: store}, nil
}
// Execute reports whether one stable user identifier exists.
func (service *ExistenceChecker) Execute(ctx context.Context, input ExistsByUserIDInput) (ExistsByUserIDResult, error) {
if ctx == nil {
return ExistsByUserIDResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
return ExistsByUserIDResult{}, err
}
exists, err := service.store.ExistsByUserID(ctx, userID)
if err != nil {
return ExistsByUserIDResult{}, shared.ServiceUnavailable(err)
}
return ExistsByUserIDResult{Exists: exists}, nil
}
// BlockByUserIDInput stores one auth-facing block-by-user-id request.
type BlockByUserIDInput struct {
// UserID stores the stable account identifier that must be blocked.
UserID string
// ReasonCode stores the machine-readable block reason.
ReasonCode string
}
// BlockByEmailInput stores one auth-facing block-by-email request.
type BlockByEmailInput struct {
// Email stores the exact normalized e-mail subject that must be blocked.
Email string
// ReasonCode stores the machine-readable block reason.
ReasonCode string
}
// BlockResult stores one auth-facing block response.
type BlockResult struct {
// Outcome reports whether the current call created a new block.
Outcome string
// UserID stores the resolved account when the blocked subject belongs to an
// existing user.
UserID string
}
// BlockByUserIDService executes the auth-facing block-by-user-id use case.
type BlockByUserIDService struct {
store ports.AuthDirectoryStore
clock ports.Clock
}
// NewBlockByUserIDService returns one block-by-user-id use case instance.
func NewBlockByUserIDService(store ports.AuthDirectoryStore, clock ports.Clock) (*BlockByUserIDService, error) {
switch {
case store == nil:
return nil, fmt.Errorf("authdirectory block-by-user-id service: auth directory store must not be nil")
case clock == nil:
return nil, fmt.Errorf("authdirectory block-by-user-id service: clock must not be nil")
default:
return &BlockByUserIDService{store: store, clock: clock}, nil
}
}
// Execute blocks one account addressed by stable user identifier.
func (service *BlockByUserIDService) Execute(ctx context.Context, input BlockByUserIDInput) (BlockResult, error) {
if ctx == nil {
return BlockResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
return BlockResult{}, err
}
reasonCode, err := shared.ParseReasonCode(input.ReasonCode)
if err != nil {
return BlockResult{}, err
}
result, err := service.store.BlockByUserID(ctx, ports.BlockByUserIDInput{
UserID: userID,
ReasonCode: reasonCode,
BlockedAt: service.clock.Now().UTC(),
})
if err != nil {
switch {
case errors.Is(err, ports.ErrNotFound):
return BlockResult{}, shared.SubjectNotFound()
default:
return BlockResult{}, shared.ServiceUnavailable(err)
}
}
if err := result.Validate(); err != nil {
return BlockResult{}, shared.InternalError(err)
}
response := BlockResult{Outcome: string(result.Outcome)}
if !result.UserID.IsZero() {
response.UserID = result.UserID.String()
}
return response, nil
}
// BlockByEmailService executes the auth-facing block-by-email use case.
type BlockByEmailService struct {
store ports.AuthDirectoryStore
clock ports.Clock
}
// NewBlockByEmailService returns one block-by-email use case instance.
func NewBlockByEmailService(store ports.AuthDirectoryStore, clock ports.Clock) (*BlockByEmailService, error) {
switch {
case store == nil:
return nil, fmt.Errorf("authdirectory block-by-email service: auth directory store must not be nil")
case clock == nil:
return nil, fmt.Errorf("authdirectory block-by-email service: clock must not be nil")
default:
return &BlockByEmailService{store: store, clock: clock}, nil
}
}
// Execute blocks one exact normalized e-mail subject.
func (service *BlockByEmailService) Execute(ctx context.Context, input BlockByEmailInput) (BlockResult, error) {
if ctx == nil {
return BlockResult{}, shared.InvalidRequest("context must not be nil")
}
email, err := shared.ParseEmail(input.Email)
if err != nil {
return BlockResult{}, err
}
reasonCode, err := shared.ParseReasonCode(input.ReasonCode)
if err != nil {
return BlockResult{}, err
}
result, err := service.store.BlockByEmail(ctx, ports.BlockByEmailInput{
Email: email,
ReasonCode: reasonCode,
BlockedAt: service.clock.Now().UTC(),
})
if err != nil {
return BlockResult{}, shared.ServiceUnavailable(err)
}
if err := result.Validate(); err != nil {
return BlockResult{}, shared.InternalError(err)
}
response := BlockResult{Outcome: string(result.Outcome)}
if !result.UserID.IsZero() {
response.UserID = result.UserID.String()
}
return response, nil
}
@@ -0,0 +1,717 @@
package authdirectory
import (
"context"
"errors"
"testing"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/shared"
"galaxy/user/internal/telemetry"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/attribute"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
)
func TestResolverExecute(t *testing.T) {
t.Parallel()
tests := []struct {
name string
store stubAuthDirectoryStore
wantKind string
wantUserID string
wantBlock string
}{
{
name: "existing",
store: stubAuthDirectoryStore{
resolveByEmail: func(_ context.Context, email common.Email) (ports.ResolveByEmailResult, error) {
require.Equal(t, common.Email("pilot@example.com"), email)
return ports.ResolveByEmailResult{
Kind: ports.AuthResolutionKindExisting,
UserID: common.UserID("user-123"),
}, nil
},
},
wantKind: "existing",
wantUserID: "user-123",
},
{
name: "creatable",
store: stubAuthDirectoryStore{
resolveByEmail: func(_ context.Context, email common.Email) (ports.ResolveByEmailResult, error) {
require.Equal(t, common.Email("pilot@example.com"), email)
return ports.ResolveByEmailResult{
Kind: ports.AuthResolutionKindCreatable,
}, nil
},
},
wantKind: "creatable",
},
{
name: "blocked",
store: stubAuthDirectoryStore{
resolveByEmail: func(_ context.Context, email common.Email) (ports.ResolveByEmailResult, error) {
require.Equal(t, common.Email("pilot@example.com"), email)
return ports.ResolveByEmailResult{
Kind: ports.AuthResolutionKindBlocked,
BlockReasonCode: common.ReasonCode("policy_blocked"),
}, nil
},
},
wantKind: "blocked",
wantBlock: "policy_blocked",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
resolver, err := NewResolver(tt.store)
require.NoError(t, err)
result, err := resolver.Execute(context.Background(), ResolveByEmailInput{
Email: " pilot@example.com ",
})
require.NoError(t, err)
require.Equal(t, tt.wantKind, result.Kind)
require.Equal(t, tt.wantUserID, result.UserID)
require.Equal(t, tt.wantBlock, result.BlockReasonCode)
})
}
}
func TestEnsurerExecuteCreatedBuildsInitialRecords(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
ensurer, err := NewEnsurer(stubAuthDirectoryStore{
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
require.Equal(t, common.Email("created@example.com"), input.Email)
require.Equal(t, common.UserID("user-created"), input.Account.UserID)
require.Equal(t, common.RaceName("player-test123"), input.Account.RaceName)
require.Equal(t, common.LanguageTag("en-US"), input.Account.PreferredLanguage)
require.Equal(t, common.TimeZoneName("Europe/Kaliningrad"), input.Account.TimeZone)
require.Equal(t, input.Account.UserID, input.Reservation.UserID)
require.Equal(t, input.Account.RaceName, input.Reservation.RaceName)
require.Equal(t, accountTestCanonicalKey(input.Account.RaceName), input.Reservation.CanonicalKey)
require.Equal(t, entitlement.PlanCodeFree, input.Entitlement.PlanCode)
require.False(t, input.Entitlement.IsPaid)
require.Equal(t, input.Account.UserID, input.Entitlement.UserID)
require.Equal(t, entitlement.EntitlementRecordID("entitlement-created"), input.EntitlementRecord.RecordID)
require.Equal(t, input.Account.UserID, input.EntitlementRecord.UserID)
require.Equal(t, input.Entitlement.PlanCode, input.EntitlementRecord.PlanCode)
require.Equal(t, input.Entitlement.StartsAt, input.EntitlementRecord.StartsAt)
require.Equal(t, input.Entitlement.Source, input.EntitlementRecord.Source)
require.Equal(t, input.Entitlement.Actor, input.EntitlementRecord.Actor)
require.Equal(t, input.Entitlement.ReasonCode, input.EntitlementRecord.ReasonCode)
return ports.EnsureByEmailResult{
Outcome: ports.EnsureByEmailOutcomeCreated,
UserID: input.Account.UserID,
}, nil
},
}, fixedClock{now: now}, fixedIDGenerator{
userID: common.UserID("user-created"),
raceName: common.RaceName("player-test123"),
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
}, stubRaceNamePolicy{})
require.NoError(t, err)
result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{
Email: "created@example.com",
RegistrationContext: &RegistrationContext{
PreferredLanguage: "en-us",
TimeZone: "Europe/Kaliningrad",
},
})
require.NoError(t, err)
require.Equal(t, "created", result.Outcome)
require.Equal(t, "user-created", result.UserID)
}
func TestEnsurerExecuteRejectsInvalidRegistrationContext(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input EnsureByEmailInput
wantErr string
}{
{
name: "invalid preferred language",
input: EnsureByEmailInput{
Email: "pilot@example.com",
RegistrationContext: &RegistrationContext{
PreferredLanguage: "bad@@tag",
TimeZone: "Europe/Kaliningrad",
},
},
wantErr: "registration_context.preferred_language must be a valid BCP 47 language tag",
},
{
name: "invalid time zone",
input: EnsureByEmailInput{
Email: "pilot@example.com",
RegistrationContext: &RegistrationContext{
PreferredLanguage: "en",
TimeZone: "Mars/Olympus",
},
},
wantErr: "registration_context.time_zone must be a valid IANA time zone name",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ensurer, err := NewEnsurer(stubAuthDirectoryStore{}, fixedClock{now: time.Unix(1_775_240_000, 0).UTC()}, fixedIDGenerator{
userID: common.UserID("user-created"),
raceName: common.RaceName("player-test123"),
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
}, stubRaceNamePolicy{})
require.NoError(t, err)
_, err = ensurer.Execute(context.Background(), tt.input)
require.Error(t, err)
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
require.Equal(t, tt.wantErr, err.Error())
})
}
}
func TestEnsurerExecuteRetriesConflicts(t *testing.T) {
t.Parallel()
attempt := 0
ensurer, err := NewEnsurer(stubAuthDirectoryStore{
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
attempt++
if attempt == 1 {
return ports.EnsureByEmailResult{}, ports.ErrConflict
}
return ports.EnsureByEmailResult{
Outcome: ports.EnsureByEmailOutcomeCreated,
UserID: input.Account.UserID,
}, nil
},
}, fixedClock{now: time.Unix(1_775_240_000, 0).UTC()}, &sequenceIDGenerator{
userIDs: []common.UserID{"user-first", "user-second"},
raceNames: []common.RaceName{"player-first", "player-second"},
entitlementRecordIDs: []entitlement.EntitlementRecordID{"entitlement-first", "entitlement-second"},
}, stubRaceNamePolicy{})
require.NoError(t, err)
result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{
Email: "retry@example.com",
RegistrationContext: &RegistrationContext{
PreferredLanguage: "en",
TimeZone: "UTC",
},
})
require.NoError(t, err)
require.Equal(t, 2, attempt)
require.Equal(t, "user-second", result.UserID)
}
func TestEnsurerExecuteReturnsExistingAndBlocked(t *testing.T) {
t.Parallel()
tests := []struct {
name string
store stubAuthDirectoryStore
want EnsureByEmailResult
}{
{
name: "existing",
store: stubAuthDirectoryStore{
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
require.Equal(t, common.Email("pilot@example.com"), input.Email)
return ports.EnsureByEmailResult{
Outcome: ports.EnsureByEmailOutcomeExisting,
UserID: common.UserID("user-existing"),
}, nil
},
},
want: EnsureByEmailResult{
Outcome: "existing",
UserID: "user-existing",
},
},
{
name: "blocked",
store: stubAuthDirectoryStore{
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
require.Equal(t, common.Email("pilot@example.com"), input.Email)
return ports.EnsureByEmailResult{
Outcome: ports.EnsureByEmailOutcomeBlocked,
BlockReasonCode: common.ReasonCode("policy_blocked"),
}, nil
},
},
want: EnsureByEmailResult{
Outcome: "blocked",
BlockReasonCode: "policy_blocked",
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ensurer, err := NewEnsurer(tt.store, fixedClock{now: time.Unix(1_775_240_000, 0).UTC()}, fixedIDGenerator{
userID: common.UserID("user-created"),
raceName: common.RaceName("player-test123"),
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
}, stubRaceNamePolicy{})
require.NoError(t, err)
result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{
Email: "pilot@example.com",
RegistrationContext: &RegistrationContext{
PreferredLanguage: "en",
TimeZone: "UTC",
},
})
require.NoError(t, err)
require.Equal(t, tt.want, result)
})
}
}
func TestEnsurerExecuteCreatedPublishesInitializedEvents(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
publisher := &recordingAuthDomainEventPublisher{}
telemetryRuntime, reader := newObservedAuthTelemetryRuntime(t)
ensurer, err := NewEnsurerWithObservability(stubAuthDirectoryStore{
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
return ports.EnsureByEmailResult{
Outcome: ports.EnsureByEmailOutcomeCreated,
UserID: input.Account.UserID,
}, nil
},
}, fixedClock{now: now}, fixedIDGenerator{
userID: common.UserID("user-created"),
raceName: common.RaceName("player-test123"),
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
}, stubRaceNamePolicy{}, nil, telemetryRuntime, publisher, publisher, publisher)
require.NoError(t, err)
result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{
Email: "created@example.com",
RegistrationContext: &RegistrationContext{
PreferredLanguage: "en-us",
TimeZone: "Europe/Kaliningrad",
},
})
require.NoError(t, err)
require.Equal(t, "created", result.Outcome)
require.Len(t, publisher.profileEvents, 1)
require.Equal(t, ports.ProfileChangedOperationInitialized, publisher.profileEvents[0].Operation)
require.Equal(t, common.Source("auth_registration"), publisher.profileEvents[0].Source)
require.Len(t, publisher.settingsEvents, 1)
require.Equal(t, ports.SettingsChangedOperationInitialized, publisher.settingsEvents[0].Operation)
require.Len(t, publisher.entitlementEvents, 1)
require.Equal(t, ports.EntitlementChangedOperationInitialized, publisher.entitlementEvents[0].Operation)
assertMetricCount(t, reader, "user.user_creation.outcomes", map[string]string{
"outcome": "created",
}, 1)
}
func TestEnsurerExecuteExistingBlockedAndFailedDoNotPublishEvents(t *testing.T) {
t.Parallel()
tests := []struct {
name string
store stubAuthDirectoryStore
input EnsureByEmailInput
wantMetric string
wantErrCode string
wantProfileLen int
}{
{
name: "existing",
store: stubAuthDirectoryStore{
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
return ports.EnsureByEmailResult{
Outcome: ports.EnsureByEmailOutcomeExisting,
UserID: common.UserID("user-existing"),
}, nil
},
},
input: EnsureByEmailInput{
Email: "pilot@example.com",
RegistrationContext: &RegistrationContext{
PreferredLanguage: "en",
TimeZone: "UTC",
},
},
wantMetric: "existing",
},
{
name: "blocked",
store: stubAuthDirectoryStore{
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
return ports.EnsureByEmailResult{
Outcome: ports.EnsureByEmailOutcomeBlocked,
BlockReasonCode: common.ReasonCode("policy_blocked"),
}, nil
},
},
input: EnsureByEmailInput{
Email: "pilot@example.com",
RegistrationContext: &RegistrationContext{
PreferredLanguage: "en",
TimeZone: "UTC",
},
},
wantMetric: "blocked",
},
{
name: "failed",
store: stubAuthDirectoryStore{},
input: EnsureByEmailInput{
Email: "pilot@example.com",
},
wantMetric: "failed",
wantErrCode: shared.ErrorCodeInvalidRequest,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
publisher := &recordingAuthDomainEventPublisher{}
telemetryRuntime, reader := newObservedAuthTelemetryRuntime(t)
ensurer, err := NewEnsurerWithObservability(tt.store, fixedClock{now: time.Unix(1_775_240_000, 0).UTC()}, fixedIDGenerator{
userID: common.UserID("user-created"),
raceName: common.RaceName("player-test123"),
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
}, stubRaceNamePolicy{}, nil, telemetryRuntime, publisher, publisher, publisher)
require.NoError(t, err)
_, err = ensurer.Execute(context.Background(), tt.input)
if tt.wantErrCode != "" {
require.Error(t, err)
require.Equal(t, tt.wantErrCode, shared.CodeOf(err))
} else {
require.NoError(t, err)
}
require.Empty(t, publisher.profileEvents)
require.Empty(t, publisher.settingsEvents)
require.Empty(t, publisher.entitlementEvents)
assertMetricCount(t, reader, "user.user_creation.outcomes", map[string]string{
"outcome": tt.wantMetric,
}, 1)
})
}
}
func TestEnsurerExecutePublishFailureDoesNotRollbackCreatedUser(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
publisher := &recordingAuthDomainEventPublisher{err: errors.New("publisher unavailable")}
telemetryRuntime, reader := newObservedAuthTelemetryRuntime(t)
ensurer, err := NewEnsurerWithObservability(stubAuthDirectoryStore{
ensureByEmail: func(_ context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
return ports.EnsureByEmailResult{
Outcome: ports.EnsureByEmailOutcomeCreated,
UserID: input.Account.UserID,
}, nil
},
}, fixedClock{now: now}, fixedIDGenerator{
userID: common.UserID("user-created"),
raceName: common.RaceName("player-test123"),
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
}, stubRaceNamePolicy{}, nil, telemetryRuntime, publisher, publisher, publisher)
require.NoError(t, err)
result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{
Email: "created@example.com",
RegistrationContext: &RegistrationContext{
PreferredLanguage: "en-us",
TimeZone: "Europe/Kaliningrad",
},
})
require.NoError(t, err)
require.Equal(t, "created", result.Outcome)
require.Len(t, publisher.profileEvents, 1)
require.Len(t, publisher.settingsEvents, 1)
require.Len(t, publisher.entitlementEvents, 1)
assertMetricCount(t, reader, "user.event_publication_failures", map[string]string{
"event_type": ports.ProfileChangedEventType,
}, 1)
assertMetricCount(t, reader, "user.event_publication_failures", map[string]string{
"event_type": ports.SettingsChangedEventType,
}, 1)
assertMetricCount(t, reader, "user.event_publication_failures", map[string]string{
"event_type": ports.EntitlementChangedEventType,
}, 1)
}
func TestBlockByUserIDServiceMapsNotFound(t *testing.T) {
t.Parallel()
service, err := NewBlockByUserIDService(stubAuthDirectoryStore{
blockByUserID: func(context.Context, ports.BlockByUserIDInput) (ports.BlockResult, error) {
return ports.BlockResult{}, ports.ErrNotFound
},
}, fixedClock{now: time.Unix(1_775_240_000, 0).UTC()})
require.NoError(t, err)
_, err = service.Execute(context.Background(), BlockByUserIDInput{
UserID: "user-missing",
ReasonCode: "policy_blocked",
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err))
}
type stubAuthDirectoryStore struct {
resolveByEmail func(context.Context, common.Email) (ports.ResolveByEmailResult, error)
ensureByEmail func(context.Context, ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error)
existsByUserID func(context.Context, common.UserID) (bool, error)
blockByUserID func(context.Context, ports.BlockByUserIDInput) (ports.BlockResult, error)
blockByEmail func(context.Context, ports.BlockByEmailInput) (ports.BlockResult, error)
}
func (store stubAuthDirectoryStore) ResolveByEmail(ctx context.Context, email common.Email) (ports.ResolveByEmailResult, error) {
if store.resolveByEmail == nil {
return ports.ResolveByEmailResult{}, errors.New("unexpected ResolveByEmail call")
}
return store.resolveByEmail(ctx, email)
}
func (store stubAuthDirectoryStore) ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error) {
if store.existsByUserID == nil {
return false, errors.New("unexpected ExistsByUserID call")
}
return store.existsByUserID(ctx, userID)
}
func (store stubAuthDirectoryStore) EnsureByEmail(ctx context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
if store.ensureByEmail == nil {
return ports.EnsureByEmailResult{}, errors.New("unexpected EnsureByEmail call")
}
return store.ensureByEmail(ctx, input)
}
func (store stubAuthDirectoryStore) BlockByUserID(ctx context.Context, input ports.BlockByUserIDInput) (ports.BlockResult, error) {
if store.blockByUserID == nil {
return ports.BlockResult{}, errors.New("unexpected BlockByUserID call")
}
return store.blockByUserID(ctx, input)
}
func (store stubAuthDirectoryStore) BlockByEmail(ctx context.Context, input ports.BlockByEmailInput) (ports.BlockResult, error) {
if store.blockByEmail == nil {
return ports.BlockResult{}, errors.New("unexpected BlockByEmail call")
}
return store.blockByEmail(ctx, input)
}
type fixedClock struct {
now time.Time
}
func (clock fixedClock) Now() time.Time {
return clock.now
}
type fixedIDGenerator struct {
userID common.UserID
raceName common.RaceName
entitlementRecordID entitlement.EntitlementRecordID
sanctionRecordID policy.SanctionRecordID
limitRecordID policy.LimitRecordID
}
func (generator fixedIDGenerator) NewUserID() (common.UserID, error) {
return generator.userID, nil
}
func (generator fixedIDGenerator) NewInitialRaceName() (common.RaceName, error) {
return generator.raceName, nil
}
func (generator fixedIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
return generator.entitlementRecordID, nil
}
func (generator fixedIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
return generator.sanctionRecordID, nil
}
func (generator fixedIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
return generator.limitRecordID, nil
}
type sequenceIDGenerator struct {
userIDs []common.UserID
raceNames []common.RaceName
entitlementRecordIDs []entitlement.EntitlementRecordID
sanctionRecordIDs []policy.SanctionRecordID
limitRecordIDs []policy.LimitRecordID
}
func (generator *sequenceIDGenerator) NewUserID() (common.UserID, error) {
value := generator.userIDs[0]
generator.userIDs = generator.userIDs[1:]
return value, nil
}
func (generator *sequenceIDGenerator) NewInitialRaceName() (common.RaceName, error) {
value := generator.raceNames[0]
generator.raceNames = generator.raceNames[1:]
return value, nil
}
func (generator *sequenceIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
value := generator.entitlementRecordIDs[0]
generator.entitlementRecordIDs = generator.entitlementRecordIDs[1:]
return value, nil
}
func (generator *sequenceIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
value := generator.sanctionRecordIDs[0]
generator.sanctionRecordIDs = generator.sanctionRecordIDs[1:]
return value, nil
}
func (generator *sequenceIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
value := generator.limitRecordIDs[0]
generator.limitRecordIDs = generator.limitRecordIDs[1:]
return value, nil
}
type stubRaceNamePolicy struct{}
func (stubRaceNamePolicy) CanonicalKey(raceName common.RaceName) (account.RaceNameCanonicalKey, error) {
return accountTestCanonicalKey(raceName), nil
}
func accountTestCanonicalKey(raceName common.RaceName) account.RaceNameCanonicalKey {
return account.RaceNameCanonicalKey("key:" + raceName.String())
}
type recordingAuthDomainEventPublisher struct {
err error
profileEvents []ports.ProfileChangedEvent
settingsEvents []ports.SettingsChangedEvent
entitlementEvents []ports.EntitlementChangedEvent
}
func (publisher *recordingAuthDomainEventPublisher) PublishProfileChanged(_ context.Context, event ports.ProfileChangedEvent) error {
if err := event.Validate(); err != nil {
return err
}
publisher.profileEvents = append(publisher.profileEvents, event)
return publisher.err
}
func (publisher *recordingAuthDomainEventPublisher) PublishSettingsChanged(_ context.Context, event ports.SettingsChangedEvent) error {
if err := event.Validate(); err != nil {
return err
}
publisher.settingsEvents = append(publisher.settingsEvents, event)
return publisher.err
}
func (publisher *recordingAuthDomainEventPublisher) PublishEntitlementChanged(_ context.Context, event ports.EntitlementChangedEvent) error {
if err := event.Validate(); err != nil {
return err
}
publisher.entitlementEvents = append(publisher.entitlementEvents, event)
return publisher.err
}
func newObservedAuthTelemetryRuntime(t *testing.T) (*telemetry.Runtime, *sdkmetric.ManualReader) {
t.Helper()
reader := sdkmetric.NewManualReader()
meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
tracerProvider := sdktrace.NewTracerProvider()
runtime, err := telemetry.NewWithProviders(meterProvider, tracerProvider)
require.NoError(t, err)
return runtime, reader
}
func assertMetricCount(t *testing.T, reader *sdkmetric.ManualReader, metricName string, wantAttrs map[string]string, wantValue int64) {
t.Helper()
var resourceMetrics metricdata.ResourceMetrics
require.NoError(t, reader.Collect(context.Background(), &resourceMetrics))
for _, scopeMetrics := range resourceMetrics.ScopeMetrics {
for _, metric := range scopeMetrics.Metrics {
if metric.Name != metricName {
continue
}
sum, ok := metric.Data.(metricdata.Sum[int64])
require.True(t, ok)
for _, point := range sum.DataPoints {
if hasMetricAttributes(point.Attributes.ToSlice(), wantAttrs) {
require.Equal(t, wantValue, point.Value)
return
}
}
}
}
require.Failf(t, "test failed", "metric %q with attrs %v not found", metricName, wantAttrs)
}
func hasMetricAttributes(values []attribute.KeyValue, want map[string]string) bool {
if len(values) != len(want) {
return false
}
for _, value := range values {
if want[string(value.Key)] != value.Value.AsString() {
return false
}
}
return true
}
var (
_ ports.AuthDirectoryStore = stubAuthDirectoryStore{}
_ ports.Clock = fixedClock{}
_ ports.IDGenerator = fixedIDGenerator{}
_ ports.IDGenerator = (*sequenceIDGenerator)(nil)
_ ports.RaceNamePolicy = stubRaceNamePolicy{}
_ ports.ProfileChangedPublisher = (*recordingAuthDomainEventPublisher)(nil)
_ ports.SettingsChangedPublisher = (*recordingAuthDomainEventPublisher)(nil)
_ ports.EntitlementChangedPublisher = (*recordingAuthDomainEventPublisher)(nil)
)
@@ -0,0 +1,121 @@
package entitlementsvc
import (
"context"
"errors"
"testing"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/ports"
"github.com/stretchr/testify/require"
)
func TestReaderGetByUserIDPublishesExpiredRepairEvent(t *testing.T) {
t.Parallel()
userID := common.UserID("user-123")
startsAt := time.Unix(1_775_240_000, 0).UTC()
endsAt := startsAt.Add(24 * time.Hour)
now := endsAt.Add(time.Hour)
snapshotStore := &fakeSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
userID: paidSnapshot(
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
endsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
),
},
}
historyStore := &fakeHistoryStore{
byUserID: map[common.UserID][]entitlement.PeriodRecord{
userID: {
paidRecord(
entitlement.EntitlementRecordID("entitlement-paid"),
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
endsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
),
},
},
}
lifecycleStore := &fakeLifecycleStore{
historyStore: historyStore,
snapshotStore: snapshotStore,
}
publisher := &recordingEntitlementPublisher{}
reader, err := NewReaderWithObservability(snapshotStore, lifecycleStore, fixedClock{now: now}, fixedIDGenerator{
recordID: entitlement.EntitlementRecordID("entitlement-free"),
}, nil, nil, publisher)
require.NoError(t, err)
got, err := reader.GetByUserID(context.Background(), userID)
require.NoError(t, err)
require.Equal(t, entitlement.PlanCodeFree, got.PlanCode)
require.Len(t, publisher.events, 1)
require.Equal(t, ports.EntitlementChangedOperationExpiredRepaired, publisher.events[0].Operation)
require.Equal(t, common.Source("entitlement_expiry_repair"), publisher.events[0].Source)
}
func TestGrantServiceExecutePublisherFailureDoesNotRollbackResult(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
currentFreeStartsAt := now.Add(-24 * time.Hour)
currentSnapshot := freeSnapshot(userID, currentFreeStartsAt, common.Source("auth_registration"), common.ReasonCode("initial_free_entitlement"))
currentRecord := freeRecord(entitlement.EntitlementRecordID("entitlement-free"), userID, currentFreeStartsAt, common.Source("auth_registration"), common.ReasonCode("initial_free_entitlement"))
lifecycleStore := &fakeLifecycleStore{}
publisher := &recordingEntitlementPublisher{err: errors.New("publisher unavailable")}
service, err := NewGrantServiceWithObservability(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
&fakeHistoryStore{byUserID: map[common.UserID][]entitlement.PeriodRecord{userID: {currentRecord}}},
fakeEffectiveReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: currentSnapshot}},
lifecycleStore,
fixedClock{now: now},
fixedIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-paid")},
nil,
nil,
publisher,
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), GrantInput{
UserID: userID.String(),
PlanCode: string(entitlement.PlanCodePaidMonthly),
Source: "admin",
ReasonCode: "manual_grant",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
StartsAt: now.Format(time.RFC3339Nano),
EndsAt: now.Add(30 * 24 * time.Hour).Format(time.RFC3339Nano),
})
require.NoError(t, err)
require.Equal(t, entitlement.PlanCodePaidMonthly, result.Entitlement.PlanCode)
require.Len(t, publisher.events, 1)
require.Equal(t, ports.EntitlementChangedOperationGranted, publisher.events[0].Operation)
}
type recordingEntitlementPublisher struct {
err error
events []ports.EntitlementChangedEvent
}
func (publisher *recordingEntitlementPublisher) PublishEntitlementChanged(_ context.Context, event ports.EntitlementChangedEvent) error {
if err := event.Validate(); err != nil {
return err
}
publisher.events = append(publisher.events, event)
return publisher.err
}
var _ ports.EntitlementChangedPublisher = (*recordingEntitlementPublisher)(nil)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,565 @@
package entitlementsvc
import (
"context"
"testing"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/shared"
"github.com/stretchr/testify/require"
)
func TestReaderGetByUserIDRepairsExpiredFinitePaidSnapshot(t *testing.T) {
t.Parallel()
userID := common.UserID("user-123")
startsAt := time.Unix(1_775_240_000, 0).UTC()
endsAt := startsAt.Add(24 * time.Hour)
now := endsAt.Add(2 * time.Hour)
snapshotStore := &fakeSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
userID: paidSnapshot(
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
endsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
),
},
}
historyStore := &fakeHistoryStore{
byUserID: map[common.UserID][]entitlement.PeriodRecord{
userID: {
paidRecord(
entitlement.EntitlementRecordID("entitlement-paid"),
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
endsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
),
},
},
}
lifecycleStore := &fakeLifecycleStore{
historyStore: historyStore,
snapshotStore: snapshotStore,
}
reader, err := NewReader(snapshotStore, lifecycleStore, fixedClock{now: now}, fixedIDGenerator{
recordID: entitlement.EntitlementRecordID("entitlement-free"),
})
require.NoError(t, err)
got, err := reader.GetByUserID(context.Background(), userID)
require.NoError(t, err)
require.Equal(t, entitlement.PlanCodeFree, got.PlanCode)
require.False(t, got.IsPaid)
require.Equal(t, endsAt, got.StartsAt)
require.Equal(t, expiryRepairSource, got.Source)
require.Equal(t, expiryRepairReasonCode, got.ReasonCode)
require.Equal(t, common.ActorRef{Type: expiryRepairActorType, ID: expiryRepairActorID}, got.Actor)
require.Len(t, historyStore.byUserID[userID], 2)
require.Equal(t, got, snapshotStore.byUserID[userID])
require.Equal(t, entitlement.EntitlementRecordID("entitlement-free"), lifecycleStore.repairInput.NewRecord.RecordID)
}
func TestGrantServiceExecuteRejectsInvalidPlanRules(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
freeSnapshot := freeSnapshot(userID, now.Add(-24*time.Hour), common.Source("auth_registration"), common.ReasonCode("initial_free_entitlement"))
freeRecord := freeRecord(entitlement.EntitlementRecordID("entitlement-free"), userID, now.Add(-24*time.Hour), common.Source("auth_registration"), common.ReasonCode("initial_free_entitlement"))
tests := []struct {
name string
input GrantInput
wantErr string
}{
{
name: "free plan not allowed",
input: GrantInput{
UserID: userID.String(),
PlanCode: string(entitlement.PlanCodeFree),
Source: "admin",
ReasonCode: "manual_grant",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
StartsAt: now.Format(time.RFC3339Nano),
},
wantErr: shared.ErrorCodeInvalidRequest,
},
{
name: "future starts at rejected",
input: GrantInput{
UserID: userID.String(),
PlanCode: string(entitlement.PlanCodePaidMonthly),
Source: "admin",
ReasonCode: "manual_grant",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
StartsAt: now.Add(time.Hour).Format(time.RFC3339Nano),
EndsAt: now.Add(31 * 24 * time.Hour).Format(time.RFC3339Nano),
},
wantErr: shared.ErrorCodeInvalidRequest,
},
{
name: "finite plan requires ends at",
input: GrantInput{
UserID: userID.String(),
PlanCode: string(entitlement.PlanCodePaidMonthly),
Source: "admin",
ReasonCode: "manual_grant",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
StartsAt: now.Format(time.RFC3339Nano),
},
wantErr: shared.ErrorCodeInvalidRequest,
},
{
name: "lifetime plan forbids ends at",
input: GrantInput{
UserID: userID.String(),
PlanCode: string(entitlement.PlanCodePaidLifetime),
Source: "admin",
ReasonCode: "manual_grant",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
StartsAt: now.Format(time.RFC3339Nano),
EndsAt: now.Add(24 * time.Hour).Format(time.RFC3339Nano),
},
wantErr: shared.ErrorCodeInvalidRequest,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
service, err := NewGrantService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
&fakeHistoryStore{byUserID: map[common.UserID][]entitlement.PeriodRecord{userID: {freeRecord}}},
fakeEffectiveReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: freeSnapshot}},
&fakeLifecycleStore{},
fixedClock{now: now},
fixedIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-paid")},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), tt.input)
require.Error(t, err)
require.Equal(t, tt.wantErr, shared.CodeOf(err))
})
}
}
func TestGrantServiceExecuteBuildsTransition(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
currentFreeStartsAt := now.Add(-24 * time.Hour)
currentSnapshot := freeSnapshot(userID, currentFreeStartsAt, common.Source("auth_registration"), common.ReasonCode("initial_free_entitlement"))
currentRecord := freeRecord(entitlement.EntitlementRecordID("entitlement-free"), userID, currentFreeStartsAt, common.Source("auth_registration"), common.ReasonCode("initial_free_entitlement"))
lifecycleStore := &fakeLifecycleStore{}
service, err := NewGrantService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
&fakeHistoryStore{byUserID: map[common.UserID][]entitlement.PeriodRecord{userID: {currentRecord}}},
fakeEffectiveReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: currentSnapshot}},
lifecycleStore,
fixedClock{now: now},
fixedIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-paid")},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), GrantInput{
UserID: userID.String(),
PlanCode: string(entitlement.PlanCodePaidMonthly),
Source: "admin",
ReasonCode: "manual_grant",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
StartsAt: now.Format(time.RFC3339Nano),
EndsAt: now.Add(30 * 24 * time.Hour).Format(time.RFC3339Nano),
})
require.NoError(t, err)
require.Equal(t, userID.String(), result.UserID)
require.Equal(t, entitlement.PlanCodePaidMonthly, result.Entitlement.PlanCode)
require.Equal(t, entitlement.EntitlementRecordID("entitlement-paid"), lifecycleStore.grantInput.NewRecord.RecordID)
require.Equal(t, currentSnapshot, lifecycleStore.grantInput.ExpectedCurrentSnapshot)
require.Equal(t, currentRecord.RecordID, lifecycleStore.grantInput.UpdatedCurrentRecord.RecordID)
require.NotNil(t, lifecycleStore.grantInput.UpdatedCurrentRecord.ClosedAt)
require.True(t, lifecycleStore.grantInput.UpdatedCurrentRecord.ClosedAt.Equal(now))
}
func TestExtendServiceExecuteBuildsExtensionSegment(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
startsAt := now.Add(-24 * time.Hour)
currentEndsAt := now.Add(24 * time.Hour)
currentSnapshot := paidSnapshot(
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
currentEndsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
currentRecord := paidRecord(
entitlement.EntitlementRecordID("entitlement-paid-1"),
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
currentEndsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
lifecycleStore := &fakeLifecycleStore{}
service, err := NewExtendService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
&fakeHistoryStore{byUserID: map[common.UserID][]entitlement.PeriodRecord{userID: {currentRecord}}},
fakeEffectiveReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: currentSnapshot}},
lifecycleStore,
fixedClock{now: now},
fixedIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-paid-2")},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), ExtendInput{
UserID: userID.String(),
Source: "admin",
ReasonCode: "manual_extend",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
EndsAt: currentEndsAt.Add(30 * 24 * time.Hour).Format(time.RFC3339Nano),
})
require.NoError(t, err)
require.Equal(t, currentEndsAt, lifecycleStore.extendInput.NewRecord.StartsAt)
require.Equal(t, startsAt, lifecycleStore.extendInput.NewSnapshot.StartsAt)
require.Equal(t, entitlement.PlanCodePaidMonthly, result.Entitlement.PlanCode)
}
func TestRevokeServiceExecuteBuildsFreeTransition(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
startsAt := now.Add(-24 * time.Hour)
currentEndsAt := now.Add(24 * time.Hour)
currentSnapshot := paidSnapshot(
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
currentEndsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
currentRecord := paidRecord(
entitlement.EntitlementRecordID("entitlement-paid-1"),
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
currentEndsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
lifecycleStore := &fakeLifecycleStore{}
service, err := NewRevokeService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
&fakeHistoryStore{byUserID: map[common.UserID][]entitlement.PeriodRecord{userID: {currentRecord}}},
fakeEffectiveReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: currentSnapshot}},
lifecycleStore,
fixedClock{now: now},
fixedIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-free-2")},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), RevokeInput{
UserID: userID.String(),
Source: "admin",
ReasonCode: "manual_revoke",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
})
require.NoError(t, err)
require.Equal(t, entitlement.PlanCodeFree, result.Entitlement.PlanCode)
require.NotNil(t, lifecycleStore.revokeInput.UpdatedCurrentRecord.ClosedAt)
require.True(t, lifecycleStore.revokeInput.UpdatedCurrentRecord.ClosedAt.Equal(now))
require.Equal(t, now, lifecycleStore.revokeInput.NewRecord.StartsAt)
}
type fakeAccountStore struct {
existsByUserID map[common.UserID]bool
}
func (store fakeAccountStore) Create(context.Context, ports.CreateAccountInput) error {
return nil
}
func (store fakeAccountStore) GetByUserID(context.Context, common.UserID) (account.UserAccount, error) {
return account.UserAccount{}, ports.ErrNotFound
}
func (store fakeAccountStore) GetByEmail(context.Context, common.Email) (account.UserAccount, error) {
return account.UserAccount{}, ports.ErrNotFound
}
func (store fakeAccountStore) GetByRaceName(context.Context, common.RaceName) (account.UserAccount, error) {
return account.UserAccount{}, ports.ErrNotFound
}
func (store fakeAccountStore) ExistsByUserID(_ context.Context, userID common.UserID) (bool, error) {
return store.existsByUserID[userID], nil
}
func (store fakeAccountStore) RenameRaceName(context.Context, ports.RenameRaceNameInput) error {
return nil
}
func (store fakeAccountStore) Update(context.Context, account.UserAccount) error {
return nil
}
type fakeSnapshotStore struct {
byUserID map[common.UserID]entitlement.CurrentSnapshot
}
func (store *fakeSnapshotStore) GetByUserID(_ context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error) {
record, ok := store.byUserID[userID]
if !ok {
return entitlement.CurrentSnapshot{}, ports.ErrNotFound
}
return record, nil
}
func (store *fakeSnapshotStore) Put(_ context.Context, record entitlement.CurrentSnapshot) error {
store.byUserID[record.UserID] = record
return nil
}
type fakeHistoryStore struct {
byUserID map[common.UserID][]entitlement.PeriodRecord
}
func (store *fakeHistoryStore) Create(_ context.Context, record entitlement.PeriodRecord) error {
store.byUserID[record.UserID] = append(store.byUserID[record.UserID], record)
return nil
}
func (store *fakeHistoryStore) GetByRecordID(_ context.Context, recordID entitlement.EntitlementRecordID) (entitlement.PeriodRecord, error) {
for _, records := range store.byUserID {
for _, record := range records {
if record.RecordID == recordID {
return record, nil
}
}
}
return entitlement.PeriodRecord{}, ports.ErrNotFound
}
func (store *fakeHistoryStore) ListByUserID(_ context.Context, userID common.UserID) ([]entitlement.PeriodRecord, error) {
records := store.byUserID[userID]
cloned := make([]entitlement.PeriodRecord, len(records))
copy(cloned, records)
return cloned, nil
}
func (store *fakeHistoryStore) Update(_ context.Context, record entitlement.PeriodRecord) error {
records := store.byUserID[record.UserID]
for idx := range records {
if records[idx].RecordID == record.RecordID {
records[idx] = record
store.byUserID[record.UserID] = records
return nil
}
}
return ports.ErrNotFound
}
type fakeEffectiveReader struct {
byUserID map[common.UserID]entitlement.CurrentSnapshot
}
func (reader fakeEffectiveReader) GetByUserID(_ context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error) {
record, ok := reader.byUserID[userID]
if !ok {
return entitlement.CurrentSnapshot{}, ports.ErrNotFound
}
return record, nil
}
type fakeLifecycleStore struct {
historyStore *fakeHistoryStore
snapshotStore *fakeSnapshotStore
grantInput ports.GrantEntitlementInput
extendInput ports.ExtendEntitlementInput
revokeInput ports.RevokeEntitlementInput
repairInput ports.RepairExpiredEntitlementInput
}
func (store *fakeLifecycleStore) Grant(_ context.Context, input ports.GrantEntitlementInput) error {
store.grantInput = input
return nil
}
func (store *fakeLifecycleStore) Extend(_ context.Context, input ports.ExtendEntitlementInput) error {
store.extendInput = input
return nil
}
func (store *fakeLifecycleStore) Revoke(_ context.Context, input ports.RevokeEntitlementInput) error {
store.revokeInput = input
return nil
}
func (store *fakeLifecycleStore) RepairExpired(_ context.Context, input ports.RepairExpiredEntitlementInput) error {
store.repairInput = input
if store.historyStore != nil {
store.historyStore.byUserID[input.NewRecord.UserID] = append(store.historyStore.byUserID[input.NewRecord.UserID], input.NewRecord)
}
if store.snapshotStore != nil {
store.snapshotStore.byUserID[input.NewSnapshot.UserID] = input.NewSnapshot
}
return nil
}
type fixedClock struct {
now time.Time
}
func (clock fixedClock) Now() time.Time {
return clock.now
}
type fixedIDGenerator struct {
recordID entitlement.EntitlementRecordID
sanctionRecordID policy.SanctionRecordID
limitRecordID policy.LimitRecordID
}
func (generator fixedIDGenerator) NewUserID() (common.UserID, error) {
return "", nil
}
func (generator fixedIDGenerator) NewInitialRaceName() (common.RaceName, error) {
return "", nil
}
func (generator fixedIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
return generator.recordID, nil
}
func (generator fixedIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
return generator.sanctionRecordID, nil
}
func (generator fixedIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
return generator.limitRecordID, nil
}
func freeSnapshot(
userID common.UserID,
startsAt time.Time,
source common.Source,
reasonCode common.ReasonCode,
) entitlement.CurrentSnapshot {
return entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
IsPaid: false,
StartsAt: startsAt,
Source: source,
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
ReasonCode: reasonCode,
UpdatedAt: startsAt,
}
}
func freeRecord(
recordID entitlement.EntitlementRecordID,
userID common.UserID,
startsAt time.Time,
source common.Source,
reasonCode common.ReasonCode,
) entitlement.PeriodRecord {
return entitlement.PeriodRecord{
RecordID: recordID,
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
Source: source,
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
ReasonCode: reasonCode,
StartsAt: startsAt,
CreatedAt: startsAt,
}
}
func paidSnapshot(
userID common.UserID,
planCode entitlement.PlanCode,
startsAt time.Time,
endsAt time.Time,
source common.Source,
reasonCode common.ReasonCode,
) entitlement.CurrentSnapshot {
return entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: planCode,
IsPaid: true,
StartsAt: startsAt,
EndsAt: timePointer(endsAt),
Source: source,
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: reasonCode,
UpdatedAt: startsAt,
}
}
func paidRecord(
recordID entitlement.EntitlementRecordID,
userID common.UserID,
planCode entitlement.PlanCode,
startsAt time.Time,
endsAt time.Time,
source common.Source,
reasonCode common.ReasonCode,
) entitlement.PeriodRecord {
return entitlement.PeriodRecord{
RecordID: recordID,
UserID: userID,
PlanCode: planCode,
Source: source,
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: reasonCode,
StartsAt: startsAt,
EndsAt: timePointer(endsAt),
CreatedAt: startsAt,
}
}
func timePointer(value time.Time) *time.Time {
utcValue := value.UTC()
return &utcValue
}
var (
_ ports.UserAccountStore = fakeAccountStore{}
_ ports.EntitlementSnapshotStore = (*fakeSnapshotStore)(nil)
_ ports.EntitlementHistoryStore = (*fakeHistoryStore)(nil)
_ ports.EntitlementLifecycleStore = (*fakeLifecycleStore)(nil)
_ ports.Clock = fixedClock{}
_ ports.IDGenerator = fixedIDGenerator{}
)
+194
View File
@@ -0,0 +1,194 @@
// Package geosync implements the trusted geo-facing declared-country sync
// command owned by User Service.
package geosync
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/shared"
"galaxy/user/internal/telemetry"
"golang.org/x/text/language"
)
const geoProfileServiceSource = common.Source("geo_profile_service")
// SyncDeclaredCountryInput stores one trusted geo-facing country-sync request.
type SyncDeclaredCountryInput struct {
// UserID identifies the regular user whose current declared country must be
// synchronized.
UserID string
// DeclaredCountry stores the new current effective declared country.
DeclaredCountry string
}
// SyncDeclaredCountryResult stores one trusted geo-facing country-sync result.
type SyncDeclaredCountryResult struct {
// UserID identifies the synchronized user.
UserID string `json:"user_id"`
// DeclaredCountry stores the current effective declared country after the
// command completes.
DeclaredCountry string `json:"declared_country"`
// UpdatedAt stores the effective account mutation timestamp. Same-value
// no-op syncs return the current stored timestamp unchanged.
UpdatedAt time.Time `json:"updated_at"`
}
// SyncService executes the trusted geo-facing declared-country sync command.
type SyncService struct {
accounts ports.UserAccountStore
clock ports.Clock
publisher ports.DeclaredCountryChangedPublisher
logger *slog.Logger
telemetry *telemetry.Runtime
}
// NewSyncService constructs one trusted declared-country sync command.
func NewSyncService(
accounts ports.UserAccountStore,
clock ports.Clock,
publisher ports.DeclaredCountryChangedPublisher,
) (*SyncService, error) {
return NewSyncServiceWithObservability(accounts, clock, publisher, nil, nil)
}
// NewSyncServiceWithObservability constructs one trusted declared-country sync
// command with optional structured logging and event-publication metrics.
func NewSyncServiceWithObservability(
accounts ports.UserAccountStore,
clock ports.Clock,
publisher ports.DeclaredCountryChangedPublisher,
logger *slog.Logger,
telemetryRuntime *telemetry.Runtime,
) (*SyncService, error) {
switch {
case accounts == nil:
return nil, fmt.Errorf("geo declared-country sync service: user account store must not be nil")
case clock == nil:
return nil, fmt.Errorf("geo declared-country sync service: clock must not be nil")
case publisher == nil:
return nil, fmt.Errorf("geo declared-country sync service: declared-country changed publisher must not be nil")
default:
return &SyncService{
accounts: accounts,
clock: clock,
publisher: publisher,
logger: logger,
telemetry: telemetryRuntime,
}, nil
}
}
// Execute synchronizes the current effective declared country of one user.
func (service *SyncService) Execute(
ctx context.Context,
input SyncDeclaredCountryInput,
) (result SyncDeclaredCountryResult, err error) {
outcome := "failed"
userIDString := ""
defer func() {
shared.LogServiceOutcome(service.logger, ctx, "declared-country sync completed", err,
"use_case", "sync_declared_country",
"outcome", outcome,
"user_id", userIDString,
"source", geoProfileServiceSource.String(),
)
}()
if ctx == nil {
return SyncDeclaredCountryResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
return SyncDeclaredCountryResult{}, err
}
userIDString = userID.String()
declaredCountry, err := parseDeclaredCountry(input.DeclaredCountry)
if err != nil {
return SyncDeclaredCountryResult{}, err
}
record, err := service.accounts.GetByUserID(ctx, userID)
switch {
case err == nil:
case errors.Is(err, ports.ErrNotFound):
return SyncDeclaredCountryResult{}, shared.SubjectNotFound()
default:
return SyncDeclaredCountryResult{}, shared.ServiceUnavailable(err)
}
if record.DeclaredCountry == declaredCountry {
outcome = "noop"
return resultFromAccount(record), nil
}
record.DeclaredCountry = declaredCountry
record.UpdatedAt = service.clock.Now().UTC()
if err := service.accounts.Update(ctx, record); err != nil {
switch {
case errors.Is(err, ports.ErrNotFound):
return SyncDeclaredCountryResult{}, shared.SubjectNotFound()
case errors.Is(err, ports.ErrConflict):
return SyncDeclaredCountryResult{}, shared.ServiceUnavailable(err)
default:
return SyncDeclaredCountryResult{}, shared.ServiceUnavailable(err)
}
}
result = resultFromAccount(record)
outcome = "updated"
if err := service.publisher.PublishDeclaredCountryChanged(ctx, ports.DeclaredCountryChangedEvent{
UserID: record.UserID,
DeclaredCountry: record.DeclaredCountry,
UpdatedAt: record.UpdatedAt,
Source: geoProfileServiceSource,
}); err != nil {
if service.telemetry != nil {
service.telemetry.RecordEventPublicationFailure(ctx, ports.DeclaredCountryChangedEventType)
}
shared.LogEventPublicationFailure(service.logger, ctx, ports.DeclaredCountryChangedEventType, err,
"use_case", "sync_declared_country",
"user_id", record.UserID.String(),
"source", geoProfileServiceSource.String(),
)
}
return result, nil
}
func parseDeclaredCountry(value string) (common.CountryCode, error) {
const message = "declared_country must be a valid ISO 3166-1 alpha-2 country code"
code := common.CountryCode(shared.NormalizeString(value))
if err := code.Validate(); err != nil {
return "", shared.InvalidRequest(message)
}
region, err := language.ParseRegion(code.String())
if err != nil || !region.IsCountry() || region.Canonicalize().String() != code.String() {
return "", shared.InvalidRequest(message)
}
return code, nil
}
func resultFromAccount(record account.UserAccount) SyncDeclaredCountryResult {
return SyncDeclaredCountryResult{
UserID: record.UserID.String(),
DeclaredCountry: record.DeclaredCountry.String(),
UpdatedAt: record.UpdatedAt.UTC(),
}
}
@@ -0,0 +1,299 @@
package geosync
import (
"context"
"errors"
"testing"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/shared"
"github.com/stretchr/testify/require"
)
func TestSyncServiceExecuteUpdatesDeclaredCountryAndPublishesEvent(t *testing.T) {
t.Parallel()
createdAt := time.Unix(1_775_240_000, 0).UTC()
updatedAt := createdAt.Add(5 * time.Minute)
record := validAccountRecord(createdAt, createdAt)
store := newFakeAccountStore(record)
publisher := &recordingDeclaredCountryChangedPublisher{
publishHook: func(event ports.DeclaredCountryChangedEvent) error {
stored, err := store.GetByUserID(context.Background(), record.UserID)
require.NoError(t, err)
require.Equal(t, common.CountryCode("FR"), stored.DeclaredCountry)
require.Equal(t, updatedAt, stored.UpdatedAt)
require.Equal(t, common.Source("geo_profile_service"), event.Source)
return nil
},
}
service, err := NewSyncService(store, fixedClock{now: updatedAt}, publisher)
require.NoError(t, err)
result, err := service.Execute(context.Background(), SyncDeclaredCountryInput{
UserID: record.UserID.String(),
DeclaredCountry: "FR",
})
require.NoError(t, err)
require.Equal(t, record.UserID.String(), result.UserID)
require.Equal(t, "FR", result.DeclaredCountry)
require.Equal(t, updatedAt, result.UpdatedAt)
require.Equal(t, 1, store.updateCalls)
stored, err := store.GetByUserID(context.Background(), record.UserID)
require.NoError(t, err)
require.Equal(t, record.Email, stored.Email)
require.Equal(t, record.RaceName, stored.RaceName)
require.Equal(t, record.PreferredLanguage, stored.PreferredLanguage)
require.Equal(t, record.TimeZone, stored.TimeZone)
require.Equal(t, common.CountryCode("FR"), stored.DeclaredCountry)
require.Equal(t, record.CreatedAt, stored.CreatedAt)
require.Equal(t, updatedAt, stored.UpdatedAt)
published := publisher.PublishedEvents()
require.Len(t, published, 1)
require.Equal(t, record.UserID, published[0].UserID)
require.Equal(t, common.CountryCode("FR"), published[0].DeclaredCountry)
require.Equal(t, updatedAt, published[0].UpdatedAt)
require.Equal(t, common.Source("geo_profile_service"), published[0].Source)
}
func TestSyncServiceExecuteSameCountryIsNoOp(t *testing.T) {
t.Parallel()
createdAt := time.Unix(1_775_240_000, 0).UTC()
record := validAccountRecord(createdAt, createdAt.Add(5*time.Minute))
store := newFakeAccountStore(record)
publisher := &recordingDeclaredCountryChangedPublisher{}
service, err := NewSyncService(store, fixedClock{now: createdAt.Add(time.Hour)}, publisher)
require.NoError(t, err)
result, err := service.Execute(context.Background(), SyncDeclaredCountryInput{
UserID: record.UserID.String(),
DeclaredCountry: record.DeclaredCountry.String(),
})
require.NoError(t, err)
require.Equal(t, record.UserID.String(), result.UserID)
require.Equal(t, record.DeclaredCountry.String(), result.DeclaredCountry)
require.Equal(t, record.UpdatedAt, result.UpdatedAt)
require.Zero(t, store.updateCalls)
require.Empty(t, publisher.PublishedEvents())
}
func TestSyncServiceExecuteRejectsInvalidDeclaredCountry(t *testing.T) {
t.Parallel()
service, err := NewSyncService(
newFakeAccountStore(validAccountRecord(time.Unix(1_775_240_000, 0).UTC(), time.Unix(1_775_240_000, 0).UTC())),
fixedClock{now: time.Unix(1_775_240_000, 0).UTC()},
&recordingDeclaredCountryChangedPublisher{},
)
require.NoError(t, err)
tests := []struct {
name string
value string
}{
{name: "alias country code", value: "UK"},
{name: "lowercase", value: "de"},
{name: "non-country region", value: "EU"},
{name: "wrong length", value: "DEU"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := service.Execute(context.Background(), SyncDeclaredCountryInput{
UserID: "user-123",
DeclaredCountry: tt.value,
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
require.EqualError(t, err, "declared_country must be a valid ISO 3166-1 alpha-2 country code")
})
}
}
func TestSyncServiceExecuteUnknownUserReturnsNotFound(t *testing.T) {
t.Parallel()
service, err := NewSyncService(
newFakeAccountStore(),
fixedClock{now: time.Unix(1_775_240_000, 0).UTC()},
&recordingDeclaredCountryChangedPublisher{},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), SyncDeclaredCountryInput{
UserID: "user-missing",
DeclaredCountry: "DE",
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err))
}
func TestSyncServiceExecutePublisherFailureDoesNotRollbackCommit(t *testing.T) {
t.Parallel()
createdAt := time.Unix(1_775_240_000, 0).UTC()
updatedAt := createdAt.Add(time.Minute)
record := validAccountRecord(createdAt, createdAt)
store := newFakeAccountStore(record)
publisher := &recordingDeclaredCountryChangedPublisher{
err: errors.New("publisher unavailable"),
}
service, err := NewSyncService(store, fixedClock{now: updatedAt}, publisher)
require.NoError(t, err)
result, err := service.Execute(context.Background(), SyncDeclaredCountryInput{
UserID: record.UserID.String(),
DeclaredCountry: "FR",
})
require.NoError(t, err)
require.Equal(t, "FR", result.DeclaredCountry)
require.Equal(t, updatedAt, result.UpdatedAt)
stored, err := store.GetByUserID(context.Background(), record.UserID)
require.NoError(t, err)
require.Equal(t, common.CountryCode("FR"), stored.DeclaredCountry)
require.Equal(t, updatedAt, stored.UpdatedAt)
published := publisher.PublishedEvents()
require.Len(t, published, 1)
require.Equal(t, common.CountryCode("FR"), published[0].DeclaredCountry)
}
type fakeAccountStore struct {
records map[common.UserID]account.UserAccount
updateCalls int
updateErr error
}
func newFakeAccountStore(records ...account.UserAccount) *fakeAccountStore {
byUserID := make(map[common.UserID]account.UserAccount, len(records))
for _, record := range records {
byUserID[record.UserID] = record
}
return &fakeAccountStore{records: byUserID}
}
func (store *fakeAccountStore) Create(context.Context, ports.CreateAccountInput) error {
return nil
}
func (store *fakeAccountStore) GetByUserID(_ context.Context, userID common.UserID) (account.UserAccount, error) {
record, ok := store.records[userID]
if !ok {
return account.UserAccount{}, ports.ErrNotFound
}
return record, nil
}
func (store *fakeAccountStore) GetByEmail(_ context.Context, email common.Email) (account.UserAccount, error) {
for _, record := range store.records {
if record.Email == email {
return record, nil
}
}
return account.UserAccount{}, ports.ErrNotFound
}
func (store *fakeAccountStore) GetByRaceName(_ context.Context, raceName common.RaceName) (account.UserAccount, error) {
for _, record := range store.records {
if record.RaceName == raceName {
return record, nil
}
}
return account.UserAccount{}, ports.ErrNotFound
}
func (store *fakeAccountStore) ExistsByUserID(_ context.Context, userID common.UserID) (bool, error) {
_, ok := store.records[userID]
return ok, nil
}
func (store *fakeAccountStore) RenameRaceName(context.Context, ports.RenameRaceNameInput) error {
return nil
}
func (store *fakeAccountStore) Update(_ context.Context, record account.UserAccount) error {
store.updateCalls++
if store.updateErr != nil {
return store.updateErr
}
if _, ok := store.records[record.UserID]; !ok {
return ports.ErrNotFound
}
store.records[record.UserID] = record
return nil
}
type recordingDeclaredCountryChangedPublisher struct {
err error
publishHook func(event ports.DeclaredCountryChangedEvent) error
published []ports.DeclaredCountryChangedEvent
}
func (publisher *recordingDeclaredCountryChangedPublisher) PublishDeclaredCountryChanged(
_ context.Context,
event ports.DeclaredCountryChangedEvent,
) error {
if err := event.Validate(); err != nil {
return err
}
publisher.published = append(publisher.published, event)
if publisher.publishHook != nil {
if err := publisher.publishHook(event); err != nil {
return err
}
}
return publisher.err
}
func (publisher *recordingDeclaredCountryChangedPublisher) PublishedEvents() []ports.DeclaredCountryChangedEvent {
events := make([]ports.DeclaredCountryChangedEvent, len(publisher.published))
copy(events, publisher.published)
return events
}
type fixedClock struct {
now time.Time
}
func (clock fixedClock) Now() time.Time {
return clock.now
}
func validAccountRecord(createdAt time.Time, updatedAt time.Time) account.UserAccount {
return account.UserAccount{
UserID: common.UserID("user-123"),
Email: common.Email("pilot@example.com"),
RaceName: common.RaceName("Pilot Nova"),
PreferredLanguage: common.LanguageTag("en"),
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
DeclaredCountry: common.CountryCode("DE"),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
}
var (
_ ports.UserAccountStore = (*fakeAccountStore)(nil)
_ ports.DeclaredCountryChangedPublisher = (*recordingDeclaredCountryChangedPublisher)(nil)
_ ports.Clock = fixedClock{}
)
@@ -0,0 +1,397 @@
// Package lobbyeligibility implements the trusted lobby-facing eligibility
// snapshot read owned by User Service.
package lobbyeligibility
import (
"context"
"errors"
"fmt"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/shared"
)
// limitCatalogEntry stores one frozen default quota for free and paid
// entitlement states.
type limitCatalogEntry struct {
code policy.LimitCode
freeValue int
paidValue int
freeEnabled bool
}
// limitCatalog stores the frozen lobby-facing effective limit defaults used
// to materialize numeric quotas from the current entitlement state.
var limitCatalog = []limitCatalogEntry{
{
code: policy.LimitCodeMaxOwnedPrivateGames,
paidValue: 3,
},
{
code: policy.LimitCodeMaxPendingPublicApplications,
freeValue: 3,
paidValue: 10,
freeEnabled: true,
},
{
code: policy.LimitCodeMaxActiveGameMemberships,
freeValue: 3,
paidValue: 10,
freeEnabled: true,
},
}
// ActorRefView stores transport-ready audit actor metadata.
type ActorRefView struct {
// Type stores the machine-readable actor type.
Type string `json:"type"`
// ID stores the optional stable actor identifier.
ID string `json:"id,omitempty"`
}
// EntitlementSnapshotView stores the transport-ready current entitlement
// snapshot used by lobby reads.
type EntitlementSnapshotView struct {
// PlanCode stores the effective entitlement plan code.
PlanCode string `json:"plan_code"`
// IsPaid reports whether the effective plan is paid.
IsPaid bool `json:"is_paid"`
// Source stores the machine-readable mutation source.
Source string `json:"source"`
// Actor stores the audit actor metadata attached to the snapshot.
Actor ActorRefView `json:"actor"`
// ReasonCode stores the machine-readable reason attached to the snapshot.
ReasonCode string `json:"reason_code"`
// StartsAt stores when the effective state started.
StartsAt time.Time `json:"starts_at"`
// EndsAt stores the optional finite effective expiry.
EndsAt *time.Time `json:"ends_at,omitempty"`
// UpdatedAt stores when the snapshot was last recomputed.
UpdatedAt time.Time `json:"updated_at"`
}
// ActiveSanctionView stores one transport-ready active sanction that matters
// to lobby flows.
type ActiveSanctionView struct {
// SanctionCode stores the active sanction code.
SanctionCode string `json:"sanction_code"`
// Scope stores the machine-readable sanction scope.
Scope string `json:"scope"`
// ReasonCode stores the machine-readable sanction reason.
ReasonCode string `json:"reason_code"`
// Actor stores the audit actor metadata attached to the sanction.
Actor ActorRefView `json:"actor"`
// AppliedAt stores when the sanction became active.
AppliedAt time.Time `json:"applied_at"`
// ExpiresAt stores the optional planned sanction expiry.
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// EffectiveLimitView stores one materialized effective lobby quota.
type EffectiveLimitView struct {
// LimitCode stores the machine-readable quota identifier.
LimitCode string `json:"limit_code"`
// Value stores the effective numeric quota after defaults and user
// overrides are applied.
Value int `json:"value"`
}
// EligibilityMarkersView stores the derived booleans consumed by Game Lobby.
type EligibilityMarkersView struct {
// CanLogin reports whether the user may currently log in.
CanLogin bool `json:"can_login"`
// CanCreatePrivateGame reports whether the user may currently create a
// private game.
CanCreatePrivateGame bool `json:"can_create_private_game"`
// CanManagePrivateGame reports whether the user may currently manage a
// private game.
CanManagePrivateGame bool `json:"can_manage_private_game"`
// CanJoinGame reports whether the user may currently join a game.
CanJoinGame bool `json:"can_join_game"`
// CanUpdateProfile reports whether the user may currently update self-
// service profile and settings fields.
CanUpdateProfile bool `json:"can_update_profile"`
}
// GetUserEligibilityInput stores one lobby-facing eligibility read request.
type GetUserEligibilityInput struct {
// UserID identifies the regular user whose effective lobby state is needed.
UserID string
}
// GetUserEligibilityResult stores one lobby-facing eligibility snapshot.
type GetUserEligibilityResult struct {
// Exists reports whether UserID currently identifies a stored user.
Exists bool `json:"exists"`
// UserID echoes the requested stable user identifier.
UserID string `json:"user_id"`
// Entitlement stores the current effective entitlement snapshot for known
// users.
Entitlement *EntitlementSnapshotView `json:"entitlement,omitempty"`
// ActiveSanctions stores only the currently active sanctions relevant to
// lobby decisions.
ActiveSanctions []ActiveSanctionView `json:"active_sanctions"`
// EffectiveLimits stores the materialized numeric quotas used by Game
// Lobby.
EffectiveLimits []EffectiveLimitView `json:"effective_limits"`
// Markers stores the derived decision booleans consumed by Game Lobby.
Markers EligibilityMarkersView `json:"markers"`
}
type entitlementReader interface {
GetByUserID(ctx context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error)
}
// SnapshotReader executes the trusted lobby-facing eligibility snapshot read.
type SnapshotReader struct {
accounts ports.UserAccountStore
entitlements entitlementReader
sanctions ports.SanctionStore
limits ports.LimitStore
clock ports.Clock
}
// NewSnapshotReader constructs one lobby-facing eligibility snapshot reader.
func NewSnapshotReader(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
) (*SnapshotReader, error) {
switch {
case accounts == nil:
return nil, fmt.Errorf("lobby eligibility snapshot reader: user account store must not be nil")
case entitlements == nil:
return nil, fmt.Errorf("lobby eligibility snapshot reader: entitlement reader must not be nil")
case sanctions == nil:
return nil, fmt.Errorf("lobby eligibility snapshot reader: sanction store must not be nil")
case limits == nil:
return nil, fmt.Errorf("lobby eligibility snapshot reader: limit store must not be nil")
case clock == nil:
return nil, fmt.Errorf("lobby eligibility snapshot reader: clock must not be nil")
default:
return &SnapshotReader{
accounts: accounts,
entitlements: entitlements,
sanctions: sanctions,
limits: limits,
clock: clock,
}, nil
}
}
// Execute returns one read-optimized eligibility snapshot for Game Lobby.
func (service *SnapshotReader) Execute(
ctx context.Context,
input GetUserEligibilityInput,
) (GetUserEligibilityResult, error) {
if ctx == nil {
return GetUserEligibilityResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
return GetUserEligibilityResult{}, err
}
result := GetUserEligibilityResult{
UserID: userID.String(),
ActiveSanctions: []ActiveSanctionView{},
EffectiveLimits: []EffectiveLimitView{},
}
exists, err := service.accounts.ExistsByUserID(ctx, userID)
if err != nil {
return GetUserEligibilityResult{}, shared.ServiceUnavailable(err)
}
if !exists {
return result, nil
}
now := service.clock.Now().UTC()
entitlementSnapshot, err := service.entitlements.GetByUserID(ctx, userID)
switch {
case err == nil:
case errors.Is(err, ports.ErrNotFound):
return GetUserEligibilityResult{}, shared.InternalError(fmt.Errorf("user %q is missing entitlement snapshot", userID))
default:
return GetUserEligibilityResult{}, shared.ServiceUnavailable(err)
}
sanctionRecords, err := service.sanctions.ListByUserID(ctx, userID)
if err != nil {
return GetUserEligibilityResult{}, shared.ServiceUnavailable(err)
}
activeSanctions, err := policy.ActiveSanctionsAt(sanctionRecords, now)
if err != nil {
return GetUserEligibilityResult{}, shared.InternalError(fmt.Errorf("evaluate active sanctions for user %q: %w", userID, err))
}
limitRecords, err := service.limits.ListByUserID(ctx, userID)
if err != nil {
return GetUserEligibilityResult{}, shared.ServiceUnavailable(err)
}
activeLimits, err := policy.ActiveLimitsAt(limitRecords, now)
if err != nil {
return GetUserEligibilityResult{}, shared.InternalError(fmt.Errorf("evaluate active limits for user %q: %w", userID, err))
}
result.Exists = true
result.Entitlement = entitlementSnapshotView(entitlementSnapshot)
result.ActiveSanctions = lobbyRelevantSanctionViews(activeSanctions)
result.EffectiveLimits = materializeEffectiveLimits(entitlementSnapshot.IsPaid, activeLimits)
result.Markers = deriveEligibilityMarkers(entitlementSnapshot.IsPaid, activeSanctions)
return result, nil
}
func entitlementSnapshotView(snapshot entitlement.CurrentSnapshot) *EntitlementSnapshotView {
return &EntitlementSnapshotView{
PlanCode: string(snapshot.PlanCode),
IsPaid: snapshot.IsPaid,
Source: snapshot.Source.String(),
Actor: actorRefView(snapshot.Actor),
ReasonCode: snapshot.ReasonCode.String(),
StartsAt: snapshot.StartsAt.UTC(),
EndsAt: cloneOptionalTime(snapshot.EndsAt),
UpdatedAt: snapshot.UpdatedAt.UTC(),
}
}
func lobbyRelevantSanctionViews(records []policy.SanctionRecord) []ActiveSanctionView {
views := make([]ActiveSanctionView, 0, len(records))
for _, record := range records {
if !isLobbyRelevantSanction(record.SanctionCode) {
continue
}
views = append(views, ActiveSanctionView{
SanctionCode: string(record.SanctionCode),
Scope: record.Scope.String(),
ReasonCode: record.ReasonCode.String(),
Actor: actorRefView(record.Actor),
AppliedAt: record.AppliedAt.UTC(),
ExpiresAt: cloneOptionalTime(record.ExpiresAt),
})
}
return views
}
func materializeEffectiveLimits(isPaid bool, overrides []policy.LimitRecord) []EffectiveLimitView {
overrideValues := make(map[policy.LimitCode]int, len(overrides))
for _, record := range overrides {
overrideValues[record.LimitCode] = record.Value
}
limits := make([]EffectiveLimitView, 0, len(limitCatalog))
for _, entry := range limitCatalog {
if !isPaid && !entry.freeEnabled {
continue
}
value := entry.freeValue
if isPaid {
value = entry.paidValue
}
if override, ok := overrideValues[entry.code]; ok {
value = override
}
limits = append(limits, EffectiveLimitView{
LimitCode: string(entry.code),
Value: value,
})
}
return limits
}
func deriveEligibilityMarkers(
isPaid bool,
activeSanctions []policy.SanctionRecord,
) EligibilityMarkersView {
loginBlocked := hasActiveSanction(activeSanctions, policy.SanctionCodeLoginBlock)
createBlocked := hasActiveSanction(activeSanctions, policy.SanctionCodePrivateGameCreateBlock)
manageBlocked := hasActiveSanction(activeSanctions, policy.SanctionCodePrivateGameManageBlock)
joinBlocked := hasActiveSanction(activeSanctions, policy.SanctionCodeGameJoinBlock)
profileBlocked := hasActiveSanction(activeSanctions, policy.SanctionCodeProfileUpdateBlock)
canLogin := !loginBlocked
return EligibilityMarkersView{
CanLogin: canLogin,
CanCreatePrivateGame: canLogin && isPaid && !createBlocked,
CanManagePrivateGame: canLogin && isPaid && !manageBlocked,
CanJoinGame: canLogin && !joinBlocked,
CanUpdateProfile: canLogin && !profileBlocked,
}
}
func hasActiveSanction(records []policy.SanctionRecord, code policy.SanctionCode) bool {
for _, record := range records {
if record.SanctionCode == code {
return true
}
}
return false
}
func isLobbyRelevantSanction(code policy.SanctionCode) bool {
switch code {
case policy.SanctionCodeLoginBlock,
policy.SanctionCodePrivateGameCreateBlock,
policy.SanctionCodePrivateGameManageBlock,
policy.SanctionCodeGameJoinBlock:
return true
default:
return false
}
}
func actorRefView(actor common.ActorRef) ActorRefView {
return ActorRefView{
Type: actor.Type.String(),
ID: actor.ID.String(),
}
}
func cloneOptionalTime(value *time.Time) *time.Time {
if value == nil {
return nil
}
cloned := value.UTC()
return &cloned
}
@@ -0,0 +1,524 @@
package lobbyeligibility
import (
"context"
"testing"
"time"
"galaxy/user/internal/adapters/redis/userstore"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/entitlementsvc"
"github.com/alicebob/miniredis/v2"
"github.com/stretchr/testify/require"
)
func TestSnapshotReaderExecuteReturnsStableNotFound(t *testing.T) {
t.Parallel()
service, err := NewSnapshotReader(
fakeAccountStore{existsByUserID: map[common.UserID]bool{}},
fakeEntitlementReader{},
fakeSanctionStore{},
fakeLimitStore{},
fixedClock{now: time.Unix(1_775_240_500, 0).UTC()},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), GetUserEligibilityInput{UserID: " user-missing "})
require.NoError(t, err)
require.False(t, result.Exists)
require.Equal(t, "user-missing", result.UserID)
require.Nil(t, result.Entitlement)
require.Empty(t, result.ActiveSanctions)
require.Empty(t, result.EffectiveLimits)
require.Equal(t, EligibilityMarkersView{}, result.Markers)
}
func TestSnapshotReaderExecuteBuildsPaidSnapshotAndDerivedState(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
userID := common.UserID("user-123")
service, err := NewSnapshotReader(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
fakeEntitlementReader{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
userID: paidEntitlementSnapshot(userID, now.Add(-24*time.Hour), now.Add(24*time.Hour)),
},
},
fakeSanctionStore{
byUserID: map[common.UserID][]policy.SanctionRecord{
userID: {
activeSanction(userID, policy.SanctionCodePrivateGameManageBlock, "lobby", now.Add(-time.Hour)),
activeSanction(userID, policy.SanctionCodeProfileUpdateBlock, "profile", now.Add(-30*time.Minute)),
expiredSanction(userID, policy.SanctionCodeGameJoinBlock, "lobby", now.Add(-2*time.Hour)),
},
},
},
fakeLimitStore{
byUserID: map[common.UserID][]policy.LimitRecord{
userID: {
activeLimit(userID, policy.LimitCodeMaxPendingPrivateInvitesSent, 17, now.Add(-time.Hour)),
activeLimit(userID, policy.LimitCodeMaxActivePrivateGames, 2, now.Add(-2*time.Hour)),
},
},
},
fixedClock{now: now},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), GetUserEligibilityInput{UserID: userID.String()})
require.NoError(t, err)
require.True(t, result.Exists)
require.NotNil(t, result.Entitlement)
require.Equal(t, "paid_monthly", result.Entitlement.PlanCode)
require.True(t, result.Entitlement.IsPaid)
require.Len(t, result.ActiveSanctions, 1)
require.Equal(t, "private_game_manage_block", result.ActiveSanctions[0].SanctionCode)
require.Equal(t, EligibilityMarkersView{
CanLogin: true,
CanCreatePrivateGame: true,
CanManagePrivateGame: false,
CanJoinGame: true,
CanUpdateProfile: false,
}, result.Markers)
require.Equal(t, []EffectiveLimitView{
{LimitCode: "max_owned_private_games", Value: 3},
{LimitCode: "max_pending_public_applications", Value: 10},
{LimitCode: "max_active_game_memberships", Value: 10},
}, result.EffectiveLimits)
}
func TestSnapshotReaderExecuteDeniesUnpaidAndLoginBlockedUsers(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
userID := common.UserID("user-123")
tests := []struct {
name string
snapshot entitlement.CurrentSnapshot
sanctions []policy.SanctionRecord
limits []policy.LimitRecord
wantSanctions []string
wantMarkers EligibilityMarkersView
wantLimits []EffectiveLimitView
}{
{
name: "unpaid defaults",
snapshot: freeEntitlementSnapshot(userID, now.Add(-24*time.Hour)),
limits: []policy.LimitRecord{activeLimit(userID, policy.LimitCodeMaxOwnedPrivateGames, 9, now.Add(-time.Hour))},
wantSanctions: []string{},
wantMarkers: EligibilityMarkersView{
CanLogin: true,
CanCreatePrivateGame: false,
CanManagePrivateGame: false,
CanJoinGame: true,
CanUpdateProfile: true,
},
wantLimits: []EffectiveLimitView{
{LimitCode: "max_pending_public_applications", Value: 3},
{LimitCode: "max_active_game_memberships", Value: 3},
},
},
{
name: "login block denies all markers",
snapshot: paidEntitlementSnapshot(userID, now.Add(-24*time.Hour), now.Add(24*time.Hour)),
sanctions: []policy.SanctionRecord{
activeSanction(userID, policy.SanctionCodeLoginBlock, "auth", now.Add(-time.Hour)),
activeSanction(userID, policy.SanctionCodeGameJoinBlock, "lobby", now.Add(-30*time.Minute)),
},
wantSanctions: []string{"game_join_block", "login_block"},
wantMarkers: EligibilityMarkersView{
CanLogin: false,
CanCreatePrivateGame: false,
CanManagePrivateGame: false,
CanJoinGame: false,
CanUpdateProfile: false,
},
wantLimits: []EffectiveLimitView{
{LimitCode: "max_owned_private_games", Value: 3},
{LimitCode: "max_pending_public_applications", Value: 10},
{LimitCode: "max_active_game_memberships", Value: 10},
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
service, err := NewSnapshotReader(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
fakeEntitlementReader{byUserID: map[common.UserID]entitlement.CurrentSnapshot{userID: tt.snapshot}},
fakeSanctionStore{byUserID: map[common.UserID][]policy.SanctionRecord{userID: tt.sanctions}},
fakeLimitStore{byUserID: map[common.UserID][]policy.LimitRecord{userID: tt.limits}},
fixedClock{now: now},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), GetUserEligibilityInput{UserID: userID.String()})
require.NoError(t, err)
require.Equal(t, tt.wantMarkers, result.Markers)
require.Equal(t, tt.wantLimits, result.EffectiveLimits)
gotSanctions := make([]string, 0, len(result.ActiveSanctions))
for _, sanction := range result.ActiveSanctions {
gotSanctions = append(gotSanctions, sanction.SanctionCode)
}
require.Equal(t, tt.wantSanctions, gotSanctions)
})
}
}
func TestSnapshotReaderExecuteRepairsExpiredPaidSnapshotWithStore(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
store := newRedisStore(t)
userID := common.UserID("user-123")
accountRecord := validAccountRecord()
require.NoError(t, store.Accounts().Create(context.Background(), ports.CreateAccountInput{
Account: accountRecord,
Reservation: account.RaceNameReservation{
CanonicalKey: account.RaceNameCanonicalKey("pilot nova"),
UserID: userID,
RaceName: accountRecord.RaceName,
ReservedAt: accountRecord.UpdatedAt,
},
}))
expiredEndsAt := now.Add(-time.Minute)
require.NoError(t, store.EntitlementSnapshots().Put(context.Background(), entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodePaidMonthly,
IsPaid: true,
StartsAt: now.Add(-30 * 24 * time.Hour),
EndsAt: timePointer(expiredEndsAt),
Source: common.Source("billing"),
Actor: common.ActorRef{Type: common.ActorType("billing"), ID: common.ActorID("invoice-1")},
ReasonCode: common.ReasonCode("renewal"),
UpdatedAt: now.Add(-2 * time.Hour),
}))
entitlementReader, err := entitlementsvc.NewReader(
store.EntitlementSnapshots(),
store.EntitlementLifecycle(),
fixedClock{now: now},
fixedIDGenerator{entitlementRecordID: entitlement.EntitlementRecordID("entitlement-expiry-repair")},
)
require.NoError(t, err)
service, err := NewSnapshotReader(
store.Accounts(),
entitlementReader,
store.Sanctions(),
store.Limits(),
fixedClock{now: now},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), GetUserEligibilityInput{UserID: userID.String()})
require.NoError(t, err)
require.True(t, result.Exists)
require.NotNil(t, result.Entitlement)
require.Equal(t, "free", result.Entitlement.PlanCode)
require.False(t, result.Entitlement.IsPaid)
require.Equal(t, expiredEndsAt, result.Entitlement.StartsAt)
require.Equal(t, []EffectiveLimitView{
{LimitCode: "max_pending_public_applications", Value: 3},
{LimitCode: "max_active_game_memberships", Value: 3},
}, result.EffectiveLimits)
storedSnapshot, err := store.EntitlementSnapshots().GetByUserID(context.Background(), userID)
require.NoError(t, err)
require.Equal(t, entitlement.PlanCodeFree, storedSnapshot.PlanCode)
require.False(t, storedSnapshot.IsPaid)
}
type fakeAccountStore struct {
existsByUserID map[common.UserID]bool
err error
}
func (store fakeAccountStore) Create(context.Context, ports.CreateAccountInput) error {
return nil
}
func (store fakeAccountStore) GetByUserID(context.Context, common.UserID) (account.UserAccount, error) {
return account.UserAccount{}, ports.ErrNotFound
}
func (store fakeAccountStore) GetByEmail(context.Context, common.Email) (account.UserAccount, error) {
return account.UserAccount{}, ports.ErrNotFound
}
func (store fakeAccountStore) GetByRaceName(context.Context, common.RaceName) (account.UserAccount, error) {
return account.UserAccount{}, ports.ErrNotFound
}
func (store fakeAccountStore) ExistsByUserID(_ context.Context, userID common.UserID) (bool, error) {
if store.err != nil {
return false, store.err
}
return store.existsByUserID[userID], nil
}
func (store fakeAccountStore) RenameRaceName(context.Context, ports.RenameRaceNameInput) error {
return nil
}
func (store fakeAccountStore) Update(context.Context, account.UserAccount) error {
return nil
}
type fakeEntitlementReader struct {
byUserID map[common.UserID]entitlement.CurrentSnapshot
err error
}
func (reader fakeEntitlementReader) GetByUserID(_ context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error) {
if reader.err != nil {
return entitlement.CurrentSnapshot{}, reader.err
}
record, ok := reader.byUserID[userID]
if !ok {
return entitlement.CurrentSnapshot{}, ports.ErrNotFound
}
return record, nil
}
type fakeSanctionStore struct {
byUserID map[common.UserID][]policy.SanctionRecord
err error
}
func (store fakeSanctionStore) Create(context.Context, policy.SanctionRecord) error {
return nil
}
func (store fakeSanctionStore) GetByRecordID(context.Context, policy.SanctionRecordID) (policy.SanctionRecord, error) {
return policy.SanctionRecord{}, ports.ErrNotFound
}
func (store fakeSanctionStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.SanctionRecord, error) {
if store.err != nil {
return nil, store.err
}
records := store.byUserID[userID]
cloned := make([]policy.SanctionRecord, len(records))
copy(cloned, records)
return cloned, nil
}
func (store fakeSanctionStore) Update(context.Context, policy.SanctionRecord) error {
return nil
}
type fakeLimitStore struct {
byUserID map[common.UserID][]policy.LimitRecord
err error
}
func (store fakeLimitStore) Create(context.Context, policy.LimitRecord) error {
return nil
}
func (store fakeLimitStore) GetByRecordID(context.Context, policy.LimitRecordID) (policy.LimitRecord, error) {
return policy.LimitRecord{}, ports.ErrNotFound
}
func (store fakeLimitStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.LimitRecord, error) {
if store.err != nil {
return nil, store.err
}
records := store.byUserID[userID]
cloned := make([]policy.LimitRecord, len(records))
copy(cloned, records)
return cloned, nil
}
func (store fakeLimitStore) Update(context.Context, policy.LimitRecord) error {
return nil
}
type fixedClock struct {
now time.Time
}
func (clock fixedClock) Now() time.Time {
return clock.now
}
type fixedIDGenerator struct {
entitlementRecordID entitlement.EntitlementRecordID
}
func (generator fixedIDGenerator) NewUserID() (common.UserID, error) {
return "", nil
}
func (generator fixedIDGenerator) NewInitialRaceName() (common.RaceName, error) {
return "", nil
}
func (generator fixedIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
return generator.entitlementRecordID, nil
}
func (generator fixedIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
return "", nil
}
func (generator fixedIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
return "", nil
}
func activeSanction(
userID common.UserID,
code policy.SanctionCode,
scope string,
appliedAt time.Time,
) policy.SanctionRecord {
return policy.SanctionRecord{
RecordID: policy.SanctionRecordID("sanction-" + string(code)),
UserID: userID,
SanctionCode: code,
Scope: common.Scope(scope),
ReasonCode: common.ReasonCode("manual_block"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: appliedAt.UTC(),
}
}
func expiredSanction(
userID common.UserID,
code policy.SanctionCode,
scope string,
appliedAt time.Time,
) policy.SanctionRecord {
record := activeSanction(userID, code, scope, appliedAt)
expiresAt := appliedAt.Add(30 * time.Minute)
record.ExpiresAt = &expiresAt
return record
}
func activeLimit(
userID common.UserID,
code policy.LimitCode,
value int,
appliedAt time.Time,
) policy.LimitRecord {
return policy.LimitRecord{
RecordID: policy.LimitRecordID("limit-" + string(code)),
UserID: userID,
LimitCode: code,
Value: value,
ReasonCode: common.ReasonCode("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: appliedAt.UTC(),
}
}
func removedLimit(
userID common.UserID,
code policy.LimitCode,
value int,
appliedAt time.Time,
) policy.LimitRecord {
record := activeLimit(userID, code, value, appliedAt)
removedAt := appliedAt.Add(15 * time.Minute)
record.RemovedAt = &removedAt
record.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")}
record.RemovedReasonCode = common.ReasonCode("manual_remove")
return record
}
func paidEntitlementSnapshot(
userID common.UserID,
startsAt time.Time,
endsAt time.Time,
) entitlement.CurrentSnapshot {
return entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodePaidMonthly,
IsPaid: true,
StartsAt: startsAt.UTC(),
EndsAt: timePointer(endsAt),
Source: common.Source("billing"),
Actor: common.ActorRef{Type: common.ActorType("billing"), ID: common.ActorID("invoice-1")},
ReasonCode: common.ReasonCode("renewal"),
UpdatedAt: startsAt.UTC(),
}
}
func freeEntitlementSnapshot(userID common.UserID, startsAt time.Time) entitlement.CurrentSnapshot {
return entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
IsPaid: false,
StartsAt: startsAt.UTC(),
Source: common.Source("auth_registration"),
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
ReasonCode: common.ReasonCode("initial_free_entitlement"),
UpdatedAt: startsAt.UTC(),
}
}
func validAccountRecord() account.UserAccount {
createdAt := time.Unix(1_775_240_000, 0).UTC()
return account.UserAccount{
UserID: common.UserID("user-123"),
Email: common.Email("pilot@example.com"),
RaceName: common.RaceName("Pilot Nova"),
PreferredLanguage: common.LanguageTag("en"),
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
CreatedAt: createdAt,
UpdatedAt: createdAt,
}
}
func newRedisStore(t *testing.T) *userstore.Store {
t.Helper()
server := miniredis.RunT(t)
store, err := userstore.New(userstore.Config{
Addr: server.Addr(),
DB: 0,
KeyspacePrefix: "user:test:",
OperationTimeout: 250 * time.Millisecond,
})
require.NoError(t, err)
t.Cleanup(func() {
_ = store.Close()
})
return store
}
func timePointer(value time.Time) *time.Time {
utcValue := value.UTC()
return &utcValue
}
var _ ports.UserAccountStore = fakeAccountStore{}
var _ ports.SanctionStore = fakeSanctionStore{}
var _ ports.LimitStore = fakeLimitStore{}
var _ ports.Clock = fixedClock{}
var _ ports.IDGenerator = fixedIDGenerator{}
@@ -0,0 +1,178 @@
package policysvc
import (
"context"
"testing"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"github.com/stretchr/testify/require"
)
func TestApplySanctionServiceExecutePublishesEvent(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
sanctionStore := newFakeSanctionStore()
limitStore := newFakeLimitStore()
publisher := &recordingPolicyPublisher{}
service, err := NewApplySanctionServiceWithObservability(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
sanctionStore,
limitStore,
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
fixedClock{now: now},
fixedIDGenerator{sanctionRecordID: policy.SanctionRecordID("sanction-1")},
nil,
nil,
publisher,
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), ApplySanctionInput{
UserID: userID.String(),
SanctionCode: string(policy.SanctionCodeLoginBlock),
Scope: "auth",
ReasonCode: "policy_blocked",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
AppliedAt: now.Add(-time.Minute).Format(time.RFC3339Nano),
ExpiresAt: now.Add(time.Hour).Format(time.RFC3339Nano),
})
require.NoError(t, err)
require.Len(t, publisher.sanctionEvents, 1)
require.Equal(t, ports.SanctionChangedOperationApplied, publisher.sanctionEvents[0].Operation)
require.Equal(t, common.Source("admin_internal_api"), publisher.sanctionEvents[0].Source)
}
func TestRemoveSanctionServiceExecuteMissingDoesNotPublishEvent(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
sanctionStore := newFakeSanctionStore()
limitStore := newFakeLimitStore()
publisher := &recordingPolicyPublisher{}
service, err := NewRemoveSanctionServiceWithObservability(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
sanctionStore,
limitStore,
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
fixedClock{now: now},
fixedIDGenerator{},
nil,
nil,
publisher,
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), RemoveSanctionInput{
UserID: userID.String(),
SanctionCode: string(policy.SanctionCodeLoginBlock),
ReasonCode: "manual_remove",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
})
require.NoError(t, err)
require.Empty(t, publisher.sanctionEvents)
}
func TestSetLimitServiceExecutePublishesEvent(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
sanctionStore := newFakeSanctionStore()
limitStore := newFakeLimitStore()
publisher := &recordingPolicyPublisher{}
service, err := NewSetLimitServiceWithObservability(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
sanctionStore,
limitStore,
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
fixedClock{now: now},
fixedIDGenerator{limitRecordID: policy.LimitRecordID("limit-1")},
nil,
nil,
publisher,
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), SetLimitInput{
UserID: userID.String(),
LimitCode: string(policy.LimitCodeMaxOwnedPrivateGames),
Value: 5,
ReasonCode: "manual_override",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
AppliedAt: now.Add(-time.Minute).Format(time.RFC3339Nano),
ExpiresAt: now.Add(time.Hour).Format(time.RFC3339Nano),
})
require.NoError(t, err)
require.Len(t, publisher.limitEvents, 1)
require.Equal(t, ports.LimitChangedOperationSet, publisher.limitEvents[0].Operation)
require.NotNil(t, publisher.limitEvents[0].Value)
require.Equal(t, 5, *publisher.limitEvents[0].Value)
}
func TestRemoveLimitServiceExecuteMissingDoesNotPublishEvent(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
sanctionStore := newFakeSanctionStore()
limitStore := newFakeLimitStore()
publisher := &recordingPolicyPublisher{}
service, err := NewRemoveLimitServiceWithObservability(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
sanctionStore,
limitStore,
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
fixedClock{now: now},
fixedIDGenerator{},
nil,
nil,
publisher,
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), RemoveLimitInput{
UserID: userID.String(),
LimitCode: string(policy.LimitCodeMaxOwnedPrivateGames),
ReasonCode: "manual_remove",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
})
require.NoError(t, err)
require.Empty(t, publisher.limitEvents)
}
type recordingPolicyPublisher struct {
sanctionEvents []ports.SanctionChangedEvent
limitEvents []ports.LimitChangedEvent
}
func (publisher *recordingPolicyPublisher) PublishSanctionChanged(_ context.Context, event ports.SanctionChangedEvent) error {
if err := event.Validate(); err != nil {
return err
}
publisher.sanctionEvents = append(publisher.sanctionEvents, event)
return nil
}
func (publisher *recordingPolicyPublisher) PublishLimitChanged(_ context.Context, event ports.LimitChangedEvent) error {
if err := event.Validate(); err != nil {
return err
}
publisher.limitEvents = append(publisher.limitEvents, event)
return nil
}
var (
_ ports.SanctionChangedPublisher = (*recordingPolicyPublisher)(nil)
_ ports.LimitChangedPublisher = (*recordingPolicyPublisher)(nil)
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,705 @@
package policysvc
import (
"context"
"testing"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/shared"
"github.com/stretchr/testify/require"
)
func TestApplySanctionServiceExecuteBuildsActiveRecord(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
sanctionStore := newFakeSanctionStore()
limitStore := newFakeLimitStore()
service, err := NewApplySanctionService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
sanctionStore,
limitStore,
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
fixedClock{now: now},
fixedIDGenerator{sanctionRecordID: policy.SanctionRecordID("sanction-1")},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), ApplySanctionInput{
UserID: userID.String(),
SanctionCode: string(policy.SanctionCodeLoginBlock),
Scope: "auth",
ReasonCode: "policy_blocked",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
AppliedAt: now.Add(-time.Minute).Format(time.RFC3339Nano),
ExpiresAt: now.Add(time.Hour).Format(time.RFC3339Nano),
})
require.NoError(t, err)
require.Equal(t, userID.String(), result.UserID)
require.Len(t, result.ActiveSanctions, 1)
require.Equal(t, string(policy.SanctionCodeLoginBlock), result.ActiveSanctions[0].SanctionCode)
records, err := sanctionStore.ListByUserID(context.Background(), userID)
require.NoError(t, err)
require.Len(t, records, 1)
require.Equal(t, policy.SanctionRecordID("sanction-1"), records[0].RecordID)
}
func TestApplySanctionServiceExecuteRejectsExpiredSanction(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
sanctionStore := newFakeSanctionStore()
limitStore := newFakeLimitStore()
service, err := NewApplySanctionService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
sanctionStore,
limitStore,
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
fixedClock{now: now},
fixedIDGenerator{sanctionRecordID: policy.SanctionRecordID("sanction-1")},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), ApplySanctionInput{
UserID: userID.String(),
SanctionCode: string(policy.SanctionCodeLoginBlock),
Scope: "auth",
ReasonCode: "policy_blocked",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
AppliedAt: now.Add(-2 * time.Hour).Format(time.RFC3339Nano),
ExpiresAt: now.Add(-time.Minute).Format(time.RFC3339Nano),
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
}
func TestApplySanctionServiceExecuteReturnsConflictWhenActiveSanctionExists(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
sanctionStore := newFakeSanctionStore()
existing := policy.SanctionRecord{
RecordID: policy.SanctionRecordID("sanction-existing"),
UserID: userID,
SanctionCode: policy.SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("policy_blocked"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(-time.Hour),
}
require.NoError(t, sanctionStore.Create(context.Background(), existing))
service, err := NewApplySanctionService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
sanctionStore,
newFakeLimitStore(),
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: newFakeLimitStore()},
fixedClock{now: now},
fixedIDGenerator{sanctionRecordID: policy.SanctionRecordID("sanction-1")},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), ApplySanctionInput{
UserID: userID.String(),
SanctionCode: string(policy.SanctionCodeLoginBlock),
Scope: "auth",
ReasonCode: "policy_blocked",
Actor: ActorInput{Type: "admin", ID: "admin-2"},
AppliedAt: now.Add(-time.Minute).Format(time.RFC3339Nano),
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeConflict, shared.CodeOf(err))
}
func TestApplySanctionServiceExecuteReturnsNotFoundForUnknownUser(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
service, err := NewApplySanctionService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{}},
newFakeSanctionStore(),
newFakeLimitStore(),
&fakePolicyLifecycleStore{sanctions: newFakeSanctionStore(), limits: newFakeLimitStore()},
fixedClock{now: now},
fixedIDGenerator{sanctionRecordID: policy.SanctionRecordID("sanction-1")},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), ApplySanctionInput{
UserID: "user-missing",
SanctionCode: string(policy.SanctionCodeLoginBlock),
Scope: "auth",
ReasonCode: "policy_blocked",
Actor: ActorInput{Type: "admin"},
AppliedAt: now.Format(time.RFC3339Nano),
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err))
}
func TestRemoveSanctionServiceExecuteIsIdempotentWhenMissing(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
sanctionStore := newFakeSanctionStore()
limitStore := newFakeLimitStore()
service, err := NewRemoveSanctionService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
sanctionStore,
limitStore,
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
fixedClock{now: now},
fixedIDGenerator{},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), RemoveSanctionInput{
UserID: userID.String(),
SanctionCode: string(policy.SanctionCodeLoginBlock),
ReasonCode: "manual_remove",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
})
require.NoError(t, err)
require.Equal(t, userID.String(), result.UserID)
require.Empty(t, result.ActiveSanctions)
}
func TestRemoveSanctionServiceExecuteTreatsConcurrentRemovalAsSuccess(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
sanctionStore := newFakeSanctionStore()
limitStore := newFakeLimitStore()
record := policy.SanctionRecord{
RecordID: policy.SanctionRecordID("sanction-1"),
UserID: userID,
SanctionCode: policy.SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("policy_blocked"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(-time.Hour),
}
require.NoError(t, sanctionStore.Create(context.Background(), record))
lifecycle := &fakePolicyLifecycleStore{
sanctions: sanctionStore,
limits: limitStore,
removeSanctionHook: func(input ports.RemoveSanctionInput) error {
updated := input.ExpectedActiveRecord
removedAt := now.Add(-time.Minute)
updated.RemovedAt = &removedAt
updated.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")}
updated.RemovedReasonCode = common.ReasonCode("manual_remove")
if err := sanctionStore.Update(context.Background(), updated); err != nil {
return err
}
return ports.ErrConflict
},
}
service, err := NewRemoveSanctionService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
sanctionStore,
limitStore,
lifecycle,
fixedClock{now: now},
fixedIDGenerator{},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), RemoveSanctionInput{
UserID: userID.String(),
SanctionCode: string(policy.SanctionCodeLoginBlock),
ReasonCode: "manual_remove",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
})
require.NoError(t, err)
require.Empty(t, result.ActiveSanctions)
}
func TestSetLimitServiceExecuteReplacesActiveLimit(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
sanctionStore := newFakeSanctionStore()
limitStore := newFakeLimitStore()
current := policy.LimitRecord{
RecordID: policy.LimitRecordID("limit-existing"),
UserID: userID,
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
Value: 3,
ReasonCode: common.ReasonCode("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(-time.Hour),
}
require.NoError(t, limitStore.Create(context.Background(), current))
service, err := NewSetLimitService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
sanctionStore,
limitStore,
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
fixedClock{now: now},
fixedIDGenerator{limitRecordID: policy.LimitRecordID("limit-new")},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), SetLimitInput{
UserID: userID.String(),
LimitCode: string(policy.LimitCodeMaxOwnedPrivateGames),
Value: 5,
ReasonCode: "manual_override",
Actor: ActorInput{Type: "admin", ID: "admin-2"},
AppliedAt: now.Format(time.RFC3339Nano),
})
require.NoError(t, err)
require.Len(t, result.ActiveLimits, 1)
require.Equal(t, 5, result.ActiveLimits[0].Value)
storedCurrent, err := limitStore.GetByRecordID(context.Background(), current.RecordID)
require.NoError(t, err)
require.NotNil(t, storedCurrent.RemovedAt)
require.True(t, storedCurrent.RemovedAt.Equal(now))
}
func TestSetLimitServiceExecuteRejectsRetroactiveReplacement(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
limitStore := newFakeLimitStore()
current := policy.LimitRecord{
RecordID: policy.LimitRecordID("limit-existing"),
UserID: userID,
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
Value: 3,
ReasonCode: common.ReasonCode("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(-time.Hour),
}
require.NoError(t, limitStore.Create(context.Background(), current))
service, err := NewSetLimitService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
newFakeSanctionStore(),
limitStore,
&fakePolicyLifecycleStore{sanctions: newFakeSanctionStore(), limits: limitStore},
fixedClock{now: now},
fixedIDGenerator{limitRecordID: policy.LimitRecordID("limit-new")},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), SetLimitInput{
UserID: userID.String(),
LimitCode: string(policy.LimitCodeMaxOwnedPrivateGames),
Value: 5,
ReasonCode: "manual_override",
Actor: ActorInput{Type: "admin", ID: "admin-2"},
AppliedAt: now.Add(-2 * time.Hour).Format(time.RFC3339Nano),
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
}
func TestSetLimitServiceExecuteRejectsRetiredLimitCodes(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
tests := []string{
string(policy.LimitCodeMaxActivePrivateGames),
string(policy.LimitCodeMaxPendingPrivateJoinRequests),
string(policy.LimitCodeMaxPendingPrivateInvitesSent),
}
for _, limitCode := range tests {
limitCode := limitCode
t.Run(limitCode, func(t *testing.T) {
t.Parallel()
service, err := NewSetLimitService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
newFakeSanctionStore(),
newFakeLimitStore(),
&fakePolicyLifecycleStore{sanctions: newFakeSanctionStore(), limits: newFakeLimitStore()},
fixedClock{now: now},
fixedIDGenerator{limitRecordID: policy.LimitRecordID("limit-new")},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), SetLimitInput{
UserID: userID.String(),
LimitCode: limitCode,
Value: 5,
ReasonCode: "manual_override",
Actor: ActorInput{Type: "admin", ID: "admin-2"},
AppliedAt: now.Format(time.RFC3339Nano),
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
})
}
}
func TestSetLimitServiceExecuteIgnoresRetiredRecordsDuringReload(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
limitStore := newFakeLimitStore()
require.NoError(t, limitStore.Create(context.Background(), policy.LimitRecord{
RecordID: policy.LimitRecordID("limit-legacy"),
UserID: userID,
LimitCode: policy.LimitCodeMaxActivePrivateGames,
Value: 9,
ReasonCode: common.ReasonCode("legacy_override"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(-time.Hour),
}))
service, err := NewSetLimitService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
newFakeSanctionStore(),
limitStore,
&fakePolicyLifecycleStore{sanctions: newFakeSanctionStore(), limits: limitStore},
fixedClock{now: now},
fixedIDGenerator{limitRecordID: policy.LimitRecordID("limit-new")},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), SetLimitInput{
UserID: userID.String(),
LimitCode: string(policy.LimitCodeMaxOwnedPrivateGames),
Value: 5,
ReasonCode: "manual_override",
Actor: ActorInput{Type: "admin", ID: "admin-2"},
AppliedAt: now.Format(time.RFC3339Nano),
})
require.NoError(t, err)
require.Len(t, result.ActiveLimits, 1)
require.Equal(t, string(policy.LimitCodeMaxOwnedPrivateGames), result.ActiveLimits[0].LimitCode)
}
func TestRemoveLimitServiceExecuteIsIdempotentWhenMissing(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
sanctionStore := newFakeSanctionStore()
limitStore := newFakeLimitStore()
service, err := NewRemoveLimitService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
sanctionStore,
limitStore,
&fakePolicyLifecycleStore{sanctions: sanctionStore, limits: limitStore},
fixedClock{now: now},
fixedIDGenerator{},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), RemoveLimitInput{
UserID: userID.String(),
LimitCode: string(policy.LimitCodeMaxOwnedPrivateGames),
ReasonCode: "manual_remove",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
})
require.NoError(t, err)
require.Empty(t, result.ActiveLimits)
}
func TestRemoveLimitServiceExecuteRejectsRetiredLimitCode(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
service, err := NewRemoveLimitService(
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
newFakeSanctionStore(),
newFakeLimitStore(),
&fakePolicyLifecycleStore{sanctions: newFakeSanctionStore(), limits: newFakeLimitStore()},
fixedClock{now: now},
fixedIDGenerator{},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), RemoveLimitInput{
UserID: userID.String(),
LimitCode: string(policy.LimitCodeMaxPendingPrivateJoinRequests),
ReasonCode: "manual_remove",
Actor: ActorInput{Type: "admin", ID: "admin-1"},
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
}
type fakeAccountStore struct {
existsByUserID map[common.UserID]bool
}
func (store fakeAccountStore) Create(context.Context, ports.CreateAccountInput) error {
return nil
}
func (store fakeAccountStore) GetByUserID(context.Context, common.UserID) (account.UserAccount, error) {
return account.UserAccount{}, ports.ErrNotFound
}
func (store fakeAccountStore) GetByEmail(context.Context, common.Email) (account.UserAccount, error) {
return account.UserAccount{}, ports.ErrNotFound
}
func (store fakeAccountStore) GetByRaceName(context.Context, common.RaceName) (account.UserAccount, error) {
return account.UserAccount{}, ports.ErrNotFound
}
func (store fakeAccountStore) ExistsByUserID(_ context.Context, userID common.UserID) (bool, error) {
return store.existsByUserID[userID], nil
}
func (store fakeAccountStore) RenameRaceName(context.Context, ports.RenameRaceNameInput) error {
return nil
}
func (store fakeAccountStore) Update(context.Context, account.UserAccount) error {
return nil
}
type fakeSanctionStore struct {
byUserID map[common.UserID][]policy.SanctionRecord
byRecordID map[policy.SanctionRecordID]policy.SanctionRecord
}
func newFakeSanctionStore() *fakeSanctionStore {
return &fakeSanctionStore{
byUserID: make(map[common.UserID][]policy.SanctionRecord),
byRecordID: make(map[policy.SanctionRecordID]policy.SanctionRecord),
}
}
func (store *fakeSanctionStore) Create(_ context.Context, record policy.SanctionRecord) error {
if err := record.Validate(); err != nil {
return err
}
if _, exists := store.byRecordID[record.RecordID]; exists {
return ports.ErrConflict
}
store.byRecordID[record.RecordID] = record
store.byUserID[record.UserID] = append(store.byUserID[record.UserID], record)
return nil
}
func (store *fakeSanctionStore) GetByRecordID(_ context.Context, recordID policy.SanctionRecordID) (policy.SanctionRecord, error) {
record, ok := store.byRecordID[recordID]
if !ok {
return policy.SanctionRecord{}, ports.ErrNotFound
}
return record, nil
}
func (store *fakeSanctionStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.SanctionRecord, error) {
records := store.byUserID[userID]
cloned := make([]policy.SanctionRecord, len(records))
copy(cloned, records)
return cloned, nil
}
func (store *fakeSanctionStore) Update(_ context.Context, record policy.SanctionRecord) error {
if err := record.Validate(); err != nil {
return err
}
if _, exists := store.byRecordID[record.RecordID]; !exists {
return ports.ErrNotFound
}
store.byRecordID[record.RecordID] = record
records := store.byUserID[record.UserID]
for index := range records {
if records[index].RecordID == record.RecordID {
records[index] = record
store.byUserID[record.UserID] = records
return nil
}
}
return ports.ErrNotFound
}
type fakeLimitStore struct {
byUserID map[common.UserID][]policy.LimitRecord
byRecordID map[policy.LimitRecordID]policy.LimitRecord
}
func newFakeLimitStore() *fakeLimitStore {
return &fakeLimitStore{
byUserID: make(map[common.UserID][]policy.LimitRecord),
byRecordID: make(map[policy.LimitRecordID]policy.LimitRecord),
}
}
func (store *fakeLimitStore) Create(_ context.Context, record policy.LimitRecord) error {
if err := record.Validate(); err != nil {
return err
}
if _, exists := store.byRecordID[record.RecordID]; exists {
return ports.ErrConflict
}
store.byRecordID[record.RecordID] = record
store.byUserID[record.UserID] = append(store.byUserID[record.UserID], record)
return nil
}
func (store *fakeLimitStore) GetByRecordID(_ context.Context, recordID policy.LimitRecordID) (policy.LimitRecord, error) {
record, ok := store.byRecordID[recordID]
if !ok {
return policy.LimitRecord{}, ports.ErrNotFound
}
return record, nil
}
func (store *fakeLimitStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.LimitRecord, error) {
records := store.byUserID[userID]
cloned := make([]policy.LimitRecord, len(records))
copy(cloned, records)
return cloned, nil
}
func (store *fakeLimitStore) Update(_ context.Context, record policy.LimitRecord) error {
if err := record.Validate(); err != nil {
return err
}
if _, exists := store.byRecordID[record.RecordID]; !exists {
return ports.ErrNotFound
}
store.byRecordID[record.RecordID] = record
records := store.byUserID[record.UserID]
for index := range records {
if records[index].RecordID == record.RecordID {
records[index] = record
store.byUserID[record.UserID] = records
return nil
}
}
return ports.ErrNotFound
}
type fakePolicyLifecycleStore struct {
sanctions *fakeSanctionStore
limits *fakeLimitStore
applySanctionHook func(input ports.ApplySanctionInput) error
removeSanctionHook func(input ports.RemoveSanctionInput) error
setLimitHook func(input ports.SetLimitInput) error
removeLimitHook func(input ports.RemoveLimitInput) error
}
func (store *fakePolicyLifecycleStore) ApplySanction(ctx context.Context, input ports.ApplySanctionInput) error {
if store.applySanctionHook != nil {
return store.applySanctionHook(input)
}
records, err := store.sanctions.ListByUserID(ctx, input.NewRecord.UserID)
if err != nil {
return err
}
active, err := policy.ActiveSanctionsAt(records, input.NewRecord.AppliedAt)
if err != nil {
return err
}
for _, record := range active {
if record.SanctionCode == input.NewRecord.SanctionCode {
return ports.ErrConflict
}
}
return store.sanctions.Create(ctx, input.NewRecord)
}
func (store *fakePolicyLifecycleStore) RemoveSanction(ctx context.Context, input ports.RemoveSanctionInput) error {
if store.removeSanctionHook != nil {
return store.removeSanctionHook(input)
}
return store.sanctions.Update(ctx, input.UpdatedRecord)
}
func (store *fakePolicyLifecycleStore) SetLimit(ctx context.Context, input ports.SetLimitInput) error {
if store.setLimitHook != nil {
return store.setLimitHook(input)
}
if input.ExpectedActiveRecord != nil {
if err := store.limits.Update(ctx, *input.UpdatedActiveRecord); err != nil {
return err
}
}
return store.limits.Create(ctx, input.NewRecord)
}
func (store *fakePolicyLifecycleStore) RemoveLimit(ctx context.Context, input ports.RemoveLimitInput) error {
if store.removeLimitHook != nil {
return store.removeLimitHook(input)
}
return store.limits.Update(ctx, input.UpdatedRecord)
}
type fixedClock struct {
now time.Time
}
func (clock fixedClock) Now() time.Time {
return clock.now
}
type fixedIDGenerator struct {
sanctionRecordID policy.SanctionRecordID
limitRecordID policy.LimitRecordID
}
func (generator fixedIDGenerator) NewUserID() (common.UserID, error) {
return "", nil
}
func (generator fixedIDGenerator) NewInitialRaceName() (common.RaceName, error) {
return "", nil
}
func (generator fixedIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
return "", nil
}
func (generator fixedIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
return generator.sanctionRecordID, nil
}
func (generator fixedIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
return generator.limitRecordID, nil
}
var (
_ ports.UserAccountStore = fakeAccountStore{}
_ ports.SanctionStore = (*fakeSanctionStore)(nil)
_ ports.LimitStore = (*fakeLimitStore)(nil)
_ ports.PolicyLifecycleStore = (*fakePolicyLifecycleStore)(nil)
_ ports.Clock = fixedClock{}
_ ports.IDGenerator = fixedIDGenerator{}
)
@@ -0,0 +1,159 @@
package selfservice
import (
"context"
"errors"
"testing"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/ports"
"github.com/stretchr/testify/require"
)
func TestProfileUpdaterExecutePublishesProfileChangedEvent(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
accountStore := newFakeAccountStore(validUserAccount())
publisher := &recordingSelfServicePublisher{}
service, err := NewProfileUpdaterWithObservability(
accountStore,
&fakeEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
},
},
fakeSanctionStore{},
fakeLimitStore{},
fixedClock{now: now},
stubRaceNamePolicy{},
nil,
nil,
publisher,
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), UpdateMyProfileInput{
UserID: "user-123",
RaceName: "Nova Prime",
})
require.NoError(t, err)
require.Equal(t, "Nova Prime", result.Account.RaceName)
require.Len(t, publisher.profileEvents, 1)
require.Equal(t, ports.ProfileChangedOperationUpdated, publisher.profileEvents[0].Operation)
require.Equal(t, common.Source("gateway_self_service"), publisher.profileEvents[0].Source)
require.Equal(t, common.RaceName("Nova Prime"), publisher.profileEvents[0].RaceName)
}
func TestProfileUpdaterExecutePublisherFailureDoesNotRollbackCommit(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
accountStore := newFakeAccountStore(validUserAccount())
publisher := &recordingSelfServicePublisher{profileErr: errors.New("publisher unavailable")}
service, err := NewProfileUpdaterWithObservability(
accountStore,
&fakeEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
},
},
fakeSanctionStore{},
fakeLimitStore{},
fixedClock{now: now},
stubRaceNamePolicy{},
nil,
nil,
publisher,
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), UpdateMyProfileInput{
UserID: "user-123",
RaceName: "Nova Prime",
})
require.NoError(t, err)
require.Equal(t, "Nova Prime", result.Account.RaceName)
require.Len(t, publisher.profileEvents, 1)
storedAccount, err := accountStore.GetByUserID(context.Background(), common.UserID("user-123"))
require.NoError(t, err)
require.Equal(t, common.RaceName("Nova Prime"), storedAccount.RaceName)
}
func TestSettingsUpdaterExecuteNoOpDoesNotPublishEvent(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
accountStore := newFakeAccountStore(account.UserAccount{
UserID: common.UserID("user-123"),
Email: common.Email("pilot@example.com"),
RaceName: common.RaceName("Pilot Nova"),
PreferredLanguage: common.LanguageTag("en-US"),
TimeZone: common.TimeZoneName("UTC"),
DeclaredCountry: common.CountryCode("DE"),
CreatedAt: time.Unix(1_775_240_000, 0).UTC(),
UpdatedAt: time.Unix(1_775_240_100, 0).UTC(),
})
publisher := &recordingSelfServicePublisher{}
service, err := NewSettingsUpdaterWithObservability(
accountStore,
&fakeEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
},
},
fakeSanctionStore{},
fakeLimitStore{},
fixedClock{now: now},
nil,
nil,
publisher,
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), UpdateMySettingsInput{
UserID: "user-123",
PreferredLanguage: "en-us",
TimeZone: " UTC ",
})
require.NoError(t, err)
require.Equal(t, "en-US", result.Account.PreferredLanguage)
require.Equal(t, "UTC", result.Account.TimeZone)
require.Empty(t, publisher.settingsEvents)
}
type recordingSelfServicePublisher struct {
profileErr error
settingsErr error
profileEvents []ports.ProfileChangedEvent
settingsEvents []ports.SettingsChangedEvent
}
func (publisher *recordingSelfServicePublisher) PublishProfileChanged(_ context.Context, event ports.ProfileChangedEvent) error {
if err := event.Validate(); err != nil {
return err
}
publisher.profileEvents = append(publisher.profileEvents, event)
return publisher.profileErr
}
func (publisher *recordingSelfServicePublisher) PublishSettingsChanged(_ context.Context, event ports.SettingsChangedEvent) error {
if err := event.Validate(); err != nil {
return err
}
publisher.settingsEvents = append(publisher.settingsEvents, event)
return publisher.settingsErr
}
var (
_ ports.ProfileChangedPublisher = (*recordingSelfServicePublisher)(nil)
_ ports.SettingsChangedPublisher = (*recordingSelfServicePublisher)(nil)
)
@@ -0,0 +1,467 @@
// Package selfservice implements the authenticated self-service account read
// and mutation use cases owned by User Service.
package selfservice
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/accountview"
"galaxy/user/internal/service/shared"
"galaxy/user/internal/telemetry"
)
const gatewaySelfServiceSource = common.Source("gateway_self_service")
// ActorRefView stores transport-ready audit actor metadata.
type ActorRefView = accountview.ActorRefView
// EntitlementSnapshotView stores the transport-ready current entitlement
// snapshot of one account.
type EntitlementSnapshotView = accountview.EntitlementSnapshotView
// ActiveSanctionView stores one transport-ready active sanction.
type ActiveSanctionView = accountview.ActiveSanctionView
// ActiveLimitView stores one transport-ready active user-specific limit.
type ActiveLimitView = accountview.ActiveLimitView
// AccountView stores the transport-ready authenticated self-service account
// aggregate.
type AccountView = accountview.AccountView
// GetMyAccountInput stores one authenticated account-read request.
type GetMyAccountInput struct {
// UserID stores the authenticated regular-user identifier.
UserID string
}
// GetMyAccountResult stores one authenticated account-read result.
type GetMyAccountResult struct {
// Account stores the read-optimized current account aggregate.
Account AccountView `json:"account"`
}
// UpdateMyProfileInput stores one self-service profile mutation request.
type UpdateMyProfileInput struct {
// UserID stores the authenticated regular-user identifier.
UserID string
// RaceName stores the requested exact replacement race name.
RaceName string
}
// UpdateMyProfileResult stores one self-service profile mutation result.
type UpdateMyProfileResult struct {
// Account stores the refreshed account aggregate after the mutation.
Account AccountView `json:"account"`
}
// UpdateMySettingsInput stores one self-service settings mutation request.
type UpdateMySettingsInput struct {
// UserID stores the authenticated regular-user identifier.
UserID string
// PreferredLanguage stores the requested BCP 47 preferred language.
PreferredLanguage string
// TimeZone stores the requested IANA time-zone name.
TimeZone string
}
// UpdateMySettingsResult stores one self-service settings mutation result.
type UpdateMySettingsResult struct {
// Account stores the refreshed account aggregate after the mutation.
Account AccountView `json:"account"`
}
type entitlementReader interface {
GetByUserID(ctx context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error)
}
// AccountGetter executes the `GetMyAccount` use case.
type AccountGetter struct {
loader *accountview.Loader
}
// NewAccountGetter constructs one authenticated account-read use case.
func NewAccountGetter(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
) (*AccountGetter, error) {
loader, err := accountview.NewLoader(accounts, entitlements, sanctions, limits, clock)
if err != nil {
return nil, fmt.Errorf("selfservice account getter: %w", err)
}
return &AccountGetter{loader: loader}, nil
}
// Execute reads the current self-service account aggregate of input.UserID.
func (service *AccountGetter) Execute(ctx context.Context, input GetMyAccountInput) (GetMyAccountResult, error) {
if ctx == nil {
return GetMyAccountResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
return GetMyAccountResult{}, err
}
state, err := service.loader.Load(ctx, userID)
if err != nil {
return GetMyAccountResult{}, err
}
return GetMyAccountResult{Account: state.View()}, nil
}
// ProfileUpdater executes the `UpdateMyProfile` use case.
type ProfileUpdater struct {
accounts ports.UserAccountStore
loader *accountview.Loader
policy ports.RaceNamePolicy
clock ports.Clock
logger *slog.Logger
telemetry *telemetry.Runtime
profilePublisher ports.ProfileChangedPublisher
}
// NewProfileUpdater constructs one self-service profile-mutation use case.
func NewProfileUpdater(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
policy ports.RaceNamePolicy,
) (*ProfileUpdater, error) {
return NewProfileUpdaterWithObservability(accounts, entitlements, sanctions, limits, clock, policy, nil, nil, nil)
}
// NewProfileUpdaterWithObservability constructs one self-service
// profile-mutation use case with optional observability hooks.
func NewProfileUpdaterWithObservability(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
policy ports.RaceNamePolicy,
logger *slog.Logger,
telemetryRuntime *telemetry.Runtime,
profilePublisher ports.ProfileChangedPublisher,
) (*ProfileUpdater, error) {
loader, err := accountview.NewLoader(accounts, entitlements, sanctions, limits, clock)
if err != nil {
return nil, fmt.Errorf("selfservice profile updater: %w", err)
}
if policy == nil {
return nil, fmt.Errorf("selfservice profile updater: race-name policy must not be nil")
}
return &ProfileUpdater{
accounts: accounts,
loader: loader,
policy: policy,
clock: clock,
logger: logger,
telemetry: telemetryRuntime,
profilePublisher: profilePublisher,
}, nil
}
// Execute updates the current self-service profile fields of input.UserID.
func (service *ProfileUpdater) Execute(ctx context.Context, input UpdateMyProfileInput) (result UpdateMyProfileResult, err error) {
outcome := "failed"
userIDString := ""
defer func() {
shared.LogServiceOutcome(service.logger, ctx, "profile update completed", err,
"use_case", "update_my_profile",
"outcome", outcome,
"user_id", userIDString,
"source", gatewaySelfServiceSource.String(),
)
}()
if ctx == nil {
return UpdateMyProfileResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
return UpdateMyProfileResult{}, err
}
userIDString = userID.String()
raceName, err := parseRaceName(input.RaceName)
if err != nil {
return UpdateMyProfileResult{}, err
}
state, err := service.loader.Load(ctx, userID)
if err != nil {
return UpdateMyProfileResult{}, err
}
if state.HasActiveSanction(policy.SanctionCodeProfileUpdateBlock) {
return UpdateMyProfileResult{}, shared.Conflict()
}
if state.AccountRecord.RaceName == raceName {
outcome = "noop"
return UpdateMyProfileResult{Account: state.View()}, nil
}
now := service.clock.Now().UTC()
currentCanonicalKey, err := service.policy.CanonicalKey(state.AccountRecord.RaceName)
if err != nil {
return UpdateMyProfileResult{}, shared.ServiceUnavailable(fmt.Errorf("canonicalize current race name: %w", err))
}
reservation, err := shared.BuildRaceNameReservation(service.policy, userID, raceName, now)
if err != nil {
return UpdateMyProfileResult{}, shared.ServiceUnavailable(err)
}
if err := service.accounts.RenameRaceName(ctx, ports.RenameRaceNameInput{
UserID: userID,
CurrentCanonicalKey: currentCanonicalKey,
NewRaceName: raceName,
NewReservation: reservation,
UpdatedAt: now,
}); err != nil {
if errors.Is(err, ports.ErrRaceNameConflict) && service.telemetry != nil {
service.telemetry.RecordRaceNameReservationConflict(ctx, "update_my_profile")
}
switch {
case errors.Is(err, ports.ErrNotFound):
return UpdateMyProfileResult{}, shared.SubjectNotFound()
case errors.Is(err, ports.ErrConflict):
return UpdateMyProfileResult{}, shared.Conflict()
default:
return UpdateMyProfileResult{}, shared.ServiceUnavailable(err)
}
}
updatedState, err := service.loader.Load(ctx, userID)
if err != nil {
return UpdateMyProfileResult{}, err
}
outcome = "updated"
result = UpdateMyProfileResult{Account: updatedState.View()}
service.publishProfileChanged(ctx, updatedState.AccountRecord)
return result, nil
}
// SettingsUpdater executes the `UpdateMySettings` use case.
type SettingsUpdater struct {
accounts ports.UserAccountStore
loader *accountview.Loader
clock ports.Clock
logger *slog.Logger
telemetry *telemetry.Runtime
settingsPublisher ports.SettingsChangedPublisher
}
// NewSettingsUpdater constructs one self-service settings-mutation use case.
func NewSettingsUpdater(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
) (*SettingsUpdater, error) {
return NewSettingsUpdaterWithObservability(accounts, entitlements, sanctions, limits, clock, nil, nil, nil)
}
// NewSettingsUpdaterWithObservability constructs one self-service
// settings-mutation use case with optional observability hooks.
func NewSettingsUpdaterWithObservability(
accounts ports.UserAccountStore,
entitlements entitlementReader,
sanctions ports.SanctionStore,
limits ports.LimitStore,
clock ports.Clock,
logger *slog.Logger,
telemetryRuntime *telemetry.Runtime,
settingsPublisher ports.SettingsChangedPublisher,
) (*SettingsUpdater, error) {
loader, err := accountview.NewLoader(accounts, entitlements, sanctions, limits, clock)
if err != nil {
return nil, fmt.Errorf("selfservice settings updater: %w", err)
}
return &SettingsUpdater{
accounts: accounts,
loader: loader,
clock: clock,
logger: logger,
telemetry: telemetryRuntime,
settingsPublisher: settingsPublisher,
}, nil
}
// Execute updates the current self-service settings fields of input.UserID.
func (service *SettingsUpdater) Execute(ctx context.Context, input UpdateMySettingsInput) (result UpdateMySettingsResult, err error) {
outcome := "failed"
userIDString := ""
defer func() {
shared.LogServiceOutcome(service.logger, ctx, "settings update completed", err,
"use_case", "update_my_settings",
"outcome", outcome,
"user_id", userIDString,
"source", gatewaySelfServiceSource.String(),
)
}()
if ctx == nil {
return UpdateMySettingsResult{}, shared.InvalidRequest("context must not be nil")
}
userID, err := shared.ParseUserID(input.UserID)
if err != nil {
return UpdateMySettingsResult{}, err
}
userIDString = userID.String()
preferredLanguage, err := parsePreferredLanguage(input.PreferredLanguage)
if err != nil {
return UpdateMySettingsResult{}, err
}
timeZone, err := parseTimeZoneName(input.TimeZone)
if err != nil {
return UpdateMySettingsResult{}, err
}
state, err := service.loader.Load(ctx, userID)
if err != nil {
return UpdateMySettingsResult{}, err
}
if state.HasActiveSanction(policy.SanctionCodeProfileUpdateBlock) {
return UpdateMySettingsResult{}, shared.Conflict()
}
if state.AccountRecord.PreferredLanguage == preferredLanguage && state.AccountRecord.TimeZone == timeZone {
outcome = "noop"
return UpdateMySettingsResult{Account: state.View()}, nil
}
record := state.AccountRecord
record.PreferredLanguage = preferredLanguage
record.TimeZone = timeZone
record.UpdatedAt = service.clock.Now().UTC()
if err := service.accounts.Update(ctx, record); err != nil {
switch {
case errors.Is(err, ports.ErrNotFound):
return UpdateMySettingsResult{}, shared.SubjectNotFound()
case errors.Is(err, ports.ErrConflict):
return UpdateMySettingsResult{}, shared.Conflict()
default:
return UpdateMySettingsResult{}, shared.ServiceUnavailable(err)
}
}
updatedState, err := service.loader.Load(ctx, userID)
if err != nil {
return UpdateMySettingsResult{}, err
}
outcome = "updated"
result = UpdateMySettingsResult{Account: updatedState.View()}
service.publishSettingsChanged(ctx, updatedState.AccountRecord)
return result, nil
}
func parseRaceName(value string) (common.RaceName, error) {
return shared.ParseRaceName(value)
}
func parsePreferredLanguage(value string) (common.LanguageTag, error) {
languageTag, err := shared.ParseLanguageTag(value)
if err != nil {
return "", reframeFieldError("preferred_language", "language tag", err)
}
return languageTag, nil
}
func parseTimeZoneName(value string) (common.TimeZoneName, error) {
timeZoneName, err := shared.ParseTimeZoneName(value)
if err != nil {
return "", reframeFieldError("time_zone", "time zone name", err)
}
return timeZoneName, nil
}
func reframeFieldError(fieldName string, valueName string, err error) error {
if err == nil {
return nil
}
message := err.Error()
prefix := valueName + " "
if strings.HasPrefix(message, prefix) {
message = fieldName + " " + strings.TrimPrefix(message, prefix)
} else {
message = fmt.Sprintf("%s: %s", fieldName, message)
}
return shared.InvalidRequest(message)
}
func (service *ProfileUpdater) publishProfileChanged(ctx context.Context, record account.UserAccount) {
if service.profilePublisher == nil {
return
}
event := ports.ProfileChangedEvent{
UserID: record.UserID,
OccurredAt: record.UpdatedAt.UTC(),
Source: gatewaySelfServiceSource,
Operation: ports.ProfileChangedOperationUpdated,
RaceName: record.RaceName,
}
if err := service.profilePublisher.PublishProfileChanged(ctx, event); err != nil {
if service.telemetry != nil {
service.telemetry.RecordEventPublicationFailure(ctx, ports.ProfileChangedEventType)
}
shared.LogEventPublicationFailure(service.logger, ctx, ports.ProfileChangedEventType, err,
"use_case", "update_my_profile",
"user_id", record.UserID.String(),
"source", gatewaySelfServiceSource.String(),
)
}
}
func (service *SettingsUpdater) publishSettingsChanged(ctx context.Context, record account.UserAccount) {
if service.settingsPublisher == nil {
return
}
event := ports.SettingsChangedEvent{
UserID: record.UserID,
OccurredAt: record.UpdatedAt.UTC(),
Source: gatewaySelfServiceSource,
Operation: ports.SettingsChangedOperationUpdated,
PreferredLanguage: record.PreferredLanguage,
TimeZone: record.TimeZone,
}
if err := service.settingsPublisher.PublishSettingsChanged(ctx, event); err != nil {
if service.telemetry != nil {
service.telemetry.RecordEventPublicationFailure(ctx, ports.SettingsChangedEventType)
}
shared.LogEventPublicationFailure(service.logger, ctx, ports.SettingsChangedEventType, err,
"use_case", "update_my_settings",
"user_id", record.UserID.String(),
"source", gatewaySelfServiceSource.String(),
)
}
}
@@ -0,0 +1,732 @@
package selfservice
import (
"context"
"strings"
"testing"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/entitlementsvc"
"galaxy/user/internal/service/shared"
"github.com/stretchr/testify/require"
)
func TestAccountGetterExecuteReturnsAggregate(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
accountStore := newFakeAccountStore(validUserAccount())
snapshotStore := &fakeEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
},
}
sanctionStore := fakeSanctionStore{
byUserID: map[common.UserID][]policy.SanctionRecord{
common.UserID("user-123"): {
validActiveSanction(common.UserID("user-123"), policy.SanctionCodeLoginBlock, now.Add(-time.Hour)),
expiredSanction(common.UserID("user-123"), policy.SanctionCodeGameJoinBlock, now.Add(-2*time.Hour)),
},
},
}
limitStore := fakeLimitStore{
byUserID: map[common.UserID][]policy.LimitRecord{
common.UserID("user-123"): {
validActiveLimit(common.UserID("user-123"), policy.LimitCodeMaxOwnedPrivateGames, 3, now.Add(-time.Hour)),
validActiveLimit(common.UserID("user-123"), policy.LimitCodeMaxActivePrivateGames, 1, now.Add(-2*time.Hour)),
},
},
}
service, err := NewAccountGetter(accountStore, snapshotStore, sanctionStore, limitStore, fixedClock{now: now})
require.NoError(t, err)
result, err := service.Execute(context.Background(), GetMyAccountInput{UserID: " user-123 "})
require.NoError(t, err)
require.Equal(t, "user-123", result.Account.UserID)
require.Equal(t, "DE", result.Account.DeclaredCountry)
require.Len(t, result.Account.ActiveSanctions, 1)
require.Equal(t, string(policy.SanctionCodeLoginBlock), result.Account.ActiveSanctions[0].SanctionCode)
require.Len(t, result.Account.ActiveLimits, 1)
require.Equal(t, string(policy.LimitCodeMaxOwnedPrivateGames), result.Account.ActiveLimits[0].LimitCode)
}
func TestAccountGetterExecuteUnknownUserReturnsNotFound(t *testing.T) {
t.Parallel()
service, err := NewAccountGetter(
newFakeAccountStore(),
&fakeEntitlementSnapshotStore{},
fakeSanctionStore{},
fakeLimitStore{},
fixedClock{now: time.Unix(1_775_240_500, 0).UTC()},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), GetMyAccountInput{UserID: "user-missing"})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err))
}
func TestAccountGetterExecuteMissingSnapshotReturnsInternalError(t *testing.T) {
t.Parallel()
service, err := NewAccountGetter(
newFakeAccountStore(validUserAccount()),
&fakeEntitlementSnapshotStore{},
fakeSanctionStore{},
fakeLimitStore{},
fixedClock{now: time.Unix(1_775_240_500, 0).UTC()},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), GetMyAccountInput{UserID: "user-123"})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeInternalError, shared.CodeOf(err))
}
func TestAccountGetterExecuteRepairsExpiredPaidSnapshot(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
expiredAt := now.Add(-time.Hour)
snapshotStore := &fakeEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): {
UserID: common.UserID("user-123"),
PlanCode: entitlement.PlanCodePaidMonthly,
IsPaid: true,
StartsAt: now.Add(-30 * 24 * time.Hour),
EndsAt: timePointer(expiredAt),
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_grant"),
UpdatedAt: expiredAt,
},
},
}
reader, err := entitlementsvc.NewReader(
snapshotStore,
&fakeEntitlementLifecycleStore{snapshotStore: snapshotStore},
fixedClock{now: now},
readerIDGenerator{recordID: entitlement.EntitlementRecordID("entitlement-free-after-expiry")},
)
require.NoError(t, err)
service, err := NewAccountGetter(
newFakeAccountStore(validUserAccount()),
reader,
fakeSanctionStore{},
fakeLimitStore{},
fixedClock{now: now},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), GetMyAccountInput{UserID: "user-123"})
require.NoError(t, err)
require.Equal(t, "free", result.Account.Entitlement.PlanCode)
require.False(t, result.Account.Entitlement.IsPaid)
require.Equal(t, expiredAt, result.Account.Entitlement.StartsAt)
}
func TestProfileUpdaterExecuteBlockedBySanction(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
accountStore := newFakeAccountStore(validUserAccount())
service, err := NewProfileUpdater(
accountStore,
&fakeEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
},
},
fakeSanctionStore{
byUserID: map[common.UserID][]policy.SanctionRecord{
common.UserID("user-123"): {
validActiveSanction(common.UserID("user-123"), policy.SanctionCodeProfileUpdateBlock, now.Add(-time.Minute)),
},
},
},
fakeLimitStore{},
fixedClock{now: now},
stubRaceNamePolicy{},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), UpdateMyProfileInput{
UserID: "user-123",
RaceName: "Nova Prime",
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeConflict, shared.CodeOf(err))
require.Equal(t, 0, accountStore.renameCalls)
}
func TestProfileUpdaterExecuteSuccessNoOpAndConflict(t *testing.T) {
t.Parallel()
tests := []struct {
name string
inputRaceName string
renameErr error
wantCode string
wantRaceName string
wantRenameCalls int
wantCurrentKey account.RaceNameCanonicalKey
wantNewKey account.RaceNameCanonicalKey
}{
{
name: "success",
inputRaceName: "Nova Prime",
wantRaceName: "Nova Prime",
wantRenameCalls: 1,
wantCurrentKey: canonicalKey(common.RaceName("Pilot Nova")),
wantNewKey: canonicalKey(common.RaceName("Nova Prime")),
},
{
name: "same canonical different exact",
inputRaceName: "P1lot Nova",
wantRaceName: "P1lot Nova",
wantRenameCalls: 1,
wantCurrentKey: canonicalKey(common.RaceName("Pilot Nova")),
wantNewKey: canonicalKey(common.RaceName("P1lot Nova")),
},
{
name: "no-op",
inputRaceName: " Pilot Nova ",
wantRaceName: "Pilot Nova",
wantRenameCalls: 0,
},
{
name: "conflict",
inputRaceName: "Taken Name",
renameErr: ports.ErrConflict,
wantCode: shared.ErrorCodeConflict,
wantRaceName: "Pilot Nova",
wantRenameCalls: 1,
wantCurrentKey: canonicalKey(common.RaceName("Pilot Nova")),
wantNewKey: canonicalKey(common.RaceName("Taken Name")),
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
accountStore := newFakeAccountStore(validUserAccount())
accountStore.renameErr = tt.renameErr
service, err := NewProfileUpdater(
accountStore,
&fakeEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
},
},
fakeSanctionStore{},
fakeLimitStore{},
fixedClock{now: now},
stubRaceNamePolicy{},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), UpdateMyProfileInput{
UserID: "user-123",
RaceName: tt.inputRaceName,
})
if tt.wantCode != "" {
require.Error(t, err)
require.Equal(t, tt.wantCode, shared.CodeOf(err))
} else {
require.NoError(t, err)
}
require.Equal(t, tt.wantRenameCalls, accountStore.renameCalls)
if tt.wantRenameCalls > 0 {
require.Equal(t, tt.wantCurrentKey, accountStore.lastRenameInput.CurrentCanonicalKey)
require.Equal(t, tt.wantNewKey, accountStore.lastRenameInput.NewReservation.CanonicalKey)
}
storedAccount, err := accountStore.GetByUserID(context.Background(), common.UserID("user-123"))
require.NoError(t, err)
require.Equal(t, tt.wantRaceName, storedAccount.RaceName.String())
if tt.wantCode == "" {
require.Equal(t, tt.wantRaceName, result.Account.RaceName)
}
})
}
}
func TestSettingsUpdaterExecuteBlockedBySanction(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
accountStore := newFakeAccountStore(validUserAccount())
service, err := NewSettingsUpdater(
accountStore,
&fakeEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
},
},
fakeSanctionStore{
byUserID: map[common.UserID][]policy.SanctionRecord{
common.UserID("user-123"): {
validActiveSanction(common.UserID("user-123"), policy.SanctionCodeProfileUpdateBlock, now.Add(-time.Minute)),
},
},
},
fakeLimitStore{},
fixedClock{now: now},
)
require.NoError(t, err)
_, err = service.Execute(context.Background(), UpdateMySettingsInput{
UserID: "user-123",
PreferredLanguage: "en-US",
TimeZone: "UTC",
})
require.Error(t, err)
require.Equal(t, shared.ErrorCodeConflict, shared.CodeOf(err))
require.Equal(t, 0, accountStore.updateCalls)
}
func TestSettingsUpdaterExecuteCanonicalizedNoOpAndInvalidInputs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
accountRecord account.UserAccount
inputLanguage string
inputTimeZone string
wantCode string
wantLanguage string
wantTimeZone string
wantUpdateCalls int
}{
{
name: "canonicalized success",
accountRecord: validUserAccount(),
inputLanguage: " en-us ",
inputTimeZone: " UTC ",
wantLanguage: "en-US",
wantTimeZone: "UTC",
wantUpdateCalls: 1,
},
{
name: "no-op",
accountRecord: account.UserAccount{
UserID: common.UserID("user-123"),
Email: common.Email("pilot@example.com"),
RaceName: common.RaceName("Pilot Nova"),
PreferredLanguage: common.LanguageTag("en-US"),
TimeZone: common.TimeZoneName("UTC"),
DeclaredCountry: common.CountryCode("DE"),
CreatedAt: time.Unix(1_775_240_000, 0).UTC(),
UpdatedAt: time.Unix(1_775_240_000, 0).UTC(),
},
inputLanguage: "en-us",
inputTimeZone: " UTC ",
wantLanguage: "en-US",
wantTimeZone: "UTC",
wantUpdateCalls: 0,
},
{
name: "invalid preferred language",
accountRecord: validUserAccount(),
inputLanguage: "bad@@tag",
inputTimeZone: "UTC",
wantCode: shared.ErrorCodeInvalidRequest,
wantLanguage: "en",
wantTimeZone: "Europe/Kaliningrad",
},
{
name: "invalid time zone",
accountRecord: validUserAccount(),
inputLanguage: "en",
inputTimeZone: "Mars/Olympus",
wantCode: shared.ErrorCodeInvalidRequest,
wantLanguage: "en",
wantTimeZone: "Europe/Kaliningrad",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_500, 0).UTC()
accountStore := newFakeAccountStore(tt.accountRecord)
service, err := NewSettingsUpdater(
accountStore,
&fakeEntitlementSnapshotStore{
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
common.UserID("user-123"): validEntitlementSnapshot(common.UserID("user-123"), now),
},
},
fakeSanctionStore{},
fakeLimitStore{},
fixedClock{now: now},
)
require.NoError(t, err)
result, err := service.Execute(context.Background(), UpdateMySettingsInput{
UserID: "user-123",
PreferredLanguage: tt.inputLanguage,
TimeZone: tt.inputTimeZone,
})
if tt.wantCode != "" {
require.Error(t, err)
require.Equal(t, tt.wantCode, shared.CodeOf(err))
} else {
require.NoError(t, err)
}
require.Equal(t, tt.wantUpdateCalls, accountStore.updateCalls)
storedAccount, err := accountStore.GetByUserID(context.Background(), common.UserID("user-123"))
require.NoError(t, err)
require.Equal(t, tt.wantLanguage, storedAccount.PreferredLanguage.String())
require.Equal(t, tt.wantTimeZone, storedAccount.TimeZone.String())
if tt.wantCode == "" {
require.Equal(t, tt.wantLanguage, result.Account.PreferredLanguage)
require.Equal(t, tt.wantTimeZone, result.Account.TimeZone)
}
})
}
}
type fakeAccountStore struct {
records map[common.UserID]account.UserAccount
renameErr error
updateErr error
renameCalls int
updateCalls int
lastRenameInput ports.RenameRaceNameInput
}
func newFakeAccountStore(records ...account.UserAccount) *fakeAccountStore {
byUserID := make(map[common.UserID]account.UserAccount, len(records))
for _, record := range records {
byUserID[record.UserID] = record
}
return &fakeAccountStore{records: byUserID}
}
func (store *fakeAccountStore) Create(_ context.Context, input ports.CreateAccountInput) error {
if input.Account.Validate() != nil || input.Reservation.Validate() != nil {
return ports.ErrConflict
}
return nil
}
func (store *fakeAccountStore) GetByUserID(_ context.Context, userID common.UserID) (account.UserAccount, error) {
record, ok := store.records[userID]
if !ok {
return account.UserAccount{}, ports.ErrNotFound
}
return record, nil
}
func (store *fakeAccountStore) GetByEmail(_ context.Context, email common.Email) (account.UserAccount, error) {
for _, record := range store.records {
if record.Email == email {
return record, nil
}
}
return account.UserAccount{}, ports.ErrNotFound
}
func (store *fakeAccountStore) GetByRaceName(_ context.Context, raceName common.RaceName) (account.UserAccount, error) {
for _, record := range store.records {
if record.RaceName == raceName {
return record, nil
}
}
return account.UserAccount{}, ports.ErrNotFound
}
func (store *fakeAccountStore) ExistsByUserID(_ context.Context, userID common.UserID) (bool, error) {
_, ok := store.records[userID]
return ok, nil
}
func (store *fakeAccountStore) RenameRaceName(_ context.Context, input ports.RenameRaceNameInput) error {
store.renameCalls++
store.lastRenameInput = input
if store.renameErr != nil {
return store.renameErr
}
if err := input.Validate(); err != nil {
return err
}
record, ok := store.records[input.UserID]
if !ok {
return ports.ErrNotFound
}
record.RaceName = input.NewRaceName
record.UpdatedAt = input.UpdatedAt.UTC()
store.records[input.UserID] = record
return nil
}
func (store *fakeAccountStore) Update(_ context.Context, record account.UserAccount) error {
store.updateCalls++
if store.updateErr != nil {
return store.updateErr
}
if _, ok := store.records[record.UserID]; !ok {
return ports.ErrNotFound
}
store.records[record.UserID] = record
return nil
}
type fakeEntitlementSnapshotStore struct {
byUserID map[common.UserID]entitlement.CurrentSnapshot
}
func (store *fakeEntitlementSnapshotStore) GetByUserID(_ context.Context, userID common.UserID) (entitlement.CurrentSnapshot, error) {
record, ok := store.byUserID[userID]
if !ok {
return entitlement.CurrentSnapshot{}, ports.ErrNotFound
}
return record, nil
}
func (store *fakeEntitlementSnapshotStore) Put(_ context.Context, record entitlement.CurrentSnapshot) error {
if store.byUserID != nil {
store.byUserID[record.UserID] = record
}
return nil
}
type fakeEntitlementLifecycleStore struct {
snapshotStore *fakeEntitlementSnapshotStore
}
func (store *fakeEntitlementLifecycleStore) Grant(context.Context, ports.GrantEntitlementInput) error {
return nil
}
func (store *fakeEntitlementLifecycleStore) Extend(context.Context, ports.ExtendEntitlementInput) error {
return nil
}
func (store *fakeEntitlementLifecycleStore) Revoke(context.Context, ports.RevokeEntitlementInput) error {
return nil
}
func (store *fakeEntitlementLifecycleStore) RepairExpired(ctx context.Context, input ports.RepairExpiredEntitlementInput) error {
if store.snapshotStore != nil {
return store.snapshotStore.Put(ctx, input.NewSnapshot)
}
return nil
}
type readerIDGenerator struct {
recordID entitlement.EntitlementRecordID
sanctionRecordID policy.SanctionRecordID
limitRecordID policy.LimitRecordID
}
func (generator readerIDGenerator) NewUserID() (common.UserID, error) {
return "", nil
}
func (generator readerIDGenerator) NewInitialRaceName() (common.RaceName, error) {
return "", nil
}
func (generator readerIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
return generator.recordID, nil
}
func (generator readerIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
return generator.sanctionRecordID, nil
}
func (generator readerIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
return generator.limitRecordID, nil
}
type fakeSanctionStore struct {
byUserID map[common.UserID][]policy.SanctionRecord
err error
}
func (store fakeSanctionStore) Create(context.Context, policy.SanctionRecord) error {
return nil
}
func (store fakeSanctionStore) GetByRecordID(context.Context, policy.SanctionRecordID) (policy.SanctionRecord, error) {
return policy.SanctionRecord{}, ports.ErrNotFound
}
func (store fakeSanctionStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.SanctionRecord, error) {
if store.err != nil {
return nil, store.err
}
records := store.byUserID[userID]
cloned := make([]policy.SanctionRecord, len(records))
copy(cloned, records)
return cloned, nil
}
func (store fakeSanctionStore) Update(context.Context, policy.SanctionRecord) error {
return nil
}
type fakeLimitStore struct {
byUserID map[common.UserID][]policy.LimitRecord
err error
}
func (store fakeLimitStore) Create(context.Context, policy.LimitRecord) error {
return nil
}
func (store fakeLimitStore) GetByRecordID(context.Context, policy.LimitRecordID) (policy.LimitRecord, error) {
return policy.LimitRecord{}, ports.ErrNotFound
}
func (store fakeLimitStore) ListByUserID(_ context.Context, userID common.UserID) ([]policy.LimitRecord, error) {
if store.err != nil {
return nil, store.err
}
records := store.byUserID[userID]
cloned := make([]policy.LimitRecord, len(records))
copy(cloned, records)
return cloned, nil
}
func (store fakeLimitStore) Update(context.Context, policy.LimitRecord) error {
return nil
}
type fixedClock struct {
now time.Time
}
func (clock fixedClock) Now() time.Time {
return clock.now
}
type stubRaceNamePolicy struct{}
func (stubRaceNamePolicy) CanonicalKey(raceName common.RaceName) (account.RaceNameCanonicalKey, error) {
return canonicalKey(raceName), nil
}
func canonicalKey(raceName common.RaceName) account.RaceNameCanonicalKey {
return account.RaceNameCanonicalKey(strings.NewReplacer(
"1", "i",
"0", "o",
"8", "b",
).Replace(strings.ToLower(raceName.String())))
}
func validUserAccount() account.UserAccount {
createdAt := time.Unix(1_775_240_000, 0).UTC()
return account.UserAccount{
UserID: common.UserID("user-123"),
Email: common.Email("pilot@example.com"),
RaceName: common.RaceName("Pilot Nova"),
PreferredLanguage: common.LanguageTag("en"),
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
DeclaredCountry: common.CountryCode("DE"),
CreatedAt: createdAt,
UpdatedAt: createdAt,
}
}
func validEntitlementSnapshot(userID common.UserID, now time.Time) entitlement.CurrentSnapshot {
return entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
IsPaid: false,
StartsAt: now.Add(-time.Hour),
Source: common.Source("auth_registration"),
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
ReasonCode: common.ReasonCode("initial_free_entitlement"),
UpdatedAt: now,
}
}
func validActiveSanction(userID common.UserID, code policy.SanctionCode, appliedAt time.Time) policy.SanctionRecord {
return policy.SanctionRecord{
RecordID: policy.SanctionRecordID("sanction-" + string(code)),
UserID: userID,
SanctionCode: code,
Scope: common.Scope("self_service"),
ReasonCode: common.ReasonCode("policy_enforced"),
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
AppliedAt: appliedAt.UTC(),
}
}
func expiredSanction(userID common.UserID, code policy.SanctionCode, appliedAt time.Time) policy.SanctionRecord {
expiresAt := appliedAt.Add(30 * time.Minute)
record := validActiveSanction(userID, code, appliedAt)
record.RecordID = policy.SanctionRecordID(record.RecordID.String() + "-expired")
record.ExpiresAt = &expiresAt
return record
}
func validActiveLimit(userID common.UserID, code policy.LimitCode, value int, appliedAt time.Time) policy.LimitRecord {
return policy.LimitRecord{
RecordID: policy.LimitRecordID("limit-" + string(code)),
UserID: userID,
LimitCode: code,
Value: value,
ReasonCode: common.ReasonCode("policy_enforced"),
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
AppliedAt: appliedAt.UTC(),
}
}
func removedLimit(userID common.UserID, code policy.LimitCode, value int, appliedAt time.Time) policy.LimitRecord {
removedAt := appliedAt.Add(30 * time.Minute)
record := validActiveLimit(userID, code, value, appliedAt)
record.RecordID = policy.LimitRecordID(record.RecordID.String() + "-removed")
record.RemovedAt = &removedAt
record.RemovedBy = common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")}
record.RemovedReasonCode = common.ReasonCode("policy_reset")
return record
}
func timePointer(value time.Time) *time.Time {
utcValue := value.UTC()
return &utcValue
}
var (
_ ports.UserAccountStore = (*fakeAccountStore)(nil)
_ ports.EntitlementSnapshotStore = (*fakeEntitlementSnapshotStore)(nil)
_ ports.EntitlementLifecycleStore = (*fakeEntitlementLifecycleStore)(nil)
_ ports.SanctionStore = fakeSanctionStore{}
_ ports.LimitStore = fakeLimitStore{}
_ ports.RaceNamePolicy = stubRaceNamePolicy{}
_ ports.IDGenerator = readerIDGenerator{}
)
+175
View File
@@ -0,0 +1,175 @@
// Package shared provides shared request parsing and error normalization used
// by the user-service application and transport layers.
package shared
import (
"errors"
"net/http"
"strings"
)
const (
// ErrorCodeInvalidRequest reports malformed or semantically invalid caller
// input.
ErrorCodeInvalidRequest = "invalid_request"
// ErrorCodeConflict reports that the requested mutation conflicts with the
// current source-of-truth state.
ErrorCodeConflict = "conflict"
// ErrorCodeSubjectNotFound reports that the requested user subject does not
// exist.
ErrorCodeSubjectNotFound = "subject_not_found"
// ErrorCodeServiceUnavailable reports that a required dependency is
// temporarily unavailable.
ErrorCodeServiceUnavailable = "service_unavailable"
// ErrorCodeInternalError reports that a local invariant failed unexpectedly.
ErrorCodeInternalError = "internal_error"
)
var internalErrorStatusCodes = map[string]int{
ErrorCodeInvalidRequest: http.StatusBadRequest,
ErrorCodeConflict: http.StatusConflict,
ErrorCodeSubjectNotFound: http.StatusNotFound,
ErrorCodeServiceUnavailable: http.StatusServiceUnavailable,
ErrorCodeInternalError: http.StatusInternalServerError,
}
var internalStableMessages = map[string]string{
ErrorCodeConflict: "request conflicts with current state",
ErrorCodeSubjectNotFound: "subject not found",
ErrorCodeServiceUnavailable: "service is unavailable",
ErrorCodeInternalError: "internal server error",
}
// InternalErrorProjection stores the transport-ready representation of one
// normalized trusted-internal error.
type InternalErrorProjection struct {
// StatusCode stores the HTTP status returned to the trusted caller.
StatusCode int
// Code stores the stable machine-readable error code written into the JSON
// envelope.
Code string
// Message stores the stable or caller-safe message written into the JSON
// envelope.
Message string
}
// ServiceError stores one normalized application-layer failure.
type ServiceError struct {
// Code stores the stable machine-readable error code.
Code string
// Message stores the caller-safe error message.
Message string
// Err stores the wrapped underlying cause when one exists.
Err error
}
// Error returns the caller-safe message of ServiceError.
func (err *ServiceError) Error() string {
if err == nil {
return ""
}
if strings.TrimSpace(err.Message) != "" {
return err.Message
}
if strings.TrimSpace(err.Code) != "" {
return err.Code
}
if err.Err != nil {
return err.Err.Error()
}
return ErrorCodeInternalError
}
// Unwrap returns the wrapped underlying cause.
func (err *ServiceError) Unwrap() error {
if err == nil {
return nil
}
return err.Err
}
// NewServiceError returns one new normalized application-layer error.
func NewServiceError(code string, message string, err error) *ServiceError {
return &ServiceError{
Code: strings.TrimSpace(code),
Message: strings.TrimSpace(message),
Err: err,
}
}
// InvalidRequest returns one normalized invalid-request error.
func InvalidRequest(message string) *ServiceError {
return NewServiceError(ErrorCodeInvalidRequest, strings.TrimSpace(message), nil)
}
// Conflict returns one normalized conflict error.
func Conflict() *ServiceError {
return NewServiceError(ErrorCodeConflict, "", nil)
}
// SubjectNotFound returns one normalized subject-not-found error.
func SubjectNotFound() *ServiceError {
return NewServiceError(ErrorCodeSubjectNotFound, "", nil)
}
// ServiceUnavailable returns one normalized dependency-unavailable error.
func ServiceUnavailable(err error) *ServiceError {
return NewServiceError(ErrorCodeServiceUnavailable, "", err)
}
// InternalError returns one normalized invariant-failure error.
func InternalError(err error) *ServiceError {
return NewServiceError(ErrorCodeInternalError, "", err)
}
// CodeOf returns the normalized service error code carried by err when one is
// available.
func CodeOf(err error) string {
serviceErr, ok := errors.AsType[*ServiceError](err)
if !ok || serviceErr == nil {
return ""
}
return serviceErr.Code
}
// ProjectInternalError normalizes err to the frozen trusted-internal HTTP
// error surface.
func ProjectInternalError(err error) InternalErrorProjection {
serviceErr, ok := errors.AsType[*ServiceError](err)
code := CodeOf(err)
if _, exists := internalErrorStatusCodes[code]; !exists {
return InternalErrorProjection{
StatusCode: http.StatusInternalServerError,
Code: ErrorCodeInternalError,
Message: internalStableMessages[ErrorCodeInternalError],
}
}
message := ""
if ok && serviceErr != nil {
message = serviceErr.Message
}
if stable, exists := internalStableMessages[code]; exists {
message = stable
}
if strings.TrimSpace(message) == "" {
message = internalStableMessages[ErrorCodeInternalError]
}
return InternalErrorProjection{
StatusCode: internalErrorStatusCodes[code],
Code: code,
Message: message,
}
}
+131
View File
@@ -0,0 +1,131 @@
package shared
import (
"fmt"
"strings"
"time"
"galaxy/user/internal/domain/common"
"golang.org/x/text/language"
)
// NormalizeString trims surrounding Unicode whitespace from value.
func NormalizeString(value string) string {
return strings.TrimSpace(value)
}
// ParseEmail trims value and validates it as one exact normalized e-mail
// subject used by the auth-facing contract.
func ParseEmail(value string) (common.Email, error) {
email := common.Email(NormalizeString(value))
if err := email.Validate(); err != nil {
return "", InvalidRequest(err.Error())
}
return email, nil
}
// ParseUserID trims value and validates it as one stable user identifier.
func ParseUserID(value string) (common.UserID, error) {
userID := common.UserID(NormalizeString(value))
if err := userID.Validate(); err != nil {
return "", InvalidRequest(err.Error())
}
return userID, nil
}
// ParseRaceName trims value and validates it as one exact stored race name.
func ParseRaceName(value string) (common.RaceName, error) {
raceName := common.RaceName(NormalizeString(value))
if err := raceName.Validate(); err != nil {
return "", InvalidRequest(err.Error())
}
return raceName, nil
}
// ParseReasonCode trims value and validates it as one machine-readable reason
// code.
func ParseReasonCode(value string) (common.ReasonCode, error) {
reasonCode := common.ReasonCode(NormalizeString(value))
if err := reasonCode.Validate(); err != nil {
return "", InvalidRequest(err.Error())
}
return reasonCode, nil
}
// ParseLanguageTag trims value and validates it against the current Stage 03
// boundary and BCP 47 semantics, returning the canonical tag form.
func ParseLanguageTag(value string) (common.LanguageTag, error) {
languageTag := common.LanguageTag(NormalizeString(value))
if err := languageTag.Validate(); err != nil {
return "", InvalidRequest(err.Error())
}
parsedTag, err := language.Parse(languageTag.String())
if err != nil {
return "", InvalidRequest("language tag must be a valid BCP 47 language tag")
}
canonicalTag := common.LanguageTag(parsedTag.String())
if err := canonicalTag.Validate(); err != nil {
return "", InvalidRequest(err.Error())
}
return canonicalTag, nil
}
// ParseTimeZoneName trims value and validates it against the current Stage 03
// boundary and IANA time-zone semantics.
func ParseTimeZoneName(value string) (common.TimeZoneName, error) {
timeZoneName := common.TimeZoneName(NormalizeString(value))
if err := timeZoneName.Validate(); err != nil {
return "", InvalidRequest(err.Error())
}
if _, err := time.LoadLocation(timeZoneName.String()); err != nil {
return "", InvalidRequest("time zone name must be a valid IANA time zone name")
}
return timeZoneName, nil
}
// ParseRegistrationPreferredLanguage trims value, validates it as one create-
// only BCP 47 registration language tag, and returns the canonical tag form.
func ParseRegistrationPreferredLanguage(value string) (common.LanguageTag, error) {
languageTag, err := ParseLanguageTag(value)
if err != nil {
return "", reframeFieldError("registration_context.preferred_language", "language tag", err)
}
return languageTag, nil
}
// ParseRegistrationTimeZoneName trims value and validates it as one create-
// only IANA registration time-zone name.
func ParseRegistrationTimeZoneName(value string) (common.TimeZoneName, error) {
timeZoneName, err := ParseTimeZoneName(value)
if err != nil {
return "", reframeFieldError("registration_context.time_zone", "time zone name", err)
}
return timeZoneName, nil
}
func reframeFieldError(fieldName string, valueName string, err error) error {
if err == nil {
return nil
}
message := err.Error()
prefix := valueName + " "
if strings.HasPrefix(message, prefix) {
message = fieldName + " " + strings.TrimPrefix(message, prefix)
} else {
message = fmt.Sprintf("%s: %s", fieldName, message)
}
return InvalidRequest(message)
}
@@ -0,0 +1,119 @@
package shared
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestParseLanguageTag(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
want string
wantErrCode string
wantErr string
}{
{
name: "canonicalizes valid tag",
input: " en-us ",
want: "en-US",
},
{
name: "rejects invalid tag",
input: "en-@",
wantErrCode: ErrorCodeInvalidRequest,
wantErr: "language tag must be a valid BCP 47 language tag",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := ParseLanguageTag(tt.input)
if tt.wantErr != "" {
require.Error(t, err)
require.Empty(t, got)
require.Equal(t, tt.wantErrCode, CodeOf(err))
require.Equal(t, tt.wantErr, err.Error())
return
}
require.NoError(t, err)
require.Equal(t, tt.want, got.String())
})
}
}
func TestParseTimeZoneName(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
want string
wantErrCode string
wantErr string
}{
{
name: "accepts valid zone",
input: " Europe/Kaliningrad ",
want: "Europe/Kaliningrad",
},
{
name: "rejects invalid zone",
input: "Mars/Olympus",
wantErrCode: ErrorCodeInvalidRequest,
wantErr: "time zone name must be a valid IANA time zone name",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := ParseTimeZoneName(tt.input)
if tt.wantErr != "" {
require.Error(t, err)
require.Empty(t, got)
require.Equal(t, tt.wantErrCode, CodeOf(err))
require.Equal(t, tt.wantErr, err.Error())
return
}
require.NoError(t, err)
require.Equal(t, tt.want, got.String())
})
}
}
func TestParseRegistrationPreferredLanguage(t *testing.T) {
t.Parallel()
got, err := ParseRegistrationPreferredLanguage(" en-us ")
require.NoError(t, err)
require.Equal(t, "en-US", got.String())
_, err = ParseRegistrationPreferredLanguage("bad@@tag")
require.Error(t, err)
require.Equal(t, ErrorCodeInvalidRequest, CodeOf(err))
require.Equal(t, "registration_context.preferred_language must be a valid BCP 47 language tag", err.Error())
}
func TestParseRegistrationTimeZoneName(t *testing.T) {
t.Parallel()
got, err := ParseRegistrationTimeZoneName(" Europe/Kaliningrad ")
require.NoError(t, err)
require.Equal(t, "Europe/Kaliningrad", got.String())
_, err = ParseRegistrationTimeZoneName("Mars/Olympus")
require.Error(t, err)
require.Equal(t, ErrorCodeInvalidRequest, CodeOf(err))
require.Equal(t, "registration_context.time_zone must be a valid IANA time zone name", err.Error())
}
@@ -0,0 +1,73 @@
package shared
import (
"context"
"log/slog"
"galaxy/user/internal/logging"
)
// LogServiceOutcome writes one structured service-level outcome log with a
// stable severity derived from err and with trace fields attached when ctx
// carries an active span.
func LogServiceOutcome(logger *slog.Logger, ctx context.Context, message string, err error, attrs ...any) {
if logger == nil {
logger = slog.Default()
}
attrs = append(attrs, logging.TraceAttrsFromContext(ctx)...)
switch {
case err == nil:
logger.InfoContext(ctx, message, attrs...)
case isExpectedServiceErrorCode(CodeOf(err)):
logger.WarnContext(ctx, message, append(attrs, "error", err.Error())...)
default:
logger.ErrorContext(ctx, message, append(attrs, "error", err.Error())...)
}
}
// MetricOutcome returns the stable low-cardinality outcome label derived from
// err for service metrics.
func MetricOutcome(err error) string {
if err == nil {
return "success"
}
code := CodeOf(err)
if code == "" {
return ErrorCodeInternalError
}
return code
}
// LogEventPublicationFailure writes one structured error log for an auxiliary
// post-commit event publication failure.
func LogEventPublicationFailure(logger *slog.Logger, ctx context.Context, eventType string, err error, attrs ...any) {
if err == nil {
return
}
if logger == nil {
logger = slog.Default()
}
attrs = append(attrs,
"event_type", eventType,
"error", err.Error(),
)
attrs = append(attrs, logging.TraceAttrsFromContext(ctx)...)
logger.ErrorContext(ctx, "auxiliary event publication failed", attrs...)
}
func isExpectedServiceErrorCode(code string) bool {
switch code {
case ErrorCodeInvalidRequest,
ErrorCodeConflict,
ErrorCodeSubjectNotFound:
return true
default:
return false
}
}
+49
View File
@@ -0,0 +1,49 @@
package shared
import (
"fmt"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/ports"
)
// BuildRaceNameReservation constructs one validated race-name reservation
// record for userID and raceName at reservedAt.
func BuildRaceNameReservation(
policy ports.RaceNamePolicy,
userID common.UserID,
raceName common.RaceName,
reservedAt time.Time,
) (account.RaceNameReservation, error) {
if policy == nil {
return account.RaceNameReservation{}, fmt.Errorf("build race-name reservation: race-name policy must not be nil")
}
if err := userID.Validate(); err != nil {
return account.RaceNameReservation{}, fmt.Errorf("build race-name reservation: %w", err)
}
if err := raceName.Validate(); err != nil {
return account.RaceNameReservation{}, fmt.Errorf("build race-name reservation: %w", err)
}
if err := common.ValidateTimestamp("build race-name reservation reserved at", reservedAt); err != nil {
return account.RaceNameReservation{}, err
}
canonicalKey, err := policy.CanonicalKey(raceName)
if err != nil {
return account.RaceNameReservation{}, fmt.Errorf("build race-name reservation: %w", err)
}
record := account.RaceNameReservation{
CanonicalKey: canonicalKey,
UserID: userID,
RaceName: raceName,
ReservedAt: reservedAt.UTC(),
}
if err := record.Validate(); err != nil {
return account.RaceNameReservation{}, fmt.Errorf("build race-name reservation: %w", err)
}
return record, nil
}
+549
View File
@@ -0,0 +1,549 @@
// Package telemetry provides shared OpenTelemetry runtime helpers and
// low-cardinality user-service instruments.
package telemetry
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
otelprom "go.opentelemetry.io/otel/exporters/prometheus"
"go.opentelemetry.io/otel/exporters/stdout/stdoutmetric"
"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/propagation"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
oteltrace "go.opentelemetry.io/otel/trace"
)
const meterName = "galaxy/user"
const (
defaultServiceName = "galaxy-user"
processExporterNone = "none"
processExporterOTLP = "otlp"
processProtocolHTTPProtobuf = "http/protobuf"
processProtocolGRPC = "grpc"
)
// ProcessConfig configures the process-wide OpenTelemetry runtime.
type ProcessConfig struct {
// ServiceName overrides the default OpenTelemetry service name.
ServiceName string
// TracesExporter selects the external traces exporter. Supported values are
// `none` and `otlp`.
TracesExporter string
// MetricsExporter selects the external metrics exporter. Supported values
// are `none` and `otlp`.
MetricsExporter string
// TracesProtocol selects the OTLP traces protocol when TracesExporter is
// `otlp`.
TracesProtocol string
// MetricsProtocol selects the OTLP metrics protocol when MetricsExporter is
// `otlp`.
MetricsProtocol string
// StdoutTracesEnabled enables the additional stdout trace exporter used for
// local development and debugging.
StdoutTracesEnabled bool
// StdoutMetricsEnabled enables the additional stdout metric exporter used
// for local development and debugging.
StdoutMetricsEnabled bool
}
// Validate reports whether cfg contains a supported OpenTelemetry exporter
// configuration.
func (cfg ProcessConfig) Validate() error {
switch cfg.TracesExporter {
case processExporterNone, processExporterOTLP:
default:
return fmt.Errorf("unsupported traces exporter %q", cfg.TracesExporter)
}
switch cfg.MetricsExporter {
case processExporterNone, processExporterOTLP:
default:
return fmt.Errorf("unsupported metrics exporter %q", cfg.MetricsExporter)
}
if cfg.TracesProtocol != "" && cfg.TracesProtocol != processProtocolHTTPProtobuf && cfg.TracesProtocol != processProtocolGRPC {
return fmt.Errorf("unsupported OTLP traces protocol %q", cfg.TracesProtocol)
}
if cfg.MetricsProtocol != "" && cfg.MetricsProtocol != processProtocolHTTPProtobuf && cfg.MetricsProtocol != processProtocolGRPC {
return fmt.Errorf("unsupported OTLP metrics protocol %q", cfg.MetricsProtocol)
}
return nil
}
// Runtime owns the user-service OpenTelemetry providers, the Prometheus
// metrics handler, and the custom low-cardinality instruments.
type Runtime struct {
tracerProvider oteltrace.TracerProvider
meterProvider metric.MeterProvider
promHandler http.Handler
shutdownMu sync.Mutex
shutdownDone bool
shutdownErr error
shutdownFns []func(context.Context) error
internalHTTPRequests metric.Int64Counter
internalHTTPDuration metric.Float64Histogram
authResolutionOutcomes metric.Int64Counter
userCreationOutcomes metric.Int64Counter
raceNameReservationConflicts metric.Int64Counter
entitlementMutations metric.Int64Counter
sanctionMutations metric.Int64Counter
limitMutations metric.Int64Counter
eventPublicationFailures metric.Int64Counter
}
// New constructs a lightweight telemetry runtime around meterProvider for
// tests and embedded use cases that do not need process-level exporter wiring.
func New(meterProvider metric.MeterProvider) (*Runtime, error) {
return NewWithProviders(meterProvider, nil)
}
// NewWithProviders constructs a telemetry runtime around explicitly supplied
// meterProvider and tracerProvider values.
func NewWithProviders(meterProvider metric.MeterProvider, tracerProvider oteltrace.TracerProvider) (*Runtime, error) {
if meterProvider == nil {
meterProvider = otel.GetMeterProvider()
}
if tracerProvider == nil {
tracerProvider = otel.GetTracerProvider()
}
if meterProvider == nil {
return nil, errors.New("new user telemetry runtime: nil meter provider")
}
if tracerProvider == nil {
return nil, errors.New("new user telemetry runtime: nil tracer provider")
}
return buildRuntime(meterProvider, tracerProvider, http.NotFoundHandler(), nil)
}
// NewProcess constructs the process-wide user-service OpenTelemetry runtime
// from cfg, installs the resulting providers globally, and returns the
// runtime.
func NewProcess(ctx context.Context, cfg ProcessConfig, logger *slog.Logger) (*Runtime, error) {
return newProcess(ctx, cfg, logger, os.Stdout, os.Stdout)
}
// TracerProvider returns the runtime tracer provider.
func (r *Runtime) TracerProvider() oteltrace.TracerProvider {
if r == nil || r.tracerProvider == nil {
return otel.GetTracerProvider()
}
return r.tracerProvider
}
// MeterProvider returns the runtime meter provider.
func (r *Runtime) MeterProvider() metric.MeterProvider {
if r == nil || r.meterProvider == nil {
return otel.GetMeterProvider()
}
return r.meterProvider
}
// Handler returns the Prometheus handler that should be mounted on the admin
// listener.
func (r *Runtime) Handler() http.Handler {
if r == nil || r.promHandler == nil {
return http.NotFoundHandler()
}
return r.promHandler
}
// Shutdown flushes and stops the configured telemetry providers. Shutdown is
// idempotent.
func (r *Runtime) Shutdown(ctx context.Context) error {
if r == nil {
return nil
}
r.shutdownMu.Lock()
if r.shutdownDone {
err := r.shutdownErr
r.shutdownMu.Unlock()
return err
}
r.shutdownDone = true
r.shutdownMu.Unlock()
var shutdownErr error
for index := len(r.shutdownFns) - 1; index >= 0; index-- {
shutdownErr = errors.Join(shutdownErr, r.shutdownFns[index](ctx))
}
r.shutdownMu.Lock()
r.shutdownErr = shutdownErr
r.shutdownMu.Unlock()
return shutdownErr
}
// RecordInternalHTTPRequest records one internal HTTP request outcome.
func (r *Runtime) RecordInternalHTTPRequest(ctx context.Context, attrs []attribute.KeyValue, duration time.Duration) {
if r == nil {
return
}
options := metric.WithAttributes(attrs...)
r.internalHTTPRequests.Add(normalizeContext(ctx), 1, options)
r.internalHTTPDuration.Record(normalizeContext(ctx), duration.Seconds()*1000, options)
}
// RecordAuthResolutionOutcome records one auth-facing resolution outcome.
func (r *Runtime) RecordAuthResolutionOutcome(ctx context.Context, operation string, outcome string) {
if r == nil {
return
}
r.authResolutionOutcomes.Add(
normalizeContext(ctx),
1,
metric.WithAttributes(
attribute.String("operation", strings.TrimSpace(operation)),
attribute.String("outcome", strings.TrimSpace(outcome)),
),
)
}
// RecordUserCreationOutcome records one ensure-by-email coarse outcome.
func (r *Runtime) RecordUserCreationOutcome(ctx context.Context, outcome string) {
if r == nil {
return
}
r.userCreationOutcomes.Add(
normalizeContext(ctx),
1,
metric.WithAttributes(attribute.String("outcome", strings.TrimSpace(outcome))),
)
}
// RecordRaceNameReservationConflict records one race-name reservation conflict
// for operation.
func (r *Runtime) RecordRaceNameReservationConflict(ctx context.Context, operation string) {
if r == nil {
return
}
r.raceNameReservationConflicts.Add(
normalizeContext(ctx),
1,
metric.WithAttributes(attribute.String("operation", strings.TrimSpace(operation))),
)
}
// RecordEntitlementMutation records one entitlement command outcome.
func (r *Runtime) RecordEntitlementMutation(ctx context.Context, command string, outcome string) {
if r == nil {
return
}
r.entitlementMutations.Add(
normalizeContext(ctx),
1,
metric.WithAttributes(
attribute.String("command", strings.TrimSpace(command)),
attribute.String("outcome", strings.TrimSpace(outcome)),
),
)
}
// RecordSanctionMutation records one sanction command outcome.
func (r *Runtime) RecordSanctionMutation(ctx context.Context, command string, outcome string) {
if r == nil {
return
}
r.sanctionMutations.Add(
normalizeContext(ctx),
1,
metric.WithAttributes(
attribute.String("command", strings.TrimSpace(command)),
attribute.String("outcome", strings.TrimSpace(outcome)),
),
)
}
// RecordLimitMutation records one limit command outcome.
func (r *Runtime) RecordLimitMutation(ctx context.Context, command string, outcome string) {
if r == nil {
return
}
r.limitMutations.Add(
normalizeContext(ctx),
1,
metric.WithAttributes(
attribute.String("command", strings.TrimSpace(command)),
attribute.String("outcome", strings.TrimSpace(outcome)),
),
)
}
// RecordEventPublicationFailure records one post-commit auxiliary event
// publication failure.
func (r *Runtime) RecordEventPublicationFailure(ctx context.Context, eventType string) {
if r == nil {
return
}
r.eventPublicationFailures.Add(
normalizeContext(ctx),
1,
metric.WithAttributes(attribute.String("event_type", strings.TrimSpace(eventType))),
)
}
func newProcess(ctx context.Context, cfg ProcessConfig, logger *slog.Logger, stdoutTraceWriter io.Writer, stdoutMetricWriter io.Writer) (*Runtime, error) {
if ctx == nil {
return nil, errors.New("new user telemetry process: nil context")
}
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("new user telemetry process: %w", err)
}
if logger == nil {
logger = slog.Default()
}
if strings.TrimSpace(cfg.ServiceName) == "" {
cfg.ServiceName = defaultServiceName
}
res, err := resource.New(
ctx,
resource.WithAttributes(attribute.String("service.name", cfg.ServiceName)),
)
if err != nil {
return nil, fmt.Errorf("new user telemetry process: resource: %w", err)
}
tracerProvider, err := newTracerProvider(ctx, res, cfg, stdoutTraceWriter)
if err != nil {
return nil, fmt.Errorf("new user telemetry process: tracer provider: %w", err)
}
registry := prometheus.NewRegistry()
prometheusExporter, err := otelprom.New(otelprom.WithRegisterer(registry))
if err != nil {
return nil, fmt.Errorf("new user telemetry process: prometheus exporter: %w", err)
}
meterProvider, err := newMeterProvider(ctx, res, cfg, prometheusExporter, stdoutMetricWriter)
if err != nil {
return nil, fmt.Errorf("new user telemetry process: meter provider: %w", err)
}
otel.SetTracerProvider(tracerProvider)
otel.SetMeterProvider(meterProvider)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
))
runtime, err := buildRuntime(
meterProvider,
tracerProvider,
promhttp.HandlerFor(registry, promhttp.HandlerOpts{}),
[]func(context.Context) error{
meterProvider.Shutdown,
tracerProvider.Shutdown,
},
)
if err != nil {
return nil, fmt.Errorf("new user telemetry process: %w", err)
}
logger.InfoContext(ctx, "user telemetry configured",
"service_name", cfg.ServiceName,
"traces_exporter", cfg.TracesExporter,
"metrics_exporter", cfg.MetricsExporter,
"stdout_traces_enabled", cfg.StdoutTracesEnabled,
"stdout_metrics_enabled", cfg.StdoutMetricsEnabled,
)
return runtime, nil
}
func buildRuntime(
meterProvider metric.MeterProvider,
tracerProvider oteltrace.TracerProvider,
promHandler http.Handler,
shutdownFns []func(context.Context) error,
) (*Runtime, error) {
meter := meterProvider.Meter(meterName)
internalHTTPRequests, err := meter.Int64Counter("user.internal_http.requests")
if err != nil {
return nil, fmt.Errorf("build user telemetry runtime: internal_http.requests: %w", err)
}
internalHTTPDuration, err := meter.Float64Histogram("user.internal_http.duration", metric.WithUnit("ms"))
if err != nil {
return nil, fmt.Errorf("build user telemetry runtime: internal_http.duration: %w", err)
}
authResolutionOutcomes, err := meter.Int64Counter("user.auth_resolution.outcomes")
if err != nil {
return nil, fmt.Errorf("build user telemetry runtime: auth_resolution.outcomes: %w", err)
}
userCreationOutcomes, err := meter.Int64Counter("user.user_creation.outcomes")
if err != nil {
return nil, fmt.Errorf("build user telemetry runtime: user_creation.outcomes: %w", err)
}
raceNameReservationConflicts, err := meter.Int64Counter("user.race_name.reservation_conflicts")
if err != nil {
return nil, fmt.Errorf("build user telemetry runtime: race_name.reservation_conflicts: %w", err)
}
entitlementMutations, err := meter.Int64Counter("user.entitlement.mutations")
if err != nil {
return nil, fmt.Errorf("build user telemetry runtime: entitlement.mutations: %w", err)
}
sanctionMutations, err := meter.Int64Counter("user.sanction.mutations")
if err != nil {
return nil, fmt.Errorf("build user telemetry runtime: sanction.mutations: %w", err)
}
limitMutations, err := meter.Int64Counter("user.limit.mutations")
if err != nil {
return nil, fmt.Errorf("build user telemetry runtime: limit.mutations: %w", err)
}
eventPublicationFailures, err := meter.Int64Counter("user.event_publication_failures")
if err != nil {
return nil, fmt.Errorf("build user telemetry runtime: event_publication_failures: %w", err)
}
if promHandler == nil {
promHandler = http.NotFoundHandler()
}
return &Runtime{
tracerProvider: tracerProvider,
meterProvider: meterProvider,
promHandler: promHandler,
shutdownFns: shutdownFns,
internalHTTPRequests: internalHTTPRequests,
internalHTTPDuration: internalHTTPDuration,
authResolutionOutcomes: authResolutionOutcomes,
userCreationOutcomes: userCreationOutcomes,
raceNameReservationConflicts: raceNameReservationConflicts,
entitlementMutations: entitlementMutations,
sanctionMutations: sanctionMutations,
limitMutations: limitMutations,
eventPublicationFailures: eventPublicationFailures,
}, nil
}
func newTracerProvider(ctx context.Context, res *resource.Resource, cfg ProcessConfig, stdoutWriter io.Writer) (*sdktrace.TracerProvider, error) {
options := []sdktrace.TracerProviderOption{sdktrace.WithResource(res)}
if cfg.TracesExporter == processExporterOTLP {
exporter, err := newOTLPTraceExporter(ctx, cfg.TracesProtocol)
if err != nil {
return nil, err
}
options = append(options, sdktrace.WithBatcher(exporter))
}
if cfg.StdoutTracesEnabled {
exporter, err := stdouttrace.New(
stdouttrace.WithPrettyPrint(),
stdouttrace.WithWriter(stdoutWriter),
)
if err != nil {
return nil, err
}
options = append(options, sdktrace.WithBatcher(exporter))
}
return sdktrace.NewTracerProvider(options...), nil
}
func newMeterProvider(
ctx context.Context,
res *resource.Resource,
cfg ProcessConfig,
prometheusExporter sdkmetric.Reader,
stdoutWriter io.Writer,
) (*sdkmetric.MeterProvider, error) {
options := []sdkmetric.Option{
sdkmetric.WithResource(res),
sdkmetric.WithReader(prometheusExporter),
}
if cfg.MetricsExporter == processExporterOTLP {
exporter, err := newOTLPMetricExporter(ctx, cfg.MetricsProtocol)
if err != nil {
return nil, err
}
options = append(options, sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exporter)))
}
if cfg.StdoutMetricsEnabled {
exporter, err := stdoutmetric.New(
stdoutmetric.WithPrettyPrint(),
stdoutmetric.WithWriter(stdoutWriter),
)
if err != nil {
return nil, err
}
options = append(options, sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exporter)))
}
return sdkmetric.NewMeterProvider(options...), nil
}
func newOTLPTraceExporter(ctx context.Context, protocol string) (sdktrace.SpanExporter, error) {
switch protocol {
case "", processProtocolHTTPProtobuf:
return otlptracehttp.New(ctx)
case processProtocolGRPC:
return otlptracegrpc.New(ctx)
default:
return nil, fmt.Errorf("unsupported OTLP traces protocol %q", protocol)
}
}
func newOTLPMetricExporter(ctx context.Context, protocol string) (sdkmetric.Exporter, error) {
switch protocol {
case "", processProtocolHTTPProtobuf:
return otlpmetrichttp.New(ctx)
case processProtocolGRPC:
return otlpmetricgrpc.New(ctx)
default:
return nil, fmt.Errorf("unsupported OTLP metrics protocol %q", protocol)
}
}
func normalizeContext(ctx context.Context) context.Context {
if ctx == nil {
return context.Background()
}
return ctx
}
+186
View File
@@ -0,0 +1,186 @@
package telemetry
import (
"bytes"
"context"
"io"
"log/slog"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/attribute"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
)
func TestNewProcessBuildsWithoutExporters(t *testing.T) {
t.Parallel()
runtime, err := newProcess(context.Background(), ProcessConfig{
ServiceName: "galaxy-user-test",
TracesExporter: processExporterNone,
MetricsExporter: processExporterNone,
}, slog.New(slog.NewTextHandler(io.Discard, nil)), io.Discard, io.Discard)
require.NoError(t, err)
assert.NotNil(t, runtime.TracerProvider())
assert.NotNil(t, runtime.MeterProvider())
assert.NotNil(t, runtime.Handler())
require.NoError(t, runtime.Shutdown(context.Background()))
require.NoError(t, runtime.Shutdown(context.Background()))
}
func TestNewProcessBuildsWithStdoutExporters(t *testing.T) {
t.Parallel()
traceBuffer := &bytes.Buffer{}
metricBuffer := &bytes.Buffer{}
runtime, err := newProcess(context.Background(), ProcessConfig{
ServiceName: "galaxy-user-test",
TracesExporter: processExporterNone,
MetricsExporter: processExporterNone,
StdoutTracesEnabled: true,
StdoutMetricsEnabled: true,
}, slog.New(slog.NewTextHandler(io.Discard, nil)), traceBuffer, metricBuffer)
require.NoError(t, err)
ctx, span := runtime.TracerProvider().Tracer("test").Start(context.Background(), "internal-request")
runtime.RecordUserCreationOutcome(ctx, "created")
span.End()
require.NoError(t, runtime.Shutdown(context.Background()))
assert.NotEmpty(t, traceBuffer.String())
assert.NotEmpty(t, metricBuffer.String())
}
func TestNewPreservesBusinessMetrics(t *testing.T) {
t.Parallel()
reader := sdkmetric.NewManualReader()
meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
tracerProvider := sdktrace.NewTracerProvider()
runtime, err := NewWithProviders(meterProvider, tracerProvider)
require.NoError(t, err)
runtime.RecordInternalHTTPRequest(context.Background(), []attribute.KeyValue{
attribute.String("route", "/api/v1/internal/users/:user_id/exists"),
attribute.String("method", "GET"),
attribute.String("edge_outcome", "success"),
}, 125*time.Millisecond)
runtime.RecordAuthResolutionOutcome(context.Background(), "resolve_by_email", "existing")
runtime.RecordUserCreationOutcome(context.Background(), "created")
runtime.RecordRaceNameReservationConflict(context.Background(), "update_my_profile")
runtime.RecordEntitlementMutation(context.Background(), "grant", "success")
runtime.RecordSanctionMutation(context.Background(), "apply", "conflict")
runtime.RecordLimitMutation(context.Background(), "remove", "subject_not_found")
runtime.RecordEventPublicationFailure(context.Background(), "user.profile.changed")
assertMetricCount(t, reader, "user.internal_http.requests", map[string]string{
"route": "/api/v1/internal/users/:user_id/exists",
"method": "GET",
"edge_outcome": "success",
}, 1)
assertHistogramCount(t, reader, "user.internal_http.duration", map[string]string{
"route": "/api/v1/internal/users/:user_id/exists",
"method": "GET",
"edge_outcome": "success",
}, 1)
assertMetricCount(t, reader, "user.auth_resolution.outcomes", map[string]string{
"operation": "resolve_by_email",
"outcome": "existing",
}, 1)
assertMetricCount(t, reader, "user.user_creation.outcomes", map[string]string{
"outcome": "created",
}, 1)
assertMetricCount(t, reader, "user.race_name.reservation_conflicts", map[string]string{
"operation": "update_my_profile",
}, 1)
assertMetricCount(t, reader, "user.entitlement.mutations", map[string]string{
"command": "grant",
"outcome": "success",
}, 1)
assertMetricCount(t, reader, "user.sanction.mutations", map[string]string{
"command": "apply",
"outcome": "conflict",
}, 1)
assertMetricCount(t, reader, "user.limit.mutations", map[string]string{
"command": "remove",
"outcome": "subject_not_found",
}, 1)
assertMetricCount(t, reader, "user.event_publication_failures", map[string]string{
"event_type": "user.profile.changed",
}, 1)
}
func assertMetricCount(t *testing.T, reader *sdkmetric.ManualReader, metricName string, wantAttrs map[string]string, wantValue int64) {
t.Helper()
var resourceMetrics metricdata.ResourceMetrics
require.NoError(t, reader.Collect(context.Background(), &resourceMetrics))
for _, scopeMetrics := range resourceMetrics.ScopeMetrics {
for _, metric := range scopeMetrics.Metrics {
if metric.Name != metricName {
continue
}
sum, ok := metric.Data.(metricdata.Sum[int64])
require.True(t, ok)
for _, point := range sum.DataPoints {
if hasMetricAttributes(point.Attributes.ToSlice(), wantAttrs) {
assert.Equal(t, wantValue, point.Value)
return
}
}
}
}
require.Failf(t, "test failed", "metric %q with attrs %v not found", metricName, wantAttrs)
}
func assertHistogramCount(t *testing.T, reader *sdkmetric.ManualReader, metricName string, wantAttrs map[string]string, wantCount uint64) {
t.Helper()
var resourceMetrics metricdata.ResourceMetrics
require.NoError(t, reader.Collect(context.Background(), &resourceMetrics))
for _, scopeMetrics := range resourceMetrics.ScopeMetrics {
for _, metric := range scopeMetrics.Metrics {
if metric.Name != metricName {
continue
}
histogram, ok := metric.Data.(metricdata.Histogram[float64])
require.True(t, ok)
for _, point := range histogram.DataPoints {
if hasMetricAttributes(point.Attributes.ToSlice(), wantAttrs) {
assert.Equal(t, wantCount, point.Count)
return
}
}
}
}
require.Failf(t, "test failed", "histogram %q with attrs %v not found", metricName, wantAttrs)
}
func hasMetricAttributes(values []attribute.KeyValue, want map[string]string) bool {
if len(values) != len(want) {
return false
}
for _, value := range values {
if want[string(value.Key)] != value.Value.AsString() {
return false
}
}
return true
}
+178 -21
View File
@@ -3,10 +3,15 @@ info:
title: Galaxy User Service Internal REST API
version: v1
description: |
This specification documents the planned trusted internal REST contract of
This specification documents the trusted internal REST contract of
`galaxy/user`.
The current runtime is implemented as an internal-only HTTP service backed
by Redis.
Scope:
- regular-user state only; system-admin identity belongs to future
`Admin Service`
- auth-facing user resolution, ensure, existence, and subject blocking
- gateway-facing authenticated account reads and self-service mutations
- lobby-facing eligibility snapshots
@@ -14,14 +19,25 @@ info:
- admin/internal reads, filtered listing, and explicit mutation commands
This specification is internal REST only. It intentionally does not
describe public edge transport, gateway gRPC, or any future asynchronous
event payloads.
describe public edge transport, gateway gRPC, or the auxiliary async
event contracts documented in `README.md` and `docs/flows.md`.
The auth-facing paths listed under `AuthIntegration` are already reserved
by `Auth / Session Service` and their route shapes must remain stable.
Current transport rules:
- request bodies are strict JSON only
- unknown fields are rejected
- trailing JSON input is rejected
- error responses use `{ "error": { "code", "message" } }`
- stable error codes are `invalid_request`, `conflict`,
`subject_not_found`, `internal_error`, and `service_unavailable`
servers:
- url: http://localhost:8091
description: Example local internal listener for User Service.
description: Default local internal listener for User Service.
tags:
- name: AuthIntegration
description: Trusted auth-facing user ownership and block-policy endpoints.
description: Trusted auth-facing user ownership and block-policy endpoints with frozen route shapes reserved by `Auth / Session Service`.
- name: MyAccount
description: Gateway-facing authenticated account queries and self-service mutations.
- name: LobbyIntegration
@@ -88,9 +104,16 @@ paths:
user when registration is allowed, or returns a blocked outcome when
policy denies the flow.
`registration_context` is create-only. Implementations must ignore it
for existing users and must not overwrite settings of an already
existing account.
`registration_context` is required on the current auth-to-user call.
Its frozen shape is `preferred_language` plus `time_zone`. The
registration context is create-only. Implementations must ignore it for
existing users and must not overwrite settings of an already existing
account.
During the current rollout `Auth / Session Service` sends temporary
`preferred_language="en"` and forwards the public confirm `time_zone`.
Gateway-side geoip language derivation is a later rollout and is not
part of the current source-of-truth contract.
requestBody:
required: true
content:
@@ -193,6 +216,10 @@ paths:
- MyAccount
operationId: updateMyProfile
summary: Update self-service profile fields
description: |
`race_name` uniqueness is enforced through a canonical reservation
policy that is case-insensitive, rejects the frozen anti-fraud
confusable pairs, and preserves the original stored casing.
parameters:
- $ref: "#/components/parameters/UserIDPath"
requestBody:
@@ -279,6 +306,14 @@ paths:
- GeoIntegration
operationId: syncDeclaredCountry
summary: Synchronize the current effective declared country
description: |
Applies the latest effective declared country chosen by
`Geo Profile Service`.
`declared_country` must be a known uppercase ISO 3166-1 alpha-2
country code. When the supplied value is already stored on the user
account, the command is a no-op and returns the existing
`updated_at` unchanged.
parameters:
- $ref: "#/components/parameters/UserIDPath"
requestBody:
@@ -330,7 +365,7 @@ paths:
tags:
- AdminUsers
operationId: getUserByEmail
summary: Read one user by normalized e-mail
summary: Read one user by exact-after-trim e-mail
requestBody:
required: true
content:
@@ -385,6 +420,15 @@ paths:
- AdminUsers
operationId: listUsers
summary: List users with deterministic pagination and rich filters
description: |
Returns full user account aggregates ordered by `created_at desc`, then
`user_id desc`.
All supplied query filters combine with logical `AND`.
`page_token` is opaque and bound to the normalized filter set that
produced it. Malformed or filter-mismatched tokens return
`400 invalid_request`.
parameters:
- $ref: "#/components/parameters/PageSize"
- $ref: "#/components/parameters/PageToken"
@@ -457,6 +501,9 @@ paths:
- AdminUsers
operationId: grantEntitlement
summary: Grant a new entitlement period
description: |
Grants a current paid entitlement when the current effective state is
`free`.
parameters:
- $ref: "#/components/parameters/UserIDPath"
requestBody:
@@ -488,6 +535,8 @@ paths:
- AdminUsers
operationId: extendEntitlement
summary: Extend the current entitlement period
description: |
Extends the current finite paid entitlement.
parameters:
- $ref: "#/components/parameters/UserIDPath"
requestBody:
@@ -519,6 +568,8 @@ paths:
- AdminUsers
operationId: revokeEntitlement
summary: Revoke the effective paid entitlement
description: |
Revokes the current effective paid entitlement.
parameters:
- $ref: "#/components/parameters/UserIDPath"
requestBody:
@@ -550,6 +601,8 @@ paths:
- AdminUsers
operationId: applySanction
summary: Apply one sanction record
description: |
Applies one new active sanction record.
parameters:
- $ref: "#/components/parameters/UserIDPath"
requestBody:
@@ -581,6 +634,8 @@ paths:
- AdminUsers
operationId: removeSanction
summary: Remove one active sanction record
description: |
Removes the current active sanction for one `sanction_code`.
parameters:
- $ref: "#/components/parameters/UserIDPath"
requestBody:
@@ -612,6 +667,9 @@ paths:
- AdminUsers
operationId: setLimit
summary: Set one active user-specific limit record
description: |
Creates one new active limit or replaces the current active record of
the same `limit_code`.
parameters:
- $ref: "#/components/parameters/UserIDPath"
requestBody:
@@ -643,6 +701,8 @@ paths:
- AdminUsers
operationId: removeLimit
summary: Remove one active user-specific limit record
description: |
Removes the current active user-specific limit for one `limit_code`.
parameters:
- $ref: "#/components/parameters/UserIDPath"
requestBody:
@@ -689,7 +749,7 @@ components:
PageToken:
name: page_token
in: query
description: Opaque deterministic pagination cursor returned by the previous page.
description: Opaque deterministic pagination cursor returned by the previous page and bound to the normalized filter set that produced it. Malformed or filter-mismatched tokens return `400 invalid_request`.
schema:
type: string
schemas:
@@ -700,27 +760,40 @@ components:
Email:
type: string
format: email
description: Normalized login and contact e-mail address.
description: |
Login and contact e-mail address. The service trims surrounding
whitespace and validates the value structurally, then treats the
trimmed value as the exact stored and lookup value. The service does
not lowercase or otherwise canonicalize e-mail before storage or exact
lookup.
RaceName:
type: string
description: |
Stored race name preserving the user-selected casing after successful
uniqueness checks.
uniqueness checks. Uniqueness is enforced against a canonical
reservation key rather than exact string equality only.
minLength: 1
maxLength: 64
LanguageTag:
type: string
description: BCP 47 language tag.
description: |
BCP 47 language tag. User Service validates semantic correctness on
auth-driven creation and stores the canonical tag form.
minLength: 1
maxLength: 32
TimeZoneName:
type: string
description: IANA time zone name.
description: |
IANA time zone name. User Service validates semantic correctness on
auth-driven creation and stores the trimmed caller value without
additional alias canonicalization.
minLength: 1
maxLength: 128
CountryCode:
type: string
description: ISO 3166-1 alpha-2 country code.
description: |
ISO 3166-1 alpha-2 country code in uppercase ASCII form. The geo sync
command additionally rejects well-formed but unknown region codes.
pattern: "^[A-Z]{2}$"
UserResolutionKind:
type: string
@@ -756,12 +829,13 @@ components:
- profile_update_block
LimitCode:
type: string
description: |
Current supported user-specific limit codes. Retired legacy codes may
still exist in stored history for backward compatibility, but they are
not part of this write or read contract.
enum:
- max_owned_private_games
- max_active_private_games
- max_pending_public_applications
- max_pending_private_join_requests
- max_pending_private_invites_sent
- max_active_game_memberships
ActorRef:
type: object
@@ -777,6 +851,12 @@ components:
description: Optional stable actor identifier.
RegistrationContext:
type: object
description: |
Frozen create-only initialization context used by the current
auth-facing ensure-by-email contract. `preferred_language` is
semantically validated as BCP 47 and stored in canonical tag form on
create. `time_zone` is semantically validated as an IANA time zone
name and stored after trim without additional alias canonicalization.
additionalProperties: false
required:
- preferred_language
@@ -786,8 +866,10 @@ components:
$ref: "#/components/schemas/LanguageTag"
description: |
Create-only initial preferred language. During the current rollout
phase `Auth / Session Service` sends a temporary `"en"` default
until gateway geoip-based language derivation is deployed.
`Auth / Session Service` sends a temporary `"en"` default and
forwards `time_zone`. Gateway-side geoip derivation is not part of
the current source-of-truth contract. Future derived values must
remain valid BCP 47 tags.
time_zone:
$ref: "#/components/schemas/TimeZoneName"
description: Create-only initial IANA time zone name.
@@ -825,6 +907,7 @@ components:
additionalProperties: false
required:
- email
- registration_context
properties:
email:
$ref: "#/components/schemas/Email"
@@ -840,6 +923,11 @@ components:
$ref: "#/components/schemas/EnsureUserOutcome"
user_id:
$ref: "#/components/schemas/UserID"
description: |
Present for `existing` and `created`. A `created` outcome returns
the durable newly materialized `user_id` created together with an
initial generated `player-<shortid>` race name and free
entitlement snapshot.
block_reason_code:
type: string
description: Present only for `outcome=blocked`.
@@ -874,6 +962,12 @@ components:
$ref: "#/components/schemas/UserID"
EntitlementSnapshot:
type: object
description: |
Materialized current effective entitlement snapshot.
The current snapshot is read-optimized and repaired lazily when a
finite paid state has already reached `ends_at`, so callers do not
observe stale paid/free state.
additionalProperties: false
required:
- plan_code
@@ -927,6 +1021,9 @@ components:
ActiveLimit:
type: object
additionalProperties: false
description: |
Current supported active user-specific limit override. Retired legacy
limit codes are ignored on reads and are not returned.
required:
- limit_code
- value
@@ -948,6 +1045,32 @@ components:
expires_at:
type: string
format: date-time
EffectiveLimit:
type: object
additionalProperties: false
description: |
Materialized numeric quota after the frozen `free` or `paid` default
catalog is combined with any active user-specific override for the same
`limit_code`.
`max_owned_private_games` is meaningful only while the current
entitlement is paid and is omitted from free effective limits.
`max_active_game_memberships` applies only to public games.
`max_pending_public_applications` stores the total public-games budget.
`Game Lobby` subtracts current active public memberships from this
value and clamps at `0` to derive remaining pending-application
headroom.
required:
- limit_code
- value
properties:
limit_code:
$ref: "#/components/schemas/LimitCode"
value:
type: integer
minimum: 0
AccountView:
type: object
additionalProperties: false
@@ -1002,6 +1125,10 @@ components:
UpdateMyProfileRequest:
type: object
additionalProperties: false
description: |
The current implementation accepts only `race_name` here. Attempts to
mutate `email` or `declared_country` are rejected as `400
invalid_request` through strict unknown-field handling.
required:
- race_name
properties:
@@ -1044,6 +1171,8 @@ components:
required:
- exists
- user_id
- active_sanctions
- effective_limits
- markers
properties:
exists:
@@ -1051,20 +1180,30 @@ components:
user_id:
$ref: "#/components/schemas/UserID"
entitlement:
description: |
Current effective entitlement snapshot. Omitted when `exists=false`.
$ref: "#/components/schemas/EntitlementSnapshot"
active_sanctions:
type: array
items:
$ref: "#/components/schemas/ActiveSanction"
effective_limits:
description: |
Materialized effective quotas for the current supported lobby
catalog. Unknown users return an empty array. Free users omit
`max_owned_private_games`.
type: array
items:
$ref: "#/components/schemas/ActiveLimit"
$ref: "#/components/schemas/EffectiveLimit"
markers:
$ref: "#/components/schemas/EligibilityMarkers"
SyncDeclaredCountryRequest:
type: object
additionalProperties: false
description: |
Synchronizes the latest effective declared country selected by
`Geo Profile Service`. Repeating the current stored value is accepted
as a no-op.
required:
- declared_country
properties:
@@ -1085,6 +1224,9 @@ components:
updated_at:
type: string
format: date-time
description: |
Effective account mutation timestamp. Same-value no-op syncs return
the existing stored timestamp unchanged.
UserAdminView:
allOf:
- $ref: "#/components/schemas/AccountView"
@@ -1127,6 +1269,12 @@ components:
GrantEntitlementRequest:
type: object
additionalProperties: false
description: |
Grants one current paid entitlement.
`plan_code=free` is invalid here. `starts_at` may be current or past,
but not future. Finite paid plans require `ends_at`, while
`paid_lifetime` forbids it.
required:
- plan_code
- source
@@ -1148,9 +1296,13 @@ components:
ends_at:
type: string
format: date-time
description: Required for `paid_monthly` and `paid_yearly`; omitted for `paid_lifetime`.
ExtendEntitlementRequest:
type: object
additionalProperties: false
description: |
Extends the current finite paid entitlement by appending one new paid
history segment.
required:
- source
- reason_code
@@ -1169,6 +1321,9 @@ components:
RevokeEntitlementRequest:
type: object
additionalProperties: false
description: |
Revokes the current effective paid entitlement and materializes a new
`free` snapshot immediately.
required:
- source
- reason_code
@@ -1183,6 +1338,8 @@ components:
EntitlementCommandResponse:
type: object
additionalProperties: false
description: Resulting current effective entitlement snapshot after one
successful trusted entitlement command.
required:
- user_id
- entitlement
+311
View File
@@ -0,0 +1,311 @@
package user
import (
"context"
"encoding/json"
"net/http"
"path/filepath"
"runtime"
"slices"
"testing"
"github.com/getkin/kin-openapi/openapi3"
"github.com/stretchr/testify/require"
)
func TestInternalOpenAPISpecValidates(t *testing.T) {
t.Parallel()
loadOpenAPISpec(t)
}
func TestInternalOpenAPISpecFreezesEnsureByEmailRegistrationContext(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
operation := getOpenAPIOperation(t, doc, "/api/v1/internal/users/ensure-by-email", http.MethodPost)
assertSchemaRef(t, requestSchemaRef(t, operation), "#/components/schemas/EnsureByEmailRequest", "ensure-by-email request schema")
requestSchema := componentSchemaRef(t, doc, "EnsureByEmailRequest")
assertRequiredFields(t, requestSchema, "email", "registration_context")
assertSchemaRef(t, requestSchema.Value.Properties["email"], "#/components/schemas/Email", "ensure-by-email email property")
assertSchemaRef(t, requestSchema.Value.Properties["registration_context"], "#/components/schemas/RegistrationContext", "ensure-by-email registration_context property")
require.Contains(t, marshalOpenAPIJSON(t, requestSchema.Value), `"additionalProperties":false`)
registrationContext := componentSchemaRef(t, doc, "RegistrationContext")
assertRequiredFields(t, registrationContext, "preferred_language", "time_zone")
assertSchemaRef(t, registrationContext.Value.Properties["preferred_language"], "#/components/schemas/LanguageTag", "registration_context preferred_language property")
assertSchemaRef(t, registrationContext.Value.Properties["time_zone"], "#/components/schemas/TimeZoneName", "registration_context time_zone property")
require.Contains(t, marshalOpenAPIJSON(t, registrationContext.Value), `"additionalProperties":false`)
}
func TestInternalOpenAPISpecFreezesSharedResponseSchemas(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
tests := []struct {
name string
path string
method string
status int
wantRef string
}{
{
name: "get my account",
path: "/api/v1/internal/users/{user_id}/account",
method: http.MethodGet,
status: http.StatusOK,
wantRef: "#/components/schemas/GetMyAccountResponse",
},
{
name: "update my profile",
path: "/api/v1/internal/users/{user_id}/profile",
method: http.MethodPost,
status: http.StatusOK,
wantRef: "#/components/schemas/GetMyAccountResponse",
},
{
name: "update my settings",
path: "/api/v1/internal/users/{user_id}/settings",
method: http.MethodPost,
status: http.StatusOK,
wantRef: "#/components/schemas/GetMyAccountResponse",
},
{
name: "get user eligibility",
path: "/api/v1/internal/users/{user_id}/eligibility",
method: http.MethodGet,
status: http.StatusOK,
wantRef: "#/components/schemas/UserEligibilityResponse",
},
{
name: "sync declared country",
path: "/api/v1/internal/users/{user_id}/declared-country/sync",
method: http.MethodPost,
status: http.StatusOK,
wantRef: "#/components/schemas/DeclaredCountrySyncResponse",
},
{
name: "get user by id",
path: "/api/v1/internal/users/{user_id}",
method: http.MethodGet,
status: http.StatusOK,
wantRef: "#/components/schemas/UserLookupResponse",
},
{
name: "get user by email",
path: "/api/v1/internal/user-lookups/by-email",
method: http.MethodPost,
status: http.StatusOK,
wantRef: "#/components/schemas/UserLookupResponse",
},
{
name: "get user by race name",
path: "/api/v1/internal/user-lookups/by-race-name",
method: http.MethodPost,
status: http.StatusOK,
wantRef: "#/components/schemas/UserLookupResponse",
},
{
name: "list users",
path: "/api/v1/internal/users",
method: http.MethodGet,
status: http.StatusOK,
wantRef: "#/components/schemas/UserListResponse",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
operation := getOpenAPIOperation(t, doc, tt.path, tt.method)
assertSchemaRef(t, responseSchemaRef(t, operation, tt.status), tt.wantRef, tt.name+" response schema")
})
}
}
func TestInternalOpenAPISpecErrorEnvelopeRemainsStable(t *testing.T) {
t.Parallel()
doc := loadOpenAPISpec(t)
errorResponse := componentSchemaRef(t, doc, "ErrorResponse")
assertRequiredFields(t, errorResponse, "error")
require.Contains(t, marshalOpenAPIJSON(t, errorResponse.Value), `"additionalProperties":false`)
assertSchemaRef(t, errorResponse.Value.Properties["error"], "#/components/schemas/ErrorBody", "ErrorResponse error property")
errorBody := componentSchemaRef(t, doc, "ErrorBody")
assertRequiredFields(t, errorBody, "code", "message")
require.Contains(t, marshalOpenAPIJSON(t, errorBody.Value), `"additionalProperties":false`)
require.JSONEq(
t,
`{"error":{"code":"invalid_request","message":"request is invalid"}}`,
string(mustMarshalJSON(t, responseExampleValue(t, doc, "InvalidRequestError", "invalidRequest"))),
)
require.JSONEq(
t,
`{"error":{"code":"subject_not_found","message":"subject not found"}}`,
string(mustMarshalJSON(t, responseExampleValue(t, doc, "SubjectNotFoundError", "subjectNotFound"))),
)
}
func loadOpenAPISpec(t *testing.T) *openapi3.T {
t.Helper()
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
require.FailNow(t, "runtime.Caller failed")
}
specPath := filepath.Join(filepath.Dir(thisFile), "openapi.yaml")
loader := openapi3.NewLoader()
doc, err := loader.LoadFromFile(specPath)
if err != nil {
require.Failf(t, "test failed", "load spec %s: %v", specPath, err)
}
if doc == nil {
require.Failf(t, "test failed", "load spec %s: returned nil document", specPath)
}
if doc.Info == nil {
require.Failf(t, "test failed", "load spec %s: missing info section", specPath)
}
if doc.Info.Version != "v1" {
require.Failf(t, "test failed", "spec %s version = %q, want v1", specPath, doc.Info.Version)
}
if err := doc.Validate(context.Background()); err != nil {
require.Failf(t, "test failed", "validate spec %s: %v", specPath, err)
}
return doc
}
func getOpenAPIOperation(t *testing.T, doc *openapi3.T, path string, method string) *openapi3.Operation {
t.Helper()
if doc.Paths == nil {
require.Failf(t, "test failed", "spec is missing paths while looking up %s %s", method, path)
}
pathItem := doc.Paths.Value(path)
if pathItem == nil {
require.Failf(t, "test failed", "spec is missing path %s", path)
}
operation := pathItem.GetOperation(method)
if operation == nil {
require.Failf(t, "test failed", "spec is missing %s operation for path %s", method, path)
}
return operation
}
func requestSchemaRef(t *testing.T, operation *openapi3.Operation) *openapi3.SchemaRef {
t.Helper()
if operation.RequestBody == nil || operation.RequestBody.Value == nil {
require.FailNow(t, "operation is missing request body")
}
mediaType := operation.RequestBody.Value.Content.Get("application/json")
if mediaType == nil || mediaType.Schema == nil {
require.FailNow(t, "operation is missing application/json request schema")
}
return mediaType.Schema
}
func responseSchemaRef(t *testing.T, operation *openapi3.Operation, status int) *openapi3.SchemaRef {
t.Helper()
if operation.Responses == nil {
require.Failf(t, "test failed", "operation is missing responses for status %d", status)
}
response := operation.Responses.Status(status)
if response == nil || response.Value == nil {
require.Failf(t, "test failed", "operation is missing response for status %d", status)
}
mediaType := response.Value.Content.Get("application/json")
if mediaType == nil || mediaType.Schema == nil {
require.Failf(t, "test failed", "operation response %d is missing application/json schema", status)
}
return mediaType.Schema
}
func componentSchemaRef(t *testing.T, doc *openapi3.T, name string) *openapi3.SchemaRef {
t.Helper()
if doc.Components == nil {
require.Failf(t, "test failed", "spec is missing components while looking up schema %s", name)
}
schema := doc.Components.Schemas[name]
if schema == nil || schema.Value == nil {
require.Failf(t, "test failed", "spec is missing schema %s", name)
}
return schema
}
func responseExampleValue(t *testing.T, doc *openapi3.T, responseName string, exampleName string) any {
t.Helper()
if doc.Components == nil {
require.Failf(t, "test failed", "spec is missing components while looking up response %s", responseName)
}
response := doc.Components.Responses[responseName]
if response == nil || response.Value == nil {
require.Failf(t, "test failed", "spec is missing response %s", responseName)
}
mediaType := response.Value.Content.Get("application/json")
if mediaType == nil {
require.Failf(t, "test failed", "response %s is missing application/json content", responseName)
}
example := mediaType.Examples[exampleName]
if example == nil || example.Value == nil {
require.Failf(t, "test failed", "response %s is missing example %s", responseName, exampleName)
}
return example.Value.Value
}
func assertSchemaRef(t *testing.T, schemaRef *openapi3.SchemaRef, want string, name string) {
t.Helper()
if schemaRef == nil {
require.Failf(t, "test failed", "%s schema ref is nil", name)
}
if schemaRef.Ref != want {
require.Failf(t, "test failed", "%s ref = %q, want %q", name, schemaRef.Ref, want)
}
}
func assertRequiredFields(t *testing.T, schemaRef *openapi3.SchemaRef, fields ...string) {
t.Helper()
required := append([]string(nil), schemaRef.Value.Required...)
slices.Sort(required)
want := append([]string(nil), fields...)
slices.Sort(want)
if !slices.Equal(required, want) {
require.Failf(t, "test failed", "schema required fields = %v, want %v", required, want)
}
}
func mustMarshalJSON(t *testing.T, value any) []byte {
t.Helper()
data, err := json.Marshal(value)
if err != nil {
require.Failf(t, "test failed", "marshal JSON: %v", err)
}
return data
}
func marshalOpenAPIJSON(t *testing.T, value any) string {
t.Helper()
return string(mustMarshalJSON(t, value))
}
+683
View File
@@ -0,0 +1,683 @@
package user
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net"
"net/http"
"strings"
"testing"
"time"
"galaxy/user/internal/app"
"galaxy/user/internal/config"
"github.com/alicebob/miniredis/v2"
"github.com/stretchr/testify/require"
)
type runtimeContractHarness struct {
baseURL string
client *http.Client
runtime *app.Runtime
cancel context.CancelFunc
runErr chan error
}
func newRuntimeContractHarness(t *testing.T) *runtimeContractHarness {
t.Helper()
redisServer := miniredis.RunT(t)
cfg := config.DefaultConfig()
cfg.Redis.Addr = redisServer.Addr()
cfg.InternalHTTP.Addr = freeLoopbackAddress(t)
cfg.AdminHTTP.Addr = ""
cfg.ShutdownTimeout = 10 * time.Second
cfg.Telemetry.TracesExporter = "none"
cfg.Telemetry.MetricsExporter = "none"
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
runtime, err := app.NewRuntime(context.Background(), cfg, logger)
require.NoError(t, err)
runCtx, cancel := context.WithCancel(context.Background())
runErr := make(chan error, 1)
go func() {
runErr <- runtime.Run(runCtx)
}()
client := &http.Client{
Timeout: 500 * time.Millisecond,
Transport: &http.Transport{
DisableKeepAlives: true,
},
}
harness := &runtimeContractHarness{
baseURL: "http://" + cfg.InternalHTTP.Addr,
client: client,
runtime: runtime,
cancel: cancel,
runErr: runErr,
}
harness.waitUntilReady(t)
t.Cleanup(func() {
cancel()
select {
case err := <-runErr:
require.NoError(t, err)
case <-time.After(cfg.ShutdownTimeout + 2*time.Second):
t.Fatalf("runtime did not stop in time")
}
require.NoError(t, runtime.Close())
client.CloseIdleConnections()
})
return harness
}
func (h *runtimeContractHarness) waitUntilReady(t *testing.T) {
t.Helper()
require.Eventually(t, func() bool {
request, err := http.NewRequest(http.MethodGet, h.baseURL+"/api/v1/internal/users/user-missing/exists", nil)
if err != nil {
return false
}
response, err := h.client.Do(request)
if err != nil {
return false
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, response.Body)
return response.StatusCode == http.StatusOK
}, 5*time.Second, 25*time.Millisecond, "user runtime did not become reachable")
}
func (h *runtimeContractHarness) ensureUser(t *testing.T, email string, preferredLanguage string, timeZone string) ensureByEmailResponse {
t.Helper()
response := h.postJSON(t, "/api/v1/internal/users/ensure-by-email", map[string]any{
"email": email,
"registration_context": map[string]string{
"preferred_language": preferredLanguage,
"time_zone": timeZone,
},
})
var body ensureByEmailResponse
requireResponseJSON(t, response, http.StatusOK, &body)
return body
}
func (h *runtimeContractHarness) getMyAccount(t *testing.T, userID string) accountResponse {
t.Helper()
response := h.get(t, "/api/v1/internal/users/"+userID+"/account")
var body accountResponse
requireResponseJSON(t, response, http.StatusOK, &body)
return body
}
func (h *runtimeContractHarness) currentEntitlementStartsAt(t *testing.T, userID string) time.Time {
t.Helper()
return h.getMyAccount(t, userID).Account.Entitlement.StartsAt
}
func (h *runtimeContractHarness) updateSettingsRaw(t *testing.T, userID string, body string) httpResponse {
t.Helper()
return h.postRawJSON(t, "/api/v1/internal/users/"+userID+"/settings", body)
}
func (h *runtimeContractHarness) getEligibility(t *testing.T, userID string) eligibilityResponse {
t.Helper()
response := h.get(t, "/api/v1/internal/users/"+userID+"/eligibility")
var body eligibilityResponse
requireResponseJSON(t, response, http.StatusOK, &body)
return body
}
func (h *runtimeContractHarness) syncDeclaredCountry(t *testing.T, userID string, country string) declaredCountrySyncResponse {
t.Helper()
response := h.postJSON(t, "/api/v1/internal/users/"+userID+"/declared-country/sync", map[string]string{
"declared_country": country,
})
var body declaredCountrySyncResponse
requireResponseJSON(t, response, http.StatusOK, &body)
return body
}
func (h *runtimeContractHarness) lookupUserByEmail(t *testing.T, email string) userLookupResponse {
t.Helper()
response := h.postJSON(t, "/api/v1/internal/user-lookups/by-email", map[string]string{
"email": email,
})
var body userLookupResponse
requireResponseJSON(t, response, http.StatusOK, &body)
return body
}
func (h *runtimeContractHarness) grantPaidEntitlement(t *testing.T, userID string, startsAt time.Time, endsAt time.Time) {
t.Helper()
response := h.postJSON(t, "/api/v1/internal/users/"+userID+"/entitlements/grant", map[string]any{
"plan_code": "paid_monthly",
"source": "admin",
"reason_code": "manual_grant",
"actor": map[string]string{
"type": "admin",
"id": "admin-1",
},
"starts_at": startsAt.UTC().Format(time.RFC3339Nano),
"ends_at": endsAt.UTC().Format(time.RFC3339Nano),
})
var body entitlementCommandResponse
requireResponseJSON(t, response, http.StatusOK, &body)
}
func (h *runtimeContractHarness) applySanction(t *testing.T, userID string, sanctionCode string, scope string, appliedAt time.Time) {
t.Helper()
response := h.postJSON(t, "/api/v1/internal/users/"+userID+"/sanctions/apply", map[string]any{
"sanction_code": sanctionCode,
"scope": scope,
"reason_code": "manual_block",
"actor": map[string]string{
"type": "admin",
"id": "admin-1",
},
"applied_at": appliedAt.UTC().Format(time.RFC3339Nano),
})
var body sanctionCommandResponse
requireResponseJSON(t, response, http.StatusOK, &body)
}
func (h *runtimeContractHarness) setLimit(t *testing.T, userID string, limitCode string, value int, appliedAt time.Time) {
t.Helper()
response := h.postJSON(t, "/api/v1/internal/users/"+userID+"/limits/set", map[string]any{
"limit_code": limitCode,
"value": value,
"reason_code": "manual_override",
"actor": map[string]string{
"type": "admin",
"id": "admin-1",
},
"applied_at": appliedAt.UTC().Format(time.RFC3339Nano),
})
var body limitCommandResponse
requireResponseJSON(t, response, http.StatusOK, &body)
}
func (h *runtimeContractHarness) listUsers(t *testing.T, rawQuery string) httpResponse {
t.Helper()
path := "/api/v1/internal/users"
if rawQuery != "" {
path += "?" + rawQuery
}
return h.get(t, path)
}
func (h *runtimeContractHarness) get(t *testing.T, path string) httpResponse {
t.Helper()
request, err := http.NewRequest(http.MethodGet, h.baseURL+path, nil)
require.NoError(t, err)
response, err := h.client.Do(request)
require.NoError(t, err)
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
require.NoError(t, err)
return httpResponse{
StatusCode: response.StatusCode,
Body: string(body),
Header: response.Header.Clone(),
}
}
func (h *runtimeContractHarness) postJSON(t *testing.T, path string, body any) httpResponse {
t.Helper()
payload, err := json.Marshal(body)
require.NoError(t, err)
return h.postRawJSON(t, path, string(payload))
}
func (h *runtimeContractHarness) postRawJSON(t *testing.T, path string, body string) httpResponse {
t.Helper()
request, err := http.NewRequest(http.MethodPost, h.baseURL+path, bytes.NewBufferString(body))
require.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
response, err := h.client.Do(request)
require.NoError(t, err)
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
require.NoError(t, err)
return httpResponse{
StatusCode: response.StatusCode,
Body: string(responseBody),
Header: response.Header.Clone(),
}
}
func TestRuntimeContractGetMyAccountReturnsAggregateAndDeclaredCountryStaysReadOnly(t *testing.T) {
t.Parallel()
h := newRuntimeContractHarness(t)
created := h.ensureUser(t, "pilot@example.com", "en", "Europe/Kaliningrad")
require.Equal(t, "created", created.Outcome)
now := time.Now().UTC().Truncate(time.Second)
h.grantPaidEntitlement(t, created.UserID, h.currentEntitlementStartsAt(t, created.UserID), now.Add(48*time.Hour))
h.applySanction(t, created.UserID, "login_block", "auth", now.Add(-30*time.Minute))
h.setLimit(t, created.UserID, "max_owned_private_games", 7, now.Add(-20*time.Minute))
syncResult := h.syncDeclaredCountry(t, created.UserID, "DE")
account := h.getMyAccount(t, created.UserID)
require.Equal(t, created.UserID, account.Account.UserID)
require.Equal(t, "pilot@example.com", account.Account.Email)
require.Equal(t, "en", account.Account.PreferredLanguage)
require.Equal(t, "Europe/Kaliningrad", account.Account.TimeZone)
require.Equal(t, "DE", account.Account.DeclaredCountry)
require.Equal(t, syncResult.UpdatedAt, account.Account.UpdatedAt)
require.Equal(t, "paid_monthly", account.Account.Entitlement.PlanCode)
require.True(t, account.Account.Entitlement.IsPaid)
require.Len(t, account.Account.ActiveSanctions, 1)
require.Equal(t, "login_block", account.Account.ActiveSanctions[0].SanctionCode)
require.Len(t, account.Account.ActiveLimits, 1)
require.Equal(t, "max_owned_private_games", account.Account.ActiveLimits[0].LimitCode)
require.Equal(t, 7, account.Account.ActiveLimits[0].Value)
response := h.updateSettingsRaw(t, created.UserID, `{"preferred_language":"en","time_zone":"UTC","declared_country":"FR"}`)
requireJSONBody(t, response, http.StatusBadRequest, `{"error":{"code":"invalid_request","message":"request body contains unknown field \"declared_country\""}}`)
}
func TestRuntimeContractEligibilitySnapshotCoversUnknownFreeAndPaidUsers(t *testing.T) {
t.Parallel()
h := newRuntimeContractHarness(t)
unknown := h.getEligibility(t, "user-missing")
require.False(t, unknown.Exists)
require.Equal(t, "user-missing", unknown.UserID)
require.Nil(t, unknown.Entitlement)
require.Empty(t, unknown.ActiveSanctions)
require.Empty(t, unknown.EffectiveLimits)
require.Equal(t, eligibilityMarkers{}, unknown.Markers)
freeUser := h.ensureUser(t, "free@example.com", "en", "UTC")
require.Equal(t, "created", freeUser.Outcome)
free := h.getEligibility(t, freeUser.UserID)
require.True(t, free.Exists)
require.NotNil(t, free.Entitlement)
require.Equal(t, "free", free.Entitlement.PlanCode)
require.False(t, free.Entitlement.IsPaid)
require.Equal(t, eligibilityMarkers{
CanLogin: true,
CanCreatePrivateGame: false,
CanManagePrivateGame: false,
CanJoinGame: true,
CanUpdateProfile: true,
}, free.Markers)
require.Equal(t, []effectiveLimitView{
{LimitCode: "max_pending_public_applications", Value: 3},
{LimitCode: "max_active_game_memberships", Value: 3},
}, free.EffectiveLimits)
paidUser := h.ensureUser(t, "paid@example.com", "en", "Europe/Paris")
require.Equal(t, "created", paidUser.Outcome)
now := time.Now().UTC().Truncate(time.Second)
h.grantPaidEntitlement(t, paidUser.UserID, h.currentEntitlementStartsAt(t, paidUser.UserID), now.Add(72*time.Hour))
h.applySanction(t, paidUser.UserID, "private_game_manage_block", "lobby", now.Add(-30*time.Minute))
h.setLimit(t, paidUser.UserID, "max_pending_public_applications", 17, now.Add(-20*time.Minute))
paid := h.getEligibility(t, paidUser.UserID)
require.True(t, paid.Exists)
require.NotNil(t, paid.Entitlement)
require.Equal(t, "paid_monthly", paid.Entitlement.PlanCode)
require.True(t, paid.Entitlement.IsPaid)
require.Len(t, paid.ActiveSanctions, 1)
require.Equal(t, "private_game_manage_block", paid.ActiveSanctions[0].SanctionCode)
require.Equal(t, eligibilityMarkers{
CanLogin: true,
CanCreatePrivateGame: true,
CanManagePrivateGame: false,
CanJoinGame: true,
CanUpdateProfile: true,
}, paid.Markers)
require.Equal(t, []effectiveLimitView{
{LimitCode: "max_owned_private_games", Value: 3},
{LimitCode: "max_pending_public_applications", Value: 17},
{LimitCode: "max_active_game_memberships", Value: 10},
}, paid.EffectiveLimits)
}
func TestRuntimeContractGeoSyncOnlyMutatesCurrentDeclaredCountry(t *testing.T) {
t.Parallel()
h := newRuntimeContractHarness(t)
created := h.ensureUser(t, "geo@example.com", "en", "Europe/Berlin")
require.Equal(t, "created", created.Outcome)
before := h.lookupUserByEmail(t, "geo@example.com")
require.Empty(t, before.User.DeclaredCountry)
first := h.syncDeclaredCountry(t, created.UserID, "DE")
after := h.lookupUserByEmail(t, "geo@example.com")
require.Equal(t, before.User.UserID, after.User.UserID)
require.Equal(t, before.User.Email, after.User.Email)
require.Equal(t, before.User.RaceName, after.User.RaceName)
require.Equal(t, before.User.PreferredLanguage, after.User.PreferredLanguage)
require.Equal(t, before.User.TimeZone, after.User.TimeZone)
require.Equal(t, before.User.Entitlement, after.User.Entitlement)
require.Equal(t, before.User.ActiveSanctions, after.User.ActiveSanctions)
require.Equal(t, before.User.ActiveLimits, after.User.ActiveLimits)
require.Equal(t, "DE", after.User.DeclaredCountry)
require.Equal(t, first.UpdatedAt, after.User.UpdatedAt)
second := h.syncDeclaredCountry(t, created.UserID, "DE")
require.Equal(t, first.UpdatedAt, second.UpdatedAt)
repeated := h.lookupUserByEmail(t, "geo@example.com")
require.Equal(t, after.User, repeated.User)
}
func TestRuntimeContractAdminListingPreservesOrderingFiltersAndPageTokenBinding(t *testing.T) {
t.Parallel()
h := newRuntimeContractHarness(t)
filtered := h.ensureUser(t, "filter@example.com", "en", "UTC")
require.Equal(t, "created", filtered.Outcome)
time.Sleep(10 * time.Millisecond)
latest := h.ensureUser(t, "latest@example.com", "en", "UTC")
require.Equal(t, "created", latest.Outcome)
now := time.Now().UTC().Truncate(time.Second)
h.grantPaidEntitlement(t, filtered.UserID, h.currentEntitlementStartsAt(t, filtered.UserID), now.Add(48*time.Hour))
h.syncDeclaredCountry(t, filtered.UserID, "DE")
h.applySanction(t, filtered.UserID, "login_block", "auth", now.Add(-30*time.Minute))
h.setLimit(t, filtered.UserID, "max_owned_private_games", 5, now.Add(-20*time.Minute))
firstPageResponse := h.listUsers(t, "page_size=1")
var firstPage userListResponse
requireResponseJSON(t, firstPageResponse, http.StatusOK, &firstPage)
require.Len(t, firstPage.Items, 1)
require.Equal(t, latest.UserID, firstPage.Items[0].UserID)
require.NotEmpty(t, firstPage.NextPageToken)
mismatchResponse := h.listUsers(t, "page_size=1&page_token="+firstPage.NextPageToken+"&paid_state=paid")
requireJSONBody(t, mismatchResponse, http.StatusBadRequest, `{"error":{"code":"invalid_request","message":"page_token is invalid or does not match current filters"}}`)
filteredResponse := h.listUsers(
t,
"paid_state=paid"+
"&paid_expires_after="+now.Add(time.Hour).Format(time.RFC3339)+
"&paid_expires_before="+now.Add(72*time.Hour).Format(time.RFC3339)+
"&declared_country=DE"+
"&sanction_code=login_block"+
"&limit_code=max_owned_private_games"+
"&can_login=false"+
"&can_create_private_game=false"+
"&can_join_game=false",
)
var filteredBody userListResponse
requireResponseJSON(t, filteredResponse, http.StatusOK, &filteredBody)
require.Len(t, filteredBody.Items, 1)
require.Equal(t, filtered.UserID, filteredBody.Items[0].UserID)
require.Equal(t, "DE", filteredBody.Items[0].DeclaredCountry)
require.Equal(t, "paid_monthly", filteredBody.Items[0].Entitlement.PlanCode)
}
type httpResponse struct {
StatusCode int
Body string
Header http.Header
}
type errorEnvelope struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
type ensureByEmailResponse struct {
Outcome string `json:"outcome"`
UserID string `json:"user_id,omitempty"`
}
type accountResponse struct {
Account accountView `json:"account"`
}
type userLookupResponse struct {
User accountView `json:"user"`
}
type userListResponse struct {
Items []accountView `json:"items"`
NextPageToken string `json:"next_page_token,omitempty"`
}
type accountView struct {
UserID string `json:"user_id"`
Email string `json:"email"`
RaceName string `json:"race_name"`
PreferredLanguage string `json:"preferred_language"`
TimeZone string `json:"time_zone"`
DeclaredCountry string `json:"declared_country,omitempty"`
Entitlement entitlementSnapshotView `json:"entitlement"`
ActiveSanctions []activeSanctionView `json:"active_sanctions"`
ActiveLimits []activeLimitView `json:"active_limits"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type entitlementSnapshotView struct {
PlanCode string `json:"plan_code"`
IsPaid bool `json:"is_paid"`
Source string `json:"source"`
Actor actorRefView `json:"actor"`
ReasonCode string `json:"reason_code"`
StartsAt time.Time `json:"starts_at"`
EndsAt *time.Time `json:"ends_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
type activeSanctionView struct {
SanctionCode string `json:"sanction_code"`
Scope string `json:"scope"`
ReasonCode string `json:"reason_code"`
Actor actorRefView `json:"actor"`
AppliedAt time.Time `json:"applied_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
type activeLimitView struct {
LimitCode string `json:"limit_code"`
Value int `json:"value"`
ReasonCode string `json:"reason_code"`
Actor actorRefView `json:"actor"`
AppliedAt time.Time `json:"applied_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
type actorRefView struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
}
type eligibilityResponse struct {
Exists bool `json:"exists"`
UserID string `json:"user_id"`
Entitlement *entitlementSnapshotView `json:"entitlement,omitempty"`
ActiveSanctions []activeSanctionView `json:"active_sanctions"`
EffectiveLimits []effectiveLimitView `json:"effective_limits"`
Markers eligibilityMarkers `json:"markers"`
}
type effectiveLimitView struct {
LimitCode string `json:"limit_code"`
Value int `json:"value"`
}
type eligibilityMarkers struct {
CanLogin bool `json:"can_login"`
CanCreatePrivateGame bool `json:"can_create_private_game"`
CanManagePrivateGame bool `json:"can_manage_private_game"`
CanJoinGame bool `json:"can_join_game"`
CanUpdateProfile bool `json:"can_update_profile"`
}
type declaredCountrySyncResponse struct {
UserID string `json:"user_id"`
DeclaredCountry string `json:"declared_country"`
UpdatedAt time.Time `json:"updated_at"`
}
type entitlementCommandResponse struct {
UserID string `json:"user_id"`
Entitlement entitlementSnapshotView `json:"entitlement"`
}
type sanctionCommandResponse struct {
UserID string `json:"user_id"`
ActiveSanctions []activeSanctionView `json:"active_sanctions"`
}
type limitCommandResponse struct {
UserID string `json:"user_id"`
ActiveLimits []activeLimitView `json:"active_limits"`
}
func requireResponseJSON(t *testing.T, response httpResponse, wantStatus int, target any) {
t.Helper()
require.Equal(t, wantStatus, response.StatusCode, "response body: %s", response.Body)
require.NoError(t, decodeStrictJSONPayload([]byte(response.Body), target))
}
func requireJSONBody(t *testing.T, response httpResponse, wantStatus int, wantBody string) {
t.Helper()
require.Equal(t, wantStatus, response.StatusCode, "response body: %s", response.Body)
require.JSONEq(t, wantBody, response.Body)
}
func decodeStrictJSONPayload(payload []byte, target any) error {
decoder := json.NewDecoder(bytes.NewReader(payload))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return errors.New("unexpected trailing JSON input")
}
return err
}
return nil
}
func freeLoopbackAddress(t *testing.T) string {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer listener.Close()
return listener.Addr().String()
}
func (view entitlementSnapshotView) Equal(other entitlementSnapshotView) bool {
return view.PlanCode == other.PlanCode &&
view.IsPaid == other.IsPaid &&
view.Source == other.Source &&
view.ReasonCode == other.ReasonCode &&
view.StartsAt.Equal(other.StartsAt) &&
optionalTimeEqual(view.EndsAt, other.EndsAt) &&
view.UpdatedAt.Equal(other.UpdatedAt)
}
func optionalTimeEqual(left *time.Time, right *time.Time) bool {
switch {
case left == nil && right == nil:
return true
case left == nil || right == nil:
return false
default:
return left.Equal(*right)
}
}
func TestEntitlementSnapshotViewEqual(t *testing.T) {
t.Parallel()
now := time.Now().UTC()
next := now.Add(time.Hour)
require.True(t, entitlementSnapshotView{
PlanCode: "free",
IsPaid: false,
Source: "auth_registration",
ReasonCode: "initial_free_entitlement",
StartsAt: now,
UpdatedAt: now,
}.Equal(entitlementSnapshotView{
PlanCode: "free",
IsPaid: false,
Source: "auth_registration",
ReasonCode: "initial_free_entitlement",
StartsAt: now,
UpdatedAt: now,
}))
require.False(t, entitlementSnapshotView{
PlanCode: "paid_monthly",
IsPaid: true,
Source: "admin",
ReasonCode: "manual_grant",
StartsAt: now,
EndsAt: &next,
UpdatedAt: now,
}.Equal(entitlementSnapshotView{
PlanCode: "paid_monthly",
IsPaid: true,
Source: "admin",
ReasonCode: "manual_grant",
StartsAt: now,
UpdatedAt: now,
}))
}
func TestEligibilityUnknownMarkersZeroValueMatchesContract(t *testing.T) {
t.Parallel()
require.Equal(t, eligibilityMarkers{}, eligibilityMarkers{})
require.False(t, strings.HasPrefix("", "user-"))
}