feat: game lobby service
This commit is contained in:
+99
-25
@@ -13,15 +13,24 @@ requests to this service's internal REST API.
|
||||
|
||||
- opaque regular-user identifiers in `user-*` form
|
||||
- exact-after-trim login e-mail addresses
|
||||
- current race name and editable self-service settings
|
||||
- current entitlement snapshot
|
||||
- active sanctions and active user-specific limits
|
||||
- `user_name` — immutable auto-generated unique platform handle in
|
||||
`player-<suffix>` form
|
||||
- `display_name` — mutable free-text user label validated by
|
||||
`pkg/util/string.go:ValidateTypeName`, not required to be unique, empty by
|
||||
default
|
||||
- editable self-service settings (`preferred_language`, `time_zone`)
|
||||
- current entitlement snapshot including `max_registered_race_names`
|
||||
- active sanctions (including `permanent_block`) and active user-specific
|
||||
limits (including `max_registered_race_names` overrides)
|
||||
- current effective `declared_country`
|
||||
- soft-delete state via `DeleteUser`
|
||||
|
||||
`User Service` is not the source of truth for:
|
||||
|
||||
- system-administrator identity
|
||||
- device sessions, challenges, or client public keys
|
||||
- in-game `race_name` values or their uniqueness — those live in the Game
|
||||
Lobby Race Name Directory
|
||||
- declared-country review workflow or history
|
||||
- edge authentication, request signing, or replay protection
|
||||
|
||||
@@ -48,15 +57,18 @@ The internal REST surface is split into five stable groups:
|
||||
- `GeoIntegration`
|
||||
- synchronize current effective `declared_country`
|
||||
- `AdminUsers`
|
||||
- lookups by `user_id`, exact-after-trim `email`, and exact `race_name`
|
||||
- lookups by `user_id`, exact-after-trim `email`, exact `user_name`, and
|
||||
exact or prefix `display_name`
|
||||
- deterministic filtered listing
|
||||
- explicit entitlement, sanction, and limit commands
|
||||
- `DeleteUser` soft-delete command
|
||||
|
||||
The public authenticated gateway boundary currently exposes exactly three
|
||||
self-service message types:
|
||||
|
||||
- `user.account.get`
|
||||
- `user.profile.update`
|
||||
- `user.profile.update` — payload carries `display_name` only; the prior
|
||||
`race_name` payload field is removed and rejected if present
|
||||
- `user.settings.update`
|
||||
|
||||
Externally these commands use authenticated gRPC plus FlatBuffers payloads.
|
||||
@@ -66,25 +78,35 @@ Internally gateway calls:
|
||||
- `POST /api/v1/internal/users/{user_id}/profile`
|
||||
- `POST /api/v1/internal/users/{user_id}/settings`
|
||||
|
||||
Additional trusted internal operations:
|
||||
|
||||
- `POST /api/v1/internal/users/{user_id}/delete` — soft-delete
|
||||
(`DeleteUser`); intended to be called only by `Admin Service`. Idempotent
|
||||
per `user_id`; a second call after soft-delete returns
|
||||
`404 subject_not_found` for external reads but keeps the deleted record
|
||||
for audit.
|
||||
|
||||
Gateway must derive `user_id` from authenticated session context only. The
|
||||
client payload never carries user identity for this boundary.
|
||||
|
||||
## Identity And Lookup Rules
|
||||
|
||||
- User IDs are opaque stable identifiers generated by `User Service`.
|
||||
- New users receive generated default race names in `player-*` form until the
|
||||
user replaces them.
|
||||
- User IDs are opaque stable identifiers generated by `User Service` and are
|
||||
the only identifier permitted as a foreign key from other models.
|
||||
- Every new user receives an auto-generated `user_name` in `player-<suffix>`
|
||||
form. The suffix is 8 characters drawn from a confusable-free alphanumeric
|
||||
alphabet. `user_name` is immutable after creation; collisions are resolved
|
||||
by retry during create (limit 10 attempts).
|
||||
- `display_name` starts empty for new accounts. Self-service may change it
|
||||
via `UpdateMyProfile`; validation delegates to
|
||||
`pkg/util/string.go:ValidateTypeName`; uniqueness is not enforced.
|
||||
- E-mail semantics are exact-after-trim.
|
||||
- The service trims surrounding whitespace.
|
||||
- The service does not lowercase, canonicalize, or alias-normalize e-mail
|
||||
values.
|
||||
- Exact lookup by e-mail uses the trimmed stored value.
|
||||
- Race-name lookup is exact by stored value.
|
||||
- Race-name uniqueness is not exact-string-only.
|
||||
- Stored casing is preserved.
|
||||
- Uniqueness is enforced by a canonical reservation key.
|
||||
- The canonical policy is case-insensitive and includes the frozen
|
||||
anti-fraud confusable-pair rules used by the race-name policy adapter.
|
||||
- `user_name` lookup is exact by stored value; `display_name` supports exact
|
||||
and prefix lookups.
|
||||
|
||||
## Auth-Facing Contract
|
||||
|
||||
@@ -128,8 +150,10 @@ Auth-facing blocking semantics:
|
||||
|
||||
Self-service reads and writes operate on one shared account aggregate:
|
||||
|
||||
- immutable:
|
||||
- `user_name`
|
||||
- profile:
|
||||
- `race_name`
|
||||
- `display_name`
|
||||
- settings:
|
||||
- `preferred_language`
|
||||
- `time_zone`
|
||||
@@ -144,6 +168,7 @@ Self-service writes return the refreshed full account aggregate.
|
||||
Forbidden self-service mutations:
|
||||
|
||||
- e-mail change
|
||||
- `user_name` change
|
||||
- direct `declared_country` change
|
||||
- direct entitlement mutation
|
||||
- direct sanction mutation
|
||||
@@ -152,14 +177,17 @@ Forbidden self-service mutations:
|
||||
Current write rules:
|
||||
|
||||
- `UpdateMyProfile`
|
||||
- changes only `race_name`
|
||||
- changes only `display_name`
|
||||
- rejects unsupported or unknown fields
|
||||
- returns the current aggregate unchanged on no-op rename
|
||||
- returns the current aggregate unchanged when the incoming value equals
|
||||
the stored one
|
||||
- `UpdateMySettings`
|
||||
- changes only `preferred_language` and `time_zone`
|
||||
- rejects unsupported or unknown fields
|
||||
- active `profile_update_block` sanction blocks both profile and settings
|
||||
writes with `409 conflict`
|
||||
- active `permanent_block` sanction blocks every self-service read and write
|
||||
with `409 conflict` and surfaces in admin reads
|
||||
|
||||
## Validation Rules
|
||||
|
||||
@@ -170,12 +198,25 @@ Current write rules:
|
||||
- keep the trimmed exact value
|
||||
- do not lowercase or canonicalize
|
||||
|
||||
### Race Name
|
||||
### user_name
|
||||
|
||||
- validate non-empty user-facing name
|
||||
- preserve accepted casing in storage and reads
|
||||
- enforce uniqueness through canonical reservation
|
||||
- reject conflicts as `409 conflict`
|
||||
- auto-generated server-side in `player-<suffix>` form
|
||||
- suffix = 8 characters drawn from a confusable-free alphanumeric alphabet
|
||||
- uniqueness enforced at store-layer; conflicts resolved by retry during
|
||||
ensure-by-email (limit 10 attempts)
|
||||
- immutable after creation; any attempt to mutate is a logic error and
|
||||
returns `500 internal_error`
|
||||
|
||||
### display_name
|
||||
|
||||
- validated through `pkg/util/string.go:ValidateTypeName`
|
||||
- empty value is accepted and rendered as no display name in downstream
|
||||
consumers
|
||||
- casing and script preserved as submitted
|
||||
- not required to be unique
|
||||
|
||||
Note: in-game `race_name` values are owned by the Game Lobby Race Name
|
||||
Directory and are not validated, stored, or reserved by `User Service`.
|
||||
|
||||
### preferred_language
|
||||
|
||||
@@ -218,12 +259,17 @@ Supported sanction codes:
|
||||
- `private_game_manage_block`
|
||||
- `game_join_block`
|
||||
- `profile_update_block`
|
||||
- `permanent_block` — terminal state; collapses every `can_*` eligibility
|
||||
marker to `false`; triggers RND cascade release in `Game Lobby` through
|
||||
`user:lifecycle_events`
|
||||
|
||||
Supported user-specific limit codes:
|
||||
|
||||
- `max_owned_private_games`
|
||||
- `max_pending_public_applications`
|
||||
- `max_active_game_memberships`
|
||||
- `max_registered_race_names` — overrides the tariff default for the RND
|
||||
registered-name quota
|
||||
|
||||
Rules:
|
||||
|
||||
@@ -256,6 +302,14 @@ Current markers:
|
||||
- `can_join_game`
|
||||
- `can_update_profile`
|
||||
|
||||
Additional materialized fields:
|
||||
|
||||
- `max_registered_race_names` — tariff-derived quota for the Game Lobby Race
|
||||
Name Directory: `free → 1`, `paid_monthly → 2`, `paid_yearly → 6`,
|
||||
`paid_lifetime → 0` (unlimited marker). A user-specific
|
||||
`max_registered_race_names` limit override, when active, replaces the
|
||||
tariff value.
|
||||
|
||||
## declared_country Ownership Split
|
||||
|
||||
Ownership is intentionally split:
|
||||
@@ -285,7 +339,8 @@ Lookups:
|
||||
|
||||
- by `user_id`
|
||||
- by exact-after-trim `email`
|
||||
- by exact `race_name`
|
||||
- by exact `user_name`
|
||||
- by exact or prefix `display_name`
|
||||
|
||||
Listing rules:
|
||||
|
||||
@@ -302,9 +357,12 @@ Listing filters include:
|
||||
- paid/free state
|
||||
- paid expiry bounds
|
||||
- current `declared_country`
|
||||
- active sanction code
|
||||
- active limit code
|
||||
- active sanction code (including `permanent_block`)
|
||||
- active limit code (including `max_registered_race_names`)
|
||||
- derived eligibility markers
|
||||
- `user_name` exact
|
||||
- `display_name` exact or prefix
|
||||
- `deleted` flag (soft-deleted accounts excluded by default)
|
||||
|
||||
## Domain Events
|
||||
|
||||
@@ -322,6 +380,20 @@ Frozen event types:
|
||||
The current effective declared-country sync remains externally observable as
|
||||
`user.declared_country.changed`.
|
||||
|
||||
### User lifecycle stream
|
||||
|
||||
Separately from the shared domain-events stream, `User Service` publishes to
|
||||
a dedicated Redis stream `user:lifecycle_events` consumed by `Game Lobby`
|
||||
for Race Name Directory cascade release. Event types:
|
||||
|
||||
- `user.lifecycle.permanent_blocked` — emitted when
|
||||
`SanctionCodePermanentBlock` becomes active on a user
|
||||
- `user.lifecycle.deleted` — emitted when `DeleteUser` succeeds
|
||||
|
||||
Event envelopes carry `user_id`, `occurred_at_ms`, mutation source, optional
|
||||
`reason_code`, and actor metadata. Delivery is at-least-once; consumers
|
||||
must be idempotent.
|
||||
|
||||
Event rules:
|
||||
|
||||
- events are post-commit only
|
||||
@@ -377,4 +449,6 @@ gateway `UNAVAILABLE`, not business results.
|
||||
|
||||
- [Internal REST contract](openapi.yaml)
|
||||
- [Service docs index](docs/README.md)
|
||||
- [Stage 21 decisions](docs/stage21-user-name-display-name.md)
|
||||
- [Stage 22 decisions](docs/stage22-permanent-block-delete-user.md)
|
||||
- [System architecture](../ARCHITECTURE.md)
|
||||
|
||||
@@ -48,7 +48,8 @@ Blocked response:
|
||||
"account": {
|
||||
"user_id": "user-123",
|
||||
"email": "pilot@example.com",
|
||||
"race_name": "Pilot Nova",
|
||||
"user_name": "player-abcdefgh",
|
||||
"display_name": "PilotNova",
|
||||
"preferred_language": "en",
|
||||
"time_zone": "Europe/Kaliningrad",
|
||||
"declared_country": "DE",
|
||||
@@ -78,7 +79,7 @@ Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"race_name": "Nova Prime"
|
||||
"display_name": "NovaPrime"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -89,7 +90,8 @@ Success:
|
||||
"account": {
|
||||
"user_id": "user-123",
|
||||
"email": "pilot@example.com",
|
||||
"race_name": "Nova Prime",
|
||||
"user_name": "player-abcdefgh",
|
||||
"display_name": "NovaPrime",
|
||||
"preferred_language": "en",
|
||||
"time_zone": "Europe/Kaliningrad",
|
||||
"entitlement": {
|
||||
@@ -151,7 +153,8 @@ Success:
|
||||
"user": {
|
||||
"user_id": "user-123",
|
||||
"email": "pilot@example.com",
|
||||
"race_name": "Pilot Nova",
|
||||
"user_name": "player-abcdefgh",
|
||||
"display_name": "PilotNova",
|
||||
"preferred_language": "en",
|
||||
"time_zone": "Europe/Kaliningrad",
|
||||
"entitlement": {
|
||||
|
||||
+6
-5
@@ -66,15 +66,16 @@ Rules:
|
||||
|
||||
### Profile update
|
||||
|
||||
`UpdateMyProfile` changes only `race_name`.
|
||||
`UpdateMyProfile` changes only `display_name`.
|
||||
|
||||
Rules:
|
||||
|
||||
- preserve stored casing on success
|
||||
- enforce canonical reservation uniqueness
|
||||
- reject conflicts as `409 conflict`
|
||||
- validate the submitted value through `pkg/util/string.go:ValidateTypeName`
|
||||
- an empty value is accepted and resets the stored display name
|
||||
- uniqueness is not enforced; multiple users may share the same value
|
||||
- `user_name` is immutable and cannot be updated through this operation
|
||||
- reject writes while `profile_update_block` is active
|
||||
- return current aggregate on no-op rename
|
||||
- return the current aggregate on no-op updates
|
||||
|
||||
### Settings update
|
||||
|
||||
|
||||
@@ -58,16 +58,19 @@ Checks:
|
||||
- `preferred_language` is a valid BCP 47 tag
|
||||
- `time_zone` is a valid IANA time-zone name
|
||||
|
||||
### race_name conflict
|
||||
### profile update rejected
|
||||
|
||||
Symptoms:
|
||||
|
||||
- profile update returns `409 conflict`
|
||||
- profile update returns `400 invalid_request` or `409 conflict`
|
||||
|
||||
Checks:
|
||||
|
||||
- desired race name is not already reserved under canonical uniqueness rules
|
||||
- submitted `display_name` passes `pkg/util/string.go:ValidateTypeName`; empty
|
||||
values are accepted and reset the stored display name
|
||||
- user is not currently blocked by `profile_update_block`
|
||||
- `user_name` is immutable; any attempt to mutate it surfaces as
|
||||
`409 conflict`
|
||||
|
||||
### declared-country sync rejected
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# Stage 21 — `user_name` + `display_name` refactor
|
||||
|
||||
## Context
|
||||
|
||||
The Game Lobby plan moved every in-game `race_name` value into the Lobby Race
|
||||
Name Directory. User Service stopped owning any in-game naming concept. The
|
||||
legacy single-valued `RaceName` field on `UserAccount`, the canonical
|
||||
race-name reservation store, and the `RaceNamePolicy` port were deleted. Two
|
||||
stable fields replace them:
|
||||
|
||||
- `user_name` — immutable auto-generated `player-<suffix>` handle, unique
|
||||
platform-wide, assigned once at account creation;
|
||||
- `display_name` — mutable optional free-text label validated by
|
||||
`pkg/util/string.go:ValidateTypeName`, empty by default, not unique.
|
||||
|
||||
## Key decisions
|
||||
|
||||
### Crockford Base32 lowercase alphabet for the suffix
|
||||
|
||||
`user_name` ends with eight characters drawn from the alphabet
|
||||
`0123456789abcdefghjkmnpqrstvwxyz` (Crockford Base32 lowercase, `i`, `l`,
|
||||
`o`, `u` excluded). Each `player-` identifier therefore has 40 bits of
|
||||
entropy and is free of visually ambiguous pairs.
|
||||
|
||||
The generator lives in
|
||||
`user/internal/adapters/local/id_generator.go:randomSuffix`; implementation
|
||||
reads five random bytes via `crypto/rand` and walks the 5-bit groups through
|
||||
the alphabet without using `encoding/base32` so the alphabet swap stays
|
||||
self-contained.
|
||||
|
||||
### Retry limit bumped to 10
|
||||
|
||||
`authdirectory.Ensurer.ensureCreateRetryLimit` moved from `8` to `10`.
|
||||
Collisions on the 40-bit suffix are expected to be extremely rare; the extra
|
||||
two attempts give a comfortable margin before the service falls back to
|
||||
`503 service_unavailable`.
|
||||
|
||||
### Canonical reservation removed
|
||||
|
||||
`RaceNameReservation`, `RaceNameCanonicalKey`, `ErrRaceNameConflict`, and
|
||||
the `reservation:race-name:*` Redis keys are gone. Uniqueness now comes from
|
||||
the single `lookup:user-name:<encoded>` index; no canonical form is
|
||||
persisted in User Service. Any existing Redis dataset must be re-initialized
|
||||
(the codebase has no production deployment, so no migration script ships).
|
||||
|
||||
### Confusable policy moved to Lobby
|
||||
|
||||
`user/internal/ports/race_name_policy.go` and
|
||||
`user/internal/adapters/local/race_name_policy.go` were deleted. The
|
||||
Unicode case-fold + digit-to-letter anti-fraud map + TR39 confusable
|
||||
skeleton landed under `lobby/internal/domain/racename/` so Stage 09R can
|
||||
wire it into the new Race Name Directory. Golden fixtures (`Pilot Nova` vs
|
||||
`P1lot N0va`, unicode `paypal` vs Cyrillic lookalike) moved with the policy.
|
||||
|
||||
### Admin surface
|
||||
|
||||
- `/api/v1/internal/user-lookups/by-race-name` is replaced by
|
||||
`/api/v1/internal/user-lookups/by-user-name` (exact 1:1 lookup).
|
||||
- Admin listing `GET /api/v1/internal/users` gains `user_name`,
|
||||
`display_name`, and `display_name_match` query parameters. `display_name`
|
||||
supports `exact` (default) and `prefix` matching.
|
||||
|
||||
### Empty `display_name`
|
||||
|
||||
An empty or whitespace-only input value on `POST /profile` is accepted and
|
||||
stored as an empty `display_name`. Non-empty values are validated by
|
||||
`pkg/util/string.go:ValidateTypeName`; internal whitespace, leading/trailing
|
||||
special characters, and unsupported characters are rejected.
|
||||
|
||||
### Eligibility snapshot gains `max_registered_race_names`
|
||||
|
||||
`lobbyeligibility.limitCatalog` now covers every tariff explicitly. The
|
||||
defaults for `max_registered_race_names` are `free=1`, `paid_monthly=2`,
|
||||
`paid_yearly=6`, `paid_lifetime=0` (unlimited marker). A
|
||||
`LimitCodeMaxRegisteredRaceNames` user-specific override, when active,
|
||||
replaces the tariff default. This feeds Stage 17A's registration quota
|
||||
enforcement and Stage 22's `permanent_block` cascading.
|
||||
|
||||
### Domain event payload
|
||||
|
||||
`ports.ProfileChangedEvent.RaceName` was replaced by `UserName` and
|
||||
`DisplayName`. The Redis stream publisher emits `user_name` on every event
|
||||
and `display_name` only when non-empty, keeping the event type
|
||||
`user.profile.changed` stable.
|
||||
|
||||
### Telemetry rename
|
||||
|
||||
`Runtime.RecordRaceNameReservationConflict` is now
|
||||
`Runtime.RecordUserNameConflict`; the metric name changed from
|
||||
`user.race_name.reservation_conflicts` to `user.user_name.conflicts`.
|
||||
|
||||
### Gateway boundary
|
||||
|
||||
The cross-module FlatBuffers boundary also moved:
|
||||
`pkg/schema/fbs/user.fbs` swaps `AccountView.race_name` for
|
||||
`user_name` + `display_name`, and `UpdateMyProfileRequest.race_name` for
|
||||
`display_name`. `pkg/transcoder/user.go`, `pkg/model/user`, gateway
|
||||
downstream tests, and the integration harness mirror the change.
|
||||
|
||||
## Files of interest
|
||||
|
||||
- Domain: `user/internal/domain/common/types.go`,
|
||||
`user/internal/domain/account/model.go`,
|
||||
`user/internal/domain/policy/model.go`
|
||||
- Storage: `user/internal/adapters/redisstate/keyspace.go`,
|
||||
`user/internal/adapters/redis/userstore/store.go`
|
||||
- Services: `user/internal/service/{authdirectory,selfservice,adminusers,accountview,lobbyeligibility,shared}/`
|
||||
- HTTP + OpenAPI: `user/internal/api/internalhttp/`, `user/openapi.yaml`
|
||||
- Lobby seed: `lobby/internal/domain/racename/{policy,types,policy_test}.go`
|
||||
- Gateway boundary: `pkg/schema/fbs/user.fbs`, `pkg/transcoder/user.go`,
|
||||
`pkg/model/user/user.go`
|
||||
@@ -0,0 +1,141 @@
|
||||
# Stage 22 — `permanent_block` Sanction and `DeleteUser` Soft-Delete
|
||||
|
||||
Stage 22 lands in `galaxy/user` the terminal-state sanction
|
||||
`permanent_block`, the soft-delete command `DeleteUser`, and the dedicated
|
||||
Redis Stream `user:lifecycle_events` that feeds the Stage 23 `Game Lobby`
|
||||
Race Name Directory cascade release.
|
||||
|
||||
## Outcomes
|
||||
|
||||
- `policy.SanctionCodePermanentBlock` joins the supported sanction
|
||||
catalogue. The sanction collapses every `can_*` eligibility marker to
|
||||
`false`, surfaces in the lobby-facing eligibility snapshot, and blocks
|
||||
every self-service read and write with `409 conflict`. Admin reads still
|
||||
return the record so operators can observe the state.
|
||||
- `LimitCodeMaxRegisteredRaceNames` — already introduced by Stage 21 — is
|
||||
now wired through the admin list index and the lifecycle write catalogue
|
||||
has no further gap (tracked here so future stages do not re-open task
|
||||
22.2).
|
||||
- `UserAccount.DeletedAt` (`*time.Time`) represents the soft-delete state
|
||||
of a regular-user record. When set, every external read path returns
|
||||
`404 subject_not_found` for the `user_id`.
|
||||
- `POST /api/v1/internal/users/{user_id}/delete` is the trusted command
|
||||
used by `Admin Service` to soft-delete a regular user. The command is
|
||||
idempotent per `user_id`: a second call after soft-delete returns
|
||||
`404 subject_not_found` and does not re-emit the lifecycle event.
|
||||
- `ports.UserLifecyclePublisher` plus `adapters/redis/lifecycleevents`
|
||||
publish exactly one `user.lifecycle.permanent_blocked` event on a
|
||||
successful permanent-block apply and exactly one `user.lifecycle.deleted`
|
||||
event on a successful `DeleteUser`. Both events carry
|
||||
`{event_type, user_id, occurred_at_ms, source, actor_type, actor_id?,
|
||||
reason_code, trace_id?}`.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Dedicated Redis Stream
|
||||
|
||||
**Decision.** Lifecycle events live on their own stream (default
|
||||
`user:lifecycle_events`) rather than extending the shared
|
||||
`user:domain_events` stream.
|
||||
|
||||
**Why.** The consumer model is different: `Game Lobby` treats lifecycle
|
||||
events as source-of-truth triggers for RND cascade release and wants a
|
||||
narrow, at-least-once stream it can pin an offset on. Co-locating the
|
||||
events with high-volume domain events (profile, settings) would force the
|
||||
consumer to filter a much larger firehose and would couple retention
|
||||
policies. A dedicated stream keeps the contract small.
|
||||
|
||||
### 2. Soft-Delete Preserves the Record
|
||||
|
||||
**Decision.** `DeleteUser` sets `UserAccount.DeletedAt` but preserves the
|
||||
account record, the email/user-name lookup keys, and the admin indexes.
|
||||
|
||||
**Why.** Audit. Compliance and support workflows need to resolve a
|
||||
`user_id` back to its last known `email`, `user_name`, and tariff state
|
||||
after the user is gone. Hard-delete would break support. External reads
|
||||
still surface the account as `subject_not_found` so the live contract is
|
||||
clean.
|
||||
|
||||
### 3. `DeleteUser` Second-Call Semantics — `404`, Not `200`
|
||||
|
||||
**Decision.** A second `DeleteUser` call for the same `user_id` returns
|
||||
`404 subject_not_found` rather than a cosmetic `200 OK` echoing the
|
||||
existing `deleted_at`.
|
||||
|
||||
**Why.** This is the exit criterion in `lobby/PLAN.md` §Stage 22: "a second
|
||||
call after soft-delete returns `subject_not_found`". It keeps the
|
||||
`user_id` subject semantics uniform across every external surface (auth,
|
||||
self-service, admin-read, lobby-eligibility, `DeleteUser` itself) — every
|
||||
post-delete access converges on the same error code. It also avoids the
|
||||
footgun of a "delete" that appears to succeed after the account is
|
||||
already gone.
|
||||
|
||||
### 4. Soft-Deleted Email Returns `blocked`, Not `existing`
|
||||
|
||||
**Decision.** `Store.ResolveByEmail` and `Store.EnsureByEmail` return the
|
||||
`blocked` outcome with `reason_code=account_deleted` when the email lookup
|
||||
resolves to a soft-deleted account. They do not try to free the email
|
||||
lookup or reassign the `user_id` to a new account.
|
||||
|
||||
**Why.** The alternative — reclaiming the email on soft-delete so
|
||||
ensure-by-email can mint a fresh `user_id` — requires coordinated mutation
|
||||
of multiple lookup keys across the delete path. The simpler rule "deleted
|
||||
emails stay blocked" mirrors common platform practice, guarantees stable
|
||||
audit trails (the old `user_id` remains resolvable by id), and sidesteps
|
||||
any ambiguity about which account an authenticator should re-bind to. If
|
||||
a compliance event demands the email be released, an explicit
|
||||
`unblock-by-email` command can be added later without changing the Stage
|
||||
22 contract.
|
||||
|
||||
### 5. Removing `permanent_block` Does Not Emit a Lifecycle Event
|
||||
|
||||
**Decision.** The `RemoveSanction` path does not publish a
|
||||
`user.lifecycle.permanent_blocked` event or any lifecycle event when it
|
||||
clears a `permanent_block` record.
|
||||
|
||||
**Why.** The spec phrasing in `lobby/PLAN.md` §22.4 is "emitted when
|
||||
`SanctionCodePermanentBlock` becomes active on a user". The inverse
|
||||
transition (admin un-blocks) is administratively supported, but Stage 23
|
||||
does not currently need a signal to "un-cascade" RND state — that decision
|
||||
is deferred. Emitting a complementary `permanent_block_removed` event now
|
||||
would lock us into a consumer-facing shape before its consumer exists.
|
||||
|
||||
### 6. Publishing Is Post-Commit, Best-Effort
|
||||
|
||||
**Decision.** Both apply-permanent-block and delete publish the lifecycle
|
||||
event after the persistence commit succeeds. Failure to publish logs and
|
||||
increments `user.event_publication_failures` but does not roll back the
|
||||
commit or fail the HTTP request.
|
||||
|
||||
**Why.** Matches the existing sanction/limit publisher shape
|
||||
(`policysvc.publishSanctionChanged` et al.) and the global rule in
|
||||
`galaxy/AGENTS.md`: never publish after a rollback; always publish after
|
||||
commit. Rolling back on a publisher failure would leak partial state
|
||||
through subsequent reads and invite inconsistent retries.
|
||||
|
||||
### 7. Admin Listing Excludes Soft-Deleted Accounts by Default
|
||||
|
||||
**Decision.** `adminusers.Lister` silently skips candidates whose
|
||||
aggregate load returns `subject_not_found` (the effect of
|
||||
`Loader.Load` for soft-deleted accounts). The OpenAPI schema exposes
|
||||
`deleted_at` on `AccountView` for cases where a caller already holds the
|
||||
record.
|
||||
|
||||
**Why.** Stage 22 needs the default behaviour to converge with the
|
||||
exit-criterion "external admin-read of a deleted user returns
|
||||
`subject_not_found`". A dedicated `deleted` filter (for admin workflows
|
||||
that explicitly want the audit trail) is out of scope — it can be added
|
||||
as a separate task without changing the core contract.
|
||||
|
||||
## Cross-References
|
||||
|
||||
- `galaxy/lobby/PLAN.md` §Stage 22 drives the exit criteria; §Stage 23 is
|
||||
the downstream lobby consumer of `user:lifecycle_events`.
|
||||
- `galaxy/ARCHITECTURE.md` §3 (User Service) and §7 (Race Name Directory)
|
||||
describe the external-facing contract realised by this stage.
|
||||
- Related module files: `internal/domain/policy/model.go`,
|
||||
`internal/domain/account/model.go`,
|
||||
`internal/service/accountdeletion/service.go`,
|
||||
`internal/service/policysvc/service.go`,
|
||||
`internal/adapters/redis/lifecycleevents/publisher.go`,
|
||||
`internal/api/internalhttp/handler.go`, and `openapi.yaml`.
|
||||
@@ -13,8 +13,16 @@ import (
|
||||
|
||||
var base32NoPadding = base32.StdEncoding.WithPadding(base32.NoPadding)
|
||||
|
||||
// userNameSuffixAlphabet is the Crockford lowercase Base32 alphabet with
|
||||
// `i`, `l`, `o`, and `u` excluded to avoid visual confusables. The chosen
|
||||
// 32 characters also keep each byte pair aligned with a 5-bit group so the
|
||||
// 5-byte random source encodes into exactly eight suffix characters.
|
||||
const userNameSuffixAlphabet = "0123456789abcdefghjkmnpqrstvwxyz"
|
||||
|
||||
const userNameSuffixLength = 8
|
||||
|
||||
// IDGenerator creates opaque stable user identifiers and generated initial
|
||||
// race names.
|
||||
// user names.
|
||||
type IDGenerator struct{}
|
||||
|
||||
// NewUserID returns one newly generated opaque user identifier.
|
||||
@@ -32,20 +40,21 @@ func (IDGenerator) NewUserID() (common.UserID, error) {
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// NewInitialRaceName returns one generated race name in the `player-<shortid>`
|
||||
// form.
|
||||
func (IDGenerator) NewInitialRaceName() (common.RaceName, error) {
|
||||
token, err := randomToken(5)
|
||||
// NewUserName returns one generated user name in the `player-<suffix>` form.
|
||||
// The suffix is eight characters drawn from the Crockford lowercase Base32
|
||||
// alphabet (confusable-free: `i`, `l`, `o`, `u` are excluded).
|
||||
func (IDGenerator) NewUserName() (common.UserName, error) {
|
||||
suffix, err := randomSuffix(userNameSuffixLength)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate initial race name: %w", err)
|
||||
return "", fmt.Errorf("generate user name: %w", err)
|
||||
}
|
||||
|
||||
raceName := common.RaceName("player-" + token)
|
||||
if err := raceName.Validate(); err != nil {
|
||||
return "", fmt.Errorf("generate initial race name: %w", err)
|
||||
userName := common.UserName("player-" + suffix)
|
||||
if err := userName.Validate(); err != nil {
|
||||
return "", fmt.Errorf("generate user name: %w", err)
|
||||
}
|
||||
|
||||
return raceName, nil
|
||||
return userName, nil
|
||||
}
|
||||
|
||||
// NewEntitlementRecordID returns one generated entitlement history record
|
||||
@@ -103,3 +112,31 @@ func randomToken(size int) (string, error) {
|
||||
|
||||
return strings.ToLower(base32NoPadding.EncodeToString(buffer)), nil
|
||||
}
|
||||
|
||||
// randomSuffix returns a length-character suffix encoded from crypto-random
|
||||
// bytes through the userNameSuffixAlphabet. Each character consumes five
|
||||
// random bits, so the caller receives `ceil(length * 5 / 8)` bytes of
|
||||
// entropy in the underlying buffer.
|
||||
func randomSuffix(length int) (string, error) {
|
||||
byteCount := (length*5 + 7) / 8
|
||||
buffer := make([]byte, byteCount)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
encoded := make([]byte, length)
|
||||
for index := range encoded {
|
||||
bitOffset := index * 5
|
||||
byteIndex := bitOffset / 8
|
||||
shift := bitOffset % 8
|
||||
|
||||
value := uint16(buffer[byteIndex]) << 8
|
||||
if byteIndex+1 < len(buffer) {
|
||||
value |= uint16(buffer[byteIndex+1])
|
||||
}
|
||||
|
||||
encoded[index] = userNameSuffixAlphabet[(value>>(16-5-shift))&0x1F]
|
||||
}
|
||||
|
||||
return string(encoded), nil
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/ports"
|
||||
|
||||
confusables "github.com/disciplinedware/go-confusables"
|
||||
"golang.org/x/text/cases"
|
||||
)
|
||||
|
||||
type confusableSkeletoner interface {
|
||||
Skeleton(string) string
|
||||
}
|
||||
|
||||
type raceNamePolicy struct {
|
||||
caseFolder cases.Caser
|
||||
skeletoner confusableSkeletoner
|
||||
}
|
||||
|
||||
var raceNameAntiFraudReplacer = strings.NewReplacer(
|
||||
"1", "i",
|
||||
"0", "o",
|
||||
"8", "b",
|
||||
)
|
||||
|
||||
// NewRaceNamePolicy returns the local Stage 06 race-name canonicalization
|
||||
// policy backed by Unicode case folding, explicit ASCII anti-fraud mappings,
|
||||
// and a TR39 confusable skeleton.
|
||||
func NewRaceNamePolicy() (ports.RaceNamePolicy, error) {
|
||||
policy := &raceNamePolicy{
|
||||
caseFolder: cases.Fold(),
|
||||
skeletoner: confusables.Default(),
|
||||
}
|
||||
if policy.skeletoner == nil {
|
||||
return nil, fmt.Errorf("new race-name policy: nil confusable skeletoner")
|
||||
}
|
||||
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
// CanonicalKey returns the stable uniqueness key for raceName.
|
||||
func (policy *raceNamePolicy) CanonicalKey(raceName common.RaceName) (account.RaceNameCanonicalKey, error) {
|
||||
switch {
|
||||
case policy == nil:
|
||||
return "", fmt.Errorf("canonicalize race name: nil policy")
|
||||
case policy.skeletoner == nil:
|
||||
return "", fmt.Errorf("canonicalize race name: nil confusable skeletoner")
|
||||
}
|
||||
if err := raceName.Validate(); err != nil {
|
||||
return "", fmt.Errorf("canonicalize race name: %w", err)
|
||||
}
|
||||
|
||||
folded := policy.caseFolder.String(raceName.String())
|
||||
antiFraudMapped := raceNameAntiFraudReplacer.Replace(folded)
|
||||
key := account.RaceNameCanonicalKey(policy.skeletoner.Skeleton(antiFraudMapped))
|
||||
if err := key.Validate(); err != nil {
|
||||
return "", fmt.Errorf("canonicalize race name: %w", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/service/shared"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRaceNamePolicyCanonicalKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
policy, err := NewRaceNamePolicy()
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
left common.RaceName
|
||||
right common.RaceName
|
||||
}{
|
||||
{
|
||||
name: "case insensitive collision",
|
||||
left: common.RaceName("Pilot Nova"),
|
||||
right: common.RaceName("pilot nova"),
|
||||
},
|
||||
{
|
||||
name: "ascii anti fraud collision",
|
||||
left: common.RaceName("Pilot Nova"),
|
||||
right: common.RaceName("P1lot N0va"),
|
||||
},
|
||||
{
|
||||
name: "unicode confusable collision",
|
||||
left: common.RaceName("paypal"),
|
||||
right: common.RaceName("раураl"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
leftKey, err := policy.CanonicalKey(tt.left)
|
||||
require.NoError(t, err)
|
||||
rightKey, err := policy.CanonicalKey(tt.right)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, rightKey, leftKey)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRaceNameReservationPreservesOriginalDisplayValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
policy, err := NewRaceNamePolicy()
|
||||
require.NoError(t, err)
|
||||
|
||||
record, err := shared.BuildRaceNameReservation(
|
||||
policy,
|
||||
common.UserID("user-123"),
|
||||
common.RaceName("P1lot Nova"),
|
||||
time.Unix(1_775_240_000, 0).UTC(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, common.RaceName("P1lot Nova"), record.RaceName)
|
||||
require.NotEqual(t, account.RaceNameCanonicalKey(""), record.CanonicalKey)
|
||||
}
|
||||
@@ -121,7 +121,10 @@ func (publisher *Publisher) PublishProfileChanged(ctx context.Context, event por
|
||||
|
||||
values := buildEnvelope(ports.ProfileChangedEventType, event.UserID.String(), event.OccurredAt, event.Source.String(), traceIDFromContext(ctx, event.TraceID))
|
||||
values["operation"] = string(event.Operation)
|
||||
values["race_name"] = event.RaceName.String()
|
||||
values["user_name"] = event.UserName.String()
|
||||
if !event.DisplayName.IsZero() {
|
||||
values["display_name"] = event.DisplayName.String()
|
||||
}
|
||||
|
||||
return publisher.publish(ctx, "publish profile changed event", values)
|
||||
}
|
||||
|
||||
@@ -27,12 +27,13 @@ func TestPublisherPublishesFlatRedisStreamEntry(t *testing.T) {
|
||||
|
||||
occurredAt := time.Unix(1_775_240_000, 0).UTC()
|
||||
err = publisher.PublishProfileChanged(context.Background(), ports.ProfileChangedEvent{
|
||||
UserID: common.UserID("user-123"),
|
||||
OccurredAt: occurredAt,
|
||||
Source: common.Source("gateway_self_service"),
|
||||
TraceID: "4bf92f3577b34da6a3ce929d0e0e4736",
|
||||
Operation: ports.ProfileChangedOperationUpdated,
|
||||
RaceName: common.RaceName("Nova Prime"),
|
||||
UserID: common.UserID("user-123"),
|
||||
OccurredAt: occurredAt,
|
||||
Source: common.Source("gateway_self_service"),
|
||||
TraceID: "4bf92f3577b34da6a3ce929d0e0e4736",
|
||||
Operation: ports.ProfileChangedOperationUpdated,
|
||||
UserName: common.UserName("player-abcdefgh"),
|
||||
DisplayName: common.DisplayName("NovaPrime"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -45,7 +46,8 @@ func TestPublisherPublishesFlatRedisStreamEntry(t *testing.T) {
|
||||
require.Equal(t, "gateway_self_service", entries[0].Values["source"])
|
||||
require.Equal(t, "4bf92f3577b34da6a3ce929d0e0e4736", entries[0].Values["trace_id"])
|
||||
require.Equal(t, string(ports.ProfileChangedOperationUpdated), entries[0].Values["operation"])
|
||||
require.Equal(t, "Nova Prime", entries[0].Values["race_name"])
|
||||
require.Equal(t, "player-abcdefgh", entries[0].Values["user_name"])
|
||||
require.Equal(t, "NovaPrime", entries[0].Values["display_name"])
|
||||
|
||||
for index := 0; index < 20; index++ {
|
||||
err = publisher.PublishSettingsChanged(context.Background(), ports.SettingsChangedEvent{
|
||||
@@ -77,10 +79,11 @@ func TestPublisherRejectsInvalidEventBeforeXAdd(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
err = publisher.PublishProfileChanged(context.Background(), ports.ProfileChangedEvent{
|
||||
UserID: common.UserID("user-123"),
|
||||
OccurredAt: time.Unix(1_775_240_000, 0).UTC(),
|
||||
Operation: ports.ProfileChangedOperationUpdated,
|
||||
RaceName: common.RaceName("Nova Prime"),
|
||||
UserID: common.UserID("user-123"),
|
||||
OccurredAt: time.Unix(1_775_240_000, 0).UTC(),
|
||||
Operation: ports.ProfileChangedOperationUpdated,
|
||||
UserName: common.UserName("player-abcdefgh"),
|
||||
DisplayName: common.DisplayName("NovaPrime"),
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// Package lifecycleevents implements the Redis Streams-backed publisher for
|
||||
// trusted user-lifecycle events consumed by `Game Lobby`.
|
||||
package lifecycleevents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/ports"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// Config configures one Redis-backed user-lifecycle publisher.
|
||||
type Config struct {
|
||||
// Addr is the Redis network address in host:port form.
|
||||
Addr string
|
||||
|
||||
// Username is the optional Redis ACL username.
|
||||
Username string
|
||||
|
||||
// Password is the optional Redis ACL password.
|
||||
Password string
|
||||
|
||||
// DB is the Redis logical database index.
|
||||
DB int
|
||||
|
||||
// TLSEnabled enables TLS with a conservative minimum protocol version.
|
||||
TLSEnabled bool
|
||||
|
||||
// Stream identifies the Redis Stream key used for lifecycle events. The
|
||||
// default platform key is `user:lifecycle_events`.
|
||||
Stream string
|
||||
|
||||
// StreamMaxLen bounds the stream with approximate trimming via
|
||||
// `XADD MAXLEN ~`.
|
||||
StreamMaxLen int64
|
||||
|
||||
// OperationTimeout bounds each Redis round trip performed by the adapter.
|
||||
OperationTimeout time.Duration
|
||||
}
|
||||
|
||||
// Publisher publishes trusted user-lifecycle events into the dedicated Redis
|
||||
// Stream consumed by `Game Lobby` for Race Name Directory cascade release.
|
||||
type Publisher struct {
|
||||
client *redis.Client
|
||||
stream string
|
||||
streamMaxLen int64
|
||||
operationTimeout time.Duration
|
||||
}
|
||||
|
||||
// New constructs a Redis-backed lifecycle-event publisher from cfg.
|
||||
func New(cfg Config) (*Publisher, error) {
|
||||
switch {
|
||||
case strings.TrimSpace(cfg.Addr) == "":
|
||||
return nil, errors.New("new redis lifecycle-event publisher: redis addr must not be empty")
|
||||
case cfg.DB < 0:
|
||||
return nil, errors.New("new redis lifecycle-event publisher: redis db must not be negative")
|
||||
case strings.TrimSpace(cfg.Stream) == "":
|
||||
return nil, errors.New("new redis lifecycle-event publisher: stream must not be empty")
|
||||
case cfg.StreamMaxLen <= 0:
|
||||
return nil, errors.New("new redis lifecycle-event publisher: stream max len must be positive")
|
||||
case cfg.OperationTimeout <= 0:
|
||||
return nil, errors.New("new redis lifecycle-event publisher: operation timeout must be positive")
|
||||
}
|
||||
|
||||
options := &redis.Options{
|
||||
Addr: cfg.Addr,
|
||||
Username: cfg.Username,
|
||||
Password: cfg.Password,
|
||||
DB: cfg.DB,
|
||||
Protocol: 2,
|
||||
DisableIdentity: true,
|
||||
}
|
||||
if cfg.TLSEnabled {
|
||||
options.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
}
|
||||
|
||||
return &Publisher{
|
||||
client: redis.NewClient(options),
|
||||
stream: cfg.Stream,
|
||||
streamMaxLen: cfg.StreamMaxLen,
|
||||
operationTimeout: cfg.OperationTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close releases the underlying Redis client resources.
|
||||
func (publisher *Publisher) Close() error {
|
||||
if publisher == nil || publisher.client == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return publisher.client.Close()
|
||||
}
|
||||
|
||||
// Ping verifies that the configured Redis backend is reachable within the
|
||||
// adapter operation timeout budget.
|
||||
func (publisher *Publisher) Ping(ctx context.Context) error {
|
||||
operationCtx, cancel, err := publisher.operationContext(ctx, "ping redis lifecycle-event publisher")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
if err := publisher.client.Ping(operationCtx).Err(); err != nil {
|
||||
return fmt.Errorf("ping redis lifecycle-event publisher: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PublishUserLifecycleEvent publishes one committed lifecycle event to the
|
||||
// configured Redis Stream.
|
||||
func (publisher *Publisher) PublishUserLifecycleEvent(ctx context.Context, event ports.UserLifecycleEvent) error {
|
||||
if err := event.Validate(); err != nil {
|
||||
return fmt.Errorf("publish user lifecycle event: %w", err)
|
||||
}
|
||||
|
||||
traceID := traceIDFromContext(ctx, event.TraceID)
|
||||
|
||||
values := map[string]any{
|
||||
"event_type": string(event.EventType),
|
||||
"user_id": event.UserID.String(),
|
||||
"occurred_at_ms": strconv.FormatInt(event.OccurredAt.UTC().UnixMilli(), 10),
|
||||
"source": event.Source.String(),
|
||||
"actor_type": event.Actor.Type.String(),
|
||||
"reason_code": event.ReasonCode.String(),
|
||||
}
|
||||
if !event.Actor.ID.IsZero() {
|
||||
values["actor_id"] = event.Actor.ID.String()
|
||||
}
|
||||
if traceID != "" {
|
||||
values["trace_id"] = traceID
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := publisher.operationContext(ctx, "publish user lifecycle event")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
if err := publisher.client.XAdd(operationCtx, &redis.XAddArgs{
|
||||
Stream: publisher.stream,
|
||||
MaxLen: publisher.streamMaxLen,
|
||||
Approx: true,
|
||||
Values: values,
|
||||
}).Err(); err != nil {
|
||||
return fmt.Errorf("publish user lifecycle event: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (publisher *Publisher) operationContext(ctx context.Context, operation string) (context.Context, context.CancelFunc, error) {
|
||||
if publisher == nil || publisher.client == nil {
|
||||
return nil, nil, fmt.Errorf("%s: nil publisher", operation)
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, nil, fmt.Errorf("%s: nil context", operation)
|
||||
}
|
||||
|
||||
operationCtx, cancel := context.WithTimeout(ctx, publisher.operationTimeout)
|
||||
return operationCtx, cancel, nil
|
||||
}
|
||||
|
||||
func traceIDFromContext(ctx context.Context, fallback string) string {
|
||||
if strings.TrimSpace(fallback) != "" {
|
||||
return fallback
|
||||
}
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
spanContext := trace.SpanContextFromContext(ctx)
|
||||
if !spanContext.IsValid() {
|
||||
return ""
|
||||
}
|
||||
|
||||
return spanContext.TraceID().String()
|
||||
}
|
||||
|
||||
var (
|
||||
_ interface{ Close() error } = (*Publisher)(nil)
|
||||
_ interface{ Ping(context.Context) error } = (*Publisher)(nil)
|
||||
_ ports.UserLifecyclePublisher = (*Publisher)(nil)
|
||||
)
|
||||
@@ -0,0 +1,154 @@
|
||||
package lifecycleevents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/ports"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPublisherPublishesPermanentBlockedEnvelope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
publisher, err := New(Config{
|
||||
Addr: server.Addr(),
|
||||
Stream: "user:lifecycle_events",
|
||||
StreamMaxLen: 10,
|
||||
OperationTimeout: time.Second,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
occurredAt := time.Unix(1_775_240_000, 0).UTC()
|
||||
require.NoError(t, publisher.PublishUserLifecycleEvent(context.Background(), ports.UserLifecycleEvent{
|
||||
EventType: ports.UserLifecyclePermanentBlockedEventType,
|
||||
UserID: common.UserID("user-123"),
|
||||
OccurredAt: occurredAt,
|
||||
Source: common.Source("admin_internal_api"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: common.ReasonCode("terminal_policy_violation"),
|
||||
TraceID: "4bf92f3577b34da6a3ce929d0e0e4736",
|
||||
}))
|
||||
|
||||
entries, err := publisher.client.XRange(context.Background(), publisher.stream, "-", "+").Result()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1)
|
||||
values := entries[0].Values
|
||||
require.Equal(t, string(ports.UserLifecyclePermanentBlockedEventType), values["event_type"])
|
||||
require.Equal(t, "user-123", values["user_id"])
|
||||
require.Equal(t, strconv.FormatInt(occurredAt.UnixMilli(), 10), values["occurred_at_ms"])
|
||||
require.Equal(t, "admin_internal_api", values["source"])
|
||||
require.Equal(t, "admin", values["actor_type"])
|
||||
require.Equal(t, "admin-1", values["actor_id"])
|
||||
require.Equal(t, "terminal_policy_violation", values["reason_code"])
|
||||
require.Equal(t, "4bf92f3577b34da6a3ce929d0e0e4736", values["trace_id"])
|
||||
}
|
||||
|
||||
func TestPublisherOmitsOptionalActorIDAndTraceID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
publisher, err := New(Config{
|
||||
Addr: server.Addr(),
|
||||
Stream: "user:lifecycle_events",
|
||||
StreamMaxLen: 10,
|
||||
OperationTimeout: time.Second,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, publisher.PublishUserLifecycleEvent(context.Background(), ports.UserLifecycleEvent{
|
||||
EventType: ports.UserLifecycleDeletedEventType,
|
||||
UserID: common.UserID("user-123"),
|
||||
OccurredAt: time.Unix(1_775_240_000, 0).UTC(),
|
||||
Source: common.Source("admin_internal_api"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin")},
|
||||
ReasonCode: common.ReasonCode("user_right_to_be_forgotten"),
|
||||
}))
|
||||
|
||||
entries, err := publisher.client.XRange(context.Background(), publisher.stream, "-", "+").Result()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1)
|
||||
values := entries[0].Values
|
||||
_, hasActorID := values["actor_id"]
|
||||
require.False(t, hasActorID)
|
||||
_, hasTraceID := values["trace_id"]
|
||||
require.False(t, hasTraceID)
|
||||
require.Equal(t, string(ports.UserLifecycleDeletedEventType), values["event_type"])
|
||||
}
|
||||
|
||||
func TestPublisherRejectsInvalidEventBeforeXAdd(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
publisher, err := New(Config{
|
||||
Addr: server.Addr(),
|
||||
Stream: "user:lifecycle_events",
|
||||
StreamMaxLen: 10,
|
||||
OperationTimeout: time.Second,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = publisher.PublishUserLifecycleEvent(context.Background(), ports.UserLifecycleEvent{
|
||||
EventType: "user.lifecycle.unknown",
|
||||
UserID: common.UserID("user-123"),
|
||||
OccurredAt: time.Unix(1_775_240_000, 0).UTC(),
|
||||
Source: common.Source("admin_internal_api"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin")},
|
||||
ReasonCode: common.ReasonCode("manual_block"),
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
length, xLenErr := publisher.client.XLen(context.Background(), publisher.stream).Result()
|
||||
require.NoError(t, xLenErr)
|
||||
require.Zero(t, length)
|
||||
}
|
||||
|
||||
func TestPublisherTrimsBeyondMaxLen(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
publisher, err := New(Config{
|
||||
Addr: server.Addr(),
|
||||
Stream: "user:lifecycle_events",
|
||||
StreamMaxLen: 5,
|
||||
OperationTimeout: time.Second,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
occurredAt := time.Unix(1_775_240_000, 0).UTC()
|
||||
for index := 0; index < 20; index++ {
|
||||
require.NoError(t, publisher.PublishUserLifecycleEvent(context.Background(), ports.UserLifecycleEvent{
|
||||
EventType: ports.UserLifecyclePermanentBlockedEventType,
|
||||
UserID: common.UserID("user-123"),
|
||||
OccurredAt: occurredAt.Add(time.Duration(index+1) * time.Second),
|
||||
Source: common.Source("admin_internal_api"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin")},
|
||||
ReasonCode: common.ReasonCode("terminal_policy_violation"),
|
||||
}))
|
||||
}
|
||||
|
||||
length, err := publisher.client.XLen(context.Background(), publisher.stream).Result()
|
||||
require.NoError(t, err)
|
||||
require.LessOrEqual(t, length, int64(20))
|
||||
}
|
||||
|
||||
func TestPublisherPingReportsReachability(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
publisher, err := New(Config{
|
||||
Addr: server.Addr(),
|
||||
Stream: "user:lifecycle_events",
|
||||
StreamMaxLen: 10,
|
||||
OperationTimeout: time.Second,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, publisher.Ping(context.Background()))
|
||||
}
|
||||
@@ -20,12 +20,14 @@ var knownSanctionCodes = []policy.SanctionCode{
|
||||
policy.SanctionCodePrivateGameManageBlock,
|
||||
policy.SanctionCodeGameJoinBlock,
|
||||
policy.SanctionCodeProfileUpdateBlock,
|
||||
policy.SanctionCodePermanentBlock,
|
||||
}
|
||||
|
||||
var knownLimitCodes = []policy.LimitCode{
|
||||
policy.LimitCodeMaxOwnedPrivateGames,
|
||||
policy.LimitCodeMaxPendingPublicApplications,
|
||||
policy.LimitCodeMaxActiveGameMemberships,
|
||||
policy.LimitCodeMaxRegisteredRaceNames,
|
||||
}
|
||||
|
||||
var knownEligibilityMarkers = []policy.EligibilityMarker{
|
||||
@@ -189,6 +191,16 @@ func deriveEligibilityMarkerValues(
|
||||
isPaid bool,
|
||||
activeSanctionCodes map[policy.SanctionCode]struct{},
|
||||
) map[policy.EligibilityMarker]bool {
|
||||
if _, permanentBlocked := activeSanctionCodes[policy.SanctionCodePermanentBlock]; permanentBlocked {
|
||||
return map[policy.EligibilityMarker]bool{
|
||||
policy.EligibilityMarkerCanLogin: false,
|
||||
policy.EligibilityMarkerCanCreatePrivateGame: false,
|
||||
policy.EligibilityMarkerCanManagePrivateGame: false,
|
||||
policy.EligibilityMarkerCanJoinGame: false,
|
||||
policy.EligibilityMarkerCanUpdateProfile: false,
|
||||
}
|
||||
}
|
||||
|
||||
_, loginBlocked := activeSanctionCodes[policy.SanctionCodeLoginBlock]
|
||||
_, createBlocked := activeSanctionCodes[policy.SanctionCodePrivateGameCreateBlock]
|
||||
_, manageBlocked := activeSanctionCodes[policy.SanctionCodePrivateGameManageBlock]
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package userstore
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"galaxy/user/internal/domain/policy"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDeriveEligibilityMarkerValuesCollapsesUnderPermanentBlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
activeCodes := map[policy.SanctionCode]struct{}{
|
||||
policy.SanctionCodePermanentBlock: {},
|
||||
}
|
||||
|
||||
values := deriveEligibilityMarkerValues(true, activeCodes)
|
||||
require.False(t, values[policy.EligibilityMarkerCanLogin])
|
||||
require.False(t, values[policy.EligibilityMarkerCanCreatePrivateGame])
|
||||
require.False(t, values[policy.EligibilityMarkerCanManagePrivateGame])
|
||||
require.False(t, values[policy.EligibilityMarkerCanJoinGame])
|
||||
require.False(t, values[policy.EligibilityMarkerCanUpdateProfile])
|
||||
}
|
||||
|
||||
func TestDeriveEligibilityMarkerValuesPermanentBlockDominatesOtherSanctions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
activeCodes := map[policy.SanctionCode]struct{}{
|
||||
policy.SanctionCodePermanentBlock: {},
|
||||
policy.SanctionCodeLoginBlock: {},
|
||||
policy.SanctionCodeGameJoinBlock: {},
|
||||
}
|
||||
|
||||
values := deriveEligibilityMarkerValues(false, activeCodes)
|
||||
for marker, value := range values {
|
||||
require.Falsef(t, value, "marker %q must be false under permanent_block", marker)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveEligibilityMarkerValuesFreeUserWithoutPermanentBlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
values := deriveEligibilityMarkerValues(false, map[policy.SanctionCode]struct{}{})
|
||||
|
||||
require.True(t, values[policy.EligibilityMarkerCanLogin])
|
||||
require.False(t, values[policy.EligibilityMarkerCanCreatePrivateGame])
|
||||
require.False(t, values[policy.EligibilityMarkerCanManagePrivateGame])
|
||||
require.True(t, values[policy.EligibilityMarkerCanJoinGame])
|
||||
require.True(t, values[policy.EligibilityMarkerCanUpdateProfile])
|
||||
}
|
||||
|
||||
func TestKnownCatalogsIncludeStage22Codes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Contains(t, knownSanctionCodes, policy.SanctionCodePermanentBlock)
|
||||
require.Contains(t, knownLimitCodes, policy.LimitCodeMaxRegisteredRaceNames)
|
||||
}
|
||||
@@ -25,21 +25,21 @@ func TestListUserIDsCreatedAtPagination(t *testing.T) {
|
||||
first := validAccountRecord()
|
||||
first.UserID = common.UserID("user-100")
|
||||
first.Email = common.Email("u100@example.com")
|
||||
first.RaceName = common.RaceName("User 100")
|
||||
first.UserName = common.UserName("player-user100aa")
|
||||
first.CreatedAt = base.Add(-time.Hour)
|
||||
first.UpdatedAt = first.CreatedAt
|
||||
|
||||
second := validAccountRecord()
|
||||
second.UserID = common.UserID("user-200")
|
||||
second.Email = common.Email("u200@example.com")
|
||||
second.RaceName = common.RaceName("User 200")
|
||||
second.UserName = common.UserName("player-user200aa")
|
||||
second.CreatedAt = base
|
||||
second.UpdatedAt = second.CreatedAt
|
||||
|
||||
third := validAccountRecord()
|
||||
third.UserID = common.UserID("user-300")
|
||||
third.Email = common.Email("u300@example.com")
|
||||
third.RaceName = common.RaceName("User 300")
|
||||
third.UserName = common.UserName("player-user300aa")
|
||||
third.CreatedAt = base
|
||||
third.UpdatedAt = third.CreatedAt
|
||||
|
||||
@@ -80,7 +80,6 @@ func TestEnsureByEmailInitialAdminIndexes(t *testing.T) {
|
||||
Account: record,
|
||||
Entitlement: validEntitlementSnapshot(record.UserID, now),
|
||||
EntitlementRecord: validEntitlementRecord(record.UserID, now),
|
||||
Reservation: raceNameReservation(record.UserID, record.RaceName, now),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.EnsureByEmailOutcomeCreated, result.Outcome)
|
||||
@@ -125,7 +124,6 @@ func TestEntitlementLifecycleSyncsAdminIndexes(t *testing.T) {
|
||||
Account: record,
|
||||
Entitlement: validEntitlementSnapshot(record.UserID, now),
|
||||
EntitlementRecord: validEntitlementRecord(record.UserID, now),
|
||||
Reservation: raceNameReservation(record.UserID, record.RaceName, now),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -248,7 +246,6 @@ func TestPolicyLifecycleSyncsAdminIndexes(t *testing.T) {
|
||||
Account: record,
|
||||
Entitlement: validEntitlementSnapshot(record.UserID, now),
|
||||
EntitlementRecord: validEntitlementRecord(record.UserID, now),
|
||||
Reservation: raceNameReservation(record.UserID, record.RaceName, now),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -325,7 +322,6 @@ func TestAdminListerReevaluatesExpiredPaidSnapshots(t *testing.T) {
|
||||
Account: record,
|
||||
Entitlement: validEntitlementSnapshot(userID, record.CreatedAt),
|
||||
EntitlementRecord: validEntitlementRecord(userID, record.CreatedAt),
|
||||
Reservation: raceNameReservation(userID, record.RaceName, record.CreatedAt),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -401,7 +397,7 @@ func (generator adminStoreIDGenerator) NewUserID() (common.UserID, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (generator adminStoreIDGenerator) NewInitialRaceName() (common.RaceName, error) {
|
||||
func (generator adminStoreIDGenerator) NewUserName() (common.UserName, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
|
||||
@@ -61,19 +61,14 @@ type Store struct {
|
||||
type accountRecord struct {
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
RaceName string `json:"race_name"`
|
||||
UserName string `json:"user_name"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
PreferredLanguage string `json:"preferred_language"`
|
||||
TimeZone string `json:"time_zone"`
|
||||
DeclaredCountry *string `json:"declared_country,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type raceNameReservationRecord struct {
|
||||
CanonicalKey string `json:"canonical_key"`
|
||||
UserID string `json:"user_id"`
|
||||
RaceName string `json:"race_name"`
|
||||
ReservedAt string `json:"reserved_at"`
|
||||
DeletedAt *string `json:"deleted_at,omitempty"`
|
||||
}
|
||||
|
||||
type blockedEmailRecord struct {
|
||||
@@ -190,8 +185,8 @@ func (store *Store) Ping(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create stores one new account record together with the exact and canonical
|
||||
// race-name lookup state.
|
||||
// Create stores one new account record together with the exact user-name
|
||||
// lookup state.
|
||||
func (store *Store) Create(ctx context.Context, input ports.CreateAccountInput) error {
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("create account in redis: %w", err)
|
||||
@@ -201,15 +196,10 @@ func (store *Store) Create(ctx context.Context, input ports.CreateAccountInput)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create account in redis: %w", err)
|
||||
}
|
||||
reservationPayload, err := marshalRaceNameReservationRecord(input.Reservation)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create account in redis: %w", err)
|
||||
}
|
||||
|
||||
accountKey := store.keyspace.Account(input.Account.UserID)
|
||||
emailLookupKey := store.keyspace.EmailLookup(input.Account.Email)
|
||||
raceNameLookupKey := store.keyspace.RaceNameLookup(input.Account.RaceName)
|
||||
reservationKey := store.keyspace.RaceNameReservation(input.Reservation.CanonicalKey)
|
||||
userNameLookupKey := store.keyspace.UserNameLookup(input.Account.UserName)
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "create account in redis")
|
||||
if err != nil {
|
||||
@@ -224,18 +214,17 @@ func (store *Store) Create(ctx context.Context, input ports.CreateAccountInput)
|
||||
if err := ensureKeyAbsent(operationCtx, tx, emailLookupKey); err != nil {
|
||||
return fmt.Errorf("create account %q in redis: %w", input.Account.UserID, err)
|
||||
}
|
||||
if err := ensureKeyAbsent(operationCtx, tx, raceNameLookupKey); err != nil {
|
||||
return fmt.Errorf("create account %q in redis: %w", input.Account.UserID, err)
|
||||
}
|
||||
if err := ensureKeyAbsent(operationCtx, tx, reservationKey); err != nil {
|
||||
if err := ensureKeyAbsent(operationCtx, tx, userNameLookupKey); err != nil {
|
||||
if errors.Is(err, ports.ErrConflict) {
|
||||
return fmt.Errorf("create account %q in redis: %w", input.Account.UserID, ports.ErrUserNameConflict)
|
||||
}
|
||||
return fmt.Errorf("create account %q in redis: %w", input.Account.UserID, err)
|
||||
}
|
||||
|
||||
_, err := tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(operationCtx, accountKey, accountPayload, 0)
|
||||
pipe.Set(operationCtx, emailLookupKey, input.Account.UserID.String(), 0)
|
||||
pipe.Set(operationCtx, raceNameLookupKey, input.Account.UserID.String(), 0)
|
||||
pipe.Set(operationCtx, reservationKey, reservationPayload, 0)
|
||||
pipe.Set(operationCtx, userNameLookupKey, input.Account.UserID.String(), 0)
|
||||
store.addCreatedAtIndex(pipe, operationCtx, input.Account)
|
||||
store.syncDeclaredCountryIndex(pipe, operationCtx, account.UserAccount{}, input.Account)
|
||||
return nil
|
||||
@@ -245,7 +234,7 @@ func (store *Store) Create(ctx context.Context, input ports.CreateAccountInput)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, accountKey, emailLookupKey, raceNameLookupKey, reservationKey)
|
||||
}, accountKey, emailLookupKey, userNameLookupKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
@@ -317,26 +306,26 @@ func (store *Store) GetByEmail(ctx context.Context, email common.Email) (account
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// GetByRaceName returns the stored account identified by the exact stored race
|
||||
// name.
|
||||
func (store *Store) GetByRaceName(ctx context.Context, raceName common.RaceName) (account.UserAccount, error) {
|
||||
if err := raceName.Validate(); err != nil {
|
||||
return account.UserAccount{}, fmt.Errorf("get account by race name from redis: %w", err)
|
||||
// GetByUserName returns the stored account identified by the exact stored
|
||||
// user name.
|
||||
func (store *Store) GetByUserName(ctx context.Context, userName common.UserName) (account.UserAccount, error) {
|
||||
if err := userName.Validate(); err != nil {
|
||||
return account.UserAccount{}, fmt.Errorf("get account by user name from redis: %w", err)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "get account by race name from redis")
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "get account by user name from redis")
|
||||
if err != nil {
|
||||
return account.UserAccount{}, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
userID, err := store.loadLookupUserID(operationCtx, store.client, store.keyspace.RaceNameLookup(raceName))
|
||||
userID, err := store.loadLookupUserID(operationCtx, store.client, store.keyspace.UserNameLookup(userName))
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return account.UserAccount{}, fmt.Errorf("get account by race name %q from redis: %w", raceName, ports.ErrNotFound)
|
||||
return account.UserAccount{}, fmt.Errorf("get account by user name %q from redis: %w", userName, ports.ErrNotFound)
|
||||
default:
|
||||
return account.UserAccount{}, fmt.Errorf("get account by race name %q from redis: %w", raceName, err)
|
||||
return account.UserAccount{}, fmt.Errorf("get account by user name %q from redis: %w", userName, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,16 +333,18 @@ func (store *Store) GetByRaceName(ctx context.Context, raceName common.RaceName)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return account.UserAccount{}, fmt.Errorf("get account by race name %q from redis: lookup references missing user %q", raceName, userID)
|
||||
return account.UserAccount{}, fmt.Errorf("get account by user name %q from redis: lookup references missing user %q", userName, userID)
|
||||
default:
|
||||
return account.UserAccount{}, fmt.Errorf("get account by race name %q from redis: %w", raceName, err)
|
||||
return account.UserAccount{}, fmt.Errorf("get account by user name %q from redis: %w", userName, err)
|
||||
}
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// ExistsByUserID reports whether userID identifies a stored account.
|
||||
// ExistsByUserID reports whether userID currently identifies a stored account
|
||||
// that is not soft-deleted. Soft-deleted accounts are treated as non-existing
|
||||
// for external callers per Stage 22.
|
||||
func (store *Store) ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error) {
|
||||
if err := userID.Validate(); err != nil {
|
||||
return false, fmt.Errorf("exists by user id from redis: %w", err)
|
||||
@@ -365,114 +356,25 @@ func (store *Store) ExistsByUserID(ctx context.Context, userID common.UserID) (b
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
exists, err := store.client.Exists(operationCtx, store.keyspace.Account(userID)).Result()
|
||||
if err != nil {
|
||||
record, err := store.loadAccount(operationCtx, store.client, userID)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("exists by user id %q from redis: %w", userID, err)
|
||||
}
|
||||
|
||||
return exists == 1, nil
|
||||
if record.IsDeleted() {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// RenameRaceName replaces the stored race name of userID and swaps the exact
|
||||
// and canonical race-name lookup state atomically.
|
||||
func (store *Store) RenameRaceName(ctx context.Context, input ports.RenameRaceNameInput) error {
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("rename account race name in redis: %w", err)
|
||||
}
|
||||
|
||||
accountKey := store.keyspace.Account(input.UserID)
|
||||
newRaceNameLookupKey := store.keyspace.RaceNameLookup(input.NewRaceName)
|
||||
newReservationKey := store.keyspace.RaceNameReservation(input.NewReservation.CanonicalKey)
|
||||
newReservationPayload, err := marshalRaceNameReservationRecord(input.NewReservation)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename account race name in redis: %w", err)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "rename account race name in redis")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
||||
record, err := store.loadAccount(operationCtx, tx, input.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, err)
|
||||
}
|
||||
if record.RaceName == input.NewRaceName {
|
||||
return nil
|
||||
}
|
||||
|
||||
currentRaceNameLookupKey := store.keyspace.RaceNameLookup(record.RaceName)
|
||||
currentLookupUserID, err := store.loadLookupUserID(operationCtx, tx, currentRaceNameLookupKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, err)
|
||||
}
|
||||
if currentLookupUserID != input.UserID {
|
||||
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, ports.ErrConflict)
|
||||
}
|
||||
|
||||
currentReservation, err := store.loadRaceNameReservation(operationCtx, tx, input.CurrentCanonicalKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, err)
|
||||
}
|
||||
if currentReservation.UserID != input.UserID || currentReservation.RaceName != record.RaceName {
|
||||
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, ports.ErrConflict)
|
||||
}
|
||||
|
||||
if err := ensureLookupAvailableOrOwned(operationCtx, tx, newRaceNameLookupKey, input.UserID); err != nil {
|
||||
if errors.Is(err, ports.ErrConflict) {
|
||||
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, ports.ErrRaceNameConflict)
|
||||
}
|
||||
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, err)
|
||||
}
|
||||
|
||||
if input.CurrentCanonicalKey != input.NewReservation.CanonicalKey {
|
||||
if err := store.ensureReservationAvailableOrOwned(operationCtx, tx, input.NewReservation.CanonicalKey, input.UserID); err != nil {
|
||||
if errors.Is(err, ports.ErrConflict) {
|
||||
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, ports.ErrRaceNameConflict)
|
||||
}
|
||||
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, err)
|
||||
}
|
||||
}
|
||||
|
||||
record.RaceName = input.NewRaceName
|
||||
record.UpdatedAt = input.UpdatedAt.UTC()
|
||||
|
||||
payload, err := marshalAccountRecord(record)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, err)
|
||||
}
|
||||
|
||||
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(operationCtx, accountKey, payload, 0)
|
||||
pipe.Set(operationCtx, newRaceNameLookupKey, input.UserID.String(), 0)
|
||||
pipe.Set(operationCtx, newReservationKey, newReservationPayload, 0)
|
||||
pipe.Del(operationCtx, currentRaceNameLookupKey)
|
||||
if input.CurrentCanonicalKey != input.NewReservation.CanonicalKey {
|
||||
pipe.Del(operationCtx, store.keyspace.RaceNameReservation(input.CurrentCanonicalKey))
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, accountKey, newRaceNameLookupKey, newReservationKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("rename account race name %q in redis: %w", input.UserID, ports.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Update replaces the stored account state for record.UserID.
|
||||
// Update replaces the stored account state for record.UserID. `email` and
|
||||
// `user_name` are immutable; any attempt to mutate them returns
|
||||
// ports.ErrConflict.
|
||||
func (store *Store) Update(ctx context.Context, record account.UserAccount) error {
|
||||
if err := record.Validate(); err != nil {
|
||||
return fmt.Errorf("update account in redis: %w", err)
|
||||
@@ -485,7 +387,7 @@ func (store *Store) Update(ctx context.Context, record account.UserAccount) erro
|
||||
|
||||
accountKey := store.keyspace.Account(record.UserID)
|
||||
emailLookupKey := store.keyspace.EmailLookup(record.Email)
|
||||
raceNameLookupKey := store.keyspace.RaceNameLookup(record.RaceName)
|
||||
userNameLookupKey := store.keyspace.UserNameLookup(record.UserName)
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "update account in redis")
|
||||
if err != nil {
|
||||
@@ -498,7 +400,7 @@ func (store *Store) Update(ctx context.Context, record account.UserAccount) erro
|
||||
if err != nil {
|
||||
return fmt.Errorf("update account %q in redis: %w", record.UserID, err)
|
||||
}
|
||||
if current.Email != record.Email || current.RaceName != record.RaceName {
|
||||
if current.Email != record.Email || current.UserName != record.UserName {
|
||||
return fmt.Errorf("update account %q in redis: %w", record.UserID, ports.ErrConflict)
|
||||
}
|
||||
|
||||
@@ -510,11 +412,11 @@ func (store *Store) Update(ctx context.Context, record account.UserAccount) erro
|
||||
return fmt.Errorf("update account %q in redis: %w", record.UserID, ports.ErrConflict)
|
||||
}
|
||||
|
||||
raceLookupUserID, err := store.loadLookupUserID(operationCtx, tx, raceNameLookupKey)
|
||||
userNameLookupUserID, err := store.loadLookupUserID(operationCtx, tx, userNameLookupKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update account %q in redis: %w", record.UserID, err)
|
||||
}
|
||||
if raceLookupUserID != record.UserID {
|
||||
if userNameLookupUserID != record.UserID {
|
||||
return fmt.Errorf("update account %q in redis: %w", record.UserID, ports.ErrConflict)
|
||||
}
|
||||
|
||||
@@ -528,7 +430,7 @@ func (store *Store) Update(ctx context.Context, record account.UserAccount) erro
|
||||
}
|
||||
|
||||
return nil
|
||||
}, accountKey, emailLookupKey, raceNameLookupKey)
|
||||
}, accountKey, emailLookupKey, userNameLookupKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
@@ -965,17 +867,31 @@ func (store *Store) ResolveByEmail(ctx context.Context, email common.Email) (por
|
||||
accountRecord, err := store.GetByEmailAccount(operationCtx, email)
|
||||
switch {
|
||||
case err == nil:
|
||||
return ports.ResolveByEmailResult{
|
||||
Kind: ports.AuthResolutionKindExisting,
|
||||
UserID: accountRecord.UserID,
|
||||
}, nil
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return ports.ResolveByEmailResult{Kind: ports.AuthResolutionKindCreatable}, nil
|
||||
default:
|
||||
return ports.ResolveByEmailResult{}, fmt.Errorf("resolve by email %q in redis: %w", email, err)
|
||||
}
|
||||
|
||||
if accountRecord.IsDeleted() {
|
||||
return ports.ResolveByEmailResult{
|
||||
Kind: ports.AuthResolutionKindBlocked,
|
||||
BlockReasonCode: deletedAccountBlockReasonCode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return ports.ResolveByEmailResult{
|
||||
Kind: ports.AuthResolutionKindExisting,
|
||||
UserID: accountRecord.UserID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// deletedAccountBlockReasonCode is the reason_code returned when an auth-facing
|
||||
// lookup resolves to a soft-deleted account. It is not a real sanction; the
|
||||
// auth/session service treats it as a blocked outcome and refuses to issue a
|
||||
// session for the subject.
|
||||
const deletedAccountBlockReasonCode common.ReasonCode = "account_deleted"
|
||||
|
||||
// EnsureByEmail atomically returns an existing user, creates a new one, or
|
||||
// reports a blocked outcome for one e-mail subject.
|
||||
func (store *Store) EnsureByEmail(ctx context.Context, input ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
|
||||
@@ -995,11 +911,6 @@ func (store *Store) EnsureByEmail(ctx context.Context, input ports.EnsureByEmail
|
||||
if err != nil {
|
||||
return ports.EnsureByEmailResult{}, fmt.Errorf("ensure by email in redis: %w", err)
|
||||
}
|
||||
reservationPayload, err := marshalRaceNameReservationRecord(input.Reservation)
|
||||
if err != nil {
|
||||
return ports.EnsureByEmailResult{}, fmt.Errorf("ensure by email in redis: %w", err)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "ensure by email in redis")
|
||||
if err != nil {
|
||||
return ports.EnsureByEmailResult{}, err
|
||||
@@ -1011,8 +922,7 @@ func (store *Store) EnsureByEmail(ctx context.Context, input ports.EnsureByEmail
|
||||
|
||||
accountKey := store.keyspace.Account(input.Account.UserID)
|
||||
emailLookupKey := store.keyspace.EmailLookup(input.Email)
|
||||
raceNameLookupKey := store.keyspace.RaceNameLookup(input.Account.RaceName)
|
||||
reservationKey := store.keyspace.RaceNameReservation(input.Reservation.CanonicalKey)
|
||||
userNameLookupKey := store.keyspace.UserNameLookup(input.Account.UserName)
|
||||
blockedEmailKey := store.keyspace.BlockedEmailSubject(input.Email)
|
||||
entitlementKey := store.keyspace.EntitlementSnapshot(input.Account.UserID)
|
||||
entitlementRecordKey := store.keyspace.EntitlementRecord(input.EntitlementRecord.RecordID)
|
||||
@@ -1039,6 +949,14 @@ func (store *Store) EnsureByEmail(ctx context.Context, input ports.EnsureByEmail
|
||||
if err != nil {
|
||||
return fmt.Errorf("ensure by email %q in redis: %w", input.Email, err)
|
||||
}
|
||||
if record.IsDeleted() {
|
||||
result = ports.EnsureByEmailResult{
|
||||
Outcome: ports.EnsureByEmailOutcomeBlocked,
|
||||
BlockReasonCode: deletedAccountBlockReasonCode,
|
||||
}
|
||||
handled = true
|
||||
return nil
|
||||
}
|
||||
result = ports.EnsureByEmailResult{
|
||||
Outcome: ports.EnsureByEmailOutcomeExisting,
|
||||
UserID: record.UserID,
|
||||
@@ -1052,15 +970,9 @@ func (store *Store) EnsureByEmail(ctx context.Context, input ports.EnsureByEmail
|
||||
if err := ensureKeyAbsent(operationCtx, tx, accountKey); err != nil {
|
||||
return fmt.Errorf("ensure by email %q in redis: %w", input.Email, err)
|
||||
}
|
||||
if err := ensureKeyAbsent(operationCtx, tx, raceNameLookupKey); err != nil {
|
||||
if err := ensureKeyAbsent(operationCtx, tx, userNameLookupKey); err != nil {
|
||||
if errors.Is(err, ports.ErrConflict) {
|
||||
return fmt.Errorf("ensure by email %q in redis: %w", input.Email, ports.ErrRaceNameConflict)
|
||||
}
|
||||
return fmt.Errorf("ensure by email %q in redis: %w", input.Email, err)
|
||||
}
|
||||
if err := ensureKeyAbsent(operationCtx, tx, reservationKey); err != nil {
|
||||
if errors.Is(err, ports.ErrConflict) {
|
||||
return fmt.Errorf("ensure by email %q in redis: %w", input.Email, ports.ErrRaceNameConflict)
|
||||
return fmt.Errorf("ensure by email %q in redis: %w", input.Email, ports.ErrUserNameConflict)
|
||||
}
|
||||
return fmt.Errorf("ensure by email %q in redis: %w", input.Email, err)
|
||||
}
|
||||
@@ -1074,8 +986,7 @@ func (store *Store) EnsureByEmail(ctx context.Context, input ports.EnsureByEmail
|
||||
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(operationCtx, accountKey, accountPayload, 0)
|
||||
pipe.Set(operationCtx, emailLookupKey, input.Account.UserID.String(), 0)
|
||||
pipe.Set(operationCtx, raceNameLookupKey, input.Account.UserID.String(), 0)
|
||||
pipe.Set(operationCtx, reservationKey, reservationPayload, 0)
|
||||
pipe.Set(operationCtx, userNameLookupKey, input.Account.UserID.String(), 0)
|
||||
pipe.Set(operationCtx, entitlementKey, entitlementPayload, 0)
|
||||
pipe.Set(operationCtx, entitlementRecordKey, entitlementRecordPayload, 0)
|
||||
pipe.ZAdd(operationCtx, entitlementHistoryKey, redis.Z{
|
||||
@@ -1100,7 +1011,7 @@ func (store *Store) EnsureByEmail(ctx context.Context, input ports.EnsureByEmail
|
||||
}
|
||||
handled = true
|
||||
return nil
|
||||
}, blockedEmailKey, emailLookupKey, accountKey, raceNameLookupKey, reservationKey, entitlementKey, entitlementRecordKey, entitlementHistoryKey)
|
||||
}, blockedEmailKey, emailLookupKey, accountKey, userNameLookupKey, entitlementKey, entitlementRecordKey, entitlementHistoryKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
@@ -1136,6 +1047,9 @@ func (store *Store) BlockByUserID(ctx context.Context, input ports.BlockByUserID
|
||||
}
|
||||
return ports.BlockResult{}, fmt.Errorf("block by user id %q in redis: %w", input.UserID, err)
|
||||
}
|
||||
if currentAccount.IsDeleted() {
|
||||
return ports.BlockResult{}, fmt.Errorf("block by user id %q in redis: %w", input.UserID, ports.ErrNotFound)
|
||||
}
|
||||
|
||||
accountKey := store.keyspace.Account(input.UserID)
|
||||
blockedEmailKey := store.keyspace.BlockedEmailSubject(currentAccount.Email)
|
||||
@@ -1145,6 +1059,9 @@ func (store *Store) BlockByUserID(ctx context.Context, input ports.BlockByUserID
|
||||
if err != nil {
|
||||
return fmt.Errorf("block by user id %q in redis: %w", input.UserID, err)
|
||||
}
|
||||
if accountRecord.IsDeleted() {
|
||||
return fmt.Errorf("block by user id %q in redis: %w", input.UserID, ports.ErrNotFound)
|
||||
}
|
||||
|
||||
blocked, err := store.loadBlockedEmail(operationCtx, tx, accountRecord.Email)
|
||||
switch {
|
||||
@@ -1327,22 +1244,6 @@ func (store *Store) loadLookupUserID(ctx context.Context, getter bytesGetter, ke
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func (store *Store) loadRaceNameReservation(
|
||||
ctx context.Context,
|
||||
getter bytesGetter,
|
||||
key account.RaceNameCanonicalKey,
|
||||
) (account.RaceNameReservation, error) {
|
||||
payload, err := getter.Get(ctx, store.keyspace.RaceNameReservation(key)).Bytes()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return account.RaceNameReservation{}, ports.ErrNotFound
|
||||
case err != nil:
|
||||
return account.RaceNameReservation{}, err
|
||||
}
|
||||
|
||||
return decodeRaceNameReservationRecord(payload)
|
||||
}
|
||||
|
||||
func (store *Store) loadBlockedEmail(ctx context.Context, getter bytesGetter, email common.Email) (authblock.BlockedEmailSubject, error) {
|
||||
payload, err := getter.Get(ctx, store.keyspace.BlockedEmailSubject(email)).Bytes()
|
||||
switch {
|
||||
@@ -1436,32 +1337,12 @@ func ensureLookupAvailableOrOwned(
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *Store) ensureReservationAvailableOrOwned(
|
||||
ctx context.Context,
|
||||
getter bytesGetter,
|
||||
key account.RaceNameCanonicalKey,
|
||||
userID common.UserID,
|
||||
) error {
|
||||
record, err := store.loadRaceNameReservation(ctx, getter, key)
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return nil
|
||||
case err != nil:
|
||||
return err
|
||||
}
|
||||
|
||||
if record.UserID != userID {
|
||||
return ports.ErrConflict
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func marshalAccountRecord(record account.UserAccount) ([]byte, error) {
|
||||
encoded := accountRecord{
|
||||
UserID: record.UserID.String(),
|
||||
Email: record.Email.String(),
|
||||
RaceName: record.RaceName.String(),
|
||||
UserName: record.UserName.String(),
|
||||
DisplayName: record.DisplayName.String(),
|
||||
PreferredLanguage: record.PreferredLanguage.String(),
|
||||
TimeZone: record.TimeZone.String(),
|
||||
CreatedAt: record.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
@@ -1471,6 +1352,10 @@ func marshalAccountRecord(record account.UserAccount) ([]byte, error) {
|
||||
value := record.DeclaredCountry.String()
|
||||
encoded.DeclaredCountry = &value
|
||||
}
|
||||
if record.DeletedAt != nil {
|
||||
value := record.DeletedAt.UTC().Format(time.RFC3339Nano)
|
||||
encoded.DeletedAt = &value
|
||||
}
|
||||
|
||||
return json.Marshal(encoded)
|
||||
}
|
||||
@@ -1493,7 +1378,8 @@ func decodeAccountRecord(payload []byte) (account.UserAccount, error) {
|
||||
record := account.UserAccount{
|
||||
UserID: common.UserID(encoded.UserID),
|
||||
Email: common.Email(encoded.Email),
|
||||
RaceName: common.RaceName(encoded.RaceName),
|
||||
UserName: common.UserName(encoded.UserName),
|
||||
DisplayName: common.DisplayName(encoded.DisplayName),
|
||||
PreferredLanguage: common.LanguageTag(encoded.PreferredLanguage),
|
||||
TimeZone: common.TimeZoneName(encoded.TimeZone),
|
||||
CreatedAt: createdAt.UTC(),
|
||||
@@ -1502,6 +1388,14 @@ func decodeAccountRecord(payload []byte) (account.UserAccount, error) {
|
||||
if encoded.DeclaredCountry != nil {
|
||||
record.DeclaredCountry = common.CountryCode(*encoded.DeclaredCountry)
|
||||
}
|
||||
if encoded.DeletedAt != nil {
|
||||
deletedAt, err := time.Parse(time.RFC3339Nano, *encoded.DeletedAt)
|
||||
if err != nil {
|
||||
return account.UserAccount{}, fmt.Errorf("decode account record deleted_at: %w", err)
|
||||
}
|
||||
deletedAt = deletedAt.UTC()
|
||||
record.DeletedAt = &deletedAt
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return account.UserAccount{}, fmt.Errorf("decode account record: %w", err)
|
||||
}
|
||||
@@ -1509,41 +1403,6 @@ func decodeAccountRecord(payload []byte) (account.UserAccount, error) {
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func marshalRaceNameReservationRecord(record account.RaceNameReservation) ([]byte, error) {
|
||||
encoded := raceNameReservationRecord{
|
||||
CanonicalKey: record.CanonicalKey.String(),
|
||||
UserID: record.UserID.String(),
|
||||
RaceName: record.RaceName.String(),
|
||||
ReservedAt: record.ReservedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
|
||||
return json.Marshal(encoded)
|
||||
}
|
||||
|
||||
func decodeRaceNameReservationRecord(payload []byte) (account.RaceNameReservation, error) {
|
||||
var encoded raceNameReservationRecord
|
||||
if err := decodeJSONPayload(payload, &encoded); err != nil {
|
||||
return account.RaceNameReservation{}, err
|
||||
}
|
||||
|
||||
reservedAt, err := time.Parse(time.RFC3339Nano, encoded.ReservedAt)
|
||||
if err != nil {
|
||||
return account.RaceNameReservation{}, fmt.Errorf("decode race-name reservation reserved_at: %w", err)
|
||||
}
|
||||
|
||||
record := account.RaceNameReservation{
|
||||
CanonicalKey: account.RaceNameCanonicalKey(encoded.CanonicalKey),
|
||||
UserID: common.UserID(encoded.UserID),
|
||||
RaceName: common.RaceName(encoded.RaceName),
|
||||
ReservedAt: reservedAt.UTC(),
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return account.RaceNameReservation{}, fmt.Errorf("decode race-name reservation: %w", err)
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func marshalBlockedEmailRecord(record authblock.BlockedEmailSubject) ([]byte, error) {
|
||||
encoded := blockedEmailRecord{
|
||||
Email: record.Email.String(),
|
||||
@@ -1902,9 +1761,9 @@ func (adapter *AccountStore) GetByEmail(ctx context.Context, email common.Email)
|
||||
return adapter.store.GetByEmail(ctx, email)
|
||||
}
|
||||
|
||||
// GetByRaceName returns the stored account identified by raceName.
|
||||
func (adapter *AccountStore) GetByRaceName(ctx context.Context, raceName common.RaceName) (account.UserAccount, error) {
|
||||
return adapter.store.GetByRaceName(ctx, raceName)
|
||||
// GetByUserName returns the stored account identified by userName.
|
||||
func (adapter *AccountStore) GetByUserName(ctx context.Context, userName common.UserName) (account.UserAccount, error) {
|
||||
return adapter.store.GetByUserName(ctx, userName)
|
||||
}
|
||||
|
||||
// ExistsByUserID reports whether userID currently identifies a stored
|
||||
@@ -1913,11 +1772,6 @@ func (adapter *AccountStore) ExistsByUserID(ctx context.Context, userID common.U
|
||||
return adapter.store.ExistsByUserID(ctx, userID)
|
||||
}
|
||||
|
||||
// RenameRaceName replaces the stored race name of userID atomically.
|
||||
func (adapter *AccountStore) RenameRaceName(ctx context.Context, input ports.RenameRaceNameInput) error {
|
||||
return adapter.store.RenameRaceName(ctx, input)
|
||||
}
|
||||
|
||||
// Update replaces the stored account state for record.UserID.
|
||||
func (adapter *AccountStore) Update(ctx context.Context, record account.UserAccount) error {
|
||||
return adapter.store.Update(ctx, record)
|
||||
|
||||
@@ -2,7 +2,6 @@ package userstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -34,18 +33,13 @@ func TestAccountStoreCreateAndLookups(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record, byEmail)
|
||||
|
||||
byRaceName, err := accountStore.GetByRaceName(context.Background(), record.RaceName)
|
||||
byUserName, err := accountStore.GetByUserName(context.Background(), record.UserName)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record, byRaceName)
|
||||
require.Equal(t, record, byUserName)
|
||||
|
||||
exists, err := accountStore.ExistsByUserID(context.Background(), record.UserID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, exists)
|
||||
|
||||
reservation, err := store.loadRaceNameReservation(context.Background(), store.client, canonicalKey(record.RaceName))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record.UserID, reservation.UserID)
|
||||
require.Equal(t, record.RaceName, reservation.RaceName)
|
||||
}
|
||||
|
||||
func TestBlockedEmailStoreUpsertAndGet(t *testing.T) {
|
||||
@@ -80,14 +74,13 @@ func TestEnsureResolveAndBlockFlows(t *testing.T) {
|
||||
Account: accountRecord,
|
||||
Entitlement: entitlementSnapshot,
|
||||
EntitlementRecord: validEntitlementRecord(accountRecord.UserID, now),
|
||||
Reservation: raceNameReservation(accountRecord.UserID, accountRecord.RaceName, accountRecord.UpdatedAt),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.EnsureByEmailOutcomeCreated, created.Outcome)
|
||||
|
||||
reservation, err := store.loadRaceNameReservation(context.Background(), store.client, canonicalKey(accountRecord.RaceName))
|
||||
byUserName, err := store.GetByUserName(context.Background(), accountRecord.UserName)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, accountRecord.UserID, reservation.UserID)
|
||||
require.Equal(t, accountRecord.UserID, byUserName.UserID)
|
||||
|
||||
entitlementHistory, err := store.ListEntitlementRecordsByUserID(context.Background(), accountRecord.UserID)
|
||||
require.NoError(t, err)
|
||||
@@ -124,7 +117,6 @@ func TestEnsureResolveAndBlockFlows(t *testing.T) {
|
||||
Account: accountRecord,
|
||||
Entitlement: entitlementSnapshot,
|
||||
EntitlementRecord: validEntitlementRecord(accountRecord.UserID, now),
|
||||
Reservation: raceNameReservation(accountRecord.UserID, accountRecord.RaceName, accountRecord.UpdatedAt),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.EnsureByEmailOutcomeBlocked, ensureBlocked.Outcome)
|
||||
@@ -156,7 +148,6 @@ func TestBlockedEmailWithoutUserPreventsEnsureCreate(t *testing.T) {
|
||||
Account: accountRecord,
|
||||
Entitlement: entitlementSnapshot,
|
||||
EntitlementRecord: validEntitlementRecord(accountRecord.UserID, now),
|
||||
Reservation: raceNameReservation(accountRecord.UserID, accountRecord.RaceName, accountRecord.UpdatedAt),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.EnsureByEmailOutcomeBlocked, ensured.Outcome)
|
||||
@@ -174,7 +165,7 @@ func TestEnsureByEmailExistingDoesNotOverwriteStoredSettings(t *testing.T) {
|
||||
existingAccount := account.UserAccount{
|
||||
UserID: common.UserID("user-existing"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
RaceName: common.RaceName("Pilot Nova"),
|
||||
UserName: common.UserName("player-abcdefgh"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
|
||||
CreatedAt: createdAt,
|
||||
@@ -187,7 +178,7 @@ func TestEnsureByEmailExistingDoesNotOverwriteStoredSettings(t *testing.T) {
|
||||
Account: account.UserAccount{
|
||||
UserID: common.UserID("user-created"),
|
||||
Email: existingAccount.Email,
|
||||
RaceName: common.RaceName("player-new123"),
|
||||
UserName: common.UserName("player-newabcde"),
|
||||
PreferredLanguage: common.LanguageTag("fr-FR"),
|
||||
TimeZone: common.TimeZoneName("UTC"),
|
||||
CreatedAt: createdAt.Add(time.Minute),
|
||||
@@ -195,7 +186,6 @@ func TestEnsureByEmailExistingDoesNotOverwriteStoredSettings(t *testing.T) {
|
||||
},
|
||||
Entitlement: validEntitlementSnapshot(common.UserID("user-created"), createdAt.Add(time.Minute)),
|
||||
EntitlementRecord: validEntitlementRecord(common.UserID("user-created"), createdAt.Add(time.Minute)),
|
||||
Reservation: raceNameReservation(common.UserID("user-created"), common.RaceName("player-new123"), createdAt.Add(time.Minute)),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.EnsureByEmailOutcomeExisting, result.Outcome)
|
||||
@@ -206,76 +196,49 @@ func TestEnsureByEmailExistingDoesNotOverwriteStoredSettings(t *testing.T) {
|
||||
require.Equal(t, existingAccount, storedAccount)
|
||||
}
|
||||
|
||||
func TestAccountStoreRenameRaceNameSwapsLookupAtomically(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
accountStore := store.Accounts()
|
||||
record := validAccountRecord()
|
||||
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record)))
|
||||
|
||||
updatedAt := record.UpdatedAt.Add(time.Minute)
|
||||
require.NoError(t, accountStore.RenameRaceName(context.Background(), renameRaceNameInput(record, common.RaceName("Nova Prime"), updatedAt)))
|
||||
|
||||
stored, err := accountStore.GetByUserID(context.Background(), record.UserID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, common.RaceName("Nova Prime"), stored.RaceName)
|
||||
require.True(t, stored.UpdatedAt.Equal(updatedAt))
|
||||
|
||||
_, err = accountStore.GetByRaceName(context.Background(), record.RaceName)
|
||||
require.ErrorIs(t, err, ports.ErrNotFound)
|
||||
|
||||
renamed, err := accountStore.GetByRaceName(context.Background(), common.RaceName("Nova Prime"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record.UserID, renamed.UserID)
|
||||
|
||||
_, err = store.loadRaceNameReservation(context.Background(), store.client, canonicalKey(record.RaceName))
|
||||
require.ErrorIs(t, err, ports.ErrNotFound)
|
||||
|
||||
reservation, err := store.loadRaceNameReservation(context.Background(), store.client, canonicalKey(common.RaceName("Nova Prime")))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, common.RaceName("Nova Prime"), reservation.RaceName)
|
||||
}
|
||||
|
||||
func TestAccountStoreRenameRaceNameAllowsSameOwnerCanonicalSlot(t *testing.T) {
|
||||
func TestAccountStoreUpdateDisplayNamePreservesImmutableFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
accountStore := store.Accounts()
|
||||
|
||||
record := validAccountRecord()
|
||||
record.RaceName = common.RaceName("Pilot Nova")
|
||||
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record)))
|
||||
|
||||
updatedAt := record.UpdatedAt.Add(time.Minute)
|
||||
require.NoError(t, accountStore.RenameRaceName(context.Background(), renameRaceNameInput(record, common.RaceName("P1lot Nova"), updatedAt)))
|
||||
updated := record
|
||||
updated.DisplayName = common.DisplayName("NovaPrime")
|
||||
updated.UpdatedAt = record.UpdatedAt.Add(time.Minute)
|
||||
|
||||
reservation, err := store.loadRaceNameReservation(context.Background(), store.client, canonicalKey(common.RaceName("P1lot Nova")))
|
||||
require.NoError(t, accountStore.Update(context.Background(), updated))
|
||||
|
||||
byUserID, err := accountStore.GetByUserID(context.Background(), record.UserID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, common.RaceName("P1lot Nova"), reservation.RaceName)
|
||||
require.Equal(t, updated, byUserID)
|
||||
|
||||
byEmail, err := accountStore.GetByEmail(context.Background(), record.Email)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, updated, byEmail)
|
||||
|
||||
byUserName, err := accountStore.GetByUserName(context.Background(), record.UserName)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, updated, byUserName)
|
||||
}
|
||||
|
||||
func TestAccountStoreRenameRaceNameReturnsConflictWhenTargetExists(t *testing.T) {
|
||||
func TestAccountStoreUpdateRejectsUserNameMutation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
accountStore := store.Accounts()
|
||||
|
||||
first := validAccountRecord()
|
||||
second := validAccountRecord()
|
||||
second.UserID = common.UserID("user-456")
|
||||
second.Email = common.Email("other@example.com")
|
||||
second.RaceName = common.RaceName("Taken Name")
|
||||
record := validAccountRecord()
|
||||
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record)))
|
||||
|
||||
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(first)))
|
||||
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(second)))
|
||||
attempted := record
|
||||
attempted.UserName = common.UserName("player-changed")
|
||||
attempted.UpdatedAt = record.UpdatedAt.Add(time.Minute)
|
||||
|
||||
err := accountStore.RenameRaceName(context.Background(), renameRaceNameInput(first, second.RaceName, first.UpdatedAt.Add(time.Minute)))
|
||||
err := accountStore.Update(context.Background(), attempted)
|
||||
require.ErrorIs(t, err, ports.ErrConflict)
|
||||
|
||||
stored, err := accountStore.GetByUserID(context.Background(), first.UserID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, first.RaceName, stored.RaceName)
|
||||
}
|
||||
|
||||
func TestAccountStoreUpdateDeclaredCountryPreservesLookups(t *testing.T) {
|
||||
@@ -301,12 +264,35 @@ func TestAccountStoreUpdateDeclaredCountryPreservesLookups(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, updated, byEmail)
|
||||
|
||||
byRaceName, err := accountStore.GetByRaceName(context.Background(), record.RaceName)
|
||||
byUserName, err := accountStore.GetByUserName(context.Background(), record.UserName)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, updated, byRaceName)
|
||||
require.Equal(t, updated, byUserName)
|
||||
}
|
||||
|
||||
func TestAccountStoreCreateReturnsConflictWhenCanonicalReservationExists(t *testing.T) {
|
||||
func TestAccountStorePersistsSoftDeleteMarker(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
accountStore := store.Accounts()
|
||||
|
||||
record := validAccountRecord()
|
||||
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record)))
|
||||
|
||||
deletedAt := record.UpdatedAt.Add(time.Hour)
|
||||
updated := record
|
||||
updated.UpdatedAt = deletedAt
|
||||
updated.DeletedAt = &deletedAt
|
||||
|
||||
require.NoError(t, accountStore.Update(context.Background(), updated))
|
||||
|
||||
byUserID, err := accountStore.GetByUserID(context.Background(), record.UserID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, byUserID.DeletedAt)
|
||||
require.True(t, byUserID.DeletedAt.Equal(deletedAt))
|
||||
require.True(t, byUserID.IsDeleted())
|
||||
}
|
||||
|
||||
func TestAccountStoreCreateReturnsUserNameConflict(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
@@ -316,12 +302,11 @@ func TestAccountStoreCreateReturnsConflictWhenCanonicalReservationExists(t *test
|
||||
second := validAccountRecord()
|
||||
second.UserID = common.UserID("user-456")
|
||||
second.Email = common.Email("other@example.com")
|
||||
second.RaceName = common.RaceName("P1lot Nova")
|
||||
|
||||
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(first)))
|
||||
|
||||
err := accountStore.Create(context.Background(), createAccountInput(second))
|
||||
require.ErrorIs(t, err, ports.ErrConflict)
|
||||
require.ErrorIs(t, err, ports.ErrUserNameConflict)
|
||||
}
|
||||
|
||||
func TestBlockByUserIDRepeatedCallsStayIdempotent(t *testing.T) {
|
||||
@@ -805,7 +790,7 @@ func validAccountRecord() account.UserAccount {
|
||||
return account.UserAccount{
|
||||
UserID: common.UserID("user-123"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
RaceName: common.RaceName("Pilot Nova"),
|
||||
UserName: common.UserName("player-abcdefgh"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
|
||||
CreatedAt: createdAt,
|
||||
@@ -889,42 +874,6 @@ func timePointer(value time.Time) *time.Time {
|
||||
|
||||
func createAccountInput(record account.UserAccount) ports.CreateAccountInput {
|
||||
return ports.CreateAccountInput{
|
||||
Account: record,
|
||||
Reservation: raceNameReservation(record.UserID, record.RaceName, record.UpdatedAt),
|
||||
Account: record,
|
||||
}
|
||||
}
|
||||
|
||||
func renameRaceNameInput(
|
||||
record account.UserAccount,
|
||||
newRaceName common.RaceName,
|
||||
updatedAt time.Time,
|
||||
) ports.RenameRaceNameInput {
|
||||
return ports.RenameRaceNameInput{
|
||||
UserID: record.UserID,
|
||||
CurrentCanonicalKey: canonicalKey(record.RaceName),
|
||||
NewRaceName: newRaceName,
|
||||
NewReservation: raceNameReservation(record.UserID, newRaceName, updatedAt),
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func raceNameReservation(
|
||||
userID common.UserID,
|
||||
raceName common.RaceName,
|
||||
reservedAt time.Time,
|
||||
) account.RaceNameReservation {
|
||||
return account.RaceNameReservation{
|
||||
CanonicalKey: canonicalKey(raceName),
|
||||
UserID: userID,
|
||||
RaceName: raceName,
|
||||
ReservedAt: reservedAt.UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalKey(raceName common.RaceName) account.RaceNameCanonicalKey {
|
||||
return account.RaceNameCanonicalKey(strings.NewReplacer(
|
||||
"1", "i",
|
||||
"0", "o",
|
||||
"8", "b",
|
||||
).Replace(strings.ToLower(raceName.String())))
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
@@ -35,15 +34,9 @@ func (k Keyspace) EmailLookup(email common.Email) string {
|
||||
return k.prefix() + "lookup:email:" + encodeKeyComponent(email.String())
|
||||
}
|
||||
|
||||
// RaceNameLookup returns the exact stored race-name lookup key.
|
||||
func (k Keyspace) RaceNameLookup(raceName common.RaceName) string {
|
||||
return k.prefix() + "lookup:race-name:" + encodeKeyComponent(raceName.String())
|
||||
}
|
||||
|
||||
// RaceNameReservation returns the replaceable canonical race-name reservation
|
||||
// key.
|
||||
func (k Keyspace) RaceNameReservation(key account.RaceNameCanonicalKey) string {
|
||||
return k.prefix() + "reservation:race-name:" + encodeKeyComponent(key.String())
|
||||
// UserNameLookup returns the exact stored user-name lookup key.
|
||||
func (k Keyspace) UserNameLookup(userName common.UserName) string {
|
||||
return k.prefix() + "lookup:user-name:" + encodeKeyComponent(userName.String())
|
||||
}
|
||||
|
||||
// BlockedEmailSubject returns the dedicated blocked-email-subject key.
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
@@ -19,8 +18,7 @@ func TestKeyspaceBuildsStableKeys(t *testing.T) {
|
||||
|
||||
require.Equal(t, "custom:account:dXNlci0xMjM", keyspace.Account(common.UserID("user-123")))
|
||||
require.Equal(t, "custom:lookup:email:cGlsb3RAZXhhbXBsZS5jb20", keyspace.EmailLookup(common.Email("pilot@example.com")))
|
||||
require.Equal(t, "custom:lookup:race-name:UGlsb3QgTm92YQ", keyspace.RaceNameLookup(common.RaceName("Pilot Nova")))
|
||||
require.Equal(t, "custom:reservation:race-name:cGlsb3Qtbm92YQ", keyspace.RaceNameReservation(account.RaceNameCanonicalKey("pilot-nova")))
|
||||
require.Equal(t, "custom:lookup:user-name:cGxheWVyLWFiY2RlZmdo", keyspace.UserNameLookup(common.UserName("player-abcdefgh")))
|
||||
require.Equal(t, "custom:blocked-email:cGlsb3RAZXhhbXBsZS5jb20", keyspace.BlockedEmailSubject(common.Email("pilot@example.com")))
|
||||
require.Equal(t, "custom:entitlement:record:ZW50aXRsZW1lbnQtMTIz", keyspace.EntitlementRecord(entitlement.EntitlementRecordID("entitlement-123")))
|
||||
require.Equal(t, "custom:sanction:record:c2FuY3Rpb24tMQ", keyspace.SanctionRecord(policy.SanctionRecordID("sanction-1")))
|
||||
|
||||
@@ -17,8 +17,8 @@ type getUserByEmailRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type getUserByRaceNameRequest struct {
|
||||
RaceName string `json:"race_name"`
|
||||
type getUserByUserNameRequest struct {
|
||||
UserName string `json:"user_name"`
|
||||
}
|
||||
|
||||
func handleGetUserByID(useCase GetUserByIDUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
@@ -61,9 +61,9 @@ func handleGetUserByEmail(useCase GetUserByEmailUseCase, timeout time.Duration)
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetUserByRaceName(useCase GetUserByRaceNameUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
func handleGetUserByUserName(useCase GetUserByUserNameUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request getUserByRaceNameRequest
|
||||
var request getUserByUserNameRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
@@ -72,8 +72,8 @@ func handleGetUserByRaceName(useCase GetUserByRaceNameUseCase, timeout time.Dura
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, adminusers.GetUserByRaceNameInput{
|
||||
RaceName: request.RaceName,
|
||||
result, err := useCase.Execute(callCtx, adminusers.GetUserByUserNameInput{
|
||||
UserName: request.UserName,
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
@@ -144,6 +144,9 @@ func buildListUsersInput(c *gin.Context) (adminusers.ListUsersInput, error) {
|
||||
DeclaredCountry: c.Query("declared_country"),
|
||||
SanctionCode: c.Query("sanction_code"),
|
||||
LimitCode: c.Query("limit_code"),
|
||||
UserName: c.Query("user_name"),
|
||||
DisplayName: c.Query("display_name"),
|
||||
DisplayNameMatch: c.Query("display_name_match"),
|
||||
CanLogin: canLogin,
|
||||
CanCreatePrivateGame: canCreatePrivateGame,
|
||||
CanJoinGame: canJoinGame,
|
||||
|
||||
@@ -27,8 +27,8 @@ func TestAdminReadHandlersSuccessCases(t *testing.T) {
|
||||
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)
|
||||
GetUserByUserName: getUserByUserNameFunc(func(_ context.Context, input adminusers.GetUserByUserNameInput) (adminusers.LookupResult, error) {
|
||||
require.Equal(t, "player-abcdefgh", input.UserName)
|
||||
return adminusers.LookupResult{User: sampleAccountView()}, nil
|
||||
}),
|
||||
ListUsers: listUsersFunc(func(_ context.Context, input adminusers.ListUsersInput) (adminusers.ListUsersResult, error) {
|
||||
@@ -52,7 +52,7 @@ func TestAdminReadHandlersSuccessCases(t *testing.T) {
|
||||
other := sampleAccountView()
|
||||
other.UserID = "user-234"
|
||||
other.Email = "second@example.com"
|
||||
other.RaceName = "Second Pilot"
|
||||
other.UserName = "player-second12"
|
||||
|
||||
return adminusers.ListUsersResult{
|
||||
Items: []accountview.AccountView{sampleAccountView(), other},
|
||||
@@ -74,7 +74,7 @@ func TestAdminReadHandlersSuccessCases(t *testing.T) {
|
||||
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"}}`,
|
||||
wantBody: `{"user":{"user_id":"user-123","email":"pilot@example.com","user_name":"player-abcdefgh","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",
|
||||
@@ -82,22 +82,22 @@ func TestAdminReadHandlersSuccessCases(t *testing.T) {
|
||||
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"}}`,
|
||||
wantBody: `{"user":{"user_id":"user-123","email":"pilot@example.com","user_name":"player-abcdefgh","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",
|
||||
name: "get user by user name",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/user-lookups/by-race-name",
|
||||
body: `{"race_name":"Pilot Nova"}`,
|
||||
path: "/api/v1/internal/user-lookups/by-user-name",
|
||||
body: `{"user_name":"player-abcdefgh"}`,
|
||||
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"}}`,
|
||||
wantBody: `{"user":{"user_id":"user-123","email":"pilot@example.com","user_name":"player-abcdefgh","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"}`,
|
||||
wantBody: `{"items":[{"user_id":"user-123","email":"pilot@example.com","user_name":"player-abcdefgh","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","user_name":"player-second12","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"}`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ func TestAdminReadHandlersErrorCases(t *testing.T) {
|
||||
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) {
|
||||
GetUserByUserName: getUserByUserNameFunc(func(context.Context, adminusers.GetUserByUserNameInput) (adminusers.LookupResult, error) {
|
||||
return adminusers.LookupResult{}, shared.SubjectNotFound()
|
||||
}),
|
||||
ListUsers: listUsersFunc(func(context.Context, adminusers.ListUsersInput) (adminusers.ListUsersResult, error) {
|
||||
@@ -169,10 +169,10 @@ func TestAdminReadHandlersErrorCases(t *testing.T) {
|
||||
wantBody: `{"error":{"code":"invalid_request","message":"request body contains unknown field \"extra\""}}`,
|
||||
},
|
||||
{
|
||||
name: "get user by race name not found",
|
||||
name: "get user by user name not found",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/user-lookups/by-race-name",
|
||||
body: `{"race_name":"Missing Pilot"}`,
|
||||
path: "/api/v1/internal/user-lookups/by-user-name",
|
||||
body: `{"user_name":"player-missingx"}`,
|
||||
wantStatus: http.StatusNotFound,
|
||||
wantBody: `{"error":{"code":"subject_not_found","message":"subject not found"}}`,
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/logging"
|
||||
"galaxy/user/internal/service/accountdeletion"
|
||||
"galaxy/user/internal/service/authdirectory"
|
||||
"galaxy/user/internal/service/entitlementsvc"
|
||||
"galaxy/user/internal/service/geosync"
|
||||
@@ -82,7 +83,7 @@ type getMyAccountResponse struct {
|
||||
}
|
||||
|
||||
type updateMyProfileRequest struct {
|
||||
RaceName string `json:"race_name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
type updateMySettingsRequest struct {
|
||||
@@ -157,6 +158,16 @@ type removeLimitRequest struct {
|
||||
Actor actorDTO `json:"actor"`
|
||||
}
|
||||
|
||||
type deleteUserRequest struct {
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Actor actorDTO `json:"actor"`
|
||||
}
|
||||
|
||||
type deleteUserResponse struct {
|
||||
UserID string `json:"user_id"`
|
||||
DeletedAt time.Time `json:"deleted_at"`
|
||||
}
|
||||
|
||||
type entitlementSnapshotResponse struct {
|
||||
PlanCode string `json:"plan_code"`
|
||||
IsPaid bool `json:"is_paid"`
|
||||
@@ -200,7 +211,7 @@ func newHandlerWithConfig(cfg Config, deps Dependencies) (http.Handler, error) {
|
||||
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.POST("/api/v1/internal/user-lookups/by-user-name", handleGetUserByUserName(normalizedDeps.GetUserByUserName, 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))
|
||||
@@ -211,6 +222,7 @@ func newHandlerWithConfig(cfg Config, deps Dependencies) (http.Handler, error) {
|
||||
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))
|
||||
engine.POST("/api/v1/internal/users/:user_id/delete", handleDeleteUser(normalizedDeps.DeleteUser, cfg.RequestTimeout))
|
||||
|
||||
return engine, nil
|
||||
}
|
||||
@@ -382,8 +394,8 @@ func handleUpdateMyProfile(useCase UpdateMyProfileUseCase, timeout time.Duration
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, selfservice.UpdateMyProfileInput{
|
||||
UserID: c.Param("user_id"),
|
||||
RaceName: request.RaceName,
|
||||
UserID: c.Param("user_id"),
|
||||
DisplayName: request.DisplayName,
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
@@ -681,6 +693,37 @@ func handleRemoveLimit(useCase RemoveLimitUseCase, timeout time.Duration) gin.Ha
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteUser(useCase DeleteUserUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request deleteUserRequest
|
||||
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, accountdeletion.Input{
|
||||
UserID: c.Param("user_id"),
|
||||
ReasonCode: request.ReasonCode,
|
||||
Actor: accountdeletion.ActorInput{
|
||||
Type: request.Actor.Type,
|
||||
ID: request.Actor.ID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, deleteUserResponse{
|
||||
UserID: result.UserID,
|
||||
DeletedAt: result.DeletedAt.UTC(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeDependencies(deps Dependencies) (Dependencies, error) {
|
||||
switch {
|
||||
case deps.ResolveByEmail == nil:
|
||||
@@ -703,8 +746,8 @@ func normalizeDependencies(deps Dependencies) (Dependencies, error) {
|
||||
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.GetUserByUserName == nil:
|
||||
return Dependencies{}, fmt.Errorf("get-user-by-user-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:
|
||||
@@ -725,6 +768,8 @@ func normalizeDependencies(deps Dependencies) (Dependencies, error) {
|
||||
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")
|
||||
case deps.DeleteUser == nil:
|
||||
return Dependencies{}, fmt.Errorf("delete-user use case must not be nil")
|
||||
default:
|
||||
if deps.Logger == nil {
|
||||
deps.Logger = slog.Default()
|
||||
|
||||
@@ -8,11 +8,11 @@ import (
|
||||
"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/accountdeletion"
|
||||
"galaxy/user/internal/service/adminusers"
|
||||
"galaxy/user/internal/service/authdirectory"
|
||||
"galaxy/user/internal/service/entitlementsvc"
|
||||
@@ -56,9 +56,9 @@ func TestAuthFacingHandlersSuccessCases(t *testing.T) {
|
||||
}),
|
||||
UpdateMyProfile: updateMyProfileFunc(func(_ context.Context, input selfservice.UpdateMyProfileInput) (selfservice.UpdateMyProfileResult, error) {
|
||||
require.Equal(t, "user-123", input.UserID)
|
||||
require.Equal(t, "Nova Prime", input.RaceName)
|
||||
require.Equal(t, "NovaPrime", input.DisplayName)
|
||||
accountView := sampleAccountView()
|
||||
accountView.RaceName = input.RaceName
|
||||
accountView.DisplayName = input.DisplayName
|
||||
return selfservice.UpdateMyProfileResult{Account: accountView}, nil
|
||||
}),
|
||||
UpdateMySettings: updateMySettingsFunc(func(_ context.Context, input selfservice.UpdateMySettingsInput) (selfservice.UpdateMySettingsResult, error) {
|
||||
@@ -211,6 +211,16 @@ func TestAuthFacingHandlersSuccessCases(t *testing.T) {
|
||||
require.Equal(t, "manual_remove", input.ReasonCode)
|
||||
return policysvc.LimitCommandResult{UserID: "user-123", ActiveLimits: []policysvc.ActiveLimitView{}}, nil
|
||||
}),
|
||||
DeleteUser: deleteUserFunc(func(_ context.Context, input accountdeletion.Input) (accountdeletion.Result, error) {
|
||||
require.Equal(t, "user-123", input.UserID)
|
||||
require.Equal(t, "user_right_to_be_forgotten", input.ReasonCode)
|
||||
require.Equal(t, "admin", input.Actor.Type)
|
||||
require.Equal(t, "admin-1", input.Actor.ID)
|
||||
return accountdeletion.Result{
|
||||
UserID: "user-123",
|
||||
DeletedAt: time.Date(2026, time.April, 24, 12, 0, 0, 0, time.UTC),
|
||||
}, nil
|
||||
}),
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
@@ -265,15 +275,15 @@ func TestAuthFacingHandlersSuccessCases(t *testing.T) {
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/users/user-123/account",
|
||||
wantStatus: http.StatusOK,
|
||||
wantBody: `{"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"}}`,
|
||||
wantBody: `{"account":{"user_id":"user-123","email":"pilot@example.com","user_name":"player-abcdefgh","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: "update my profile",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/users/user-123/profile",
|
||||
body: `{"race_name":"Nova Prime"}`,
|
||||
body: `{"display_name":"NovaPrime"}`,
|
||||
wantStatus: http.StatusOK,
|
||||
wantBody: `{"account":{"user_id":"user-123","email":"pilot@example.com","race_name":"Nova Prime","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"}}`,
|
||||
wantBody: `{"account":{"user_id":"user-123","email":"pilot@example.com","user_name":"player-abcdefgh","display_name":"NovaPrime","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: "update my settings",
|
||||
@@ -281,14 +291,14 @@ func TestAuthFacingHandlersSuccessCases(t *testing.T) {
|
||||
path: "/api/v1/internal/users/user-123/settings",
|
||||
body: `{"preferred_language":"en-US","time_zone":"UTC"}`,
|
||||
wantStatus: http.StatusOK,
|
||||
wantBody: `{"account":{"user_id":"user-123","email":"pilot@example.com","race_name":"Pilot Nova","preferred_language":"en-US","time_zone":"UTC","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"}}`,
|
||||
wantBody: `{"account":{"user_id":"user-123","email":"pilot@example.com","user_name":"player-abcdefgh","preferred_language":"en-US","time_zone":"UTC","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 eligibility",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/users/user-123/eligibility",
|
||||
wantStatus: http.StatusOK,
|
||||
wantBody: `{"exists":true,"user_id":"user-123","entitlement":{"plan_code":"paid_monthly","is_paid":true,"source":"billing","actor":{"type":"billing","id":"invoice-1"},"reason_code":"renewal","starts_at":"2026-04-09T10:00:00Z","ends_at":"2026-05-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[{"sanction_code":"private_game_create_block","scope":"lobby","reason_code":"manual_block","actor":{"type":"admin","id":"admin-1"},"applied_at":"2026-04-09T10:00:00Z","expires_at":"2026-05-09T10:00:00Z"}],"effective_limits":[{"limit_code":"max_owned_private_games","value":3},{"limit_code":"max_pending_public_applications","value":10},{"limit_code":"max_active_game_memberships","value":10}],"markers":{"can_login":true,"can_create_private_game":false,"can_manage_private_game":true,"can_join_game":true,"can_update_profile":true}}`,
|
||||
wantBody: `{"exists":true,"user_id":"user-123","entitlement":{"plan_code":"paid_monthly","is_paid":true,"source":"billing","actor":{"type":"billing","id":"invoice-1"},"reason_code":"renewal","starts_at":"2026-04-09T10:00:00Z","ends_at":"2026-05-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[{"sanction_code":"private_game_create_block","scope":"lobby","reason_code":"manual_block","actor":{"type":"admin","id":"admin-1"},"applied_at":"2026-04-09T10:00:00Z","expires_at":"2026-05-09T10:00:00Z"}],"effective_limits":[{"limit_code":"max_owned_private_games","value":3},{"limit_code":"max_pending_public_applications","value":10},{"limit_code":"max_active_game_memberships","value":10},{"limit_code":"max_registered_race_names","value":2}],"markers":{"can_login":true,"can_create_private_game":false,"can_manage_private_game":true,"can_join_game":true,"can_update_profile":true}}`,
|
||||
},
|
||||
{
|
||||
name: "get user eligibility not found snapshot",
|
||||
@@ -369,6 +379,14 @@ func TestAuthFacingHandlersSuccessCases(t *testing.T) {
|
||||
wantStatus: http.StatusOK,
|
||||
wantBody: `{"user_id":"user-123","active_limits":[]}`,
|
||||
},
|
||||
{
|
||||
name: "delete user",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/users/user-123/delete",
|
||||
body: `{"reason_code":"user_right_to_be_forgotten","actor":{"type":"admin","id":"admin-1"}}`,
|
||||
wantStatus: http.StatusOK,
|
||||
wantBody: `{"user_id":"user-123","deleted_at":"2026-04-24T12:00:00Z"}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -659,7 +677,7 @@ func TestSelfServiceHandlersRejectUnknownFieldsAndProjectErrors(t *testing.T) {
|
||||
name: "update my profile conflict",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/users/user-123/profile",
|
||||
body: `{"race_name":"Taken Name"}`,
|
||||
body: `{"display_name":"TakenName"}`,
|
||||
wantStatus: http.StatusConflict,
|
||||
wantBody: `{"error":{"code":"conflict","message":"request conflicts with current state"}}`,
|
||||
},
|
||||
@@ -667,7 +685,7 @@ func TestSelfServiceHandlersRejectUnknownFieldsAndProjectErrors(t *testing.T) {
|
||||
name: "update my profile rejects email field",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/users/user-123/profile",
|
||||
body: `{"race_name":"Nova Prime","email":"pilot@example.com"}`,
|
||||
body: `{"display_name":"NovaPrime","email":"pilot@example.com"}`,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: `{"error":{"code":"invalid_request","message":"request body contains unknown field \"email\""}}`,
|
||||
},
|
||||
@@ -776,9 +794,9 @@ func TestEnsureByEmailHandlerRejectsSemanticRegistrationContext(t *testing.T) {
|
||||
|
||||
ensurer, err := authdirectory.NewEnsurer(handlerTestStore{}, handlerTestClock{now: time.Unix(1_775_240_000, 0).UTC()}, handlerTestIDGenerator{
|
||||
userID: common.UserID("user-created"),
|
||||
raceName: common.RaceName("player-test123"),
|
||||
userName: common.UserName("player-test123"),
|
||||
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
|
||||
}, handlerTestRaceNamePolicy{})
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
handler := mustNewHandler(t, Dependencies{
|
||||
@@ -937,8 +955,8 @@ func mustNewHandler(t *testing.T, deps Dependencies) http.Handler {
|
||||
return adminusers.LookupResult{}, nil
|
||||
})
|
||||
}
|
||||
if deps.GetUserByRaceName == nil {
|
||||
deps.GetUserByRaceName = getUserByRaceNameFunc(func(context.Context, adminusers.GetUserByRaceNameInput) (adminusers.LookupResult, error) {
|
||||
if deps.GetUserByUserName == nil {
|
||||
deps.GetUserByUserName = getUserByUserNameFunc(func(context.Context, adminusers.GetUserByUserNameInput) (adminusers.LookupResult, error) {
|
||||
return adminusers.LookupResult{}, nil
|
||||
})
|
||||
}
|
||||
@@ -967,6 +985,11 @@ func mustNewHandler(t *testing.T, deps Dependencies) http.Handler {
|
||||
return policysvc.LimitCommandResult{}, nil
|
||||
})
|
||||
}
|
||||
if deps.DeleteUser == nil {
|
||||
deps.DeleteUser = deleteUserFunc(func(context.Context, accountdeletion.Input) (accountdeletion.Result, error) {
|
||||
return accountdeletion.Result{}, nil
|
||||
})
|
||||
}
|
||||
|
||||
handler, err := newHandlerWithConfig(Config{
|
||||
Addr: "127.0.0.1:0",
|
||||
@@ -1046,9 +1069,9 @@ func (fn getUserByEmailFunc) Execute(ctx context.Context, input adminusers.GetUs
|
||||
return fn(ctx, input)
|
||||
}
|
||||
|
||||
type getUserByRaceNameFunc func(context.Context, adminusers.GetUserByRaceNameInput) (adminusers.LookupResult, error)
|
||||
type getUserByUserNameFunc func(context.Context, adminusers.GetUserByUserNameInput) (adminusers.LookupResult, error)
|
||||
|
||||
func (fn getUserByRaceNameFunc) Execute(ctx context.Context, input adminusers.GetUserByRaceNameInput) (adminusers.LookupResult, error) {
|
||||
func (fn getUserByUserNameFunc) Execute(ctx context.Context, input adminusers.GetUserByUserNameInput) (adminusers.LookupResult, error) {
|
||||
return fn(ctx, input)
|
||||
}
|
||||
|
||||
@@ -1112,6 +1135,12 @@ func (fn removeLimitFunc) Execute(ctx context.Context, input policysvc.RemoveLim
|
||||
return fn(ctx, input)
|
||||
}
|
||||
|
||||
type deleteUserFunc func(context.Context, accountdeletion.Input) (accountdeletion.Result, error)
|
||||
|
||||
func (fn deleteUserFunc) Execute(ctx context.Context, input accountdeletion.Input) (accountdeletion.Result, error) {
|
||||
return fn(ctx, input)
|
||||
}
|
||||
|
||||
type handlerTestStore struct{}
|
||||
|
||||
func (handlerTestStore) ResolveByEmail(context.Context, common.Email) (ports.ResolveByEmailResult, error) {
|
||||
@@ -1144,7 +1173,7 @@ func (clock handlerTestClock) Now() time.Time {
|
||||
|
||||
type handlerTestIDGenerator struct {
|
||||
userID common.UserID
|
||||
raceName common.RaceName
|
||||
userName common.UserName
|
||||
entitlementRecordID entitlement.EntitlementRecordID
|
||||
sanctionRecordID policy.SanctionRecordID
|
||||
limitRecordID policy.LimitRecordID
|
||||
@@ -1154,8 +1183,8 @@ func (generator handlerTestIDGenerator) NewUserID() (common.UserID, error) {
|
||||
return generator.userID, nil
|
||||
}
|
||||
|
||||
func (generator handlerTestIDGenerator) NewInitialRaceName() (common.RaceName, error) {
|
||||
return generator.raceName, nil
|
||||
func (generator handlerTestIDGenerator) NewUserName() (common.UserName, error) {
|
||||
return generator.userName, nil
|
||||
}
|
||||
|
||||
func (generator handlerTestIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
|
||||
@@ -1170,18 +1199,13 @@ func (generator handlerTestIDGenerator) NewLimitRecordID() (policy.LimitRecordID
|
||||
return generator.limitRecordID, nil
|
||||
}
|
||||
|
||||
type handlerTestRaceNamePolicy struct{}
|
||||
|
||||
func (handlerTestRaceNamePolicy) CanonicalKey(raceName common.RaceName) (account.RaceNameCanonicalKey, error) {
|
||||
return account.RaceNameCanonicalKey("key:" + raceName.String()), nil
|
||||
}
|
||||
|
||||
func sampleAccountView() selfservice.AccountView {
|
||||
timestamp := time.Date(2026, time.April, 9, 10, 0, 0, 0, time.UTC)
|
||||
return selfservice.AccountView{
|
||||
UserID: "user-123",
|
||||
Email: "pilot@example.com",
|
||||
RaceName: "Pilot Nova",
|
||||
UserName: "player-abcdefgh",
|
||||
PreferredLanguage: "en",
|
||||
TimeZone: "Europe/Kaliningrad",
|
||||
DeclaredCountry: "DE",
|
||||
@@ -1240,6 +1264,7 @@ func sampleEligibilityView(exists bool) lobbyeligibility.GetUserEligibilityResul
|
||||
{LimitCode: "max_owned_private_games", Value: 3},
|
||||
{LimitCode: "max_pending_public_applications", Value: 10},
|
||||
{LimitCode: "max_active_game_memberships", Value: 10},
|
||||
{LimitCode: "max_registered_race_names", Value: 2},
|
||||
},
|
||||
Markers: lobbyeligibility.EligibilityMarkersView{
|
||||
CanLogin: true,
|
||||
@@ -1260,5 +1285,4 @@ var (
|
||||
_ ports.AuthDirectoryStore = handlerTestStore{}
|
||||
_ ports.Clock = handlerTestClock{}
|
||||
_ ports.IDGenerator = handlerTestIDGenerator{}
|
||||
_ ports.RaceNamePolicy = handlerTestRaceNamePolicy{}
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/service/accountdeletion"
|
||||
"galaxy/user/internal/service/adminusers"
|
||||
"galaxy/user/internal/service/authdirectory"
|
||||
"galaxy/user/internal/service/entitlementsvc"
|
||||
@@ -98,12 +99,12 @@ type GetUserByEmailUseCase interface {
|
||||
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
|
||||
// GetUserByUserNameUseCase describes the trusted admin exact-read by stored
|
||||
// user name consumed by the HTTP transport layer.
|
||||
type GetUserByUserNameUseCase interface {
|
||||
// Execute returns the full current account aggregate for one stored user
|
||||
// name.
|
||||
Execute(ctx context.Context, input adminusers.GetUserByRaceNameInput) (adminusers.LookupResult, error)
|
||||
Execute(ctx context.Context, input adminusers.GetUserByUserNameInput) (adminusers.LookupResult, error)
|
||||
}
|
||||
|
||||
// ListUsersUseCase describes the trusted admin paginated listing use case
|
||||
@@ -178,6 +179,14 @@ type RemoveLimitUseCase interface {
|
||||
Execute(ctx context.Context, input policysvc.RemoveLimitInput) (policysvc.LimitCommandResult, error)
|
||||
}
|
||||
|
||||
// DeleteUserUseCase describes the trusted `DeleteUser` soft-delete use case
|
||||
// consumed by the HTTP transport layer.
|
||||
type DeleteUserUseCase interface {
|
||||
// Execute soft-deletes one regular-user account and emits a
|
||||
// `user.lifecycle.deleted` event on success.
|
||||
Execute(ctx context.Context, input accountdeletion.Input) (accountdeletion.Result, error)
|
||||
}
|
||||
|
||||
// Config describes the trusted internal HTTP listener owned by the user
|
||||
// service.
|
||||
type Config struct {
|
||||
@@ -252,9 +261,9 @@ type Dependencies struct {
|
||||
// e-mail.
|
||||
GetUserByEmail GetUserByEmailUseCase
|
||||
|
||||
// GetUserByRaceName executes the trusted admin exact-read by exact stored
|
||||
// race name.
|
||||
GetUserByRaceName GetUserByRaceNameUseCase
|
||||
// GetUserByUserName executes the trusted admin exact-read by stored user
|
||||
// name.
|
||||
GetUserByUserName GetUserByUserNameUseCase
|
||||
|
||||
// ListUsers executes the trusted admin paginated filtered listing use case.
|
||||
ListUsers ListUsersUseCase
|
||||
@@ -288,6 +297,9 @@ type Dependencies struct {
|
||||
// RemoveLimit executes the trusted limit-remove use case.
|
||||
RemoveLimit RemoveLimitUseCase
|
||||
|
||||
// DeleteUser executes the trusted `DeleteUser` soft-delete use case.
|
||||
DeleteUser DeleteUserUseCase
|
||||
|
||||
// Logger writes structured transport logs. When nil, the default logger is
|
||||
// used.
|
||||
Logger *slog.Logger
|
||||
|
||||
@@ -11,10 +11,12 @@ import (
|
||||
|
||||
"galaxy/user/internal/adapters/local"
|
||||
"galaxy/user/internal/adapters/redis/domainevents"
|
||||
"galaxy/user/internal/adapters/redis/lifecycleevents"
|
||||
"galaxy/user/internal/adapters/redis/userstore"
|
||||
"galaxy/user/internal/adminapi"
|
||||
"galaxy/user/internal/api/internalhttp"
|
||||
"galaxy/user/internal/config"
|
||||
"galaxy/user/internal/service/accountdeletion"
|
||||
"galaxy/user/internal/service/adminusers"
|
||||
"galaxy/user/internal/service/authdirectory"
|
||||
"galaxy/user/internal/service/entitlementsvc"
|
||||
@@ -128,12 +130,27 @@ func NewRuntime(ctx context.Context, cfg config.Config, logger *slog.Logger) (*R
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: %w", err))
|
||||
}
|
||||
|
||||
lifecycleEventPublisher, err := lifecycleevents.New(lifecycleevents.Config{
|
||||
Addr: cfg.Redis.Addr,
|
||||
Username: cfg.Redis.Username,
|
||||
Password: cfg.Redis.Password,
|
||||
DB: cfg.Redis.DB,
|
||||
TLSEnabled: cfg.Redis.TLSEnabled,
|
||||
Stream: cfg.Redis.LifecycleEventsStream,
|
||||
StreamMaxLen: cfg.Redis.LifecycleEventsStreamMaxLen,
|
||||
OperationTimeout: cfg.Redis.OperationTimeout,
|
||||
})
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: redis lifecycle-event publisher: %w", err))
|
||||
}
|
||||
runtime.cleanupFns = append(runtime.cleanupFns, lifecycleEventPublisher.Close)
|
||||
|
||||
if err := pingDependency(ctx, "redis lifecycle-event publisher", lifecycleEventPublisher); 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)
|
||||
@@ -147,7 +164,6 @@ func NewRuntime(ctx context.Context, cfg config.Config, logger *slog.Logger) (*R
|
||||
store,
|
||||
clock,
|
||||
idGenerator,
|
||||
raceNamePolicy,
|
||||
componentLogger("authdirectory"),
|
||||
telemetryRuntime,
|
||||
domainEventPublisher,
|
||||
@@ -233,7 +249,6 @@ func NewRuntime(ctx context.Context, cfg config.Config, logger *slog.Logger) (*R
|
||||
store.Sanctions(),
|
||||
store.Limits(),
|
||||
clock,
|
||||
raceNamePolicy,
|
||||
componentLogger("selfservice"),
|
||||
telemetryRuntime,
|
||||
domainEventPublisher,
|
||||
@@ -262,9 +277,9 @@ func NewRuntime(ctx context.Context, cfg config.Config, logger *slog.Logger) (*R
|
||||
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)
|
||||
getUserByUserName, err := adminusers.NewByUserNameGetter(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))
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: admin get-user-by-user-name: %w", err))
|
||||
}
|
||||
listUsers, err := adminusers.NewLister(store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), clock, store)
|
||||
if err != nil {
|
||||
@@ -294,10 +309,21 @@ func NewRuntime(ctx context.Context, cfg config.Config, logger *slog.Logger) (*R
|
||||
componentLogger("policysvc"),
|
||||
telemetryRuntime,
|
||||
domainEventPublisher,
|
||||
lifecycleEventPublisher,
|
||||
)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: apply sanction service: %w", err))
|
||||
}
|
||||
deleteUser, err := accountdeletion.NewServiceWithObservability(
|
||||
store.Accounts(),
|
||||
clock,
|
||||
lifecycleEventPublisher,
|
||||
componentLogger("accountdeletion"),
|
||||
telemetryRuntime,
|
||||
)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: delete user service: %w", err))
|
||||
}
|
||||
removeSanction, err := policysvc.NewRemoveSanctionServiceWithObservability(
|
||||
store.Accounts(),
|
||||
store.Sanctions(),
|
||||
@@ -358,7 +384,7 @@ func NewRuntime(ctx context.Context, cfg config.Config, logger *slog.Logger) (*R
|
||||
UpdateMySettings: settingsUpdater,
|
||||
GetUserByID: getUserByID,
|
||||
GetUserByEmail: getUserByEmail,
|
||||
GetUserByRaceName: getUserByRaceName,
|
||||
GetUserByUserName: getUserByUserName,
|
||||
ListUsers: listUsers,
|
||||
GetUserEligibility: userEligibility,
|
||||
SyncDeclaredCountry: syncDeclaredCountry,
|
||||
@@ -369,6 +395,7 @@ func NewRuntime(ctx context.Context, cfg config.Config, logger *slog.Logger) (*R
|
||||
RemoveSanction: removeSanction,
|
||||
SetLimit: setLimit,
|
||||
RemoveLimit: removeLimit,
|
||||
DeleteUser: deleteUser,
|
||||
Logger: logger.With("component", "internal_http"),
|
||||
Telemetry: telemetryRuntime,
|
||||
})
|
||||
|
||||
@@ -27,15 +27,17 @@ const (
|
||||
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"
|
||||
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"
|
||||
redisLifecycleEventsStreamEnvVar = "USERSERVICE_REDIS_LIFECYCLE_EVENTS_STREAM"
|
||||
redisLifecycleEventsStreamMaxLenEnvVar = "USERSERVICE_REDIS_LIFECYCLE_EVENTS_STREAM_MAX_LEN"
|
||||
|
||||
otelServiceNameEnvVar = "OTEL_SERVICE_NAME"
|
||||
otelTracesExporterEnvVar = "OTEL_TRACES_EXPORTER"
|
||||
@@ -57,8 +59,10 @@ const (
|
||||
defaultRedisDB = 0
|
||||
defaultRedisOperationTimeout = 250 * time.Millisecond
|
||||
defaultRedisKeyspacePrefix = "user:"
|
||||
defaultDomainEventsStream = "user:domain_events"
|
||||
defaultDomainEventsStreamMaxLen = 1024
|
||||
defaultDomainEventsStream = "user:domain_events"
|
||||
defaultDomainEventsStreamMaxLen = 1024
|
||||
defaultLifecycleEventsStream = "user:lifecycle_events"
|
||||
defaultLifecycleEventsStreamMaxLen = 1024
|
||||
defaultOTelServiceName = "galaxy-user"
|
||||
otelExporterNone = "none"
|
||||
otelExporterOTLP = "otlp"
|
||||
@@ -197,6 +201,15 @@ type RedisConfig struct {
|
||||
// DomainEventsStreamMaxLen bounds the domain-events Redis Stream with
|
||||
// approximate trimming.
|
||||
DomainEventsStreamMaxLen int64
|
||||
|
||||
// LifecycleEventsStream stores the Redis Stream key used for trusted
|
||||
// user-lifecycle events (permanent_block, delete) consumed by
|
||||
// `Game Lobby` for Race Name Directory cascade release.
|
||||
LifecycleEventsStream string
|
||||
|
||||
// LifecycleEventsStreamMaxLen bounds the lifecycle-events Redis Stream
|
||||
// with approximate trimming.
|
||||
LifecycleEventsStreamMaxLen int64
|
||||
}
|
||||
|
||||
// TLSConfig returns the conservative TLS configuration used by Redis adapters
|
||||
@@ -224,6 +237,10 @@ func (cfg RedisConfig) Validate() error {
|
||||
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")
|
||||
case strings.TrimSpace(cfg.LifecycleEventsStream) == "":
|
||||
return fmt.Errorf("redis lifecycle events stream must not be empty")
|
||||
case cfg.LifecycleEventsStreamMaxLen <= 0:
|
||||
return fmt.Errorf("redis lifecycle events stream max len must be positive")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -312,11 +329,13 @@ func DefaultConfig() Config {
|
||||
},
|
||||
AdminHTTP: DefaultAdminHTTPConfig(),
|
||||
Redis: RedisConfig{
|
||||
DB: defaultRedisDB,
|
||||
OperationTimeout: defaultRedisOperationTimeout,
|
||||
KeyspacePrefix: defaultRedisKeyspacePrefix,
|
||||
DomainEventsStream: defaultDomainEventsStream,
|
||||
DomainEventsStreamMaxLen: defaultDomainEventsStreamMaxLen,
|
||||
DB: defaultRedisDB,
|
||||
OperationTimeout: defaultRedisOperationTimeout,
|
||||
KeyspacePrefix: defaultRedisKeyspacePrefix,
|
||||
DomainEventsStream: defaultDomainEventsStream,
|
||||
DomainEventsStreamMaxLen: defaultDomainEventsStreamMaxLen,
|
||||
LifecycleEventsStream: defaultLifecycleEventsStream,
|
||||
LifecycleEventsStreamMaxLen: defaultLifecycleEventsStreamMaxLen,
|
||||
},
|
||||
Telemetry: TelemetryConfig{
|
||||
ServiceName: defaultOTelServiceName,
|
||||
@@ -415,6 +434,11 @@ func LoadFromEnv() (Config, error) {
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Redis.LifecycleEventsStream = loadString(redisLifecycleEventsStreamEnvVar, cfg.Redis.LifecycleEventsStream)
|
||||
cfg.Redis.LifecycleEventsStreamMaxLen, err = loadInt64(redisLifecycleEventsStreamMaxLenEnvVar, cfg.Redis.LifecycleEventsStreamMaxLen)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.Telemetry.ServiceName = loadString(otelServiceNameEnvVar, cfg.Telemetry.ServiceName)
|
||||
cfg.Telemetry.TracesExporter = normalizeExporterValue(loadString(otelTracesExporterEnvVar, cfg.Telemetry.TracesExporter))
|
||||
|
||||
@@ -4,38 +4,11 @@ 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.
|
||||
@@ -44,8 +17,11 @@ type UserAccount struct {
|
||||
// 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
|
||||
// UserName stores the immutable auto-generated `player-<suffix>` handle.
|
||||
UserName common.UserName
|
||||
|
||||
// DisplayName stores the optional mutable free-text user-facing label.
|
||||
DisplayName common.DisplayName
|
||||
|
||||
// PreferredLanguage stores the current declared language tag.
|
||||
PreferredLanguage common.LanguageTag
|
||||
@@ -62,10 +38,23 @@ type UserAccount struct {
|
||||
|
||||
// UpdatedAt stores the last account mutation timestamp.
|
||||
UpdatedAt time.Time
|
||||
|
||||
// DeletedAt stores the soft-delete timestamp set by the `DeleteUser`
|
||||
// command. A nil value means the account is live. A non-nil value marks
|
||||
// the record as soft-deleted: external auth, self-service, admin-read,
|
||||
// and lobby-eligibility operations must reject subsequent access with
|
||||
// `subject_not_found`.
|
||||
DeletedAt *time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether UserAccount satisfies the frozen Stage 02
|
||||
// structural invariants.
|
||||
// IsDeleted reports whether the account has been soft-deleted through the
|
||||
// `DeleteUser` command.
|
||||
func (record UserAccount) IsDeleted() bool {
|
||||
return record.DeletedAt != nil
|
||||
}
|
||||
|
||||
// Validate reports whether UserAccount satisfies the Stage 21 structural
|
||||
// invariants, including the Stage 22 soft-delete rules.
|
||||
func (record UserAccount) Validate() error {
|
||||
if err := record.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("user account user id: %w", err)
|
||||
@@ -73,8 +62,11 @@ func (record UserAccount) Validate() error {
|
||||
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.UserName.Validate(); err != nil {
|
||||
return fmt.Errorf("user account user name: %w", err)
|
||||
}
|
||||
if err := record.DisplayName.Validate(); err != nil {
|
||||
return fmt.Errorf("user account display name: %w", err)
|
||||
}
|
||||
if err := record.PreferredLanguage.Validate(); err != nil {
|
||||
return fmt.Errorf("user account preferred language: %w", err)
|
||||
@@ -96,40 +88,13 @@ func (record UserAccount) Validate() error {
|
||||
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
|
||||
if record.DeletedAt != nil {
|
||||
if err := common.ValidateTimestamp("user account deleted at", *record.DeletedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.DeletedAt.Before(record.CreatedAt) {
|
||||
return fmt.Errorf("user account deleted at must not be before created at")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -21,11 +21,11 @@ func TestUserAccountValidate(t *testing.T) {
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid without declared country",
|
||||
name: "valid without declared country or display name",
|
||||
record: UserAccount{
|
||||
UserID: common.UserID("user-123"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
RaceName: common.RaceName("Pilot Nova"),
|
||||
UserName: common.UserName("player-abcdefgh"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Berlin"),
|
||||
CreatedAt: createdAt,
|
||||
@@ -33,11 +33,12 @@ func TestUserAccountValidate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid with declared country",
|
||||
name: "valid with declared country and display name",
|
||||
record: UserAccount{
|
||||
UserID: common.UserID("user-123"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
RaceName: common.RaceName("Pilot Nova"),
|
||||
UserName: common.UserName("player-abcdefgh"),
|
||||
DisplayName: common.DisplayName("PilotNova"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Berlin"),
|
||||
DeclaredCountry: common.CountryCode("DE"),
|
||||
@@ -45,12 +46,38 @@ func TestUserAccountValidate(t *testing.T) {
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing user name",
|
||||
record: UserAccount{
|
||||
UserID: common.UserID("user-123"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Berlin"),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid display name",
|
||||
record: UserAccount{
|
||||
UserID: common.UserID("user-123"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
UserName: common.UserName("player-abcdefgh"),
|
||||
DisplayName: common.DisplayName("Pilot Nova"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Berlin"),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "updated before created",
|
||||
record: UserAccount{
|
||||
UserID: common.UserID("user-123"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
RaceName: common.RaceName("Pilot Nova"),
|
||||
UserName: common.UserName("player-abcdefgh"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Berlin"),
|
||||
CreatedAt: createdAt,
|
||||
@@ -58,53 +85,50 @@ func TestUserAccountValidate(t *testing.T) {
|
||||
},
|
||||
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: "valid soft-deleted after update",
|
||||
record: UserAccount{
|
||||
UserID: common.UserID("user-123"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
UserName: common.UserName("player-abcdefgh"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Berlin"),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
DeletedAt: timePtr(updatedAt),
|
||||
},
|
||||
},
|
||||
{
|
||||
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(),
|
||||
name: "deleted at before created",
|
||||
record: UserAccount{
|
||||
UserID: common.UserID("user-123"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
UserName: common.UserName("player-abcdefgh"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Berlin"),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
DeletedAt: timePtr(createdAt.Add(-time.Second)),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "deleted at zero",
|
||||
record: UserAccount{
|
||||
UserID: common.UserID("user-123"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
UserName: common.UserName("player-abcdefgh"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Berlin"),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
DeletedAt: timePtr(time.Time{}),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -117,3 +141,28 @@ func TestRaceNameReservationValidate(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserAccountIsDeleted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
createdAt := time.Unix(1_775_240_000, 0).UTC()
|
||||
record := UserAccount{
|
||||
UserID: common.UserID("user-123"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
UserName: common.UserName("player-abcdefgh"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Berlin"),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
}
|
||||
require.False(t, record.IsDeleted())
|
||||
|
||||
deleted := record
|
||||
deletedAt := createdAt.Add(time.Minute)
|
||||
deleted.DeletedAt = &deletedAt
|
||||
require.True(t, deleted.IsDeleted())
|
||||
}
|
||||
|
||||
func timePtr(value time.Time) *time.Time {
|
||||
return &value
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@ import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/util"
|
||||
)
|
||||
|
||||
const (
|
||||
maxRaceNameLength = 64
|
||||
maxUserNameLength = 64
|
||||
maxLanguageTagLength = 32
|
||||
maxTimeZoneNameLength = 128
|
||||
)
|
||||
@@ -64,29 +66,64 @@ func (email Email) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RaceName stores one original-casing race name selected for the user
|
||||
// account.
|
||||
type RaceName string
|
||||
// UserName stores one immutable auto-generated platform handle in
|
||||
// `player-<suffix>` form. It is unique platform-wide and never changes after
|
||||
// account creation.
|
||||
type UserName string
|
||||
|
||||
// String returns RaceName as its stored value.
|
||||
func (name RaceName) String() string {
|
||||
// String returns UserName as its stored value.
|
||||
func (name UserName) String() string {
|
||||
return string(name)
|
||||
}
|
||||
|
||||
// IsZero reports whether RaceName does not contain a usable value.
|
||||
func (name RaceName) IsZero() bool {
|
||||
// IsZero reports whether UserName does not contain a usable value.
|
||||
func (name UserName) 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 {
|
||||
// Validate reports whether UserName is non-empty, trimmed, uses the frozen
|
||||
// `player-` prefix, and stays within the reserved length bound.
|
||||
func (name UserName) Validate() error {
|
||||
raw := string(name)
|
||||
if err := validateToken("race name", raw); err != nil {
|
||||
if err := validatePrefixedToken("user name", raw, "player-"); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(raw) > maxRaceNameLength {
|
||||
return fmt.Errorf("race name must be at most %d bytes", maxRaceNameLength)
|
||||
if len(raw) > maxUserNameLength {
|
||||
return fmt.Errorf("user name must be at most %d bytes", maxUserNameLength)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisplayName stores one optional free-text user-facing label. It may be
|
||||
// empty and is not required to be unique; validation delegates to
|
||||
// galaxy/util.ValidateTypeName when a value is present.
|
||||
type DisplayName string
|
||||
|
||||
// String returns DisplayName as its stored value.
|
||||
func (name DisplayName) String() string {
|
||||
return string(name)
|
||||
}
|
||||
|
||||
// IsZero reports whether DisplayName is empty after trimming surrounding
|
||||
// whitespace.
|
||||
func (name DisplayName) IsZero() bool {
|
||||
return strings.TrimSpace(string(name)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether DisplayName is either empty or a valid
|
||||
// util.ValidateTypeName value. Trimming is the caller's responsibility;
|
||||
// Validate rejects values that still contain surrounding whitespace.
|
||||
func (name DisplayName) Validate() error {
|
||||
raw := string(name)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(raw) != raw {
|
||||
return fmt.Errorf("display name must not contain surrounding whitespace")
|
||||
}
|
||||
if _, ok := util.ValidateTypeName(raw); !ok {
|
||||
return fmt.Errorf("display name %q is invalid", raw)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -65,17 +65,51 @@ func TestEmailValidate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRaceNameValidate(t *testing.T) {
|
||||
func TestUserNameValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value RaceName
|
||||
value UserName
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", value: RaceName("Admiral Nova")},
|
||||
{name: "empty", value: RaceName(""), wantErr: true},
|
||||
{name: "too long", value: RaceName("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmn"), wantErr: true},
|
||||
{name: "valid", value: UserName("player-abcd1234")},
|
||||
{name: "empty", value: UserName(""), wantErr: true},
|
||||
{name: "wrong prefix", value: UserName("user-abcdefgh"), wantErr: true},
|
||||
{name: "prefix only", value: UserName("player-"), wantErr: true},
|
||||
{name: "surrounding whitespace", value: UserName(" player-abcd1234 "), 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 TestDisplayNameValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value DisplayName
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "empty accepted", value: DisplayName("")},
|
||||
{name: "valid simple", value: DisplayName("PilotNova")},
|
||||
{name: "valid unicode", value: DisplayName("АдмиралНова")},
|
||||
{name: "internal whitespace", value: DisplayName("Pilot Nova"), wantErr: true},
|
||||
{name: "leading whitespace", value: DisplayName(" PilotNova"), wantErr: true},
|
||||
{name: "trailing whitespace", value: DisplayName("PilotNova "), wantErr: true},
|
||||
{name: "leading special", value: DisplayName("-Pilot"), wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -30,6 +30,14 @@ const (
|
||||
// SanctionCodeProfileUpdateBlock denies self-service profile/settings
|
||||
// mutations.
|
||||
SanctionCodeProfileUpdateBlock SanctionCode = "profile_update_block"
|
||||
|
||||
// SanctionCodePermanentBlock marks the account as permanently disabled.
|
||||
// It is a terminal sanction: every `can_*` eligibility marker collapses to
|
||||
// false while it is active, self-service reads and writes are rejected
|
||||
// with 409 conflict, and Game Lobby performs Race Name Directory cascade
|
||||
// release when it observes the corresponding `user:lifecycle_events`
|
||||
// event.
|
||||
SanctionCodePermanentBlock SanctionCode = "permanent_block"
|
||||
)
|
||||
|
||||
// IsKnown reports whether SanctionCode belongs to the frozen v1 catalog.
|
||||
@@ -39,7 +47,8 @@ func (code SanctionCode) IsKnown() bool {
|
||||
SanctionCodePrivateGameCreateBlock,
|
||||
SanctionCodePrivateGameManageBlock,
|
||||
SanctionCodeGameJoinBlock,
|
||||
SanctionCodeProfileUpdateBlock:
|
||||
SanctionCodeProfileUpdateBlock,
|
||||
SanctionCodePermanentBlock:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -63,6 +72,12 @@ const (
|
||||
// LimitCodeMaxActiveGameMemberships limits how many active public-game
|
||||
// memberships the user may hold at once.
|
||||
LimitCodeMaxActiveGameMemberships LimitCode = "max_active_game_memberships"
|
||||
|
||||
// LimitCodeMaxRegisteredRaceNames overrides the tariff default quota for
|
||||
// permanent race-name registrations in the Game Lobby Race Name Directory.
|
||||
// The value `0` denotes an unlimited quota and is the canonical marker used
|
||||
// by the `paid_lifetime` tariff default.
|
||||
LimitCodeMaxRegisteredRaceNames LimitCode = "max_registered_race_names"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -91,7 +106,8 @@ func (code LimitCode) IsSupported() bool {
|
||||
switch code {
|
||||
case LimitCodeMaxOwnedPrivateGames,
|
||||
LimitCodeMaxPendingPublicApplications,
|
||||
LimitCodeMaxActiveGameMemberships:
|
||||
LimitCodeMaxActiveGameMemberships,
|
||||
LimitCodeMaxRegisteredRaceNames:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -127,6 +127,50 @@ func TestActiveSanctionsAt(t *testing.T) {
|
||||
require.Equal(t, SanctionCodeProfileUpdateBlock, active[0].SanctionCode)
|
||||
}
|
||||
|
||||
func TestSanctionCodeCatalog(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.True(t, SanctionCodeLoginBlock.IsKnown())
|
||||
require.True(t, SanctionCodePrivateGameCreateBlock.IsKnown())
|
||||
require.True(t, SanctionCodePrivateGameManageBlock.IsKnown())
|
||||
require.True(t, SanctionCodeGameJoinBlock.IsKnown())
|
||||
require.True(t, SanctionCodeProfileUpdateBlock.IsKnown())
|
||||
require.True(t, SanctionCodePermanentBlock.IsKnown())
|
||||
require.False(t, SanctionCode("unknown_code").IsKnown())
|
||||
}
|
||||
|
||||
func TestActiveSanctionsAtPermanentBlockCoexistsWithOtherCodes(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: SanctionCodePermanentBlock,
|
||||
Scope: common.Scope("platform"),
|
||||
ReasonCode: common.ReasonCode("terminal_policy_violation"),
|
||||
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")},
|
||||
AppliedAt: now.Add(-2 * time.Hour),
|
||||
},
|
||||
}
|
||||
|
||||
active, err := ActiveSanctionsAt(records, now)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, active, 2)
|
||||
require.Equal(t, SanctionCodeLoginBlock, active[0].SanctionCode)
|
||||
require.Equal(t, SanctionCodePermanentBlock, active[1].SanctionCode)
|
||||
}
|
||||
|
||||
func TestActiveSanctionsAtDuplicateActiveCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package ports
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
@@ -14,9 +13,6 @@ import (
|
||||
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.
|
||||
@@ -24,61 +20,6 @@ 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
|
||||
}
|
||||
@@ -87,7 +28,7 @@ func (input RenameRaceNameInput) Validate() error {
|
||||
// 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
|
||||
// ErrConflict when the user id, e-mail, or exact user-name lookup already
|
||||
// exists.
|
||||
Create(ctx context.Context, input CreateAccountInput) error
|
||||
|
||||
@@ -98,33 +39,17 @@ type UserAccountStore interface {
|
||||
// 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)
|
||||
// GetByUserName returns the stored account identified by the exact stored
|
||||
// user name.
|
||||
GetByUserName(ctx context.Context, userName common.UserName) (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.
|
||||
//
|
||||
// Implementations must wrap ErrConflict when the replacement record
|
||||
// attempts to mutate `user_name` or `email`.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -130,9 +130,6 @@ type EnsureByEmailInput struct {
|
||||
// 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.
|
||||
@@ -149,9 +146,6 @@ func (input EnsureByEmailInput) Validate() error {
|
||||
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")
|
||||
}
|
||||
@@ -161,12 +155,6 @@ func (input EnsureByEmailInput) Validate() error {
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -182,8 +182,13 @@ type ProfileChangedEvent struct {
|
||||
// Operation stores the profile-change event kind.
|
||||
Operation ProfileChangedOperation
|
||||
|
||||
// RaceName stores the latest exact race name after the commit.
|
||||
RaceName common.RaceName
|
||||
// UserName stores the immutable handle associated with the account at the
|
||||
// moment the event is published.
|
||||
UserName common.UserName
|
||||
|
||||
// DisplayName stores the latest display name after the commit. An empty
|
||||
// value is valid and means no display name is set.
|
||||
DisplayName common.DisplayName
|
||||
}
|
||||
|
||||
// Validate reports whether event is structurally complete.
|
||||
@@ -194,8 +199,11 @@ func (event ProfileChangedEvent) Validate() error {
|
||||
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)
|
||||
if err := event.UserName.Validate(); err != nil {
|
||||
return fmt.Errorf("profile changed event user name: %w", err)
|
||||
}
|
||||
if err := event.DisplayName.Validate(); err != nil {
|
||||
return fmt.Errorf("profile changed event display name: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -22,10 +22,10 @@ var (
|
||||
)
|
||||
|
||||
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
|
||||
// ErrUserNameConflict reports that a mutation specifically failed because
|
||||
// the auto-generated `user_name` lookup 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)
|
||||
ErrUserNameConflict = fmt.Errorf("%w: user name conflict", ErrConflict)
|
||||
)
|
||||
|
||||
@@ -6,14 +6,15 @@ import (
|
||||
"galaxy/user/internal/domain/policy"
|
||||
)
|
||||
|
||||
// IDGenerator creates new user identifiers and generated initial race names.
|
||||
// IDGenerator creates new user identifiers and auto-generated user 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)
|
||||
// NewUserName returns one generated immutable user name in the
|
||||
// `player-<suffix>` form. The suffix is eight characters drawn from a
|
||||
// confusable-free alphanumeric alphabet.
|
||||
NewUserName() (common.UserName, error)
|
||||
|
||||
// NewEntitlementRecordID returns one newly generated entitlement history
|
||||
// record identifier.
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
)
|
||||
|
||||
// UserLifecycleEventType identifies one user-lifecycle event kind propagated
|
||||
// to `Game Lobby` through the dedicated Redis Stream.
|
||||
type UserLifecycleEventType string
|
||||
|
||||
const (
|
||||
// UserLifecyclePermanentBlockedEventType identifies the post-commit event
|
||||
// emitted when `SanctionCodePermanentBlock` becomes active on an account.
|
||||
UserLifecyclePermanentBlockedEventType UserLifecycleEventType = "user.lifecycle.permanent_blocked"
|
||||
|
||||
// UserLifecycleDeletedEventType identifies the post-commit event emitted
|
||||
// when a trusted `DeleteUser` command soft-deletes an account.
|
||||
UserLifecycleDeletedEventType UserLifecycleEventType = "user.lifecycle.deleted"
|
||||
)
|
||||
|
||||
// IsKnown reports whether the event type belongs to the frozen vocabulary.
|
||||
func (eventType UserLifecycleEventType) IsKnown() bool {
|
||||
switch eventType {
|
||||
case UserLifecyclePermanentBlockedEventType, UserLifecycleDeletedEventType:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// UserLifecycleEvent stores one post-commit user-lifecycle event envelope
|
||||
// published to the `user:lifecycle_events` Redis Stream and consumed by
|
||||
// `Game Lobby` for Race Name Directory cascade release.
|
||||
type UserLifecycleEvent struct {
|
||||
// EventType stores the frozen lifecycle event discriminator.
|
||||
EventType UserLifecycleEventType
|
||||
|
||||
// UserID identifies the regular user whose lifecycle state changed.
|
||||
UserID common.UserID
|
||||
|
||||
// OccurredAt stores the committed mutation timestamp.
|
||||
OccurredAt time.Time
|
||||
|
||||
// Source stores the machine-readable mutation source. For Stage 22 this is
|
||||
// always `admin_internal_api`.
|
||||
Source common.Source
|
||||
|
||||
// Actor stores the audit actor metadata attached to the committed
|
||||
// mutation.
|
||||
Actor common.ActorRef
|
||||
|
||||
// ReasonCode stores the committed reason_code for the mutation.
|
||||
ReasonCode common.ReasonCode
|
||||
|
||||
// TraceID stores the optional OpenTelemetry trace identifier propagated
|
||||
// from the current request context.
|
||||
TraceID string
|
||||
}
|
||||
|
||||
// Validate reports whether event is structurally complete.
|
||||
func (event UserLifecycleEvent) Validate() error {
|
||||
if !event.EventType.IsKnown() {
|
||||
return fmt.Errorf("user lifecycle event type %q is unsupported", event.EventType)
|
||||
}
|
||||
if err := event.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("user lifecycle event user id: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("user lifecycle event occurred at", event.OccurredAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := event.Source.Validate(); err != nil {
|
||||
return fmt.Errorf("user lifecycle event source: %w", err)
|
||||
}
|
||||
if err := event.Actor.Validate(); err != nil {
|
||||
return fmt.Errorf("user lifecycle event actor: %w", err)
|
||||
}
|
||||
if err := event.ReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("user lifecycle event reason code: %w", err)
|
||||
}
|
||||
if event.TraceID != "" && strings.TrimSpace(event.TraceID) != event.TraceID {
|
||||
return fmt.Errorf("user lifecycle event trace id must not contain surrounding whitespace")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserLifecyclePublisher publishes one committed user-lifecycle event to the
|
||||
// dedicated `user:lifecycle_events` Redis Stream.
|
||||
type UserLifecyclePublisher interface {
|
||||
// PublishUserLifecycleEvent propagates one committed lifecycle event. The
|
||||
// implementation must validate the event envelope and perform exactly one
|
||||
// idempotent append per call.
|
||||
PublishUserLifecycleEvent(ctx context.Context, event UserLifecycleEvent) error
|
||||
}
|
||||
@@ -21,6 +21,29 @@ const (
|
||||
MaxUserListPageSize = 200
|
||||
)
|
||||
|
||||
// DisplayNameMatchMode selects between exact and prefix `display_name`
|
||||
// comparison used by the admin listing filter.
|
||||
type DisplayNameMatchMode string
|
||||
|
||||
const (
|
||||
// DisplayNameMatchModeExact matches `display_name` exactly after trimming.
|
||||
DisplayNameMatchModeExact DisplayNameMatchMode = "exact"
|
||||
|
||||
// DisplayNameMatchModePrefix matches `display_name` by stored-value prefix
|
||||
// after trimming.
|
||||
DisplayNameMatchModePrefix DisplayNameMatchMode = "prefix"
|
||||
)
|
||||
|
||||
// IsKnown reports whether the mode belongs to the supported vocabulary.
|
||||
func (mode DisplayNameMatchMode) IsKnown() bool {
|
||||
switch mode {
|
||||
case DisplayNameMatchModeExact, DisplayNameMatchModePrefix:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// UserListFilters stores the frozen admin-listing filter set.
|
||||
type UserListFilters struct {
|
||||
// PaidState stores the optional coarse free-versus-paid filter.
|
||||
@@ -43,6 +66,16 @@ type UserListFilters struct {
|
||||
// LimitCode stores the optional active user-specific limit filter.
|
||||
LimitCode policy.LimitCode
|
||||
|
||||
// UserName stores the optional exact `user_name` filter.
|
||||
UserName common.UserName
|
||||
|
||||
// DisplayName stores the optional `display_name` filter value.
|
||||
DisplayName common.DisplayName
|
||||
|
||||
// DisplayNameMatch selects between exact and prefix comparison for
|
||||
// DisplayName. The zero value is treated as DisplayNameMatchModeExact.
|
||||
DisplayNameMatch DisplayNameMatchMode
|
||||
|
||||
// CanLogin stores the optional derived login-eligibility filter.
|
||||
CanLogin *bool
|
||||
|
||||
@@ -76,6 +109,22 @@ func (filters UserListFilters) Validate() error {
|
||||
if filters.LimitCode != "" && !filters.LimitCode.IsKnown() {
|
||||
return fmt.Errorf("limit code %q is unsupported", filters.LimitCode)
|
||||
}
|
||||
if !filters.UserName.IsZero() {
|
||||
if err := filters.UserName.Validate(); err != nil {
|
||||
return fmt.Errorf("user name: %w", err)
|
||||
}
|
||||
}
|
||||
if !filters.DisplayName.IsZero() {
|
||||
if err := filters.DisplayName.Validate(); err != nil {
|
||||
return fmt.Errorf("display name: %w", err)
|
||||
}
|
||||
}
|
||||
if filters.DisplayNameMatch != "" && !filters.DisplayNameMatch.IsKnown() {
|
||||
return fmt.Errorf("display name match mode %q is unsupported", filters.DisplayNameMatch)
|
||||
}
|
||||
if filters.DisplayName.IsZero() && filters.DisplayNameMatch != "" {
|
||||
return fmt.Errorf("display name match mode requires a display_name value")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
// Package accountdeletion implements the trusted `DeleteUser` soft-delete
|
||||
// command owned by User Service.
|
||||
package accountdeletion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/ports"
|
||||
"galaxy/user/internal/service/shared"
|
||||
"galaxy/user/internal/telemetry"
|
||||
)
|
||||
|
||||
const adminInternalAPISource = common.Source("admin_internal_api")
|
||||
|
||||
// Input stores one trusted `DeleteUser` command request.
|
||||
type Input struct {
|
||||
// UserID identifies the regular-user account to soft-delete.
|
||||
UserID string
|
||||
|
||||
// ReasonCode stores the machine-readable mutation reason.
|
||||
ReasonCode string
|
||||
|
||||
// Actor stores the audit actor metadata attached to the mutation.
|
||||
Actor ActorInput
|
||||
}
|
||||
|
||||
// ActorInput stores one transport-facing audit actor payload.
|
||||
type ActorInput struct {
|
||||
// Type stores the machine-readable actor type.
|
||||
Type string
|
||||
|
||||
// ID stores the optional stable actor identifier.
|
||||
ID string
|
||||
}
|
||||
|
||||
// Result stores one trusted `DeleteUser` command outcome.
|
||||
type Result struct {
|
||||
// UserID identifies the soft-deleted account.
|
||||
UserID string `json:"user_id"`
|
||||
|
||||
// DeletedAt stores the committed soft-delete timestamp.
|
||||
DeletedAt time.Time `json:"deleted_at"`
|
||||
}
|
||||
|
||||
// Service executes the explicit trusted `DeleteUser` soft-delete command.
|
||||
type Service struct {
|
||||
accounts ports.UserAccountStore
|
||||
clock ports.Clock
|
||||
lifecyclePublisher ports.UserLifecyclePublisher
|
||||
logger *slog.Logger
|
||||
telemetry *telemetry.Runtime
|
||||
}
|
||||
|
||||
// NewService constructs one `DeleteUser` use case without optional
|
||||
// observability hooks.
|
||||
func NewService(
|
||||
accounts ports.UserAccountStore,
|
||||
clock ports.Clock,
|
||||
lifecyclePublisher ports.UserLifecyclePublisher,
|
||||
) (*Service, error) {
|
||||
return NewServiceWithObservability(accounts, clock, lifecyclePublisher, nil, nil)
|
||||
}
|
||||
|
||||
// NewServiceWithObservability constructs one `DeleteUser` use case with
|
||||
// optional observability hooks.
|
||||
func NewServiceWithObservability(
|
||||
accounts ports.UserAccountStore,
|
||||
clock ports.Clock,
|
||||
lifecyclePublisher ports.UserLifecyclePublisher,
|
||||
logger *slog.Logger,
|
||||
telemetryRuntime *telemetry.Runtime,
|
||||
) (*Service, error) {
|
||||
switch {
|
||||
case accounts == nil:
|
||||
return nil, fmt.Errorf("account deletion service: user account store must not be nil")
|
||||
case clock == nil:
|
||||
return nil, fmt.Errorf("account deletion service: clock must not be nil")
|
||||
case lifecyclePublisher == nil:
|
||||
return nil, fmt.Errorf("account deletion service: lifecycle publisher must not be nil")
|
||||
default:
|
||||
return &Service{
|
||||
accounts: accounts,
|
||||
clock: clock,
|
||||
lifecyclePublisher: lifecyclePublisher,
|
||||
logger: logger,
|
||||
telemetry: telemetryRuntime,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Execute soft-deletes the account identified by input.UserID. The command is
|
||||
// idempotent per `user_id`: calling it after the account is already
|
||||
// soft-deleted returns `subject_not_found` and does not re-publish the
|
||||
// lifecycle event.
|
||||
func (service *Service) Execute(ctx context.Context, input Input) (result Result, err error) {
|
||||
outcome := shared.ErrorCodeInternalError
|
||||
userIDString := strings.TrimSpace(input.UserID)
|
||||
reasonCodeValue := strings.TrimSpace(input.ReasonCode)
|
||||
actorTypeValue := strings.TrimSpace(input.Actor.Type)
|
||||
actorIDValue := strings.TrimSpace(input.Actor.ID)
|
||||
defer func() {
|
||||
if service.telemetry != nil {
|
||||
service.telemetry.RecordUserLifecycleMutation(ctx, "delete", outcome)
|
||||
}
|
||||
shared.LogServiceOutcome(service.logger, ctx, "delete user completed", err,
|
||||
"use_case", "delete_user",
|
||||
"command", "delete",
|
||||
"outcome", outcome,
|
||||
"user_id", userIDString,
|
||||
"source", adminInternalAPISource.String(),
|
||||
"reason_code", reasonCodeValue,
|
||||
"actor_type", actorTypeValue,
|
||||
"actor_id", actorIDValue,
|
||||
)
|
||||
}()
|
||||
|
||||
if ctx == nil {
|
||||
outcome = shared.ErrorCodeInvalidRequest
|
||||
return Result{}, shared.InvalidRequest("context must not be nil")
|
||||
}
|
||||
|
||||
userID, err := shared.ParseUserID(input.UserID)
|
||||
if err != nil {
|
||||
outcome = shared.MetricOutcome(err)
|
||||
return Result{}, err
|
||||
}
|
||||
userIDString = userID.String()
|
||||
|
||||
reasonCode, err := shared.ParseReasonCode(input.ReasonCode)
|
||||
if err != nil {
|
||||
outcome = shared.MetricOutcome(err)
|
||||
return Result{}, err
|
||||
}
|
||||
reasonCodeValue = reasonCode.String()
|
||||
|
||||
actor, err := parseActor(input.Actor)
|
||||
if err != nil {
|
||||
outcome = shared.MetricOutcome(err)
|
||||
return Result{}, err
|
||||
}
|
||||
actorTypeValue = actor.Type.String()
|
||||
actorIDValue = actor.ID.String()
|
||||
|
||||
record, err := service.accounts.GetByUserID(ctx, userID)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
outcome = shared.ErrorCodeSubjectNotFound
|
||||
return Result{}, shared.SubjectNotFound()
|
||||
default:
|
||||
outcome = shared.ErrorCodeServiceUnavailable
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
if record.IsDeleted() {
|
||||
outcome = shared.ErrorCodeSubjectNotFound
|
||||
return Result{}, shared.SubjectNotFound()
|
||||
}
|
||||
|
||||
now := service.clock.Now().UTC()
|
||||
record.UpdatedAt = now
|
||||
record.DeletedAt = &now
|
||||
|
||||
if err := service.accounts.Update(ctx, record); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
outcome = shared.ErrorCodeSubjectNotFound
|
||||
return Result{}, shared.SubjectNotFound()
|
||||
case errors.Is(err, ports.ErrConflict):
|
||||
outcome = shared.ErrorCodeConflict
|
||||
return Result{}, shared.Conflict()
|
||||
default:
|
||||
outcome = shared.ErrorCodeServiceUnavailable
|
||||
return Result{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
}
|
||||
|
||||
outcome = "success"
|
||||
result = Result{
|
||||
UserID: userID.String(),
|
||||
DeletedAt: now,
|
||||
}
|
||||
publishDeleted(ctx, service.lifecyclePublisher, service.telemetry, service.logger, userID, now, actor, reasonCode)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseActor(input ActorInput) (common.ActorRef, error) {
|
||||
ref := common.ActorRef{
|
||||
Type: common.ActorType(shared.NormalizeString(input.Type)),
|
||||
ID: common.ActorID(shared.NormalizeString(input.ID)),
|
||||
}
|
||||
if err := ref.Validate(); err != nil {
|
||||
if ref.Type.IsZero() {
|
||||
return common.ActorRef{}, shared.InvalidRequest("actor.type must not be empty")
|
||||
}
|
||||
return common.ActorRef{}, shared.InvalidRequest(err.Error())
|
||||
}
|
||||
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
func publishDeleted(
|
||||
ctx context.Context,
|
||||
publisher ports.UserLifecyclePublisher,
|
||||
telemetryRuntime *telemetry.Runtime,
|
||||
logger *slog.Logger,
|
||||
userID common.UserID,
|
||||
occurredAt time.Time,
|
||||
actor common.ActorRef,
|
||||
reasonCode common.ReasonCode,
|
||||
) {
|
||||
if publisher == nil {
|
||||
return
|
||||
}
|
||||
|
||||
event := ports.UserLifecycleEvent{
|
||||
EventType: ports.UserLifecycleDeletedEventType,
|
||||
UserID: userID,
|
||||
OccurredAt: occurredAt,
|
||||
Source: adminInternalAPISource,
|
||||
Actor: actor,
|
||||
ReasonCode: reasonCode,
|
||||
}
|
||||
if err := publisher.PublishUserLifecycleEvent(ctx, event); err != nil {
|
||||
if telemetryRuntime != nil {
|
||||
telemetryRuntime.RecordEventPublicationFailure(ctx, string(ports.UserLifecycleDeletedEventType))
|
||||
}
|
||||
shared.LogEventPublicationFailure(logger, ctx, string(ports.UserLifecycleDeletedEventType), err,
|
||||
"use_case", "delete_user",
|
||||
"user_id", userID.String(),
|
||||
"source", adminInternalAPISource.String(),
|
||||
"reason_code", reasonCode.String(),
|
||||
"actor_type", actor.Type.String(),
|
||||
"actor_id", actor.ID.String(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package accountdeletion
|
||||
|
||||
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 TestServiceExecuteSoftDeletesAndEmitsLifecycleEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
created := now.Add(-24 * time.Hour)
|
||||
|
||||
accounts := newFakeAccountStore()
|
||||
accounts.records[userID] = account.UserAccount{
|
||||
UserID: userID,
|
||||
Email: common.Email("pilot@example.com"),
|
||||
UserName: common.UserName("player-abcdefgh"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Berlin"),
|
||||
CreatedAt: created,
|
||||
UpdatedAt: created,
|
||||
}
|
||||
|
||||
publisher := &fakeLifecyclePublisher{}
|
||||
service, err := NewService(accounts, fixedClock{now: now}, publisher)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), Input{
|
||||
UserID: userID.String(),
|
||||
ReasonCode: "user_right_to_be_forgotten",
|
||||
Actor: ActorInput{Type: "admin", ID: "admin-1"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, userID.String(), result.UserID)
|
||||
require.True(t, result.DeletedAt.Equal(now))
|
||||
|
||||
stored := accounts.records[userID]
|
||||
require.NotNil(t, stored.DeletedAt)
|
||||
require.True(t, stored.DeletedAt.Equal(now))
|
||||
|
||||
require.Len(t, publisher.events, 1)
|
||||
emitted := publisher.events[0]
|
||||
require.Equal(t, ports.UserLifecycleDeletedEventType, emitted.EventType)
|
||||
require.Equal(t, userID, emitted.UserID)
|
||||
require.True(t, emitted.OccurredAt.Equal(now))
|
||||
require.Equal(t, common.Source("admin_internal_api"), emitted.Source)
|
||||
require.Equal(t, common.ReasonCode("user_right_to_be_forgotten"), emitted.ReasonCode)
|
||||
require.Equal(t, common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}, emitted.Actor)
|
||||
}
|
||||
|
||||
func TestServiceExecuteSecondCallReturnsSubjectNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
created := now.Add(-24 * time.Hour)
|
||||
alreadyDeleted := now.Add(-time.Hour)
|
||||
|
||||
accounts := newFakeAccountStore()
|
||||
accounts.records[userID] = account.UserAccount{
|
||||
UserID: userID,
|
||||
Email: common.Email("pilot@example.com"),
|
||||
UserName: common.UserName("player-abcdefgh"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Berlin"),
|
||||
CreatedAt: created,
|
||||
UpdatedAt: alreadyDeleted,
|
||||
DeletedAt: &alreadyDeleted,
|
||||
}
|
||||
|
||||
publisher := &fakeLifecyclePublisher{}
|
||||
service, err := NewService(accounts, fixedClock{now: now}, publisher)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), Input{
|
||||
UserID: userID.String(),
|
||||
ReasonCode: "user_right_to_be_forgotten",
|
||||
Actor: ActorInput{Type: "admin"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err))
|
||||
require.Empty(t, publisher.events)
|
||||
}
|
||||
|
||||
func TestServiceExecuteUnknownUserReturnsSubjectNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
accounts := newFakeAccountStore()
|
||||
publisher := &fakeLifecyclePublisher{}
|
||||
service, err := NewService(accounts, fixedClock{now: time.Unix(1_775_240_500, 0).UTC()}, publisher)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), Input{
|
||||
UserID: "user-missing",
|
||||
ReasonCode: "manual",
|
||||
Actor: ActorInput{Type: "admin"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err))
|
||||
require.Empty(t, publisher.events)
|
||||
}
|
||||
|
||||
func TestServiceExecuteInvalidActorRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
accounts := newFakeAccountStore()
|
||||
publisher := &fakeLifecyclePublisher{}
|
||||
service, err := NewService(accounts, fixedClock{now: time.Unix(1_775_240_500, 0).UTC()}, publisher)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), Input{
|
||||
UserID: "user-123",
|
||||
ReasonCode: "manual",
|
||||
Actor: ActorInput{},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, shared.ErrorCodeInvalidRequest, shared.CodeOf(err))
|
||||
require.Empty(t, publisher.events)
|
||||
}
|
||||
|
||||
func TestServiceExecuteStoreConflictSurfacesAsConflict(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
created := now.Add(-24 * time.Hour)
|
||||
|
||||
accounts := newFakeAccountStore()
|
||||
accounts.records[userID] = account.UserAccount{
|
||||
UserID: userID,
|
||||
Email: common.Email("pilot@example.com"),
|
||||
UserName: common.UserName("player-abcdefgh"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Berlin"),
|
||||
CreatedAt: created,
|
||||
UpdatedAt: created,
|
||||
}
|
||||
accounts.updateErr = ports.ErrConflict
|
||||
|
||||
publisher := &fakeLifecyclePublisher{}
|
||||
service, err := NewService(accounts, fixedClock{now: now}, publisher)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), Input{
|
||||
UserID: userID.String(),
|
||||
ReasonCode: "manual",
|
||||
Actor: ActorInput{Type: "admin"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, shared.ErrorCodeConflict, shared.CodeOf(err))
|
||||
require.Empty(t, publisher.events)
|
||||
}
|
||||
|
||||
type fakeAccountStore struct {
|
||||
records map[common.UserID]account.UserAccount
|
||||
updateErr error
|
||||
}
|
||||
|
||||
func newFakeAccountStore() *fakeAccountStore {
|
||||
return &fakeAccountStore{records: map[common.UserID]account.UserAccount{}}
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) Create(context.Context, ports.CreateAccountInput) error {
|
||||
return errors.New("unexpected Create in accountdeletion tests")
|
||||
}
|
||||
|
||||
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, common.Email) (account.UserAccount, error) {
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) GetByUserName(context.Context, common.UserName) (account.UserAccount, error) {
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) ExistsByUserID(_ context.Context, userID common.UserID) (bool, error) {
|
||||
record, ok := store.records[userID]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
return !record.IsDeleted(), nil
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) Update(_ context.Context, record account.UserAccount) error {
|
||||
if store.updateErr != nil {
|
||||
return store.updateErr
|
||||
}
|
||||
store.records[record.UserID] = record
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeLifecyclePublisher struct {
|
||||
events []ports.UserLifecycleEvent
|
||||
err error
|
||||
}
|
||||
|
||||
func (publisher *fakeLifecyclePublisher) PublishUserLifecycleEvent(_ context.Context, event ports.UserLifecycleEvent) error {
|
||||
if publisher.err != nil {
|
||||
return publisher.err
|
||||
}
|
||||
publisher.events = append(publisher.events, event)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fixedClock struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (clock fixedClock) Now() time.Time {
|
||||
return clock.now
|
||||
}
|
||||
@@ -104,8 +104,13 @@ type AccountView struct {
|
||||
// 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"`
|
||||
// UserName stores the immutable `player-<suffix>` handle assigned at
|
||||
// account creation.
|
||||
UserName string `json:"user_name"`
|
||||
|
||||
// DisplayName stores the current optional free-text user label. An empty
|
||||
// value indicates no display name is set.
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
|
||||
// PreferredLanguage stores the current BCP 47 preferred language.
|
||||
PreferredLanguage string `json:"preferred_language"`
|
||||
@@ -177,7 +182,8 @@ func (aggregate Aggregate) View() AccountView {
|
||||
view := AccountView{
|
||||
UserID: aggregate.AccountRecord.UserID.String(),
|
||||
Email: aggregate.AccountRecord.Email.String(),
|
||||
RaceName: aggregate.AccountRecord.RaceName.String(),
|
||||
UserName: aggregate.AccountRecord.UserName.String(),
|
||||
DisplayName: aggregate.AccountRecord.DisplayName.String(),
|
||||
PreferredLanguage: aggregate.AccountRecord.PreferredLanguage.String(),
|
||||
TimeZone: aggregate.AccountRecord.TimeZone.String(),
|
||||
Entitlement: EntitlementSnapshotView{
|
||||
@@ -280,6 +286,9 @@ func (loader *Loader) Load(ctx context.Context, userID common.UserID) (Aggregate
|
||||
default:
|
||||
return Aggregate{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
if accountRecord.IsDeleted() {
|
||||
return Aggregate{}, shared.SubjectNotFound()
|
||||
}
|
||||
|
||||
entitlementSnapshot, err := loader.entitlements.GetByUserID(ctx, userID)
|
||||
switch {
|
||||
|
||||
@@ -35,11 +35,10 @@ type GetUserByEmailInput struct {
|
||||
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
|
||||
// GetUserByUserNameInput stores one exact trusted lookup by stored user name.
|
||||
type GetUserByUserNameInput struct {
|
||||
// UserName stores the exact `player-<suffix>` handle to resolve.
|
||||
UserName string
|
||||
}
|
||||
|
||||
// ListUsersInput stores one trusted administrative user-list request.
|
||||
@@ -71,6 +70,16 @@ type ListUsersInput struct {
|
||||
// LimitCode stores the optional active user-specific limit filter.
|
||||
LimitCode string
|
||||
|
||||
// UserName stores the optional exact `user_name` filter.
|
||||
UserName string
|
||||
|
||||
// DisplayName stores the optional `display_name` filter value.
|
||||
DisplayName string
|
||||
|
||||
// DisplayNameMatch selects between `exact` (default) and `prefix` matching
|
||||
// for DisplayName. An empty value is treated as `exact`.
|
||||
DisplayNameMatch string
|
||||
|
||||
// CanLogin stores the optional derived login-eligibility filter.
|
||||
CanLogin *bool
|
||||
|
||||
@@ -207,40 +216,39 @@ func (service *ByEmailGetter) Execute(ctx context.Context, input GetUserByEmailI
|
||||
return LookupResult{User: aggregate.View()}, nil
|
||||
}
|
||||
|
||||
// ByRaceNameGetter executes exact trusted lookups by exact stored race name.
|
||||
type ByRaceNameGetter struct {
|
||||
// ByUserNameGetter executes exact trusted lookups by stored user name.
|
||||
type ByUserNameGetter struct {
|
||||
support readSupport
|
||||
}
|
||||
|
||||
// NewByRaceNameGetter constructs one exact admin lookup by exact stored race
|
||||
// name.
|
||||
func NewByRaceNameGetter(
|
||||
// NewByUserNameGetter constructs one exact admin lookup by stored user name.
|
||||
func NewByUserNameGetter(
|
||||
accounts ports.UserAccountStore,
|
||||
entitlements entitlementReader,
|
||||
sanctions ports.SanctionStore,
|
||||
limits ports.LimitStore,
|
||||
clock ports.Clock,
|
||||
) (*ByRaceNameGetter, error) {
|
||||
) (*ByUserNameGetter, 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 nil, fmt.Errorf("admin users by-user-name getter: %w", err)
|
||||
}
|
||||
|
||||
return &ByRaceNameGetter{support: support}, nil
|
||||
return &ByUserNameGetter{support: support}, nil
|
||||
}
|
||||
|
||||
// Execute resolves one exact user by exact stored race name.
|
||||
func (service *ByRaceNameGetter) Execute(ctx context.Context, input GetUserByRaceNameInput) (LookupResult, error) {
|
||||
// Execute resolves one exact user by stored user name.
|
||||
func (service *ByUserNameGetter) Execute(ctx context.Context, input GetUserByUserNameInput) (LookupResult, error) {
|
||||
if ctx == nil {
|
||||
return LookupResult{}, shared.InvalidRequest("context must not be nil")
|
||||
}
|
||||
|
||||
raceName, err := shared.ParseRaceName(input.RaceName)
|
||||
userName, err := shared.ParseUserName(input.UserName)
|
||||
if err != nil {
|
||||
return LookupResult{}, err
|
||||
}
|
||||
|
||||
record, err := service.support.accounts.GetByRaceName(ctx, raceName)
|
||||
record, err := service.support.accounts.GetByUserName(ctx, userName)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
@@ -333,7 +341,19 @@ func (service *Lister) Execute(ctx context.Context, input ListUsersInput) (ListU
|
||||
candidateID := candidatePage.UserIDs[0]
|
||||
|
||||
aggregate, err := service.support.loader.Load(ctx, candidateID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case err == nil:
|
||||
case shared.CodeOf(err) == shared.ErrorCodeSubjectNotFound:
|
||||
// Soft-deleted accounts are silently skipped from the default admin
|
||||
// listing per Stage 22. The candidate index may still reference them
|
||||
// while their account record carries a DeletedAt timestamp.
|
||||
if nextToken == "" {
|
||||
result.NextPageToken = ""
|
||||
return result, nil
|
||||
}
|
||||
currentToken = nextToken
|
||||
continue
|
||||
default:
|
||||
return ListUsersResult{}, err
|
||||
}
|
||||
if matchesFilters(aggregate, filters) {
|
||||
@@ -382,6 +402,18 @@ func parseListFilters(input ListUsersInput) (ports.UserListFilters, error) {
|
||||
if err != nil {
|
||||
return ports.UserListFilters{}, err
|
||||
}
|
||||
userName, err := parseListUserName(input.UserName)
|
||||
if err != nil {
|
||||
return ports.UserListFilters{}, err
|
||||
}
|
||||
displayName, err := parseListDisplayName(input.DisplayName)
|
||||
if err != nil {
|
||||
return ports.UserListFilters{}, err
|
||||
}
|
||||
displayNameMatch, err := parseListDisplayNameMatch(input.DisplayNameMatch, displayName)
|
||||
if err != nil {
|
||||
return ports.UserListFilters{}, err
|
||||
}
|
||||
|
||||
filters := ports.UserListFilters{
|
||||
PaidState: paidState,
|
||||
@@ -390,6 +422,9 @@ func parseListFilters(input ListUsersInput) (ports.UserListFilters, error) {
|
||||
DeclaredCountry: declaredCountry,
|
||||
SanctionCode: sanctionCode,
|
||||
LimitCode: limitCode,
|
||||
UserName: userName,
|
||||
DisplayName: displayName,
|
||||
DisplayNameMatch: displayNameMatch,
|
||||
CanLogin: input.CanLogin,
|
||||
CanCreatePrivateGame: input.CanCreatePrivateGame,
|
||||
CanJoinGame: input.CanJoinGame,
|
||||
@@ -401,6 +436,40 @@ func parseListFilters(input ListUsersInput) (ports.UserListFilters, error) {
|
||||
return filters, nil
|
||||
}
|
||||
|
||||
func parseListUserName(value string) (common.UserName, error) {
|
||||
trimmed := shared.NormalizeString(value)
|
||||
if trimmed == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return shared.ParseUserName(trimmed)
|
||||
}
|
||||
|
||||
func parseListDisplayName(value string) (common.DisplayName, error) {
|
||||
trimmed := shared.NormalizeString(value)
|
||||
if trimmed == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return shared.ParseDisplayName(trimmed)
|
||||
}
|
||||
|
||||
func parseListDisplayNameMatch(value string, displayName common.DisplayName) (ports.DisplayNameMatchMode, error) {
|
||||
trimmed := shared.NormalizeString(value)
|
||||
if trimmed == "" {
|
||||
return "", nil
|
||||
}
|
||||
mode := ports.DisplayNameMatchMode(trimmed)
|
||||
if !mode.IsKnown() {
|
||||
return "", shared.InvalidRequest(fmt.Sprintf("display_name_match %q is unsupported", trimmed))
|
||||
}
|
||||
if displayName.IsZero() {
|
||||
return "", shared.InvalidRequest("display_name_match requires display_name")
|
||||
}
|
||||
|
||||
return mode, nil
|
||||
}
|
||||
|
||||
func parsePaidState(value string) (entitlement.PaidState, error) {
|
||||
state := entitlement.PaidState(shared.NormalizeString(value))
|
||||
if !state.IsKnown() {
|
||||
@@ -477,6 +546,23 @@ func matchesFilters(aggregate accountview.Aggregate, filters ports.UserListFilte
|
||||
if filters.LimitCode != "" && !aggregate.HasActiveLimit(filters.LimitCode) {
|
||||
return false
|
||||
}
|
||||
if !filters.UserName.IsZero() && aggregate.AccountRecord.UserName != filters.UserName {
|
||||
return false
|
||||
}
|
||||
if !filters.DisplayName.IsZero() {
|
||||
recordDisplayName := aggregate.AccountRecord.DisplayName.String()
|
||||
filterValue := filters.DisplayName.String()
|
||||
switch filters.DisplayNameMatch {
|
||||
case ports.DisplayNameMatchModePrefix:
|
||||
if !strings.HasPrefix(recordDisplayName, filterValue) {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
if recordDisplayName != filterValue {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
canLogin, canCreatePrivateGame, canJoinGame := deriveFilterEligibility(aggregate)
|
||||
if filters.CanLogin != nil && canLogin != *filters.CanLogin {
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestByIDGetterExecuteReturnsAggregate(t *testing.T) {
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
service, err := NewByIDGetter(
|
||||
newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "Pilot Nova", now)),
|
||||
newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "player-abcdefgh", now)),
|
||||
&fakeAdminEntitlementSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
common.UserID("user-123"): validAdminFreeSnapshot(common.UserID("user-123"), now),
|
||||
@@ -75,12 +75,12 @@ func TestByEmailGetterExecuteUnknownUserReturnsNotFound(t *testing.T) {
|
||||
require.Equal(t, shared.ErrorCodeSubjectNotFound, shared.CodeOf(err))
|
||||
}
|
||||
|
||||
func TestByRaceNameGetterExecuteReturnsAggregate(t *testing.T) {
|
||||
func TestByUserNameGetterExecuteReturnsAggregate(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)),
|
||||
service, err := NewByUserNameGetter(
|
||||
newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "player-abcdefgh", now)),
|
||||
&fakeAdminEntitlementSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
common.UserID("user-123"): validAdminFreeSnapshot(common.UserID("user-123"), now),
|
||||
@@ -92,10 +92,10 @@ func TestByRaceNameGetterExecuteReturnsAggregate(t *testing.T) {
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), GetUserByRaceNameInput{RaceName: " Pilot Nova "})
|
||||
result, err := service.Execute(context.Background(), GetUserByUserNameInput{UserName: " player-abcdefgh "})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "user-123", result.User.UserID)
|
||||
require.Equal(t, "Pilot Nova", result.User.RaceName)
|
||||
require.Equal(t, "player-abcdefgh", result.User.UserName)
|
||||
}
|
||||
|
||||
func TestListerExecuteAppliesCombinedFiltersWithLogicalAND(t *testing.T) {
|
||||
@@ -111,9 +111,9 @@ func TestListerExecuteAppliesCombinedFiltersWithLogicalAND(t *testing.T) {
|
||||
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),
|
||||
validAdminUserAccount("user-300", "u300@example.com", "player-user300a", now),
|
||||
validAdminUserAccount("user-200", "u200@example.com", "player-user200a", now),
|
||||
validAdminUserAccount("user-100", "u100@example.com", "player-user100a", now),
|
||||
)
|
||||
snapshotStore := &fakeAdminEntitlementSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
@@ -197,9 +197,9 @@ func TestListerExecuteDefaultAndMaximumPageSize(t *testing.T) {
|
||||
|
||||
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),
|
||||
validAdminUserAccount("user-300", "u300@example.com", "player-user300a", now),
|
||||
validAdminUserAccount("user-200", "u200@example.com", "player-user200a", now),
|
||||
validAdminUserAccount("user-100", "u100@example.com", "player-user100a", now),
|
||||
)
|
||||
snapshotStore := &fakeAdminEntitlementSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
@@ -273,7 +273,7 @@ func TestListerExecuteInvalidPageTokenReturnsInvalidRequest(t *testing.T) {
|
||||
|
||||
now := time.Unix(1_775_240_500, 0).UTC()
|
||||
service, err := NewLister(
|
||||
newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "Pilot Nova", now)),
|
||||
newFakeAdminAccountStore(validAdminUserAccount("user-123", "pilot@example.com", "player-abcdefgh", now)),
|
||||
&fakeAdminEntitlementSnapshotStore{
|
||||
byUserID: map[common.UserID]entitlement.CurrentSnapshot{
|
||||
common.UserID("user-123"): validAdminFreeSnapshot(common.UserID("user-123"), now),
|
||||
@@ -360,8 +360,8 @@ 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) NewUserName() (common.UserName, error) {
|
||||
return "", errors.New("unexpected NewUserName call")
|
||||
}
|
||||
|
||||
func (generator adminReaderIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
|
||||
@@ -379,9 +379,8 @@ func (generator adminReaderIDGenerator) NewLimitRecordID() (policy.LimitRecordID
|
||||
type fakeAdminAccountStore struct {
|
||||
byUserID map[common.UserID]account.UserAccount
|
||||
byEmail map[common.Email]common.UserID
|
||||
byRaceName map[common.RaceName]common.UserID
|
||||
byUserName map[common.UserName]common.UserID
|
||||
updateErr error
|
||||
renameErr error
|
||||
createErr error
|
||||
existsByID map[common.UserID]bool
|
||||
}
|
||||
@@ -390,14 +389,14 @@ func newFakeAdminAccountStore(records ...account.UserAccount) *fakeAdminAccountS
|
||||
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)),
|
||||
byUserName: make(map[common.UserName]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.byUserName[record.UserName] = record.UserID
|
||||
store.existsByID[record.UserID] = true
|
||||
}
|
||||
|
||||
@@ -426,8 +425,8 @@ func (store *fakeAdminAccountStore) GetByEmail(_ context.Context, email common.E
|
||||
return store.byUserID[userID], nil
|
||||
}
|
||||
|
||||
func (store *fakeAdminAccountStore) GetByRaceName(_ context.Context, raceName common.RaceName) (account.UserAccount, error) {
|
||||
userID, ok := store.byRaceName[raceName]
|
||||
func (store *fakeAdminAccountStore) GetByUserName(_ context.Context, userName common.UserName) (account.UserAccount, error) {
|
||||
userID, ok := store.byUserName[userName]
|
||||
if !ok {
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
@@ -439,10 +438,6 @@ func (store *fakeAdminAccountStore) ExistsByUserID(_ context.Context, userID com
|
||||
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
|
||||
}
|
||||
@@ -547,11 +542,11 @@ func (store *fakeAdminListStore) ListUserIDs(_ context.Context, input ports.List
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func validAdminUserAccount(userID string, email string, raceName string, now time.Time) account.UserAccount {
|
||||
func validAdminUserAccount(userID string, email string, userName string, now time.Time) account.UserAccount {
|
||||
return account.UserAccount{
|
||||
UserID: common.UserID(userID),
|
||||
Email: common.Email(email),
|
||||
RaceName: common.RaceName(raceName),
|
||||
UserName: common.UserName(userName),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
|
||||
DeclaredCountry: common.CountryCode("DE"),
|
||||
|
||||
@@ -22,7 +22,7 @@ const (
|
||||
initialEntitlementActorType common.ActorType = "service"
|
||||
initialEntitlementActorID common.ActorID = "user-service"
|
||||
|
||||
ensureCreateRetryLimit = 8
|
||||
ensureCreateRetryLimit = 10
|
||||
)
|
||||
|
||||
// ResolveByEmailInput stores one auth-facing resolve-by-email request.
|
||||
@@ -155,7 +155,6 @@ type Ensurer struct {
|
||||
store ports.AuthDirectoryStore
|
||||
clock ports.Clock
|
||||
idGenerator ports.IDGenerator
|
||||
policy ports.RaceNamePolicy
|
||||
logger *slog.Logger
|
||||
telemetry *telemetry.Runtime
|
||||
profilePublisher ports.ProfileChangedPublisher
|
||||
@@ -168,9 +167,8 @@ 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)
|
||||
return NewEnsurerWithObservability(store, clock, idGenerator, nil, nil, nil, nil, nil)
|
||||
}
|
||||
|
||||
// NewEnsurerWithObservability returns one ensure-by-email use case instance
|
||||
@@ -180,7 +178,6 @@ func NewEnsurerWithObservability(
|
||||
store ports.AuthDirectoryStore,
|
||||
clock ports.Clock,
|
||||
idGenerator ports.IDGenerator,
|
||||
policy ports.RaceNamePolicy,
|
||||
logger *slog.Logger,
|
||||
telemetryRuntime *telemetry.Runtime,
|
||||
profilePublisher ports.ProfileChangedPublisher,
|
||||
@@ -194,14 +191,11 @@ func NewEnsurerWithObservability(
|
||||
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,
|
||||
@@ -256,7 +250,7 @@ func (service *Ensurer) Execute(ctx context.Context, input EnsureByEmailInput) (
|
||||
if err != nil {
|
||||
return EnsureByEmailResult{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
raceName, err := service.idGenerator.NewInitialRaceName()
|
||||
userName, err := service.idGenerator.NewUserName()
|
||||
if err != nil {
|
||||
return EnsureByEmailResult{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
@@ -264,7 +258,7 @@ func (service *Ensurer) Execute(ctx context.Context, input EnsureByEmailInput) (
|
||||
accountRecord := account.UserAccount{
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
RaceName: raceName,
|
||||
UserName: userName,
|
||||
PreferredLanguage: preferredLanguage,
|
||||
TimeZone: timeZone,
|
||||
CreatedAt: now,
|
||||
@@ -294,21 +288,16 @@ func (service *Ensurer) Execute(ctx context.Context, input EnsureByEmailInput) (
|
||||
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.ErrUserNameConflict) && service.telemetry != nil {
|
||||
service.telemetry.RecordUserNameConflict(ctx, "ensure_by_email")
|
||||
}
|
||||
if errors.Is(err, ports.ErrConflict) {
|
||||
continue
|
||||
@@ -349,11 +338,12 @@ func (service *Ensurer) publishInitializedEvents(
|
||||
occurredAt := accountRecord.UpdatedAt.UTC()
|
||||
|
||||
service.publishProfileChanged(ctx, ports.ProfileChangedEvent{
|
||||
UserID: accountRecord.UserID,
|
||||
OccurredAt: occurredAt,
|
||||
Source: initialEntitlementSource,
|
||||
Operation: ports.ProfileChangedOperationInitialized,
|
||||
RaceName: accountRecord.RaceName,
|
||||
UserID: accountRecord.UserID,
|
||||
OccurredAt: occurredAt,
|
||||
Source: initialEntitlementSource,
|
||||
Operation: ports.ProfileChangedOperationInitialized,
|
||||
UserName: accountRecord.UserName,
|
||||
DisplayName: accountRecord.DisplayName,
|
||||
})
|
||||
service.publishSettingsChanged(ctx, ports.SettingsChangedEvent{
|
||||
UserID: accountRecord.UserID,
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
@@ -101,12 +100,9 @@ func TestEnsurerExecuteCreatedBuildsInitialRecords(t *testing.T) {
|
||||
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.UserName("player-test123"), input.Account.UserName)
|
||||
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)
|
||||
@@ -124,9 +120,9 @@ func TestEnsurerExecuteCreatedBuildsInitialRecords(t *testing.T) {
|
||||
},
|
||||
}, fixedClock{now: now}, fixedIDGenerator{
|
||||
userID: common.UserID("user-created"),
|
||||
raceName: common.RaceName("player-test123"),
|
||||
userName: common.UserName("player-test123"),
|
||||
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
|
||||
}, stubRaceNamePolicy{})
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{
|
||||
@@ -180,9 +176,9 @@ func TestEnsurerExecuteRejectsInvalidRegistrationContext(t *testing.T) {
|
||||
|
||||
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"),
|
||||
userName: common.UserName("player-test123"),
|
||||
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
|
||||
}, stubRaceNamePolicy{})
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ensurer.Execute(context.Background(), tt.input)
|
||||
@@ -210,9 +206,9 @@ func TestEnsurerExecuteRetriesConflicts(t *testing.T) {
|
||||
},
|
||||
}, 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"},
|
||||
userNames: []common.UserName{"player-firstxyz", "player-secondxy"},
|
||||
entitlementRecordIDs: []entitlement.EntitlementRecordID{"entitlement-first", "entitlement-second"},
|
||||
}, stubRaceNamePolicy{})
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{
|
||||
@@ -276,9 +272,9 @@ func TestEnsurerExecuteReturnsExistingAndBlocked(t *testing.T) {
|
||||
|
||||
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"),
|
||||
userName: common.UserName("player-test123"),
|
||||
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
|
||||
}, stubRaceNamePolicy{})
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{
|
||||
@@ -310,9 +306,9 @@ func TestEnsurerExecuteCreatedPublishesInitializedEvents(t *testing.T) {
|
||||
},
|
||||
}, fixedClock{now: now}, fixedIDGenerator{
|
||||
userID: common.UserID("user-created"),
|
||||
raceName: common.RaceName("player-test123"),
|
||||
userName: common.UserName("player-test123"),
|
||||
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
|
||||
}, stubRaceNamePolicy{}, nil, telemetryRuntime, publisher, publisher, publisher)
|
||||
}, nil, telemetryRuntime, publisher, publisher, publisher)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{
|
||||
@@ -407,9 +403,9 @@ func TestEnsurerExecuteExistingBlockedAndFailedDoNotPublishEvents(t *testing.T)
|
||||
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"),
|
||||
userName: common.UserName("player-test123"),
|
||||
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
|
||||
}, stubRaceNamePolicy{}, nil, telemetryRuntime, publisher, publisher, publisher)
|
||||
}, nil, telemetryRuntime, publisher, publisher, publisher)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ensurer.Execute(context.Background(), tt.input)
|
||||
@@ -446,9 +442,9 @@ func TestEnsurerExecutePublishFailureDoesNotRollbackCreatedUser(t *testing.T) {
|
||||
},
|
||||
}, fixedClock{now: now}, fixedIDGenerator{
|
||||
userID: common.UserID("user-created"),
|
||||
raceName: common.RaceName("player-test123"),
|
||||
userName: common.UserName("player-test123"),
|
||||
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
|
||||
}, stubRaceNamePolicy{}, nil, telemetryRuntime, publisher, publisher, publisher)
|
||||
}, nil, telemetryRuntime, publisher, publisher, publisher)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := ensurer.Execute(context.Background(), EnsureByEmailInput{
|
||||
@@ -546,7 +542,7 @@ func (clock fixedClock) Now() time.Time {
|
||||
|
||||
type fixedIDGenerator struct {
|
||||
userID common.UserID
|
||||
raceName common.RaceName
|
||||
userName common.UserName
|
||||
entitlementRecordID entitlement.EntitlementRecordID
|
||||
sanctionRecordID policy.SanctionRecordID
|
||||
limitRecordID policy.LimitRecordID
|
||||
@@ -556,8 +552,8 @@ 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) NewUserName() (common.UserName, error) {
|
||||
return generator.userName, nil
|
||||
}
|
||||
|
||||
func (generator fixedIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
|
||||
@@ -574,7 +570,7 @@ func (generator fixedIDGenerator) NewLimitRecordID() (policy.LimitRecordID, erro
|
||||
|
||||
type sequenceIDGenerator struct {
|
||||
userIDs []common.UserID
|
||||
raceNames []common.RaceName
|
||||
userNames []common.UserName
|
||||
entitlementRecordIDs []entitlement.EntitlementRecordID
|
||||
sanctionRecordIDs []policy.SanctionRecordID
|
||||
limitRecordIDs []policy.LimitRecordID
|
||||
@@ -586,9 +582,9 @@ func (generator *sequenceIDGenerator) NewUserID() (common.UserID, error) {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (generator *sequenceIDGenerator) NewInitialRaceName() (common.RaceName, error) {
|
||||
value := generator.raceNames[0]
|
||||
generator.raceNames = generator.raceNames[1:]
|
||||
func (generator *sequenceIDGenerator) NewUserName() (common.UserName, error) {
|
||||
value := generator.userNames[0]
|
||||
generator.userNames = generator.userNames[1:]
|
||||
return value, nil
|
||||
}
|
||||
|
||||
@@ -610,16 +606,6 @@ func (generator *sequenceIDGenerator) NewLimitRecordID() (policy.LimitRecordID,
|
||||
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
|
||||
@@ -710,7 +696,6 @@ var (
|
||||
_ 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)
|
||||
|
||||
@@ -312,7 +312,7 @@ func (store fakeAccountStore) GetByEmail(context.Context, common.Email) (account
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store fakeAccountStore) GetByRaceName(context.Context, common.RaceName) (account.UserAccount, error) {
|
||||
func (store fakeAccountStore) GetByUserName(context.Context, common.UserName) (account.UserAccount, error) {
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
@@ -320,9 +320,6 @@ func (store fakeAccountStore) ExistsByUserID(_ context.Context, userID common.Us
|
||||
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
|
||||
@@ -454,7 +451,7 @@ func (generator fixedIDGenerator) NewUserID() (common.UserID, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (generator fixedIDGenerator) NewInitialRaceName() (common.RaceName, error) {
|
||||
func (generator fixedIDGenerator) NewUserName() (common.UserName, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
|
||||
@@ -127,6 +127,9 @@ func (service *SyncService) Execute(
|
||||
default:
|
||||
return SyncDeclaredCountryResult{}, shared.ServiceUnavailable(err)
|
||||
}
|
||||
if record.IsDeleted() {
|
||||
return SyncDeclaredCountryResult{}, shared.SubjectNotFound()
|
||||
}
|
||||
|
||||
if record.DeclaredCountry == declaredCountry {
|
||||
outcome = "noop"
|
||||
|
||||
@@ -48,7 +48,7 @@ func TestSyncServiceExecuteUpdatesDeclaredCountryAndPublishesEvent(t *testing.T)
|
||||
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.UserName, stored.UserName)
|
||||
require.Equal(t, record.PreferredLanguage, stored.PreferredLanguage)
|
||||
require.Equal(t, record.TimeZone, stored.TimeZone)
|
||||
require.Equal(t, common.CountryCode("FR"), stored.DeclaredCountry)
|
||||
@@ -210,9 +210,9 @@ func (store *fakeAccountStore) GetByEmail(_ context.Context, email common.Email)
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) GetByRaceName(_ context.Context, raceName common.RaceName) (account.UserAccount, error) {
|
||||
func (store *fakeAccountStore) GetByUserName(_ context.Context, userName common.UserName) (account.UserAccount, error) {
|
||||
for _, record := range store.records {
|
||||
if record.RaceName == raceName {
|
||||
if record.UserName == userName {
|
||||
return record, nil
|
||||
}
|
||||
}
|
||||
@@ -225,10 +225,6 @@ func (store *fakeAccountStore) ExistsByUserID(_ context.Context, userID common.U
|
||||
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 {
|
||||
@@ -283,7 +279,7 @@ func validAccountRecord(createdAt time.Time, updatedAt time.Time) account.UserAc
|
||||
return account.UserAccount{
|
||||
UserID: common.UserID("user-123"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
RaceName: common.RaceName("Pilot Nova"),
|
||||
UserName: common.UserName("player-abcdefgh"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
|
||||
DeclaredCountry: common.CountryCode("DE"),
|
||||
|
||||
@@ -15,33 +15,66 @@ import (
|
||||
"galaxy/user/internal/service/shared"
|
||||
)
|
||||
|
||||
// limitCatalogEntry stores one frozen default quota for free and paid
|
||||
// entitlement states.
|
||||
// limitCatalogEntry stores the frozen default quota for every tariff plan
|
||||
// plus the toggle that decides whether a `free` default is materialized at
|
||||
// all.
|
||||
type limitCatalogEntry struct {
|
||||
code policy.LimitCode
|
||||
freeValue int
|
||||
paidValue int
|
||||
monthlyValue int
|
||||
yearlyValue int
|
||||
lifetimeValue int
|
||||
freeEnabled bool
|
||||
}
|
||||
|
||||
// planValue returns the frozen default quota for plan.
|
||||
func (entry limitCatalogEntry) planValue(plan entitlement.PlanCode) int {
|
||||
switch plan {
|
||||
case entitlement.PlanCodePaidMonthly:
|
||||
return entry.monthlyValue
|
||||
case entitlement.PlanCodePaidYearly:
|
||||
return entry.yearlyValue
|
||||
case entitlement.PlanCodePaidLifetime:
|
||||
return entry.lifetimeValue
|
||||
default:
|
||||
return entry.freeValue
|
||||
}
|
||||
}
|
||||
|
||||
// limitCatalog stores the frozen lobby-facing effective limit defaults used
|
||||
// to materialize numeric quotas from the current entitlement state.
|
||||
// to materialize numeric quotas from the current entitlement state. Paid
|
||||
// plans share the same default unless stated otherwise; per-plan values
|
||||
// diverge only for `max_registered_race_names`.
|
||||
var limitCatalog = []limitCatalogEntry{
|
||||
{
|
||||
code: policy.LimitCodeMaxOwnedPrivateGames,
|
||||
paidValue: 3,
|
||||
code: policy.LimitCodeMaxOwnedPrivateGames,
|
||||
monthlyValue: 3,
|
||||
yearlyValue: 3,
|
||||
lifetimeValue: 3,
|
||||
},
|
||||
{
|
||||
code: policy.LimitCodeMaxPendingPublicApplications,
|
||||
freeValue: 3,
|
||||
paidValue: 10,
|
||||
freeEnabled: true,
|
||||
code: policy.LimitCodeMaxPendingPublicApplications,
|
||||
freeValue: 3,
|
||||
monthlyValue: 10,
|
||||
yearlyValue: 10,
|
||||
lifetimeValue: 10,
|
||||
freeEnabled: true,
|
||||
},
|
||||
{
|
||||
code: policy.LimitCodeMaxActiveGameMemberships,
|
||||
freeValue: 3,
|
||||
paidValue: 10,
|
||||
freeEnabled: true,
|
||||
code: policy.LimitCodeMaxActiveGameMemberships,
|
||||
freeValue: 3,
|
||||
monthlyValue: 10,
|
||||
yearlyValue: 10,
|
||||
lifetimeValue: 10,
|
||||
freeEnabled: true,
|
||||
},
|
||||
{
|
||||
code: policy.LimitCodeMaxRegisteredRaceNames,
|
||||
freeValue: 1,
|
||||
monthlyValue: 2,
|
||||
yearlyValue: 6,
|
||||
lifetimeValue: 0,
|
||||
freeEnabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -268,7 +301,7 @@ func (service *SnapshotReader) Execute(
|
||||
result.Exists = true
|
||||
result.Entitlement = entitlementSnapshotView(entitlementSnapshot)
|
||||
result.ActiveSanctions = lobbyRelevantSanctionViews(activeSanctions)
|
||||
result.EffectiveLimits = materializeEffectiveLimits(entitlementSnapshot.IsPaid, activeLimits)
|
||||
result.EffectiveLimits = materializeEffectiveLimits(entitlementSnapshot.PlanCode, activeLimits)
|
||||
result.Markers = deriveEligibilityMarkers(entitlementSnapshot.IsPaid, activeSanctions)
|
||||
|
||||
return result, nil
|
||||
@@ -308,22 +341,20 @@ func lobbyRelevantSanctionViews(records []policy.SanctionRecord) []ActiveSanctio
|
||||
return views
|
||||
}
|
||||
|
||||
func materializeEffectiveLimits(isPaid bool, overrides []policy.LimitRecord) []EffectiveLimitView {
|
||||
func materializeEffectiveLimits(plan entitlement.PlanCode, overrides []policy.LimitRecord) []EffectiveLimitView {
|
||||
overrideValues := make(map[policy.LimitCode]int, len(overrides))
|
||||
for _, record := range overrides {
|
||||
overrideValues[record.LimitCode] = record.Value
|
||||
}
|
||||
|
||||
isPaid := plan.IsPaid()
|
||||
limits := make([]EffectiveLimitView, 0, len(limitCatalog))
|
||||
for _, entry := range limitCatalog {
|
||||
if !isPaid && !entry.freeEnabled {
|
||||
continue
|
||||
}
|
||||
|
||||
value := entry.freeValue
|
||||
if isPaid {
|
||||
value = entry.paidValue
|
||||
}
|
||||
value := entry.planValue(plan)
|
||||
if override, ok := overrideValues[entry.code]; ok {
|
||||
value = override
|
||||
}
|
||||
@@ -341,6 +372,10 @@ func deriveEligibilityMarkers(
|
||||
isPaid bool,
|
||||
activeSanctions []policy.SanctionRecord,
|
||||
) EligibilityMarkersView {
|
||||
if hasActiveSanction(activeSanctions, policy.SanctionCodePermanentBlock) {
|
||||
return EligibilityMarkersView{}
|
||||
}
|
||||
|
||||
loginBlocked := hasActiveSanction(activeSanctions, policy.SanctionCodeLoginBlock)
|
||||
createBlocked := hasActiveSanction(activeSanctions, policy.SanctionCodePrivateGameCreateBlock)
|
||||
manageBlocked := hasActiveSanction(activeSanctions, policy.SanctionCodePrivateGameManageBlock)
|
||||
@@ -373,7 +408,8 @@ func isLobbyRelevantSanction(code policy.SanctionCode) bool {
|
||||
case policy.SanctionCodeLoginBlock,
|
||||
policy.SanctionCodePrivateGameCreateBlock,
|
||||
policy.SanctionCodePrivateGameManageBlock,
|
||||
policy.SanctionCodeGameJoinBlock:
|
||||
policy.SanctionCodeGameJoinBlock,
|
||||
policy.SanctionCodePermanentBlock:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -95,6 +95,7 @@ func TestSnapshotReaderExecuteBuildsPaidSnapshotAndDerivedState(t *testing.T) {
|
||||
{LimitCode: "max_owned_private_games", Value: 3},
|
||||
{LimitCode: "max_pending_public_applications", Value: 10},
|
||||
{LimitCode: "max_active_game_memberships", Value: 10},
|
||||
{LimitCode: "max_registered_race_names", Value: 2},
|
||||
}, result.EffectiveLimits)
|
||||
}
|
||||
|
||||
@@ -128,6 +129,7 @@ func TestSnapshotReaderExecuteDeniesUnpaidAndLoginBlockedUsers(t *testing.T) {
|
||||
wantLimits: []EffectiveLimitView{
|
||||
{LimitCode: "max_pending_public_applications", Value: 3},
|
||||
{LimitCode: "max_active_game_memberships", Value: 3},
|
||||
{LimitCode: "max_registered_race_names", Value: 1},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -149,6 +151,7 @@ func TestSnapshotReaderExecuteDeniesUnpaidAndLoginBlockedUsers(t *testing.T) {
|
||||
{LimitCode: "max_owned_private_games", Value: 3},
|
||||
{LimitCode: "max_pending_public_applications", Value: 10},
|
||||
{LimitCode: "max_active_game_memberships", Value: 10},
|
||||
{LimitCode: "max_registered_race_names", Value: 2},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -181,6 +184,71 @@ func TestSnapshotReaderExecuteDeniesUnpaidAndLoginBlockedUsers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotReaderExecutePermanentBlockCollapsesMarkers(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
|
||||
}{
|
||||
{
|
||||
name: "permanent_block alone on paid user",
|
||||
snapshot: paidEntitlementSnapshot(userID, now.Add(-24*time.Hour), now.Add(24*time.Hour)),
|
||||
sanctions: []policy.SanctionRecord{
|
||||
activeSanction(userID, policy.SanctionCodePermanentBlock, "platform", now.Add(-time.Hour)),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "permanent_block alone on free user",
|
||||
snapshot: freeEntitlementSnapshot(userID, now.Add(-24*time.Hour)),
|
||||
sanctions: []policy.SanctionRecord{
|
||||
activeSanction(userID, policy.SanctionCodePermanentBlock, "platform", now.Add(-time.Hour)),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "permanent_block dominates login_block",
|
||||
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.SanctionCodePermanentBlock, "platform", now.Add(-30*time.Minute)),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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{},
|
||||
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.Equal(t, EligibilityMarkersView{}, result.Markers,
|
||||
"every can_* marker must be false under permanent_block")
|
||||
|
||||
gotSanctions := make([]string, 0, len(result.ActiveSanctions))
|
||||
for _, sanction := range result.ActiveSanctions {
|
||||
gotSanctions = append(gotSanctions, sanction.SanctionCode)
|
||||
}
|
||||
require.Contains(t, gotSanctions, string(policy.SanctionCodePermanentBlock),
|
||||
"permanent_block must surface in the eligibility snapshot")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotReaderExecuteRepairsExpiredPaidSnapshotWithStore(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -191,12 +259,6 @@ func TestSnapshotReaderExecuteRepairsExpiredPaidSnapshotWithStore(t *testing.T)
|
||||
|
||||
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)
|
||||
@@ -239,6 +301,7 @@ func TestSnapshotReaderExecuteRepairsExpiredPaidSnapshotWithStore(t *testing.T)
|
||||
require.Equal(t, []EffectiveLimitView{
|
||||
{LimitCode: "max_pending_public_applications", Value: 3},
|
||||
{LimitCode: "max_active_game_memberships", Value: 3},
|
||||
{LimitCode: "max_registered_race_names", Value: 1},
|
||||
}, result.EffectiveLimits)
|
||||
|
||||
storedSnapshot, err := store.EntitlementSnapshots().GetByUserID(context.Background(), userID)
|
||||
@@ -264,7 +327,7 @@ func (store fakeAccountStore) GetByEmail(context.Context, common.Email) (account
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store fakeAccountStore) GetByRaceName(context.Context, common.RaceName) (account.UserAccount, error) {
|
||||
func (store fakeAccountStore) GetByUserName(context.Context, common.UserName) (account.UserAccount, error) {
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
@@ -276,10 +339,6 @@ func (store fakeAccountStore) ExistsByUserID(_ context.Context, userID common.Us
|
||||
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
|
||||
}
|
||||
@@ -374,7 +433,7 @@ func (generator fixedIDGenerator) NewUserID() (common.UserID, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (generator fixedIDGenerator) NewInitialRaceName() (common.RaceName, error) {
|
||||
func (generator fixedIDGenerator) NewUserName() (common.UserName, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
@@ -486,7 +545,7 @@ func validAccountRecord() account.UserAccount {
|
||||
return account.UserAccount{
|
||||
UserID: common.UserID("user-123"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
RaceName: common.RaceName("Pilot Nova"),
|
||||
UserName: common.UserName("player-abcdefgh"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
|
||||
CreatedAt: createdAt,
|
||||
|
||||
@@ -21,6 +21,7 @@ func TestApplySanctionServiceExecutePublishesEvent(t *testing.T) {
|
||||
limitStore := newFakeLimitStore()
|
||||
publisher := &recordingPolicyPublisher{}
|
||||
|
||||
lifecyclePublisher := &fakeLifecyclePublisher{}
|
||||
service, err := NewApplySanctionServiceWithObservability(
|
||||
fakeAccountStore{existsByUserID: map[common.UserID]bool{userID: true}},
|
||||
sanctionStore,
|
||||
@@ -31,6 +32,7 @@ func TestApplySanctionServiceExecutePublishesEvent(t *testing.T) {
|
||||
nil,
|
||||
nil,
|
||||
publisher,
|
||||
lifecyclePublisher,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -47,8 +49,130 @@ func TestApplySanctionServiceExecutePublishesEvent(t *testing.T) {
|
||||
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)
|
||||
require.Empty(t, lifecyclePublisher.events,
|
||||
"login_block must not emit a user.lifecycle.permanent_blocked event")
|
||||
}
|
||||
|
||||
func TestApplySanctionServiceExecutePermanentBlockPublishesLifecycleEvent(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{}
|
||||
lifecyclePublisher := &fakeLifecyclePublisher{}
|
||||
|
||||
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,
|
||||
lifecyclePublisher,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
appliedAt := now.Add(-time.Minute)
|
||||
_, err = service.Execute(context.Background(), ApplySanctionInput{
|
||||
UserID: userID.String(),
|
||||
SanctionCode: string(policy.SanctionCodePermanentBlock),
|
||||
Scope: "platform",
|
||||
ReasonCode: "terminal_policy_violation",
|
||||
Actor: ActorInput{Type: "admin", ID: "admin-1"},
|
||||
AppliedAt: appliedAt.Format(time.RFC3339Nano),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, publisher.sanctionEvents, 1)
|
||||
require.Len(t, lifecyclePublisher.events, 1)
|
||||
emitted := lifecyclePublisher.events[0]
|
||||
require.Equal(t, ports.UserLifecyclePermanentBlockedEventType, emitted.EventType)
|
||||
require.Equal(t, userID, emitted.UserID)
|
||||
require.True(t, emitted.OccurredAt.Equal(appliedAt.UTC()))
|
||||
require.Equal(t, common.Source("admin_internal_api"), emitted.Source)
|
||||
require.Equal(t, common.ReasonCode("terminal_policy_violation"), emitted.ReasonCode)
|
||||
require.Equal(t, common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}, emitted.Actor)
|
||||
}
|
||||
|
||||
func TestRemoveSanctionServicePermanentBlockDoesNotEmitLifecycleEvent(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{}
|
||||
lifecyclePublisher := &fakeLifecyclePublisher{}
|
||||
|
||||
// First, apply permanent_block so a subsequent remove has an active record
|
||||
// to target.
|
||||
applyService, 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,
|
||||
lifecyclePublisher,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = applyService.Execute(context.Background(), ApplySanctionInput{
|
||||
UserID: userID.String(),
|
||||
SanctionCode: string(policy.SanctionCodePermanentBlock),
|
||||
Scope: "platform",
|
||||
ReasonCode: "terminal_policy_violation",
|
||||
Actor: ActorInput{Type: "admin", ID: "admin-1"},
|
||||
AppliedAt: now.Add(-time.Hour).Format(time.RFC3339Nano),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, lifecyclePublisher.events, 1)
|
||||
|
||||
removeService, 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 = removeService.Execute(context.Background(), RemoveSanctionInput{
|
||||
UserID: userID.String(),
|
||||
SanctionCode: string(policy.SanctionCodePermanentBlock),
|
||||
ReasonCode: "appeal_granted",
|
||||
Actor: ActorInput{Type: "admin", ID: "admin-2"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, lifecyclePublisher.events, 1,
|
||||
"remove-sanction must not emit an additional lifecycle event")
|
||||
}
|
||||
|
||||
type fakeLifecyclePublisher struct {
|
||||
events []ports.UserLifecycleEvent
|
||||
}
|
||||
|
||||
func (publisher *fakeLifecyclePublisher) PublishUserLifecycleEvent(_ context.Context, event ports.UserLifecycleEvent) error {
|
||||
if err := event.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
publisher.events = append(publisher.events, event)
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ ports.UserLifecyclePublisher = (*fakeLifecyclePublisher)(nil)
|
||||
|
||||
func TestRemoveSanctionServiceExecuteMissingDoesNotPublishEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -267,10 +267,11 @@ func (support commandSupport) loadActiveLimits(
|
||||
|
||||
// ApplySanctionService executes the explicit trusted sanction-apply command.
|
||||
type ApplySanctionService struct {
|
||||
support commandSupport
|
||||
logger *slog.Logger
|
||||
telemetry *telemetry.Runtime
|
||||
publisher ports.SanctionChangedPublisher
|
||||
support commandSupport
|
||||
logger *slog.Logger
|
||||
telemetry *telemetry.Runtime
|
||||
publisher ports.SanctionChangedPublisher
|
||||
lifecyclePublisher ports.UserLifecyclePublisher
|
||||
}
|
||||
|
||||
// NewApplySanctionService constructs one sanction-apply use case.
|
||||
@@ -282,11 +283,13 @@ func NewApplySanctionService(
|
||||
clock ports.Clock,
|
||||
idGenerator ports.IDGenerator,
|
||||
) (*ApplySanctionService, error) {
|
||||
return NewApplySanctionServiceWithObservability(accounts, sanctions, limits, lifecycle, clock, idGenerator, nil, nil, nil)
|
||||
return NewApplySanctionServiceWithObservability(accounts, sanctions, limits, lifecycle, clock, idGenerator, nil, nil, nil, nil)
|
||||
}
|
||||
|
||||
// NewApplySanctionServiceWithObservability constructs one sanction-apply use
|
||||
// case with optional observability hooks.
|
||||
// case with optional observability hooks. `lifecyclePublisher` is consulted
|
||||
// when the newly applied sanction is `SanctionCodePermanentBlock`: one
|
||||
// `user.lifecycle.permanent_blocked` event is emitted after the commit.
|
||||
func NewApplySanctionServiceWithObservability(
|
||||
accounts ports.UserAccountStore,
|
||||
sanctions ports.SanctionStore,
|
||||
@@ -297,6 +300,7 @@ func NewApplySanctionServiceWithObservability(
|
||||
logger *slog.Logger,
|
||||
telemetryRuntime *telemetry.Runtime,
|
||||
publisher ports.SanctionChangedPublisher,
|
||||
lifecyclePublisher ports.UserLifecyclePublisher,
|
||||
) (*ApplySanctionService, error) {
|
||||
support, err := newCommandSupport(accounts, sanctions, limits, lifecycle, clock, idGenerator)
|
||||
if err != nil {
|
||||
@@ -304,10 +308,11 @@ func NewApplySanctionServiceWithObservability(
|
||||
}
|
||||
|
||||
return &ApplySanctionService{
|
||||
support: support,
|
||||
logger: logger,
|
||||
telemetry: telemetryRuntime,
|
||||
publisher: publisher,
|
||||
support: support,
|
||||
logger: logger,
|
||||
telemetry: telemetryRuntime,
|
||||
publisher: publisher,
|
||||
lifecyclePublisher: lifecyclePublisher,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -389,6 +394,9 @@ func (service *ApplySanctionService) Execute(ctx context.Context, input ApplySan
|
||||
ActiveSanctions: sanctionViews(active),
|
||||
}
|
||||
publishSanctionChanged(ctx, service.publisher, service.telemetry, service.logger, "apply_sanction", ports.SanctionChangedOperationApplied, record)
|
||||
if record.SanctionCode == policy.SanctionCodePermanentBlock {
|
||||
publishUserLifecyclePermanentBlocked(ctx, service.lifecyclePublisher, service.telemetry, service.logger, record)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -1177,6 +1185,40 @@ func publishSanctionChanged(
|
||||
}
|
||||
}
|
||||
|
||||
func publishUserLifecyclePermanentBlocked(
|
||||
ctx context.Context,
|
||||
publisher ports.UserLifecyclePublisher,
|
||||
telemetryRuntime *telemetry.Runtime,
|
||||
logger *slog.Logger,
|
||||
record policy.SanctionRecord,
|
||||
) {
|
||||
if publisher == nil {
|
||||
return
|
||||
}
|
||||
|
||||
event := ports.UserLifecycleEvent{
|
||||
EventType: ports.UserLifecyclePermanentBlockedEventType,
|
||||
UserID: record.UserID,
|
||||
OccurredAt: record.AppliedAt.UTC(),
|
||||
Source: adminInternalAPISource,
|
||||
Actor: record.Actor,
|
||||
ReasonCode: record.ReasonCode,
|
||||
}
|
||||
if err := publisher.PublishUserLifecycleEvent(ctx, event); err != nil {
|
||||
if telemetryRuntime != nil {
|
||||
telemetryRuntime.RecordEventPublicationFailure(ctx, string(ports.UserLifecyclePermanentBlockedEventType))
|
||||
}
|
||||
shared.LogEventPublicationFailure(logger, ctx, string(ports.UserLifecyclePermanentBlockedEventType), err,
|
||||
"use_case", "apply_sanction",
|
||||
"user_id", record.UserID.String(),
|
||||
"source", adminInternalAPISource.String(),
|
||||
"reason_code", record.ReasonCode.String(),
|
||||
"actor_type", record.Actor.Type.String(),
|
||||
"actor_id", record.Actor.ID.String(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func publishLimitChanged(
|
||||
ctx context.Context,
|
||||
publisher ports.LimitChangedPublisher,
|
||||
|
||||
@@ -468,7 +468,7 @@ func (store fakeAccountStore) GetByEmail(context.Context, common.Email) (account
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store fakeAccountStore) GetByRaceName(context.Context, common.RaceName) (account.UserAccount, error) {
|
||||
func (store fakeAccountStore) GetByUserName(context.Context, common.UserName) (account.UserAccount, error) {
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
@@ -476,9 +476,6 @@ func (store fakeAccountStore) ExistsByUserID(_ context.Context, userID common.Us
|
||||
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
|
||||
@@ -679,7 +676,7 @@ func (generator fixedIDGenerator) NewUserID() (common.UserID, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (generator fixedIDGenerator) NewInitialRaceName() (common.RaceName, error) {
|
||||
func (generator fixedIDGenerator) NewUserName() (common.UserName, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ func TestProfileUpdaterExecutePublishesProfileChangedEvent(t *testing.T) {
|
||||
fakeSanctionStore{},
|
||||
fakeLimitStore{},
|
||||
fixedClock{now: now},
|
||||
stubRaceNamePolicy{},
|
||||
nil,
|
||||
nil,
|
||||
publisher,
|
||||
@@ -39,15 +38,16 @@ func TestProfileUpdaterExecutePublishesProfileChangedEvent(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), UpdateMyProfileInput{
|
||||
UserID: "user-123",
|
||||
RaceName: "Nova Prime",
|
||||
UserID: "user-123",
|
||||
DisplayName: "NovaPrime",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Nova Prime", result.Account.RaceName)
|
||||
require.Equal(t, "NovaPrime", result.Account.DisplayName)
|
||||
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)
|
||||
require.Equal(t, common.DisplayName("NovaPrime"), publisher.profileEvents[0].DisplayName)
|
||||
require.Equal(t, common.UserName("player-abcdefgh"), publisher.profileEvents[0].UserName)
|
||||
}
|
||||
|
||||
func TestProfileUpdaterExecutePublisherFailureDoesNotRollbackCommit(t *testing.T) {
|
||||
@@ -67,7 +67,6 @@ func TestProfileUpdaterExecutePublisherFailureDoesNotRollbackCommit(t *testing.T
|
||||
fakeSanctionStore{},
|
||||
fakeLimitStore{},
|
||||
fixedClock{now: now},
|
||||
stubRaceNamePolicy{},
|
||||
nil,
|
||||
nil,
|
||||
publisher,
|
||||
@@ -75,16 +74,16 @@ func TestProfileUpdaterExecutePublisherFailureDoesNotRollbackCommit(t *testing.T
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), UpdateMyProfileInput{
|
||||
UserID: "user-123",
|
||||
RaceName: "Nova Prime",
|
||||
UserID: "user-123",
|
||||
DisplayName: "NovaPrime",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Nova Prime", result.Account.RaceName)
|
||||
require.Equal(t, "NovaPrime", result.Account.DisplayName)
|
||||
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)
|
||||
require.Equal(t, common.DisplayName("NovaPrime"), storedAccount.DisplayName)
|
||||
}
|
||||
|
||||
func TestSettingsUpdaterExecuteNoOpDoesNotPublishEvent(t *testing.T) {
|
||||
@@ -94,7 +93,7 @@ func TestSettingsUpdaterExecuteNoOpDoesNotPublishEvent(t *testing.T) {
|
||||
accountStore := newFakeAccountStore(account.UserAccount{
|
||||
UserID: common.UserID("user-123"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
RaceName: common.RaceName("Pilot Nova"),
|
||||
UserName: common.UserName("player-abcdefgh"),
|
||||
PreferredLanguage: common.LanguageTag("en-US"),
|
||||
TimeZone: common.TimeZoneName("UTC"),
|
||||
DeclaredCountry: common.CountryCode("DE"),
|
||||
|
||||
@@ -55,8 +55,9 @@ type UpdateMyProfileInput struct {
|
||||
// UserID stores the authenticated regular-user identifier.
|
||||
UserID string
|
||||
|
||||
// RaceName stores the requested exact replacement race name.
|
||||
RaceName string
|
||||
// DisplayName stores the requested replacement display name. An empty
|
||||
// value resets the stored display name.
|
||||
DisplayName string
|
||||
}
|
||||
|
||||
// UpdateMyProfileResult stores one self-service profile mutation result.
|
||||
@@ -123,6 +124,9 @@ func (service *AccountGetter) Execute(ctx context.Context, input GetMyAccountInp
|
||||
if err != nil {
|
||||
return GetMyAccountResult{}, err
|
||||
}
|
||||
if state.HasActiveSanction(policy.SanctionCodePermanentBlock) {
|
||||
return GetMyAccountResult{}, shared.Conflict()
|
||||
}
|
||||
|
||||
return GetMyAccountResult{Account: state.View()}, nil
|
||||
}
|
||||
@@ -131,7 +135,6 @@ func (service *AccountGetter) Execute(ctx context.Context, input GetMyAccountInp
|
||||
type ProfileUpdater struct {
|
||||
accounts ports.UserAccountStore
|
||||
loader *accountview.Loader
|
||||
policy ports.RaceNamePolicy
|
||||
clock ports.Clock
|
||||
logger *slog.Logger
|
||||
telemetry *telemetry.Runtime
|
||||
@@ -145,9 +148,8 @@ func NewProfileUpdater(
|
||||
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)
|
||||
return NewProfileUpdaterWithObservability(accounts, entitlements, sanctions, limits, clock, nil, nil, nil)
|
||||
}
|
||||
|
||||
// NewProfileUpdaterWithObservability constructs one self-service
|
||||
@@ -158,7 +160,6 @@ func NewProfileUpdaterWithObservability(
|
||||
sanctions ports.SanctionStore,
|
||||
limits ports.LimitStore,
|
||||
clock ports.Clock,
|
||||
policy ports.RaceNamePolicy,
|
||||
logger *slog.Logger,
|
||||
telemetryRuntime *telemetry.Runtime,
|
||||
profilePublisher ports.ProfileChangedPublisher,
|
||||
@@ -167,14 +168,10 @@ func NewProfileUpdaterWithObservability(
|
||||
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,
|
||||
@@ -204,7 +201,7 @@ func (service *ProfileUpdater) Execute(ctx context.Context, input UpdateMyProfil
|
||||
return UpdateMyProfileResult{}, err
|
||||
}
|
||||
userIDString = userID.String()
|
||||
raceName, err := parseRaceName(input.RaceName)
|
||||
displayName, err := shared.ParseDisplayName(input.DisplayName)
|
||||
if err != nil {
|
||||
return UpdateMyProfileResult{}, err
|
||||
}
|
||||
@@ -213,33 +210,22 @@ func (service *ProfileUpdater) Execute(ctx context.Context, input UpdateMyProfil
|
||||
if err != nil {
|
||||
return UpdateMyProfileResult{}, err
|
||||
}
|
||||
if state.HasActiveSanction(policy.SanctionCodePermanentBlock) {
|
||||
return UpdateMyProfileResult{}, shared.Conflict()
|
||||
}
|
||||
if state.HasActiveSanction(policy.SanctionCodeProfileUpdateBlock) {
|
||||
return UpdateMyProfileResult{}, shared.Conflict()
|
||||
}
|
||||
if state.AccountRecord.RaceName == raceName {
|
||||
if state.AccountRecord.DisplayName == displayName {
|
||||
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")
|
||||
}
|
||||
record := state.AccountRecord
|
||||
record.DisplayName = displayName
|
||||
record.UpdatedAt = now
|
||||
if err := service.accounts.Update(ctx, record); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return UpdateMyProfileResult{}, shared.SubjectNotFound()
|
||||
@@ -344,6 +330,9 @@ func (service *SettingsUpdater) Execute(ctx context.Context, input UpdateMySetti
|
||||
if err != nil {
|
||||
return UpdateMySettingsResult{}, err
|
||||
}
|
||||
if state.HasActiveSanction(policy.SanctionCodePermanentBlock) {
|
||||
return UpdateMySettingsResult{}, shared.Conflict()
|
||||
}
|
||||
if state.HasActiveSanction(policy.SanctionCodeProfileUpdateBlock) {
|
||||
return UpdateMySettingsResult{}, shared.Conflict()
|
||||
}
|
||||
@@ -379,10 +368,6 @@ func (service *SettingsUpdater) Execute(ctx context.Context, input UpdateMySetti
|
||||
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 {
|
||||
@@ -423,11 +408,12 @@ func (service *ProfileUpdater) publishProfileChanged(ctx context.Context, record
|
||||
}
|
||||
|
||||
event := ports.ProfileChangedEvent{
|
||||
UserID: record.UserID,
|
||||
OccurredAt: record.UpdatedAt.UTC(),
|
||||
Source: gatewaySelfServiceSource,
|
||||
Operation: ports.ProfileChangedOperationUpdated,
|
||||
RaceName: record.RaceName,
|
||||
UserID: record.UserID,
|
||||
OccurredAt: record.UpdatedAt.UTC(),
|
||||
Source: gatewaySelfServiceSource,
|
||||
Operation: ports.ProfileChangedOperationUpdated,
|
||||
UserName: record.UserName,
|
||||
DisplayName: record.DisplayName,
|
||||
}
|
||||
if err := service.profilePublisher.PublishProfileChanged(ctx, event); err != nil {
|
||||
if service.telemetry != nil {
|
||||
|
||||
@@ -2,7 +2,6 @@ package selfservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -156,74 +155,63 @@ func TestProfileUpdaterExecuteBlockedBySanction(t *testing.T) {
|
||||
},
|
||||
fakeLimitStore{},
|
||||
fixedClock{now: now},
|
||||
stubRaceNamePolicy{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), UpdateMyProfileInput{
|
||||
UserID: "user-123",
|
||||
RaceName: "Nova Prime",
|
||||
UserID: "user-123",
|
||||
DisplayName: "NovaPrime",
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, shared.ErrorCodeConflict, shared.CodeOf(err))
|
||||
require.Equal(t, 0, accountStore.renameCalls)
|
||||
require.Equal(t, 0, accountStore.updateCalls)
|
||||
}
|
||||
|
||||
func TestProfileUpdaterExecuteSuccessNoOpAndConflict(t *testing.T) {
|
||||
func TestProfileUpdaterExecuteDisplayNameUpdates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
inputRaceName string
|
||||
renameErr error
|
||||
inputDisplay string
|
||||
updateErr error
|
||||
wantCode string
|
||||
wantRaceName string
|
||||
wantRenameCalls int
|
||||
wantCurrentKey account.RaceNameCanonicalKey
|
||||
wantNewKey account.RaceNameCanonicalKey
|
||||
wantDisplay string
|
||||
wantUpdateCalls int
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
inputRaceName: "Nova Prime",
|
||||
wantRaceName: "Nova Prime",
|
||||
wantRenameCalls: 1,
|
||||
wantCurrentKey: canonicalKey(common.RaceName("Pilot Nova")),
|
||||
wantNewKey: canonicalKey(common.RaceName("Nova Prime")),
|
||||
name: "set display name",
|
||||
inputDisplay: "NovaPrime",
|
||||
wantDisplay: "NovaPrime",
|
||||
wantUpdateCalls: 1,
|
||||
},
|
||||
{
|
||||
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: "trims input",
|
||||
inputDisplay: " NovaPrime ",
|
||||
wantDisplay: "NovaPrime",
|
||||
wantUpdateCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "no-op",
|
||||
inputRaceName: " Pilot Nova ",
|
||||
wantRaceName: "Pilot Nova",
|
||||
wantRenameCalls: 0,
|
||||
name: "reset to empty",
|
||||
inputDisplay: " ",
|
||||
wantDisplay: "",
|
||||
wantUpdateCalls: 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")),
|
||||
name: "invalid display name rejected",
|
||||
inputDisplay: "Nova Prime",
|
||||
wantCode: shared.ErrorCodeInvalidRequest,
|
||||
wantDisplay: "",
|
||||
wantUpdateCalls: 0,
|
||||
},
|
||||
}
|
||||
|
||||
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
|
||||
accountStore.updateErr = tt.updateErr
|
||||
service, err := NewProfileUpdater(
|
||||
accountStore,
|
||||
&fakeEntitlementSnapshotStore{
|
||||
@@ -234,13 +222,12 @@ func TestProfileUpdaterExecuteSuccessNoOpAndConflict(t *testing.T) {
|
||||
fakeSanctionStore{},
|
||||
fakeLimitStore{},
|
||||
fixedClock{now: now},
|
||||
stubRaceNamePolicy{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), UpdateMyProfileInput{
|
||||
UserID: "user-123",
|
||||
RaceName: tt.inputRaceName,
|
||||
UserID: "user-123",
|
||||
DisplayName: tt.inputDisplay,
|
||||
})
|
||||
if tt.wantCode != "" {
|
||||
require.Error(t, err)
|
||||
@@ -249,17 +236,13 @@ func TestProfileUpdaterExecuteSuccessNoOpAndConflict(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
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.wantRaceName, storedAccount.RaceName.String())
|
||||
require.Equal(t, tt.wantDisplay, storedAccount.DisplayName.String())
|
||||
if tt.wantCode == "" {
|
||||
require.Equal(t, tt.wantRaceName, result.Account.RaceName)
|
||||
require.Equal(t, tt.wantDisplay, result.Account.DisplayName)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -326,7 +309,7 @@ func TestSettingsUpdaterExecuteCanonicalizedNoOpAndInvalidInputs(t *testing.T) {
|
||||
accountRecord: account.UserAccount{
|
||||
UserID: common.UserID("user-123"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
RaceName: common.RaceName("Pilot Nova"),
|
||||
UserName: common.UserName("player-abcdefgh"),
|
||||
PreferredLanguage: common.LanguageTag("en-US"),
|
||||
TimeZone: common.TimeZoneName("UTC"),
|
||||
DeclaredCountry: common.CountryCode("DE"),
|
||||
@@ -406,12 +389,9 @@ func TestSettingsUpdaterExecuteCanonicalizedNoOpAndInvalidInputs(t *testing.T) {
|
||||
}
|
||||
|
||||
type fakeAccountStore struct {
|
||||
records map[common.UserID]account.UserAccount
|
||||
renameErr error
|
||||
updateErr error
|
||||
renameCalls int
|
||||
updateCalls int
|
||||
lastRenameInput ports.RenameRaceNameInput
|
||||
records map[common.UserID]account.UserAccount
|
||||
updateErr error
|
||||
updateCalls int
|
||||
}
|
||||
|
||||
func newFakeAccountStore(records ...account.UserAccount) *fakeAccountStore {
|
||||
@@ -424,7 +404,7 @@ func newFakeAccountStore(records ...account.UserAccount) *fakeAccountStore {
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) Create(_ context.Context, input ports.CreateAccountInput) error {
|
||||
if input.Account.Validate() != nil || input.Reservation.Validate() != nil {
|
||||
if input.Account.Validate() != nil {
|
||||
return ports.ErrConflict
|
||||
}
|
||||
|
||||
@@ -450,9 +430,9 @@ func (store *fakeAccountStore) GetByEmail(_ context.Context, email common.Email)
|
||||
return account.UserAccount{}, ports.ErrNotFound
|
||||
}
|
||||
|
||||
func (store *fakeAccountStore) GetByRaceName(_ context.Context, raceName common.RaceName) (account.UserAccount, error) {
|
||||
func (store *fakeAccountStore) GetByUserName(_ context.Context, userName common.UserName) (account.UserAccount, error) {
|
||||
for _, record := range store.records {
|
||||
if record.RaceName == raceName {
|
||||
if record.UserName == userName {
|
||||
return record, nil
|
||||
}
|
||||
}
|
||||
@@ -465,27 +445,6 @@ func (store *fakeAccountStore) ExistsByUserID(_ context.Context, userID common.U
|
||||
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 {
|
||||
@@ -553,7 +512,7 @@ func (generator readerIDGenerator) NewUserID() (common.UserID, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (generator readerIDGenerator) NewInitialRaceName() (common.RaceName, error) {
|
||||
func (generator readerIDGenerator) NewUserName() (common.UserName, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
@@ -633,26 +592,12 @@ 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"),
|
||||
UserName: common.UserName("player-abcdefgh"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
|
||||
DeclaredCountry: common.CountryCode("DE"),
|
||||
@@ -727,6 +672,5 @@ var (
|
||||
_ ports.EntitlementLifecycleStore = (*fakeEntitlementLifecycleStore)(nil)
|
||||
_ ports.SanctionStore = fakeSanctionStore{}
|
||||
_ ports.LimitStore = fakeLimitStore{}
|
||||
_ ports.RaceNamePolicy = stubRaceNamePolicy{}
|
||||
_ ports.IDGenerator = readerIDGenerator{}
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/util"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
@@ -36,14 +37,29 @@ func ParseUserID(value string) (common.UserID, 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 {
|
||||
// ParseUserName trims value and validates it as one exact stored user name.
|
||||
func ParseUserName(value string) (common.UserName, error) {
|
||||
userName := common.UserName(NormalizeString(value))
|
||||
if err := userName.Validate(); err != nil {
|
||||
return "", InvalidRequest(err.Error())
|
||||
}
|
||||
|
||||
return raceName, nil
|
||||
return userName, nil
|
||||
}
|
||||
|
||||
// ParseDisplayName trims value and validates it as one self-service display
|
||||
// name. An empty trimmed value is accepted and represents a reset to no
|
||||
// display name.
|
||||
func ParseDisplayName(value string) (common.DisplayName, error) {
|
||||
trimmed := NormalizeString(value)
|
||||
if trimmed == "" {
|
||||
return "", nil
|
||||
}
|
||||
if _, ok := util.ValidateTypeName(trimmed); !ok {
|
||||
return "", InvalidRequest(fmt.Sprintf("display_name %q is invalid", trimmed))
|
||||
}
|
||||
|
||||
return common.DisplayName(trimmed), nil
|
||||
}
|
||||
|
||||
// ParseReasonCode trims value and validates it as one machine-readable reason
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -115,10 +115,11 @@ type Runtime struct {
|
||||
internalHTTPDuration metric.Float64Histogram
|
||||
authResolutionOutcomes metric.Int64Counter
|
||||
userCreationOutcomes metric.Int64Counter
|
||||
raceNameReservationConflicts metric.Int64Counter
|
||||
userNameConflicts metric.Int64Counter
|
||||
entitlementMutations metric.Int64Counter
|
||||
sanctionMutations metric.Int64Counter
|
||||
limitMutations metric.Int64Counter
|
||||
lifecycleMutations metric.Int64Counter
|
||||
eventPublicationFailures metric.Int64Counter
|
||||
}
|
||||
|
||||
@@ -250,14 +251,14 @@ func (r *Runtime) RecordUserCreationOutcome(ctx context.Context, outcome string)
|
||||
)
|
||||
}
|
||||
|
||||
// RecordRaceNameReservationConflict records one race-name reservation conflict
|
||||
// for operation.
|
||||
func (r *Runtime) RecordRaceNameReservationConflict(ctx context.Context, operation string) {
|
||||
// RecordUserNameConflict records one user-name generation conflict observed
|
||||
// during operation.
|
||||
func (r *Runtime) RecordUserNameConflict(ctx context.Context, operation string) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
|
||||
r.raceNameReservationConflicts.Add(
|
||||
r.userNameConflicts.Add(
|
||||
normalizeContext(ctx),
|
||||
1,
|
||||
metric.WithAttributes(attribute.String("operation", strings.TrimSpace(operation))),
|
||||
@@ -312,6 +313,23 @@ func (r *Runtime) RecordLimitMutation(ctx context.Context, command string, outco
|
||||
)
|
||||
}
|
||||
|
||||
// RecordUserLifecycleMutation records one trusted user-lifecycle command
|
||||
// outcome (currently `apply_permanent_block` and `delete`).
|
||||
func (r *Runtime) RecordUserLifecycleMutation(ctx context.Context, command string, outcome string) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
|
||||
r.lifecycleMutations.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) {
|
||||
@@ -419,9 +437,9 @@ func buildRuntime(
|
||||
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")
|
||||
userNameConflicts, err := meter.Int64Counter("user.user_name.conflicts")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build user telemetry runtime: race_name.reservation_conflicts: %w", err)
|
||||
return nil, fmt.Errorf("build user telemetry runtime: user_name.conflicts: %w", err)
|
||||
}
|
||||
entitlementMutations, err := meter.Int64Counter("user.entitlement.mutations")
|
||||
if err != nil {
|
||||
@@ -435,6 +453,10 @@ func buildRuntime(
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build user telemetry runtime: limit.mutations: %w", err)
|
||||
}
|
||||
lifecycleMutations, err := meter.Int64Counter("user.lifecycle.mutations")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build user telemetry runtime: lifecycle.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)
|
||||
@@ -453,10 +475,11 @@ func buildRuntime(
|
||||
internalHTTPDuration: internalHTTPDuration,
|
||||
authResolutionOutcomes: authResolutionOutcomes,
|
||||
userCreationOutcomes: userCreationOutcomes,
|
||||
raceNameReservationConflicts: raceNameReservationConflicts,
|
||||
userNameConflicts: userNameConflicts,
|
||||
entitlementMutations: entitlementMutations,
|
||||
sanctionMutations: sanctionMutations,
|
||||
limitMutations: limitMutations,
|
||||
lifecycleMutations: lifecycleMutations,
|
||||
eventPublicationFailures: eventPublicationFailures,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ func TestNewPreservesBusinessMetrics(t *testing.T) {
|
||||
}, 125*time.Millisecond)
|
||||
runtime.RecordAuthResolutionOutcome(context.Background(), "resolve_by_email", "existing")
|
||||
runtime.RecordUserCreationOutcome(context.Background(), "created")
|
||||
runtime.RecordRaceNameReservationConflict(context.Background(), "update_my_profile")
|
||||
runtime.RecordUserNameConflict(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")
|
||||
@@ -97,7 +97,7 @@ func TestNewPreservesBusinessMetrics(t *testing.T) {
|
||||
assertMetricCount(t, reader, "user.user_creation.outcomes", map[string]string{
|
||||
"outcome": "created",
|
||||
}, 1)
|
||||
assertMetricCount(t, reader, "user.race_name.reservation_conflicts", map[string]string{
|
||||
assertMetricCount(t, reader, "user.user_name.conflicts", map[string]string{
|
||||
"operation": "update_my_profile",
|
||||
}, 1)
|
||||
assertMetricCount(t, reader, "user.entitlement.mutations", map[string]string{
|
||||
|
||||
+139
-28
@@ -217,9 +217,10 @@ paths:
|
||||
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.
|
||||
Accepts only `display_name`. Validation delegates to
|
||||
`pkg/util/string.go:ValidateTypeName`; an empty value is accepted
|
||||
and resets any stored display name. `user_name` is immutable and is
|
||||
not returned in the request body.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/UserIDPath"
|
||||
requestBody:
|
||||
@@ -387,21 +388,21 @@ paths:
|
||||
$ref: "#/components/responses/InternalError"
|
||||
"503":
|
||||
$ref: "#/components/responses/ServiceUnavailableError"
|
||||
/api/v1/internal/user-lookups/by-race-name:
|
||||
/api/v1/internal/user-lookups/by-user-name:
|
||||
post:
|
||||
tags:
|
||||
- AdminUsers
|
||||
operationId: getUserByRaceName
|
||||
summary: Read one user by exact race name
|
||||
operationId: getUserByUserName
|
||||
summary: Read one user by exact user name
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserLookupByRaceNameRequest"
|
||||
$ref: "#/components/schemas/UserLookupByUserNameRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Exact user lookup result for the supplied `race_name`.
|
||||
description: Exact user lookup result for the supplied `user_name`.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -482,6 +483,24 @@ paths:
|
||||
description: Filter by the derived game-join eligibility marker.
|
||||
schema:
|
||||
type: boolean
|
||||
- name: user_name
|
||||
in: query
|
||||
description: Filter by exact `user_name`.
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserName"
|
||||
- name: display_name
|
||||
in: query
|
||||
description: Filter by `display_name`. Combined with `display_name_match`.
|
||||
schema:
|
||||
$ref: "#/components/schemas/DisplayName"
|
||||
- name: display_name_match
|
||||
in: query
|
||||
description: Match mode for `display_name`; defaults to `exact`.
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- exact
|
||||
- prefix
|
||||
responses:
|
||||
"200":
|
||||
description: Deterministically ordered page of users.
|
||||
@@ -728,6 +747,46 @@ paths:
|
||||
$ref: "#/components/responses/InternalError"
|
||||
"503":
|
||||
$ref: "#/components/responses/ServiceUnavailableError"
|
||||
/api/v1/internal/users/{user_id}/delete:
|
||||
post:
|
||||
tags:
|
||||
- AdminUsers
|
||||
operationId: deleteUser
|
||||
summary: Soft-delete one regular-user account
|
||||
description: |
|
||||
Soft-deletes the account identified by `user_id`. The account record is
|
||||
preserved for audit with a `deleted_at` timestamp. Subsequent external
|
||||
auth, self-service, admin-read, and lobby-eligibility operations
|
||||
addressing the same `user_id` return `404 subject_not_found`.
|
||||
|
||||
The command is idempotent per `user_id`: calling it after the account
|
||||
is already soft-deleted returns `404 subject_not_found` and does not
|
||||
re-emit the `user.lifecycle.deleted` event.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/UserIDPath"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DeleteUserRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Soft-delete applied successfully.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DeleteUserResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"404":
|
||||
$ref: "#/components/responses/SubjectNotFoundError"
|
||||
"409":
|
||||
$ref: "#/components/responses/ConflictError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
"503":
|
||||
$ref: "#/components/responses/ServiceUnavailableError"
|
||||
components:
|
||||
parameters:
|
||||
UserIDPath:
|
||||
@@ -766,14 +825,24 @@ components:
|
||||
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:
|
||||
UserName:
|
||||
type: string
|
||||
description: |
|
||||
Stored race name preserving the user-selected casing after successful
|
||||
uniqueness checks. Uniqueness is enforced against a canonical
|
||||
reservation key rather than exact string equality only.
|
||||
minLength: 1
|
||||
Immutable auto-generated platform handle in `player-<suffix>` form.
|
||||
The suffix is eight characters drawn from a confusable-free
|
||||
alphanumeric alphabet. Assigned once at account creation and never
|
||||
changes thereafter.
|
||||
pattern: "^player-[a-z0-9]{8}$"
|
||||
minLength: 15
|
||||
maxLength: 64
|
||||
DisplayName:
|
||||
type: string
|
||||
description: |
|
||||
Optional free-text user-facing label. Validated by
|
||||
`pkg/util/string.go:ValidateTypeName`. Empty values are accepted and
|
||||
represent no display name. Uniqueness is not enforced.
|
||||
minLength: 0
|
||||
maxLength: 30
|
||||
LanguageTag:
|
||||
type: string
|
||||
description: |
|
||||
@@ -827,6 +896,7 @@ components:
|
||||
- private_game_manage_block
|
||||
- game_join_block
|
||||
- profile_update_block
|
||||
- permanent_block
|
||||
LimitCode:
|
||||
type: string
|
||||
description: |
|
||||
@@ -837,6 +907,7 @@ components:
|
||||
- max_owned_private_games
|
||||
- max_pending_public_applications
|
||||
- max_active_game_memberships
|
||||
- max_registered_race_names
|
||||
ActorRef:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
@@ -926,8 +997,9 @@ components:
|
||||
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.
|
||||
initial auto-generated `user_name` handle and the free
|
||||
entitlement snapshot. `display_name` defaults to empty for new
|
||||
accounts.
|
||||
block_reason_code:
|
||||
type: string
|
||||
description: Present only for `outcome=blocked`.
|
||||
@@ -1077,7 +1149,7 @@ components:
|
||||
required:
|
||||
- user_id
|
||||
- email
|
||||
- race_name
|
||||
- user_name
|
||||
- preferred_language
|
||||
- time_zone
|
||||
- entitlement
|
||||
@@ -1090,8 +1162,10 @@ components:
|
||||
$ref: "#/components/schemas/UserID"
|
||||
email:
|
||||
$ref: "#/components/schemas/Email"
|
||||
race_name:
|
||||
$ref: "#/components/schemas/RaceName"
|
||||
user_name:
|
||||
$ref: "#/components/schemas/UserName"
|
||||
display_name:
|
||||
$ref: "#/components/schemas/DisplayName"
|
||||
preferred_language:
|
||||
$ref: "#/components/schemas/LanguageTag"
|
||||
time_zone:
|
||||
@@ -1114,6 +1188,15 @@ components:
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
deleted_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: |
|
||||
Soft-delete timestamp. Present only when the account has been
|
||||
soft-deleted by a trusted `DeleteUser` command. External reads by
|
||||
stable `user_id` return `404 subject_not_found` for such accounts;
|
||||
admin listings exclude them unless explicitly asked via the
|
||||
`deleted` filter.
|
||||
GetMyAccountResponse:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
@@ -1126,14 +1209,15 @@ components:
|
||||
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.
|
||||
Accepts only `display_name`. An empty value is accepted and resets
|
||||
any stored display name. Any other field (including the legacy
|
||||
`race_name` payload) is rejected as `400 invalid_request` through
|
||||
strict unknown-field handling.
|
||||
required:
|
||||
- race_name
|
||||
- display_name
|
||||
properties:
|
||||
race_name:
|
||||
$ref: "#/components/schemas/RaceName"
|
||||
display_name:
|
||||
$ref: "#/components/schemas/DisplayName"
|
||||
UpdateMySettingsRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
@@ -1238,14 +1322,14 @@ components:
|
||||
properties:
|
||||
email:
|
||||
$ref: "#/components/schemas/Email"
|
||||
UserLookupByRaceNameRequest:
|
||||
UserLookupByUserNameRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- race_name
|
||||
- user_name
|
||||
properties:
|
||||
race_name:
|
||||
$ref: "#/components/schemas/RaceName"
|
||||
user_name:
|
||||
$ref: "#/components/schemas/UserName"
|
||||
UserLookupResponse:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
@@ -1451,6 +1535,33 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ActiveLimit"
|
||||
DeleteUserRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
description: |
|
||||
Soft-delete command payload. The caller is expected to be
|
||||
`Admin Service`. `actor.type` must be non-empty; `actor.id` is
|
||||
optional.
|
||||
required:
|
||||
- reason_code
|
||||
- actor
|
||||
properties:
|
||||
reason_code:
|
||||
type: string
|
||||
actor:
|
||||
$ref: "#/components/schemas/ActorRef"
|
||||
DeleteUserResponse:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- user_id
|
||||
- deleted_at
|
||||
properties:
|
||||
user_id:
|
||||
$ref: "#/components/schemas/UserID"
|
||||
deleted_at:
|
||||
type: string
|
||||
format: date-time
|
||||
ErrorResponse:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
|
||||
@@ -102,8 +102,8 @@ func TestInternalOpenAPISpecFreezesSharedResponseSchemas(t *testing.T) {
|
||||
wantRef: "#/components/schemas/UserLookupResponse",
|
||||
},
|
||||
{
|
||||
name: "get user by race name",
|
||||
path: "/api/v1/internal/user-lookups/by-race-name",
|
||||
name: "get user by user name",
|
||||
path: "/api/v1/internal/user-lookups/by-user-name",
|
||||
method: http.MethodPost,
|
||||
status: http.StatusOK,
|
||||
wantRef: "#/components/schemas/UserLookupResponse",
|
||||
@@ -115,6 +115,13 @@ func TestInternalOpenAPISpecFreezesSharedResponseSchemas(t *testing.T) {
|
||||
status: http.StatusOK,
|
||||
wantRef: "#/components/schemas/UserListResponse",
|
||||
},
|
||||
{
|
||||
name: "delete user",
|
||||
path: "/api/v1/internal/users/{user_id}/delete",
|
||||
method: http.MethodPost,
|
||||
status: http.StatusOK,
|
||||
wantRef: "#/components/schemas/DeleteUserResponse",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -128,6 +135,47 @@ func TestInternalOpenAPISpecFreezesSharedResponseSchemas(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalOpenAPISpecFreezesDeleteUserRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadOpenAPISpec(t)
|
||||
operation := getOpenAPIOperation(t, doc, "/api/v1/internal/users/{user_id}/delete", http.MethodPost)
|
||||
|
||||
assertSchemaRef(t, requestSchemaRef(t, operation), "#/components/schemas/DeleteUserRequest", "delete user request schema")
|
||||
|
||||
requestSchema := componentSchemaRef(t, doc, "DeleteUserRequest")
|
||||
assertRequiredFields(t, requestSchema, "reason_code", "actor")
|
||||
assertSchemaRef(t, requestSchema.Value.Properties["actor"], "#/components/schemas/ActorRef", "delete user request actor property")
|
||||
require.Contains(t, marshalOpenAPIJSON(t, requestSchema.Value), `"additionalProperties":false`)
|
||||
|
||||
responseSchema := componentSchemaRef(t, doc, "DeleteUserResponse")
|
||||
assertRequiredFields(t, responseSchema, "user_id", "deleted_at")
|
||||
require.Contains(t, marshalOpenAPIJSON(t, responseSchema.Value), `"additionalProperties":false`)
|
||||
}
|
||||
|
||||
func TestInternalOpenAPISpecSanctionCodeEnumIncludesPermanentBlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadOpenAPISpec(t)
|
||||
schema := componentSchemaRef(t, doc, "SanctionCode")
|
||||
|
||||
enumValues := make([]string, 0, len(schema.Value.Enum))
|
||||
for _, value := range schema.Value.Enum {
|
||||
stringValue, ok := value.(string)
|
||||
require.True(t, ok, "SanctionCode enum entry must be a string")
|
||||
enumValues = append(enumValues, stringValue)
|
||||
}
|
||||
|
||||
require.ElementsMatch(t, []string{
|
||||
"login_block",
|
||||
"private_game_create_block",
|
||||
"private_game_manage_block",
|
||||
"game_join_block",
|
||||
"profile_update_block",
|
||||
"permanent_block",
|
||||
}, enumValues)
|
||||
}
|
||||
|
||||
func TestInternalOpenAPISpecErrorEnvelopeRemainsStable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+170
-10
@@ -24,9 +24,10 @@ type runtimeContractHarness struct {
|
||||
baseURL string
|
||||
client *http.Client
|
||||
|
||||
runtime *app.Runtime
|
||||
cancel context.CancelFunc
|
||||
runErr chan error
|
||||
runtime *app.Runtime
|
||||
cancel context.CancelFunc
|
||||
runErr chan error
|
||||
redisServer *miniredis.Miniredis
|
||||
}
|
||||
|
||||
func newRuntimeContractHarness(t *testing.T) *runtimeContractHarness {
|
||||
@@ -60,11 +61,12 @@ func newRuntimeContractHarness(t *testing.T) *runtimeContractHarness {
|
||||
}
|
||||
|
||||
harness := &runtimeContractHarness{
|
||||
baseURL: "http://" + cfg.InternalHTTP.Addr,
|
||||
client: client,
|
||||
runtime: runtime,
|
||||
cancel: cancel,
|
||||
runErr: runErr,
|
||||
baseURL: "http://" + cfg.InternalHTTP.Addr,
|
||||
client: client,
|
||||
runtime: runtime,
|
||||
cancel: cancel,
|
||||
runErr: runErr,
|
||||
redisServer: redisServer,
|
||||
}
|
||||
harness.waitUntilReady(t)
|
||||
|
||||
@@ -222,6 +224,35 @@ func (h *runtimeContractHarness) setLimit(t *testing.T, userID string, limitCode
|
||||
requireResponseJSON(t, response, http.StatusOK, &body)
|
||||
}
|
||||
|
||||
func (h *runtimeContractHarness) deleteUser(t *testing.T, userID string, reasonCode string) httpResponse {
|
||||
t.Helper()
|
||||
|
||||
return h.postJSON(t, "/api/v1/internal/users/"+userID+"/delete", map[string]any{
|
||||
"reason_code": reasonCode,
|
||||
"actor": map[string]string{
|
||||
"type": "admin",
|
||||
"id": "admin-1",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *runtimeContractHarness) lifecycleStreamEntries(t *testing.T) []map[string]string {
|
||||
t.Helper()
|
||||
|
||||
stream, err := h.redisServer.Stream("user:lifecycle_events")
|
||||
require.NoError(t, err)
|
||||
entries := make([]map[string]string, 0, len(stream))
|
||||
for _, entry := range stream {
|
||||
require.Equal(t, 0, len(entry.Values)%2, "stream entry values must come in key/value pairs")
|
||||
values := make(map[string]string, len(entry.Values)/2)
|
||||
for index := 0; index < len(entry.Values); index += 2 {
|
||||
values[entry.Values[index]] = entry.Values[index+1]
|
||||
}
|
||||
entries = append(entries, values)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func (h *runtimeContractHarness) listUsers(t *testing.T, rawQuery string) httpResponse {
|
||||
t.Helper()
|
||||
|
||||
@@ -344,6 +375,7 @@ func TestRuntimeContractEligibilitySnapshotCoversUnknownFreeAndPaidUsers(t *test
|
||||
require.Equal(t, []effectiveLimitView{
|
||||
{LimitCode: "max_pending_public_applications", Value: 3},
|
||||
{LimitCode: "max_active_game_memberships", Value: 3},
|
||||
{LimitCode: "max_registered_race_names", Value: 1},
|
||||
}, free.EffectiveLimits)
|
||||
|
||||
paidUser := h.ensureUser(t, "paid@example.com", "en", "Europe/Paris")
|
||||
@@ -371,6 +403,7 @@ func TestRuntimeContractEligibilitySnapshotCoversUnknownFreeAndPaidUsers(t *test
|
||||
{LimitCode: "max_owned_private_games", Value: 3},
|
||||
{LimitCode: "max_pending_public_applications", Value: 17},
|
||||
{LimitCode: "max_active_game_memberships", Value: 10},
|
||||
{LimitCode: "max_registered_race_names", Value: 2},
|
||||
}, paid.EffectiveLimits)
|
||||
}
|
||||
|
||||
@@ -388,7 +421,8 @@ func TestRuntimeContractGeoSyncOnlyMutatesCurrentDeclaredCountry(t *testing.T) {
|
||||
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.UserName, after.User.UserName)
|
||||
require.Equal(t, before.User.DisplayName, after.User.DisplayName)
|
||||
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)
|
||||
@@ -404,6 +438,131 @@ func TestRuntimeContractGeoSyncOnlyMutatesCurrentDeclaredCountry(t *testing.T) {
|
||||
require.Equal(t, after.User, repeated.User)
|
||||
}
|
||||
|
||||
func TestRuntimeContractPermanentBlockCollapsesEligibilityMarkers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := newRuntimeContractHarness(t)
|
||||
created := h.ensureUser(t, "blocked@example.com", "en", "UTC")
|
||||
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(72*time.Hour))
|
||||
h.applySanction(t, created.UserID, "permanent_block", "platform", now.Add(-5*time.Minute))
|
||||
|
||||
eligibility := h.getEligibility(t, created.UserID)
|
||||
require.True(t, eligibility.Exists)
|
||||
require.Equal(t, eligibilityMarkers{}, eligibility.Markers,
|
||||
"every can_* marker must be false under permanent_block")
|
||||
|
||||
var permanentBlockSeen bool
|
||||
for _, sanction := range eligibility.ActiveSanctions {
|
||||
if sanction.SanctionCode == "permanent_block" {
|
||||
permanentBlockSeen = true
|
||||
}
|
||||
}
|
||||
require.True(t, permanentBlockSeen,
|
||||
"permanent_block must surface in the lobby eligibility snapshot")
|
||||
}
|
||||
|
||||
func TestRuntimeContractPermanentBlockBlocksSelfService(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := newRuntimeContractHarness(t)
|
||||
created := h.ensureUser(t, "self-blocked@example.com", "en", "UTC")
|
||||
require.Equal(t, "created", created.Outcome)
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
h.applySanction(t, created.UserID, "permanent_block", "platform", now.Add(-time.Minute))
|
||||
|
||||
readResponse := h.get(t, "/api/v1/internal/users/"+created.UserID+"/account")
|
||||
requireJSONBody(t, readResponse, http.StatusConflict,
|
||||
`{"error":{"code":"conflict","message":"request conflicts with current state"}}`)
|
||||
|
||||
profileResponse := h.postJSON(t, "/api/v1/internal/users/"+created.UserID+"/profile", map[string]string{
|
||||
"display_name": "Nova",
|
||||
})
|
||||
requireJSONBody(t, profileResponse, http.StatusConflict,
|
||||
`{"error":{"code":"conflict","message":"request conflicts with current state"}}`)
|
||||
|
||||
settingsResponse := h.postJSON(t, "/api/v1/internal/users/"+created.UserID+"/settings", map[string]string{
|
||||
"preferred_language": "en",
|
||||
"time_zone": "UTC",
|
||||
})
|
||||
requireJSONBody(t, settingsResponse, http.StatusConflict,
|
||||
`{"error":{"code":"conflict","message":"request conflicts with current state"}}`)
|
||||
}
|
||||
|
||||
func TestRuntimeContractPermanentBlockEmitsLifecycleEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := newRuntimeContractHarness(t)
|
||||
created := h.ensureUser(t, "lifecycle-block@example.com", "en", "UTC")
|
||||
require.Equal(t, "created", created.Outcome)
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
h.applySanction(t, created.UserID, "permanent_block", "platform", now.Add(-time.Minute))
|
||||
|
||||
entries := h.lifecycleStreamEntries(t)
|
||||
require.Len(t, entries, 1)
|
||||
require.Equal(t, "user.lifecycle.permanent_blocked", entries[0]["event_type"])
|
||||
require.Equal(t, created.UserID, entries[0]["user_id"])
|
||||
require.Equal(t, "admin_internal_api", entries[0]["source"])
|
||||
require.Equal(t, "admin", entries[0]["actor_type"])
|
||||
require.Equal(t, "admin-1", entries[0]["actor_id"])
|
||||
require.Equal(t, "manual_block", entries[0]["reason_code"])
|
||||
}
|
||||
|
||||
func TestRuntimeContractDeleteUserIsIdempotentAndEmitsLifecycleEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := newRuntimeContractHarness(t)
|
||||
created := h.ensureUser(t, "delete@example.com", "en", "UTC")
|
||||
require.Equal(t, "created", created.Outcome)
|
||||
|
||||
firstResponse := h.deleteUser(t, created.UserID, "user_right_to_be_forgotten")
|
||||
require.Equal(t, http.StatusOK, firstResponse.StatusCode, "response body: %s", firstResponse.Body)
|
||||
|
||||
var firstBody struct {
|
||||
UserID string `json:"user_id"`
|
||||
DeletedAt time.Time `json:"deleted_at"`
|
||||
}
|
||||
require.NoError(t, decodeStrictJSONPayload([]byte(firstResponse.Body), &firstBody))
|
||||
require.Equal(t, created.UserID, firstBody.UserID)
|
||||
require.False(t, firstBody.DeletedAt.IsZero())
|
||||
|
||||
entries := h.lifecycleStreamEntries(t)
|
||||
require.Len(t, entries, 1)
|
||||
require.Equal(t, "user.lifecycle.deleted", entries[0]["event_type"])
|
||||
require.Equal(t, created.UserID, entries[0]["user_id"])
|
||||
|
||||
secondResponse := h.deleteUser(t, created.UserID, "user_right_to_be_forgotten")
|
||||
requireJSONBody(t, secondResponse, http.StatusNotFound,
|
||||
`{"error":{"code":"subject_not_found","message":"subject not found"}}`)
|
||||
|
||||
entriesAfterSecond := h.lifecycleStreamEntries(t)
|
||||
require.Len(t, entriesAfterSecond, 1,
|
||||
"second DeleteUser call must not re-emit a lifecycle event")
|
||||
|
||||
eligibility := h.getEligibility(t, created.UserID)
|
||||
require.False(t, eligibility.Exists)
|
||||
|
||||
accountResponse := h.get(t, "/api/v1/internal/users/"+created.UserID+"/account")
|
||||
requireJSONBody(t, accountResponse, http.StatusNotFound,
|
||||
`{"error":{"code":"subject_not_found","message":"subject not found"}}`)
|
||||
|
||||
lookupResponse := h.lookupUserByEmailRaw(t, "delete@example.com")
|
||||
requireJSONBody(t, lookupResponse, http.StatusNotFound,
|
||||
`{"error":{"code":"subject_not_found","message":"subject not found"}}`)
|
||||
}
|
||||
|
||||
func (h *runtimeContractHarness) lookupUserByEmailRaw(t *testing.T, email string) httpResponse {
|
||||
t.Helper()
|
||||
|
||||
return h.postJSON(t, "/api/v1/internal/user-lookups/by-email", map[string]string{
|
||||
"email": email,
|
||||
})
|
||||
}
|
||||
|
||||
func TestRuntimeContractAdminListingPreservesOrderingFiltersAndPageTokenBinding(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -487,7 +646,8 @@ type userListResponse struct {
|
||||
type accountView struct {
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
RaceName string `json:"race_name"`
|
||||
UserName string `json:"user_name"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
PreferredLanguage string `json:"preferred_language"`
|
||||
TimeZone string `json:"time_zone"`
|
||||
DeclaredCountry string `json:"declared_country,omitempty"`
|
||||
|
||||
Reference in New Issue
Block a user