feat: user service
This commit is contained in:
+30
-8
@@ -43,7 +43,9 @@ The current v1 platform uses Redis as the main data store and Redis Streams as t
|
||||
|
||||
* The platform exposes a single external entry point: **Edge Gateway**.
|
||||
* Public unauthenticated flows use REST/JSON.
|
||||
* Authenticated user traffic uses signed gRPC over HTTP/2 with protobuf control envelopes and FlatBuffers payload bytes.
|
||||
* Authenticated user edge traffic uses signed gRPC over HTTP/2 with protobuf control envelopes and FlatBuffers payload bytes.
|
||||
* Trusted synchronous inter-service traffic uses REST/JSON unless a service-specific contract states otherwise.
|
||||
* For the direct `Gateway -> User` self-service boundary, gateway keeps the external authenticated gRPC + FlatBuffers contract and performs REST/JSON transcoding toward `User Service` internally.
|
||||
* The gateway handles only edge concerns: parsing, authentication, integrity checks, anti-replay, rate limiting, routing, and push delivery. Business authorization and domain rules remain in downstream services.
|
||||
* `Auth / Session Service` is the source of truth for `device_session`, but it is not on the hot path of every authenticated request. Gateway authenticates steady-state traffic from session cache and lifecycle updates.
|
||||
* `Game Lobby` owns platform-level metadata of game sessions.
|
||||
@@ -65,6 +67,10 @@ The gateway already distinguishes:
|
||||
* public REST/JSON for unauthenticated traffic such as health checks and public auth;
|
||||
* authenticated gRPC over HTTP/2 for verified commands and push delivery.
|
||||
|
||||
For downstream business services, the current default trusted transport is
|
||||
strict REST/JSON. Gateway may therefore authenticate and verify one external
|
||||
FlatBuffers command, then transcode it to one trusted downstream REST call.
|
||||
|
||||
The public auth contract is:
|
||||
|
||||
* `send-email-code(email) -> challenge_id`
|
||||
@@ -230,17 +236,22 @@ Direct integrations:
|
||||
|
||||
## 3. [User Service](user/README.md)
|
||||
|
||||
`User Service` owns user identity and profile as platform-level business data.
|
||||
`User Service` owns regular-user identity and profile as platform-level
|
||||
business data.
|
||||
|
||||
It is the source of truth for:
|
||||
|
||||
* `user_id`;
|
||||
* profile fields and editable user settings;
|
||||
* role model, including admin role;
|
||||
* `user_id` of regular platform users;
|
||||
* regular-user profile fields and editable user settings;
|
||||
* current tariff/entitlement state;
|
||||
* user-specific limits and platform sanctions;
|
||||
* latest effective `declared_country`.
|
||||
|
||||
System-administrator identity remains outside this service and belongs to the
|
||||
later `Admin Service`. Trusted administrative reads and mutations against
|
||||
regular-user state do not make `User Service` the owner of administrator
|
||||
identity.
|
||||
|
||||
It is directly reachable through gateway for selected user-facing operations such as:
|
||||
|
||||
* reading and editing allowed profile fields;
|
||||
@@ -253,6 +264,17 @@ Not every profile mutation goes directly here. For example:
|
||||
* email change must use a code-confirm flow;
|
||||
* `declared_country` change remains under admin approval flow via `Geo Profile Service`.
|
||||
|
||||
Architectural rules fixed for this service:
|
||||
|
||||
* `User Service` owns regular-user identity only; system-admin identity is out
|
||||
of scope.
|
||||
* `User Service` stores only the current effective `declared_country`; review
|
||||
workflow and history belong to `Geo Profile Service`.
|
||||
* During the current auth-registration rollout, `Auth / Session Service`
|
||||
passes temporary `preferred_language="en"` plus the confirmed `time_zone`
|
||||
into `User Service`. Gateway-side geoip language derivation is a later
|
||||
rollout step and is not part of the current source-of-truth contract.
|
||||
|
||||
Future billing does not become a direct dependency of other services. `Billing Service` will feed entitlement/payment outcomes into `User Service`, and the rest of the platform will continue to use `User Service` as the source of truth for current entitlements.
|
||||
|
||||
## 4. Mail Service
|
||||
@@ -533,7 +555,7 @@ flowchart TD
|
||||
N["Notification Service"]
|
||||
M["Mail Service"]
|
||||
|
||||
U -->|"users, roles, tariffs, limits, sanctions, current declared_country"| X1["Platform user identity"]
|
||||
U -->|"regular users, profile/settings, tariffs, limits, sanctions, current declared_country"| X1["Platform user identity"]
|
||||
A -->|"challenges, device sessions, revoke/block state"| X2["Auth/session state"]
|
||||
L -->|"game metadata, invites, applications, membership, roster"| X3["Platform game records"]
|
||||
G -->|"runtime state, current turn, engine health, engine mapping, engine version registry"| X4["Running-game state"]
|
||||
@@ -918,8 +940,8 @@ Recommended order for implementation is:
|
||||
2. **Auth / Session Service** (implemented)
|
||||
Public auth flow, `device_session`, revoke/block lifecycle, gateway session projection.
|
||||
|
||||
3. **User Service** (planned)
|
||||
Platform user identity, roles, tariffs/entitlements, user limits, settings, sanctions, and current `declared_country`.
|
||||
3. **User Service** (implemented)
|
||||
Regular-user identity, profile/settings, tariffs/entitlements, user limits, sanctions, and current `declared_country`.
|
||||
|
||||
4. **Mail Service**
|
||||
Internal email delivery for auth codes first, later for platform notifications.
|
||||
|
||||
+8
-7
@@ -368,10 +368,9 @@ The testing plan follows this service order:
|
||||
|
||||
* create user
|
||||
* find by email
|
||||
* normalized email uniqueness
|
||||
* exact-after-trim e-mail storage and lookup semantics
|
||||
* generated default `race_name` for new users
|
||||
* `race_name` uniqueness and confusable-substitution policy
|
||||
* role assignment
|
||||
* tariff/entitlement fields
|
||||
* Profile tests:
|
||||
|
||||
@@ -400,7 +399,7 @@ The testing plan follows this service order:
|
||||
* resolve existing/creatable/blocked decision for auth
|
||||
* `ensure-by-email` create-only `registration_context` semantics
|
||||
* current `declared_country` read/write path
|
||||
* exact lookup by `user_id`, normalized `email`, and `race_name`
|
||||
* exact lookup by `user_id`, exact-after-trim `email`, and exact `race_name`
|
||||
* paginated filtered listing with deterministic ordering
|
||||
* Storage and API contract tests:
|
||||
|
||||
@@ -417,13 +416,15 @@ The testing plan follows this service order:
|
||||
* blocked-by-policy outcome
|
||||
* `Gateway <-> User`
|
||||
|
||||
* authenticated profile read
|
||||
* authenticated allowed profile update
|
||||
* tariff and settings read paths
|
||||
* authenticated `user.account.get`
|
||||
* authenticated successful `user.profile.update`
|
||||
* authenticated successful `user.settings.update`
|
||||
* `profile_update_block` conflict projection
|
||||
* invalid-request projection for malformed self-service payload values
|
||||
* `Gateway <-> Auth / Session <-> User`
|
||||
|
||||
* first registration by email
|
||||
* repeat login by same email
|
||||
* repeat login by same email without overwriting create-only settings
|
||||
* blocked email/user behavior
|
||||
|
||||
### Regression tests to keep from this stage onward
|
||||
|
||||
@@ -146,6 +146,9 @@ key registered for the created device session.
|
||||
rollout phase, successful confirms forward create-only user registration
|
||||
context to `User Service` as `preferred_language="en"` and the supplied
|
||||
`time_zone` until gateway geoip-based language derivation is deployed.
|
||||
`User Service` now validates `preferred_language` as BCP 47 and canonicalizes
|
||||
the stored value on creation, so any future derived language must already be a
|
||||
valid BCP 47 tag before auth forwards it.
|
||||
|
||||
Public boundary rules:
|
||||
|
||||
|
||||
+23
-23
@@ -4,45 +4,45 @@ go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/alicebob/miniredis/v2 v2.37.0
|
||||
github.com/getkin/kin-openapi v0.134.0
|
||||
github.com/getkin/kin-openapi v0.135.0
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0
|
||||
go.opentelemetry.io/otel v1.42.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0
|
||||
go.opentelemetry.io/otel/metric v1.42.0
|
||||
go.opentelemetry.io/otel/sdk v1.42.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0
|
||||
go.opentelemetry.io/otel/trace v1.42.0
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0
|
||||
go.opentelemetry.io/otel v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0
|
||||
go.opentelemetry.io/otel/metric v1.43.0
|
||||
go.opentelemetry.io/otel/sdk v1.43.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0
|
||||
go.opentelemetry.io/otel/trace v1.43.0
|
||||
go.uber.org/zap v1.27.1
|
||||
golang.org/x/crypto v0.49.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.2 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
@@ -55,9 +55,9 @@ require (
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c // indirect
|
||||
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/oasdiff/yaml v0.0.9 // indirect
|
||||
github.com/oasdiff/yaml3 v0.0.9 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
|
||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
@@ -68,11 +68,11 @@ require (
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
golang.org/x/arch v0.24.0 // indirect
|
||||
golang.org/x/arch v0.25.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
|
||||
+24
-48
@@ -4,12 +4,10 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
@@ -23,10 +21,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/getkin/kin-openapi v0.134.0 h1:/L5+1+kfe6dXh8Ot/wqiTgUkjOIEJiC0bbYVziHB8rU=
|
||||
github.com/getkin/kin-openapi v0.134.0/go.mod h1:wK6ZLG/VgoETO9pcLJ/VmAtIcl/DNlMayNTb716EUxE=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/getkin/kin-openapi v0.135.0 h1:751SjYfbiwqukYuVjwYEIKNfrSwS5YpA7DZnKSwQgtg=
|
||||
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
@@ -44,12 +40,10 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
|
||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
@@ -84,12 +78,9 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c h1:7ACFcSaQsrWtrH4WHHfUqE1C+f8r2uv8KGaW0jTNjus=
|
||||
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c/go.mod h1:JKox4Gszkxt57kj27u7rvi7IFoIULvCZHUsBTUmQM/s=
|
||||
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b h1:vivRhVUAa9t1q0Db4ZmezBP8pWQWnXHFokZj0AOea2g=
|
||||
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48=
|
||||
github.com/oasdiff/yaml3 v0.0.9 h1:rWPrKccrdUm8J0F3sGuU+fuh9+1K/RdJlWF7O/9yw2g=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
||||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@@ -127,34 +118,20 @@ go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0 h1:E7DmskpIO7ZR6QI6zKSEKIDNUYoKw9oHXP23gzbCdU0=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0/go.mod h1:WB2cS9y+AwqqKhoo9gw6/ZxlSjFBUQGZ8BQOaD3FVXM=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.42.0 h1:B2Pew5ufEtgkjLF+tSkXjgYZXQr9m7aCm1wLKB0URbU=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.42.0/go.mod h1:iPgUcSEF5DORW6+yNbdw/YevUy+QqJ508ncjhrRSCjc=
|
||||
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
|
||||
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 h1:H7O6RlGOMTizyl3R08Kn5pdM06bnH8oscSj7o11tmLA=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0/go.mod h1:mBFWu/WOVDkWWsR7Tx7h6EpQB8wsv7P0Yrh0Pb7othc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 h1:uLXP+3mghfMf7XmV4PkGfFhFKuNWoCvvx5wP/wOXo0o=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0/go.mod h1:v0Tj04armyT59mnURNUJf7RCKcKzq+lgJs6QSjHjaTc=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs=
|
||||
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
|
||||
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
|
||||
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
|
||||
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0 h1:5FXSL2s6afUC1bzNzl1iedZZ8yqR7GOhbCoEXtyeK6Q=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.43.0 h1:CETqV3QLLPTy5yNrqyMr41VnAOOD4lsRved7n4QG00A=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
@@ -167,8 +144,7 @@ go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
||||
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
package authsession
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/authsession/internal/adapters/userservice"
|
||||
"galaxy/authsession/internal/domain/common"
|
||||
"galaxy/authsession/internal/domain/userresolution"
|
||||
"galaxy/authsession/internal/ports"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
)
|
||||
|
||||
func TestUserServiceRESTClientWorksAgainstRealUserServiceRuntime(t *testing.T) {
|
||||
redisServer := miniredis.RunT(t)
|
||||
internalAddr := freeTCPAddress(t)
|
||||
binaryPath := buildUserServiceBinary(t)
|
||||
process := startUserServiceProcess(t, binaryPath, map[string]string{
|
||||
"USERSERVICE_INTERNAL_HTTP_ADDR": internalAddr,
|
||||
"USERSERVICE_REDIS_ADDR": redisServer.Addr(),
|
||||
})
|
||||
waitForTCP(t, process, internalAddr)
|
||||
|
||||
client, err := userservice.NewRESTClient(userservice.Config{
|
||||
BaseURL: "http://" + internalAddr,
|
||||
RequestTimeout: 500 * time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRESTClient() error = %v, want nil", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = client.Close()
|
||||
})
|
||||
|
||||
creatableEmail := common.Email("pilot@example.com")
|
||||
|
||||
resolution, err := client.ResolveByEmail(context.Background(), creatableEmail)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveByEmail(creatable) error = %v, want nil", err)
|
||||
}
|
||||
if got, want := resolution.Kind, userresolution.KindCreatable; got != want {
|
||||
t.Fatalf("ResolveByEmail(creatable).Kind = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
created, err := client.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{
|
||||
Email: creatableEmail,
|
||||
RegistrationContext: &ports.RegistrationContext{
|
||||
PreferredLanguage: "en",
|
||||
TimeZone: "Europe/Kaliningrad",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureUserByEmail(created) error = %v, want nil", err)
|
||||
}
|
||||
if got, want := created.Outcome, ports.EnsureUserOutcomeCreated; got != want {
|
||||
t.Fatalf("EnsureUserByEmail(created).Outcome = %q, want %q", got, want)
|
||||
}
|
||||
if created.UserID.IsZero() {
|
||||
t.Fatalf("EnsureUserByEmail(created).UserID = zero, want non-zero")
|
||||
}
|
||||
|
||||
existing, err := client.ResolveByEmail(context.Background(), creatableEmail)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveByEmail(existing) error = %v, want nil", err)
|
||||
}
|
||||
if got, want := existing.Kind, userresolution.KindExisting; got != want {
|
||||
t.Fatalf("ResolveByEmail(existing).Kind = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := existing.UserID, created.UserID; got != want {
|
||||
t.Fatalf("ResolveByEmail(existing).UserID = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
exists, err := client.ExistsByUserID(context.Background(), created.UserID)
|
||||
if err != nil {
|
||||
t.Fatalf("ExistsByUserID(existing) error = %v, want nil", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Fatalf("ExistsByUserID(existing) = false, want true")
|
||||
}
|
||||
|
||||
blocked, err := client.BlockByUserID(context.Background(), ports.BlockUserByIDInput{
|
||||
UserID: created.UserID,
|
||||
ReasonCode: userresolution.BlockReasonCode("policy_blocked"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BlockByUserID() error = %v, want nil", err)
|
||||
}
|
||||
if got, want := blocked.Outcome, ports.BlockUserOutcomeBlocked; got != want {
|
||||
t.Fatalf("BlockByUserID().Outcome = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := blocked.UserID, created.UserID; got != want {
|
||||
t.Fatalf("BlockByUserID().UserID = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
repeated, err := client.BlockByEmail(context.Background(), ports.BlockUserByEmailInput{
|
||||
Email: creatableEmail,
|
||||
ReasonCode: userresolution.BlockReasonCode("policy_blocked"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BlockByEmail(repeated) error = %v, want nil", err)
|
||||
}
|
||||
if got, want := repeated.Outcome, ports.BlockUserOutcomeAlreadyBlocked; got != want {
|
||||
t.Fatalf("BlockByEmail(repeated).Outcome = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := repeated.UserID, created.UserID; got != want {
|
||||
t.Fatalf("BlockByEmail(repeated).UserID = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
blockedResolution, err := client.ResolveByEmail(context.Background(), creatableEmail)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveByEmail(blocked) error = %v, want nil", err)
|
||||
}
|
||||
if got, want := blockedResolution.Kind, userresolution.KindBlocked; got != want {
|
||||
t.Fatalf("ResolveByEmail(blocked).Kind = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := blockedResolution.BlockReasonCode, userresolution.BlockReasonCode("policy_blocked"); got != want {
|
||||
t.Fatalf("ResolveByEmail(blocked).BlockReasonCode = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
type userServiceProcess struct {
|
||||
cmd *exec.Cmd
|
||||
doneCh chan struct{}
|
||||
logs bytes.Buffer
|
||||
}
|
||||
|
||||
func startUserServiceProcess(t *testing.T, binaryPath string, env map[string]string) *userServiceProcess {
|
||||
t.Helper()
|
||||
|
||||
cmd := exec.Command(binaryPath)
|
||||
cmd.Env = mergeEnvironment(os.Environ(), env)
|
||||
|
||||
process := &userServiceProcess{
|
||||
cmd: cmd,
|
||||
doneCh: make(chan struct{}),
|
||||
}
|
||||
cmd.Stdout = &process.logs
|
||||
cmd.Stderr = &process.logs
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start user service process: %v", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
_ = cmd.Wait()
|
||||
close(process.doneCh)
|
||||
}()
|
||||
|
||||
t.Cleanup(func() {
|
||||
stopUserServiceProcess(t, process)
|
||||
if t.Failed() {
|
||||
t.Logf("userservice logs:\n%s", process.logs.String())
|
||||
}
|
||||
})
|
||||
|
||||
return process
|
||||
}
|
||||
|
||||
func stopUserServiceProcess(t *testing.T, process *userServiceProcess) {
|
||||
t.Helper()
|
||||
|
||||
if process == nil || process.cmd == nil || process.cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case <-process.doneCh:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
_ = process.cmd.Process.Signal(syscall.SIGTERM)
|
||||
|
||||
select {
|
||||
case <-process.doneCh:
|
||||
case <-time.After(5 * time.Second):
|
||||
_ = process.cmd.Process.Kill()
|
||||
<-process.doneCh
|
||||
}
|
||||
}
|
||||
|
||||
func waitForTCP(t *testing.T, process *userServiceProcess, address string) {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-process.doneCh:
|
||||
t.Fatalf("userservice exited before %s became reachable\n%s", address, process.logs.String())
|
||||
default:
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", address, 100*time.Millisecond)
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
|
||||
t.Fatalf("userservice did not become reachable at %s\n%s", address, process.logs.String())
|
||||
}
|
||||
|
||||
func freeTCPAddress(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("reserve free TCP address: %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
return listener.Addr().String()
|
||||
}
|
||||
|
||||
func buildUserServiceBinary(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
outputPath := filepath.Join(t.TempDir(), "userservice")
|
||||
cmd := exec.Command("go", "build", "-o", outputPath, "./user/cmd/userservice")
|
||||
cmd.Dir = repositoryRoot(t)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("build userservice binary: %v\n%s", err, output)
|
||||
}
|
||||
|
||||
return outputPath
|
||||
}
|
||||
|
||||
func repositoryRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("resolve repository root: runtime caller unavailable")
|
||||
}
|
||||
|
||||
return filepath.Clean(filepath.Join(filepath.Dir(file), ".."))
|
||||
}
|
||||
|
||||
func mergeEnvironment(base []string, overrides map[string]string) []string {
|
||||
values := make(map[string]string, len(base)+len(overrides))
|
||||
for _, entry := range base {
|
||||
name, value, ok := strings.Cut(entry, "=")
|
||||
if ok {
|
||||
values[name] = value
|
||||
}
|
||||
}
|
||||
for name, value := range overrides {
|
||||
values[name] = value
|
||||
}
|
||||
|
||||
merged := make([]string, 0, len(values))
|
||||
for name, value := range values {
|
||||
merged = append(merged, fmt.Sprintf("%s=%s", name, value))
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
var _ io.Writer = (*bytes.Buffer)(nil)
|
||||
+7
-7
@@ -4,22 +4,22 @@ go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/go-playground/validator/v10 v10.30.1
|
||||
github.com/go-playground/validator/v10 v10.30.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
@@ -27,7 +27,7 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
@@ -35,7 +35,7 @@ require (
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.24.0 // indirect
|
||||
golang.org/x/arch v0.25.0 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
|
||||
+7
-14
@@ -1,9 +1,7 @@
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -11,8 +9,7 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
@@ -20,10 +17,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
@@ -48,8 +43,7 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
@@ -75,8 +69,7 @@ github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2W
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
||||
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
||||
+34
-4
@@ -23,6 +23,8 @@ Optional integrations:
|
||||
- `GATEWAY_ADMIN_HTTP_ADDR` enables the private `/metrics` listener;
|
||||
- `GATEWAY_AUTH_SERVICE_BASE_URL` enables real public auth handling through
|
||||
Auth / Session Service public HTTP;
|
||||
- `GATEWAY_USER_SERVICE_BASE_URL` enables direct authenticated self-service
|
||||
routing to User Service internal HTTP;
|
||||
- injected downstream routes are required for successful `ExecuteCommand`.
|
||||
|
||||
Operational caveats:
|
||||
@@ -118,6 +120,10 @@ The public auth JSON contract uses a challenge-token flow:
|
||||
key for the device session being created.
|
||||
`time_zone` is the client-selected IANA time zone name forwarded unchanged to
|
||||
`Auth / Session Service`.
|
||||
The current create-path source of truth for `preferred_language` is still the
|
||||
temporary authsession-to-user rollout using `"en"`. Gateway-side language
|
||||
derivation is a later rollout. The public `confirm-email-code` DTO itself
|
||||
remains unchanged.
|
||||
|
||||
These routes remain unauthenticated and delegate only through an injected
|
||||
`AuthServiceClient`.
|
||||
@@ -322,10 +328,24 @@ The authenticated transport uses a split contract:
|
||||
- signatures are computed over canonical envelope fields and a hash of raw
|
||||
FlatBuffers bytes.
|
||||
|
||||
The gateway treats authenticated request `payload_bytes` as opaque business
|
||||
data.
|
||||
It verifies integrity and forwards verified bytes downstream without rewriting
|
||||
them.
|
||||
The gateway verifies authenticated payload bytes before any downstream call.
|
||||
Most downstream routes may still treat those bytes as opaque, but the gateway
|
||||
is also allowed to transcode verified FlatBuffers payloads into trusted
|
||||
downstream REST/JSON calls when the concrete downstream contract requires it.
|
||||
|
||||
The current direct `Gateway -> User` self-service boundary uses that pattern:
|
||||
|
||||
- external message types:
|
||||
- `user.account.get`
|
||||
- `user.profile.update`
|
||||
- `user.settings.update`
|
||||
- external payloads and responses:
|
||||
- FlatBuffers
|
||||
- internal downstream transport:
|
||||
- strict REST/JSON to User Service
|
||||
- business error projection:
|
||||
- gateway `result_code`
|
||||
- FlatBuffers error payload mirroring User Service `code` and `message`
|
||||
|
||||
The request envelope version literal is `v1`.
|
||||
`payload_hash` is the raw 32-byte SHA-256 digest of `payload_bytes`.
|
||||
@@ -965,6 +985,11 @@ failing process startup.
|
||||
Resolves the target downstream service or adapter by the full exact-match
|
||||
`message_type` literal.
|
||||
|
||||
The default `cmd/gateway` wiring keeps the reserved `user.*` self-service
|
||||
message types mounted even when `GATEWAY_USER_SERVICE_BASE_URL` is unset. In
|
||||
that configuration they fail closed as dependency-unavailable instead of
|
||||
falling through to a generic route miss.
|
||||
|
||||
### DownstreamClient
|
||||
|
||||
Executes a verified authenticated command against a downstream internal service
|
||||
@@ -972,6 +997,11 @@ and returns response payload bytes plus a stable opaque result code.
|
||||
An empty or whitespace-only result code is treated as an internal downstream
|
||||
contract violation.
|
||||
|
||||
Downstream clients may be pure pass-through adapters or gateway-owned
|
||||
transcoding adapters. The current User Service adapter decodes authenticated
|
||||
FlatBuffers payloads, calls the trusted internal REST API, and re-encodes the
|
||||
result into FlatBuffers before the signed gateway response is emitted.
|
||||
|
||||
### EventSubscriber
|
||||
|
||||
Subscribes to internal pub/sub topics used for:
|
||||
|
||||
+5
-2
@@ -3,5 +3,8 @@
|
||||
## 1. Suggest User's Preferred Language when registering a new User
|
||||
|
||||
Upon user's device/session registration flow, `preferred_language` value
|
||||
must be obtained via existing [geoip](../pkg/geoip) package by returned Country.
|
||||
When geoip feils to return country by ip, fallback is `en` language.
|
||||
must be obtained via existing [geoip](../pkg/geoip) package by returned
|
||||
country.
|
||||
The derived value must be emitted as a valid BCP 47 language tag because
|
||||
`User Service` now validates that contract semantically on create.
|
||||
When geoip fails to return country by IP, fallback is `en`.
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"galaxy/gateway/internal/authn"
|
||||
"galaxy/gateway/internal/config"
|
||||
"galaxy/gateway/internal/downstream"
|
||||
"galaxy/gateway/internal/downstream/userservice"
|
||||
"galaxy/gateway/internal/events"
|
||||
"galaxy/gateway/internal/grpcapi"
|
||||
"galaxy/gateway/internal/logging"
|
||||
@@ -184,12 +185,27 @@ func newAuthenticatedGRPCDependencies(ctx context.Context, cfg config.Config, lo
|
||||
)
|
||||
}
|
||||
|
||||
userRoutes, closeUserServiceRoutes, err := userservice.NewRoutes(cfg.UserService.BaseURL)
|
||||
if err != nil {
|
||||
closeErr := errors.Join(
|
||||
fallbackSessionCache.Close(),
|
||||
replayStore.Close(),
|
||||
sessionSubscriber.Close(),
|
||||
clientEventSubscriber.Close(),
|
||||
)
|
||||
return grpcapi.ServerDependencies{}, nil, nil, errors.Join(
|
||||
fmt.Errorf("build authenticated grpc dependencies: user service routes: %w", err),
|
||||
closeErr,
|
||||
)
|
||||
}
|
||||
|
||||
cleanup := func() error {
|
||||
return errors.Join(
|
||||
fallbackSessionCache.Close(),
|
||||
replayStore.Close(),
|
||||
sessionSubscriber.Close(),
|
||||
clientEventSubscriber.Close(),
|
||||
closeUserServiceRoutes(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -227,7 +243,7 @@ func newAuthenticatedGRPCDependencies(ctx context.Context, cfg config.Config, lo
|
||||
|
||||
return grpcapi.ServerDependencies{
|
||||
Service: grpcapi.NewFanOutPushStreamService(pushHub, responseSigner, nil, logger),
|
||||
Router: downstream.NewStaticRouter(nil),
|
||||
Router: downstream.NewStaticRouter(userRoutes),
|
||||
ResponseSigner: responseSigner,
|
||||
SessionCache: sessionCache,
|
||||
ReplayStore: replayStore,
|
||||
|
||||
@@ -89,6 +89,26 @@ Example `ExecuteCommandResponse`:
|
||||
}
|
||||
```
|
||||
|
||||
Example authenticated self-service request metadata:
|
||||
|
||||
```json
|
||||
{
|
||||
"protocolVersion": "v1",
|
||||
"deviceSessionId": "device-session-123",
|
||||
"messageType": "user.account.get",
|
||||
"timestampMs": "1775121600000",
|
||||
"requestId": "request-account-123",
|
||||
"payloadBytes": "RkxBVEJVRkZFUlNfVVNFUl9SRVFVRVNU",
|
||||
"payloadHash": "5fY6Q8V9mK8x2B7v6v0V0m0i1rQ2QF0rQ8V1Yt1r8Ys=",
|
||||
"signature": "3o4v8f3h0Y6I0x1bS7zY+8m0bV1Lk4D3yq8J2n8F1rD7yK9v8M1Q0w2s4a6f8d0Q0m3L6y8R1t5w7x9z0a2cA=="
|
||||
}
|
||||
```
|
||||
|
||||
The external payload remains FlatBuffers. The current `Gateway -> User`
|
||||
self-service adapter decodes that payload, calls the trusted internal
|
||||
User Service REST API, then re-encodes the returned account aggregate or error
|
||||
envelope back into FlatBuffers before signing the response.
|
||||
|
||||
Example bootstrap `GatewayEvent` sent after `SubscribeEvents` opens:
|
||||
|
||||
```json
|
||||
|
||||
@@ -52,6 +52,24 @@ sequenceDiagram
|
||||
Gateway-->>Client: ExecuteCommandResponse + signature
|
||||
```
|
||||
|
||||
## Direct Gateway -> User Self-Service Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Gateway
|
||||
participant User as User Service
|
||||
|
||||
Client->>Gateway: ExecuteCommand(user.account.get | user.profile.update | user.settings.update)
|
||||
Gateway->>Gateway: verify envelope + session + signature + replay
|
||||
Gateway->>Gateway: decode FlatBuffers payload
|
||||
Gateway->>User: trusted REST/JSON internal request
|
||||
User-->>Gateway: JSON account aggregate or JSON error envelope
|
||||
Gateway->>Gateway: encode FlatBuffers success or error payload
|
||||
Gateway->>Gateway: sign response
|
||||
Gateway-->>Client: ExecuteCommandResponse(result_code, payload_bytes, signature)
|
||||
```
|
||||
|
||||
## SubscribeEvents Lifecycle
|
||||
|
||||
```mermaid
|
||||
|
||||
@@ -55,5 +55,7 @@ Notes:
|
||||
- The admin listener is optional and serves only Prometheus text metrics.
|
||||
- Public auth routing stays available without an upstream adapter, but returns
|
||||
`503 service_unavailable`.
|
||||
- Authenticated gRPC starts with an empty static router; `ExecuteCommand`
|
||||
remains `UNIMPLEMENTED` until downstream routes are injected.
|
||||
- The default runtime reserves direct `user.*` authenticated self-service
|
||||
routes. When `GATEWAY_USER_SERVICE_BASE_URL` is unset those routes stay
|
||||
mounted but fail closed as dependency-unavailable instead of returning a
|
||||
route miss.
|
||||
|
||||
+22
-22
@@ -6,22 +6,22 @@ require (
|
||||
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1
|
||||
buf.build/go/protovalidate v1.1.3
|
||||
github.com/alicebob/miniredis/v2 v2.37.0
|
||||
github.com/getkin/kin-openapi v0.134.0
|
||||
github.com/getkin/kin-openapi v0.135.0
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/google/flatbuffers v25.12.19+incompatible
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0
|
||||
go.opentelemetry.io/otel v1.42.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.64.0
|
||||
go.opentelemetry.io/otel/metric v1.42.0
|
||||
go.opentelemetry.io/otel/sdk v1.42.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0
|
||||
go.opentelemetry.io/otel/trace v1.42.0
|
||||
go.opentelemetry.io/otel v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.65.0
|
||||
go.opentelemetry.io/otel/metric v1.43.0
|
||||
go.opentelemetry.io/otel/sdk v1.43.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0
|
||||
go.opentelemetry.io/otel/trace v1.43.0
|
||||
go.uber.org/zap v1.27.1
|
||||
golang.org/x/time v0.15.0
|
||||
google.golang.org/grpc v1.80.0
|
||||
@@ -32,24 +32,24 @@ require (
|
||||
cel.dev/expr v0.25.1 // indirect
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.2 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/cel-go v0.27.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
@@ -64,15 +64,15 @@ require (
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c // indirect
|
||||
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/oasdiff/yaml v0.0.9 // indirect
|
||||
github.com/oasdiff/yaml3 v0.0.9 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
|
||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/otlptranslator v1.0.0 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
@@ -81,12 +81,12 @@ require (
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/arch v0.24.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
golang.org/x/arch v0.25.0 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
|
||||
+24
-48
@@ -16,12 +16,10 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
@@ -35,10 +33,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/getkin/kin-openapi v0.134.0 h1:/L5+1+kfe6dXh8Ot/wqiTgUkjOIEJiC0bbYVziHB8rU=
|
||||
github.com/getkin/kin-openapi v0.134.0/go.mod h1:wK6ZLG/VgoETO9pcLJ/VmAtIcl/DNlMayNTb716EUxE=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/getkin/kin-openapi v0.135.0 h1:751SjYfbiwqukYuVjwYEIKNfrSwS5YpA7DZnKSwQgtg=
|
||||
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
@@ -56,12 +52,10 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
|
||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
@@ -106,12 +100,9 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c h1:7ACFcSaQsrWtrH4WHHfUqE1C+f8r2uv8KGaW0jTNjus=
|
||||
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c/go.mod h1:JKox4Gszkxt57kj27u7rvi7IFoIULvCZHUsBTUmQM/s=
|
||||
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b h1:vivRhVUAa9t1q0Db4ZmezBP8pWQWnXHFokZj0AOea2g=
|
||||
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48=
|
||||
github.com/oasdiff/yaml3 v0.0.9 h1:rWPrKccrdUm8J0F3sGuU+fuh9+1K/RdJlWF7O/9yw2g=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
||||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@@ -124,8 +115,7 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
|
||||
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
@@ -161,32 +151,20 @@ go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0 h1:E7DmskpIO7ZR6QI6zKSEKIDNUYoKw9oHXP23gzbCdU0=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0/go.mod h1:WB2cS9y+AwqqKhoo9gw6/ZxlSjFBUQGZ8BQOaD3FVXM=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0 h1:5FXSL2s6afUC1bzNzl1iedZZ8yqR7GOhbCoEXtyeK6Q=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.42.0 h1:B2Pew5ufEtgkjLF+tSkXjgYZXQr9m7aCm1wLKB0URbU=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.42.0/go.mod h1:iPgUcSEF5DORW6+yNbdw/YevUy+QqJ508ncjhrRSCjc=
|
||||
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
|
||||
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 h1:uLXP+3mghfMf7XmV4PkGfFhFKuNWoCvvx5wP/wOXo0o=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0/go.mod h1:v0Tj04armyT59mnURNUJf7RCKcKzq+lgJs6QSjHjaTc=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs=
|
||||
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
|
||||
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
|
||||
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
|
||||
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.43.0 h1:CETqV3QLLPTy5yNrqyMr41VnAOOD4lsRved7n4QG00A=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
@@ -198,12 +176,10 @@ go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
||||
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE=
|
||||
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
|
||||
|
||||
@@ -45,6 +45,11 @@ const (
|
||||
// public-auth delegation.
|
||||
authServiceBaseURLEnvVar = "GATEWAY_AUTH_SERVICE_BASE_URL"
|
||||
|
||||
// userServiceBaseURLEnvVar names the environment variable that configures
|
||||
// the optional User Service internal HTTP base URL used by authenticated
|
||||
// gateway self-service delegation.
|
||||
userServiceBaseURLEnvVar = "GATEWAY_USER_SERVICE_BASE_URL"
|
||||
|
||||
// adminHTTPAddrEnvVar names the environment variable that configures the
|
||||
// private admin HTTP listener address. When it is empty, the admin listener
|
||||
// remains disabled.
|
||||
@@ -479,6 +484,15 @@ type AuthServiceConfig struct {
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
// UserServiceConfig describes the optional authenticated self-service upstream
|
||||
// used by the gateway runtime.
|
||||
type UserServiceConfig struct {
|
||||
// BaseURL is the absolute base URL of the User Service internal HTTP API.
|
||||
// When BaseURL is empty, the gateway keeps using its built-in unavailable
|
||||
// downstream adapter for the reserved `user.*` routes.
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
// AdminHTTPConfig describes the private operational HTTP listener used for
|
||||
// metrics exposure. The listener remains disabled when Addr is empty.
|
||||
type AdminHTTPConfig struct {
|
||||
@@ -610,6 +624,10 @@ type Config struct {
|
||||
// Session Service.
|
||||
AuthService AuthServiceConfig
|
||||
|
||||
// UserService configures the optional authenticated self-service
|
||||
// delegation to User Service.
|
||||
UserService UserServiceConfig
|
||||
|
||||
// AdminHTTP configures the optional private admin listener used for metrics
|
||||
// exposure.
|
||||
AdminHTTP AdminHTTPConfig
|
||||
@@ -791,6 +809,13 @@ func DefaultAuthServiceConfig() AuthServiceConfig {
|
||||
return AuthServiceConfig{}
|
||||
}
|
||||
|
||||
// DefaultUserServiceConfig returns the default authenticated self-service
|
||||
// upstream settings. The zero value keeps the built-in unavailable adapter
|
||||
// active for reserved `user.*` routes.
|
||||
func DefaultUserServiceConfig() UserServiceConfig {
|
||||
return UserServiceConfig{}
|
||||
}
|
||||
|
||||
// LoadFromEnv loads Config from the process environment, applies defaults for
|
||||
// omitted settings, and validates the resulting values.
|
||||
func LoadFromEnv() (Config, error) {
|
||||
@@ -799,6 +824,7 @@ func LoadFromEnv() (Config, error) {
|
||||
Logging: DefaultLoggingConfig(),
|
||||
PublicHTTP: DefaultPublicHTTPConfig(),
|
||||
AuthService: DefaultAuthServiceConfig(),
|
||||
UserService: DefaultUserServiceConfig(),
|
||||
AdminHTTP: DefaultAdminHTTPConfig(),
|
||||
AuthenticatedGRPC: DefaultAuthenticatedGRPCConfig(),
|
||||
SessionCacheRedis: DefaultSessionCacheRedisConfig(),
|
||||
@@ -856,6 +882,11 @@ func LoadFromEnv() (Config, error) {
|
||||
cfg.AuthService.BaseURL = rawAuthServiceBaseURL
|
||||
}
|
||||
|
||||
rawUserServiceBaseURL, ok := os.LookupEnv(userServiceBaseURLEnvVar)
|
||||
if ok {
|
||||
cfg.UserService.BaseURL = rawUserServiceBaseURL
|
||||
}
|
||||
|
||||
rawAdminHTTPAddr, ok := os.LookupEnv(adminHTTPAddrEnvVar)
|
||||
if ok {
|
||||
cfg.AdminHTTP.Addr = rawAdminHTTPAddr
|
||||
@@ -1124,6 +1155,17 @@ func LoadFromEnv() (Config, error) {
|
||||
}
|
||||
cfg.AuthService.BaseURL = strings.TrimRight(parsedAuthServiceBaseURL.String(), "/")
|
||||
}
|
||||
cfg.UserService.BaseURL = strings.TrimSpace(cfg.UserService.BaseURL)
|
||||
if cfg.UserService.BaseURL != "" {
|
||||
parsedUserServiceBaseURL, err := url.Parse(cfg.UserService.BaseURL)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("load gateway config: parse %s: %w", userServiceBaseURLEnvVar, err)
|
||||
}
|
||||
if parsedUserServiceBaseURL.Scheme == "" || parsedUserServiceBaseURL.Host == "" {
|
||||
return Config{}, fmt.Errorf("load gateway config: %s must be an absolute URL", userServiceBaseURLEnvVar)
|
||||
}
|
||||
cfg.UserService.BaseURL = strings.TrimRight(parsedUserServiceBaseURL.String(), "/")
|
||||
}
|
||||
if addr := strings.TrimSpace(cfg.AdminHTTP.Addr); addr != "" {
|
||||
cfg.AdminHTTP.Addr = addr
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/pem"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -14,6 +15,8 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var configEnvMu sync.Mutex
|
||||
|
||||
func TestLoadFromEnv(t *testing.T) {
|
||||
customResponseSignerPrivateKeyPEMPath := new(string)
|
||||
*customResponseSignerPrivateKeyPEMPath = writeTestResponseSignerPEMFile(t)
|
||||
@@ -27,6 +30,9 @@ func TestLoadFromEnv(t *testing.T) {
|
||||
customAuthServiceBaseURL := new(string)
|
||||
*customAuthServiceBaseURL = " http://127.0.0.1:8082/ "
|
||||
|
||||
customUserServiceBaseURL := new(string)
|
||||
*customUserServiceBaseURL = " http://127.0.0.1:8083/ "
|
||||
|
||||
customAuthenticatedGRPCAddr := new(string)
|
||||
*customAuthenticatedGRPCAddr = "127.0.0.1:9191"
|
||||
|
||||
@@ -80,6 +86,7 @@ func TestLoadFromEnv(t *testing.T) {
|
||||
shutdownTimeout *string
|
||||
publicHTTPAddr *string
|
||||
authServiceBaseURL *string
|
||||
userServiceBaseURL *string
|
||||
authenticatedGRPCAddr *string
|
||||
authenticatedGRPCFreshnessWindow *string
|
||||
sessionCacheRedisAddr *string
|
||||
@@ -217,6 +224,40 @@ func TestLoadFromEnv(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom user service base url",
|
||||
userServiceBaseURL: customUserServiceBaseURL,
|
||||
sessionCacheRedisAddr: customSessionCacheRedisAddr,
|
||||
responseSignerPrivateKeyPEMPath: customResponseSignerPrivateKeyPEMPath,
|
||||
want: Config{
|
||||
ShutdownTimeout: 5 * time.Second,
|
||||
Logging: DefaultLoggingConfig(),
|
||||
PublicHTTP: DefaultPublicHTTPConfig(),
|
||||
UserService: UserServiceConfig{
|
||||
BaseURL: "http://127.0.0.1:8083",
|
||||
},
|
||||
AdminHTTP: DefaultAdminHTTPConfig(),
|
||||
AuthenticatedGRPC: DefaultAuthenticatedGRPCConfig(),
|
||||
SessionCacheRedis: SessionCacheRedisConfig{
|
||||
Addr: "127.0.0.1:6379",
|
||||
DB: defaultSessionCacheRedisDB,
|
||||
KeyPrefix: defaultSessionCacheRedisKeyPrefix,
|
||||
LookupTimeout: defaultSessionCacheRedisLookupTimeout,
|
||||
},
|
||||
ReplayRedis: DefaultReplayRedisConfig(),
|
||||
SessionEventsRedis: SessionEventsRedisConfig{
|
||||
Stream: "gateway:session_events",
|
||||
ReadBlockTimeout: defaultSessionEventsRedisReadBlockTimeout,
|
||||
},
|
||||
ClientEventsRedis: ClientEventsRedisConfig{
|
||||
Stream: "gateway:client_events",
|
||||
ReadBlockTimeout: defaultClientEventsRedisReadBlockTimeout,
|
||||
},
|
||||
ResponseSigner: ResponseSignerConfig{
|
||||
PrivateKeyPEMPath: *customResponseSignerPrivateKeyPEMPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom authenticated grpc address",
|
||||
authenticatedGRPCAddr: customAuthenticatedGRPCAddr,
|
||||
@@ -368,6 +409,7 @@ func TestLoadFromEnv(t *testing.T) {
|
||||
shutdownTimeoutEnvVar,
|
||||
publicHTTPAddrEnvVar,
|
||||
authServiceBaseURLEnvVar,
|
||||
userServiceBaseURLEnvVar,
|
||||
authenticatedGRPCAddrEnvVar,
|
||||
authenticatedGRPCFreshnessWindowEnvVar,
|
||||
sessionCacheRedisAddrEnvVar,
|
||||
@@ -379,6 +421,7 @@ func TestLoadFromEnv(t *testing.T) {
|
||||
setEnvValue(t, shutdownTimeoutEnvVar, tt.shutdownTimeout)
|
||||
setEnvValue(t, publicHTTPAddrEnvVar, tt.publicHTTPAddr)
|
||||
setEnvValue(t, authServiceBaseURLEnvVar, tt.authServiceBaseURL)
|
||||
setEnvValue(t, userServiceBaseURLEnvVar, tt.userServiceBaseURL)
|
||||
setEnvValue(t, authenticatedGRPCAddrEnvVar, tt.authenticatedGRPCAddr)
|
||||
setEnvValue(t, authenticatedGRPCFreshnessWindowEnvVar, tt.authenticatedGRPCFreshnessWindow)
|
||||
setEnvValue(t, sessionCacheRedisAddrEnvVar, tt.sessionCacheRedisAddr)
|
||||
@@ -492,7 +535,7 @@ func TestLoadFromEnvOperationalSettings(t *testing.T) {
|
||||
restoreEnvs(t, append(
|
||||
append(
|
||||
append(
|
||||
append(operationalEnvVars(), sessionCacheRedisEnvVars()...),
|
||||
append(append(operationalEnvVars(), authServiceBaseURLEnvVar, userServiceBaseURLEnvVar), sessionCacheRedisEnvVars()...),
|
||||
sessionEventsRedisEnvVars()...,
|
||||
),
|
||||
clientEventsRedisEnvVars()...,
|
||||
@@ -563,6 +606,8 @@ func TestLoadFromEnvAuthService(t *testing.T) {
|
||||
|
||||
restoreEnvs(t,
|
||||
authServiceBaseURLEnvVar,
|
||||
userServiceBaseURLEnvVar,
|
||||
logLevelEnvVar,
|
||||
sessionCacheRedisAddrEnvVar,
|
||||
sessionEventsRedisStreamEnvVar,
|
||||
clientEventsRedisStreamEnvVar,
|
||||
@@ -581,6 +626,72 @@ func TestLoadFromEnvAuthService(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromEnvUserService(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
customSessionCacheRedisAddr := new(string)
|
||||
*customSessionCacheRedisAddr = "127.0.0.1:6379"
|
||||
|
||||
customSessionEventsRedisStream := new(string)
|
||||
*customSessionEventsRedisStream = "gateway:session_events"
|
||||
|
||||
customClientEventsRedisStream := new(string)
|
||||
*customClientEventsRedisStream = "gateway:client_events"
|
||||
|
||||
customResponseSignerPrivateKeyPEMPath := new(string)
|
||||
*customResponseSignerPrivateKeyPEMPath = writeTestResponseSignerPEMFile(t)
|
||||
|
||||
invalidRelativeURL := new(string)
|
||||
*invalidRelativeURL = "/user"
|
||||
|
||||
invalidURL := new(string)
|
||||
*invalidURL = "://bad"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value *string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "relative url rejected",
|
||||
value: invalidRelativeURL,
|
||||
wantErr: userServiceBaseURLEnvVar + " must be an absolute URL",
|
||||
},
|
||||
{
|
||||
name: "malformed url rejected",
|
||||
value: invalidURL,
|
||||
wantErr: "parse " + userServiceBaseURLEnvVar,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
restoreEnvs(t,
|
||||
authServiceBaseURLEnvVar,
|
||||
userServiceBaseURLEnvVar,
|
||||
logLevelEnvVar,
|
||||
sessionCacheRedisAddrEnvVar,
|
||||
sessionEventsRedisStreamEnvVar,
|
||||
clientEventsRedisStreamEnvVar,
|
||||
responseSignerPrivateKeyPEMPathEnvVar,
|
||||
)
|
||||
setEnvValue(t, userServiceBaseURLEnvVar, tt.value)
|
||||
setEnvValue(t, sessionCacheRedisAddrEnvVar, customSessionCacheRedisAddr)
|
||||
setEnvValue(t, sessionEventsRedisStreamEnvVar, customSessionEventsRedisStream)
|
||||
setEnvValue(t, clientEventsRedisStreamEnvVar, customClientEventsRedisStream)
|
||||
setEnvValue(t, responseSignerPrivateKeyPEMPathEnvVar, customResponseSignerPrivateKeyPEMPath)
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromEnvAuthenticatedGRPCAntiAbuse(t *testing.T) {
|
||||
customSessionCacheRedisAddr := new(string)
|
||||
*customSessionCacheRedisAddr = "127.0.0.1:6379"
|
||||
@@ -1276,6 +1387,9 @@ func setEnvValue(t *testing.T, envVar string, value *string) {
|
||||
func restoreEnvs(t *testing.T, envVars ...string) {
|
||||
t.Helper()
|
||||
|
||||
configEnvMu.Lock()
|
||||
t.Cleanup(configEnvMu.Unlock)
|
||||
|
||||
for _, envVar := range envVars {
|
||||
restoreEnv(t, envVar)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
// Package userservice implements the authenticated Gateway -> User Service
|
||||
// self-service downstream adapter.
|
||||
package userservice
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"galaxy/gateway/internal/downstream"
|
||||
usermodel "galaxy/model/user"
|
||||
"galaxy/transcoder"
|
||||
)
|
||||
|
||||
const (
|
||||
getMyAccountResultCodeOK = "ok"
|
||||
|
||||
userServiceAccountPathSuffix = "/account"
|
||||
userServiceProfilePathSuffix = "/profile"
|
||||
userServiceSettingsPathSuffix = "/settings"
|
||||
)
|
||||
|
||||
var stableErrorMessages = map[string]string{
|
||||
"invalid_request": "request is invalid",
|
||||
"subject_not_found": "subject not found",
|
||||
"conflict": "request conflicts with current state",
|
||||
"internal_error": "internal server error",
|
||||
}
|
||||
|
||||
// HTTPClient implements downstream.Client against the trusted internal User
|
||||
// Service REST API while preserving FlatBuffers at the external authenticated
|
||||
// gateway boundary.
|
||||
type HTTPClient struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewHTTPClient constructs one User Service downstream client backed by the
|
||||
// trusted internal REST API at baseURL.
|
||||
func NewHTTPClient(baseURL string) (*HTTPClient, error) {
|
||||
transport, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
return nil, errors.New("new user service HTTP client: default transport is not *http.Transport")
|
||||
}
|
||||
|
||||
return newHTTPClient(baseURL, &http.Client{
|
||||
Transport: transport.Clone(),
|
||||
})
|
||||
}
|
||||
|
||||
func newHTTPClient(baseURL string, httpClient *http.Client) (*HTTPClient, error) {
|
||||
if httpClient == nil {
|
||||
return nil, errors.New("new user service HTTP client: http client must not be nil")
|
||||
}
|
||||
|
||||
trimmedBaseURL := strings.TrimSpace(baseURL)
|
||||
if trimmedBaseURL == "" {
|
||||
return nil, errors.New("new user service HTTP client: base URL must not be empty")
|
||||
}
|
||||
|
||||
parsedBaseURL, err := url.Parse(strings.TrimRight(trimmedBaseURL, "/"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new user service HTTP client: parse base URL: %w", err)
|
||||
}
|
||||
if parsedBaseURL.Scheme == "" || parsedBaseURL.Host == "" {
|
||||
return nil, errors.New("new user service HTTP client: base URL must be absolute")
|
||||
}
|
||||
|
||||
return &HTTPClient{
|
||||
baseURL: parsedBaseURL.String(),
|
||||
httpClient: httpClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close releases idle HTTP connections owned by the client transport.
|
||||
func (c *HTTPClient) Close() error {
|
||||
if c == nil || c.httpClient == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
type idleCloser interface {
|
||||
CloseIdleConnections()
|
||||
}
|
||||
|
||||
if transport, ok := c.httpClient.Transport.(idleCloser); ok {
|
||||
transport.CloseIdleConnections()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecuteCommand routes one authenticated gateway command to the matching
|
||||
// trusted internal User Service self-service route.
|
||||
func (c *HTTPClient) ExecuteCommand(ctx context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
|
||||
if c == nil || c.httpClient == nil {
|
||||
return downstream.UnaryResult{}, errors.New("execute user service command: nil client")
|
||||
}
|
||||
if ctx == nil {
|
||||
return downstream.UnaryResult{}, errors.New("execute user service command: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return downstream.UnaryResult{}, err
|
||||
}
|
||||
if strings.TrimSpace(command.UserID) == "" {
|
||||
return downstream.UnaryResult{}, errors.New("execute user service command: user_id must not be empty")
|
||||
}
|
||||
|
||||
switch command.MessageType {
|
||||
case usermodel.MessageTypeGetMyAccount:
|
||||
if _, err := transcoder.PayloadToGetMyAccountRequest(command.PayloadBytes); err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute user service command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeGetMyAccount(ctx, command.UserID)
|
||||
case usermodel.MessageTypeUpdateMyProfile:
|
||||
request, err := transcoder.PayloadToUpdateMyProfileRequest(command.PayloadBytes)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute user service command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeUpdateMyProfile(ctx, command.UserID, request)
|
||||
case usermodel.MessageTypeUpdateMySettings:
|
||||
request, err := transcoder.PayloadToUpdateMySettingsRequest(command.PayloadBytes)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute user service command %q: %w", command.MessageType, err)
|
||||
}
|
||||
return c.executeUpdateMySettings(ctx, command.UserID, request)
|
||||
default:
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute user service command: unsupported message type %q", command.MessageType)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *HTTPClient) executeGetMyAccount(ctx context.Context, userID string) (downstream.UnaryResult, error) {
|
||||
payload, statusCode, err := c.doRequest(ctx, http.MethodGet, c.userPath(userID, userServiceAccountPathSuffix), nil)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute get my account: %w", err)
|
||||
}
|
||||
|
||||
return projectResponse(statusCode, payload)
|
||||
}
|
||||
|
||||
func (c *HTTPClient) executeUpdateMyProfile(ctx context.Context, userID string, request *usermodel.UpdateMyProfileRequest) (downstream.UnaryResult, error) {
|
||||
payload, statusCode, err := c.doRequest(ctx, http.MethodPost, c.userPath(userID, userServiceProfilePathSuffix), request)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute update my profile: %w", err)
|
||||
}
|
||||
|
||||
return projectResponse(statusCode, payload)
|
||||
}
|
||||
|
||||
func (c *HTTPClient) executeUpdateMySettings(ctx context.Context, userID string, request *usermodel.UpdateMySettingsRequest) (downstream.UnaryResult, error) {
|
||||
payload, statusCode, err := c.doRequest(ctx, http.MethodPost, c.userPath(userID, userServiceSettingsPathSuffix), request)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("execute update my settings: %w", err)
|
||||
}
|
||||
|
||||
return projectResponse(statusCode, payload)
|
||||
}
|
||||
|
||||
func (c *HTTPClient) doRequest(ctx context.Context, method string, targetURL string, requestBody any) ([]byte, int, error) {
|
||||
if c == nil || c.httpClient == nil {
|
||||
return nil, 0, errors.New("nil client")
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if requestBody != nil {
|
||||
payload, err := json.Marshal(requestBody)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("marshal request body: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(payload)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, method, targetURL, bodyReader)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
if requestBody != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
payload, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("read response body: %w", err)
|
||||
}
|
||||
|
||||
return payload, response.StatusCode, nil
|
||||
}
|
||||
|
||||
func (c *HTTPClient) userPath(userID string, suffix string) string {
|
||||
return c.baseURL + "/api/v1/internal/users/" + url.PathEscape(userID) + suffix
|
||||
}
|
||||
|
||||
func projectResponse(statusCode int, payload []byte) (downstream.UnaryResult, error) {
|
||||
switch {
|
||||
case statusCode == http.StatusOK:
|
||||
var response usermodel.AccountResponse
|
||||
if err := decodeStrictJSONPayload(payload, &response); err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("decode success response: %w", err)
|
||||
}
|
||||
|
||||
payloadBytes, err := transcoder.AccountResponseToPayload(&response)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
|
||||
}
|
||||
|
||||
return downstream.UnaryResult{
|
||||
ResultCode: getMyAccountResultCodeOK,
|
||||
PayloadBytes: payloadBytes,
|
||||
}, nil
|
||||
case statusCode == http.StatusServiceUnavailable:
|
||||
return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable
|
||||
case statusCode >= 400 && statusCode <= 599:
|
||||
errorResponse, err := decodeUserServiceError(statusCode, payload)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("decode error response: %w", err)
|
||||
}
|
||||
|
||||
payloadBytes, err := transcoder.ErrorResponseToPayload(errorResponse)
|
||||
if err != nil {
|
||||
return downstream.UnaryResult{}, fmt.Errorf("encode error response payload: %w", err)
|
||||
}
|
||||
|
||||
return downstream.UnaryResult{
|
||||
ResultCode: errorResponse.Error.Code,
|
||||
PayloadBytes: payloadBytes,
|
||||
}, nil
|
||||
default:
|
||||
return downstream.UnaryResult{}, fmt.Errorf("unexpected HTTP status %d", statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeUserServiceError(statusCode int, payload []byte) (*usermodel.ErrorResponse, error) {
|
||||
var response usermodel.ErrorResponse
|
||||
if err := decodeStrictJSONPayload(payload, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response.Error.Code = normalizeErrorCode(statusCode, response.Error.Code)
|
||||
response.Error.Message = normalizeErrorMessage(response.Error.Code, response.Error.Message)
|
||||
|
||||
if strings.TrimSpace(response.Error.Code) == "" {
|
||||
return nil, errors.New("missing error code")
|
||||
}
|
||||
if strings.TrimSpace(response.Error.Message) == "" {
|
||||
return nil, errors.New("missing error message")
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
func normalizeErrorCode(statusCode int, code string) string {
|
||||
trimmed := strings.TrimSpace(code)
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
switch statusCode {
|
||||
case http.StatusBadRequest:
|
||||
return "invalid_request"
|
||||
case http.StatusNotFound:
|
||||
return "subject_not_found"
|
||||
case http.StatusConflict:
|
||||
return "conflict"
|
||||
default:
|
||||
return "internal_error"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeErrorMessage(code string, message string) string {
|
||||
trimmed := strings.TrimSpace(message)
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
if stable, ok := stableErrorMessages[code]; ok {
|
||||
return stable
|
||||
}
|
||||
|
||||
return stableErrorMessages["internal_error"]
|
||||
}
|
||||
|
||||
func decodeStrictJSONPayload(payload []byte, target any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return errors.New("unexpected trailing JSON input")
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ downstream.Client = (*HTTPClient)(nil)
|
||||
@@ -0,0 +1,399 @@
|
||||
package userservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/gateway/internal/downstream"
|
||||
usermodel "galaxy/model/user"
|
||||
"galaxy/transcoder"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewHTTPClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
wantURL string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "absolute URL is normalized",
|
||||
baseURL: " http://127.0.0.1:8081/ ",
|
||||
wantURL: "http://127.0.0.1:8081",
|
||||
},
|
||||
{
|
||||
name: "empty base URL is rejected",
|
||||
baseURL: " ",
|
||||
wantErr: "base URL must not be empty",
|
||||
},
|
||||
{
|
||||
name: "relative base URL is rejected",
|
||||
baseURL: "/relative",
|
||||
wantErr: "base URL must be absolute",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, err := NewHTTPClient(tt.baseURL)
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantURL, client.baseURL)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientExecuteGetMyAccountSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantResponse := sampleAccountResponse()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
require.Equal(t, http.MethodGet, request.Method)
|
||||
require.Equal(t, "/api/v1/internal/users/user-123/account", request.URL.Path)
|
||||
require.NoError(t, json.NewEncoder(writer).Encode(wantResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestHTTPClient(t, server)
|
||||
payload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
|
||||
UserID: "user-123",
|
||||
MessageType: usermodel.MessageTypeGetMyAccount,
|
||||
PayloadBytes: payload,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, getMyAccountResultCodeOK, result.ResultCode)
|
||||
|
||||
decoded, err := transcoder.PayloadToAccountResponse(result.PayloadBytes)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, wantResponse, decoded)
|
||||
}
|
||||
|
||||
func TestHTTPClientExecuteUpdateMyProfileProjectsConflict(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
require.Equal(t, http.MethodPost, request.Method)
|
||||
require.Equal(t, "/api/v1/internal/users/user-123/profile", request.URL.Path)
|
||||
|
||||
body, err := io.ReadAll(request.Body)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"race_name":"Nova Prime"}`, string(body))
|
||||
|
||||
writer.WriteHeader(http.StatusConflict)
|
||||
require.NoError(t, json.NewEncoder(writer).Encode(&usermodel.ErrorResponse{
|
||||
Error: usermodel.ErrorBody{
|
||||
Code: "conflict",
|
||||
Message: "request conflicts with current state",
|
||||
},
|
||||
}))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestHTTPClient(t, server)
|
||||
payload, err := transcoder.UpdateMyProfileRequestToPayload(&usermodel.UpdateMyProfileRequest{RaceName: "Nova Prime"})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
|
||||
UserID: "user-123",
|
||||
MessageType: usermodel.MessageTypeUpdateMyProfile,
|
||||
PayloadBytes: payload,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "conflict", result.ResultCode)
|
||||
|
||||
decoded, err := transcoder.PayloadToErrorResponse(result.PayloadBytes)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, &usermodel.ErrorResponse{
|
||||
Error: usermodel.ErrorBody{
|
||||
Code: "conflict",
|
||||
Message: "request conflicts with current state",
|
||||
},
|
||||
}, decoded)
|
||||
}
|
||||
|
||||
func TestHTTPClientExecuteUpdateMySettingsProjectsInvalidRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
require.Equal(t, http.MethodPost, request.Method)
|
||||
require.Equal(t, "/api/v1/internal/users/user-123/settings", request.URL.Path)
|
||||
|
||||
body, err := io.ReadAll(request.Body)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{"preferred_language":"bad","time_zone":"Mars/Base"}`, string(body))
|
||||
|
||||
writer.WriteHeader(http.StatusBadRequest)
|
||||
require.NoError(t, json.NewEncoder(writer).Encode(&usermodel.ErrorResponse{
|
||||
Error: usermodel.ErrorBody{
|
||||
Code: "invalid_request",
|
||||
Message: "request is invalid",
|
||||
},
|
||||
}))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestHTTPClient(t, server)
|
||||
payload, err := transcoder.UpdateMySettingsRequestToPayload(&usermodel.UpdateMySettingsRequest{
|
||||
PreferredLanguage: "bad",
|
||||
TimeZone: "Mars/Base",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
|
||||
UserID: "user-123",
|
||||
MessageType: usermodel.MessageTypeUpdateMySettings,
|
||||
PayloadBytes: payload,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "invalid_request", result.ResultCode)
|
||||
|
||||
decoded, err := transcoder.PayloadToErrorResponse(result.PayloadBytes)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "invalid_request", decoded.Error.Code)
|
||||
}
|
||||
|
||||
func TestHTTPClientExecuteCommandProjectsSubjectNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.WriteHeader(http.StatusNotFound)
|
||||
require.NoError(t, json.NewEncoder(writer).Encode(&usermodel.ErrorResponse{
|
||||
Error: usermodel.ErrorBody{
|
||||
Code: "subject_not_found",
|
||||
Message: "subject not found",
|
||||
},
|
||||
}))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestHTTPClient(t, server)
|
||||
payload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
|
||||
UserID: "user-missing",
|
||||
MessageType: usermodel.MessageTypeGetMyAccount,
|
||||
PayloadBytes: payload,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "subject_not_found", result.ResultCode)
|
||||
}
|
||||
|
||||
func TestHTTPClientExecuteCommandMaps503ToUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.WriteHeader(http.StatusServiceUnavailable)
|
||||
require.NoError(t, json.NewEncoder(writer).Encode(&usermodel.ErrorResponse{
|
||||
Error: usermodel.ErrorBody{
|
||||
Code: "service_unavailable",
|
||||
Message: "service is unavailable",
|
||||
},
|
||||
}))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestHTTPClient(t, server)
|
||||
payload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
|
||||
UserID: "user-123",
|
||||
MessageType: usermodel.MessageTypeGetMyAccount,
|
||||
PayloadBytes: payload,
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, downstream.ErrDownstreamUnavailable)
|
||||
}
|
||||
|
||||
func TestHTTPClientExecuteCommandUsesCallerContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
<-request.Context().Done()
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestHTTPClient(t, server)
|
||||
payload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{})
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, err = client.ExecuteCommand(ctx, downstream.AuthenticatedCommand{
|
||||
UserID: "user-123",
|
||||
MessageType: usermodel.MessageTypeGetMyAccount,
|
||||
PayloadBytes: payload,
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
func TestHTTPClientExecuteCommandRejectsMalformedSuccessPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
_, _ = writer.Write([]byte(`{"account":{"user_id":"user-123","unexpected":true}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestHTTPClient(t, server)
|
||||
payload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
|
||||
UserID: "user-123",
|
||||
MessageType: usermodel.MessageTypeGetMyAccount,
|
||||
PayloadBytes: payload,
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "decode success response")
|
||||
}
|
||||
|
||||
func TestHTTPClientExecuteCommandRejectsUnsupportedMessageType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
|
||||
client := newTestHTTPClient(t, server)
|
||||
|
||||
_, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
|
||||
UserID: "user-123",
|
||||
MessageType: "user.unsupported",
|
||||
PayloadBytes: []byte("payload"),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unsupported message type")
|
||||
}
|
||||
|
||||
func TestNewRoutesReserveUserMessageTypesWhenUnconfigured(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
routes, closeFn, err := NewRoutes("")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, closeFn())
|
||||
|
||||
router := downstream.NewStaticRouter(routes)
|
||||
for _, messageType := range []string{
|
||||
usermodel.MessageTypeGetMyAccount,
|
||||
usermodel.MessageTypeUpdateMyProfile,
|
||||
usermodel.MessageTypeUpdateMySettings,
|
||||
} {
|
||||
client, routeErr := router.Route(messageType)
|
||||
require.NoError(t, routeErr)
|
||||
|
||||
_, execErr := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
|
||||
UserID: "user-123",
|
||||
MessageType: messageType,
|
||||
})
|
||||
require.Error(t, execErr)
|
||||
assert.ErrorIs(t, execErr, downstream.ErrDownstreamUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnavailableClientReturnsDownstreamUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := unavailableClient{}.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{})
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, downstream.ErrDownstreamUnavailable)
|
||||
}
|
||||
|
||||
func newTestHTTPClient(t *testing.T, server *httptest.Server) *HTTPClient {
|
||||
t.Helper()
|
||||
|
||||
client, err := newHTTPClient(server.URL, server.Client())
|
||||
require.NoError(t, err)
|
||||
return client
|
||||
}
|
||||
|
||||
func sampleAccountResponse() *usermodel.AccountResponse {
|
||||
now := time.Date(2026, time.April, 9, 10, 0, 0, 0, time.UTC)
|
||||
expiresAt := now.Add(30 * 24 * time.Hour)
|
||||
|
||||
return &usermodel.AccountResponse{
|
||||
Account: usermodel.Account{
|
||||
UserID: "user-123",
|
||||
Email: "pilot@example.com",
|
||||
RaceName: "Pilot Nova",
|
||||
PreferredLanguage: "en",
|
||||
TimeZone: "Europe/Kaliningrad",
|
||||
DeclaredCountry: "DE",
|
||||
Entitlement: usermodel.EntitlementSnapshot{
|
||||
PlanCode: "free",
|
||||
IsPaid: false,
|
||||
Source: "auth_registration",
|
||||
Actor: usermodel.ActorRef{Type: "service", ID: "user-service"},
|
||||
ReasonCode: "initial_free_entitlement",
|
||||
StartsAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
ActiveSanctions: []usermodel.ActiveSanction{
|
||||
{
|
||||
SanctionCode: "profile_update_block",
|
||||
Scope: "lobby",
|
||||
ReasonCode: "manual_block",
|
||||
Actor: usermodel.ActorRef{Type: "admin", ID: "admin-1"},
|
||||
AppliedAt: now,
|
||||
ExpiresAt: &expiresAt,
|
||||
},
|
||||
},
|
||||
ActiveLimits: []usermodel.ActiveLimit{
|
||||
{
|
||||
LimitCode: "max_owned_private_games",
|
||||
Value: 3,
|
||||
ReasonCode: "manual_override",
|
||||
Actor: usermodel.ActorRef{Type: "admin", ID: "admin-1"},
|
||||
AppliedAt: now,
|
||||
},
|
||||
},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeUserServiceErrorNormalizesBlankFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
response, err := decodeUserServiceError(http.StatusBadRequest, []byte(`{"error":{"code":" ","message":" "}}`))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "invalid_request", response.Error.Code)
|
||||
assert.Equal(t, "request is invalid", response.Error.Message)
|
||||
}
|
||||
|
||||
func TestHTTPClientExecuteCommandRejectsNilContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
|
||||
client := newTestHTTPClient(t, server)
|
||||
|
||||
_, err := client.ExecuteCommand(nil, downstream.AuthenticatedCommand{})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "nil context")
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package userservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"galaxy/gateway/internal/downstream"
|
||||
usermodel "galaxy/model/user"
|
||||
)
|
||||
|
||||
var noOpClose = func() error { return nil }
|
||||
|
||||
// NewRoutes returns the reserved authenticated gateway routes owned by the
|
||||
// Gateway -> User self-service boundary.
|
||||
//
|
||||
// When baseURL is empty, the returned routes still reserve the stable
|
||||
// `user.*` message types but resolve them to a dependency-unavailable client
|
||||
// so callers receive the transport-level unavailable outcome instead of a
|
||||
// route-miss error.
|
||||
func NewRoutes(baseURL string) (map[string]downstream.Client, func() error, error) {
|
||||
client := downstream.Client(unavailableClient{})
|
||||
closeFn := noOpClose
|
||||
|
||||
if baseURL != "" {
|
||||
httpClient, err := NewHTTPClient(baseURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
client = httpClient
|
||||
closeFn = httpClient.Close
|
||||
}
|
||||
|
||||
return map[string]downstream.Client{
|
||||
usermodel.MessageTypeGetMyAccount: client,
|
||||
usermodel.MessageTypeUpdateMyProfile: client,
|
||||
usermodel.MessageTypeUpdateMySettings: client,
|
||||
}, closeFn, nil
|
||||
}
|
||||
|
||||
type unavailableClient struct{}
|
||||
|
||||
func (unavailableClient) ExecuteCommand(context.Context, downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
|
||||
return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable
|
||||
}
|
||||
|
||||
var _ downstream.Client = unavailableClient{}
|
||||
+3
-13
@@ -12,14 +12,11 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
|
||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
|
||||
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
@@ -44,8 +41,6 @@ github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjH
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
|
||||
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
|
||||
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
|
||||
@@ -53,6 +48,7 @@ github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3c
|
||||
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
@@ -67,19 +63,12 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 h1:H7O6RlGOMTizyl3R08Kn5pdM06bnH8oscSj7o11tmLA=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0/go.mod h1:mBFWu/WOVDkWWsR7Tx7h6EpQB8wsv7P0Yrh0Pb7othc=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
@@ -115,6 +104,7 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -134,6 +124,7 @@ golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0=
|
||||
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8=
|
||||
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548=
|
||||
@@ -183,5 +174,4 @@ google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhH
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
+26
-3
@@ -8,13 +8,25 @@ Each suite must raise real service processes, speak only over public HTTP/gRPC/R
|
||||
```text
|
||||
integration/
|
||||
├── README.md
|
||||
├── go.mod
|
||||
├── authsessionuser/
|
||||
│ ├── authsession_user_test.go
|
||||
│ └── harness_test.go
|
||||
├── gatewayauthsession/
|
||||
│ ├── harness_test.go
|
||||
│ └── gateway_authsession_test.go
|
||||
├── gatewayauthsessionuser/
|
||||
│ ├── gateway_authsession_user_test.go
|
||||
│ └── harness_test.go
|
||||
├── gatewayuser/
|
||||
│ ├── gateway_user_test.go
|
||||
│ └── harness_test.go
|
||||
├── go.mod
|
||||
├── go.sum
|
||||
└── internal/
|
||||
├── contracts/
|
||||
│ └── gatewayv1/
|
||||
│ ├── gatewayv1/
|
||||
│ │ └── contract.go
|
||||
│ └── userv1/
|
||||
│ └── contract.go
|
||||
└── harness/
|
||||
├── binary.go
|
||||
@@ -35,8 +47,12 @@ integration/
|
||||
## Current Boundary Suites
|
||||
|
||||
- `gatewayauthsession` verifies the integration boundary between real `Edge Gateway` and real `Auth / Session Service`.
|
||||
- `authsessionuser` verifies the integration boundary between real `Auth / Session Service` and real `User Service`.
|
||||
- `gatewayuser` verifies the direct authenticated self-service boundary between real `Edge Gateway` and real `User Service`.
|
||||
- `gatewayauthsessionuser` verifies the full public-auth plus authenticated-account chain across real `Edge Gateway`, real `Auth / Session Service`, and real `User Service`.
|
||||
|
||||
The current fast suite uses one isolated `miniredis` instance plus external stateful HTTP stubs for mail and user services.
|
||||
The current fast suites use one isolated `miniredis` instance plus either
|
||||
real downstream processes or external stateful HTTP stubs where appropriate.
|
||||
|
||||
## Running
|
||||
|
||||
@@ -45,14 +61,21 @@ Run from the module directory:
|
||||
```bash
|
||||
cd integration
|
||||
go test ./gatewayauthsession/...
|
||||
go test ./authsessionuser/...
|
||||
go test ./gatewayuser/...
|
||||
go test ./gatewayauthsessionuser/...
|
||||
```
|
||||
|
||||
Useful regression commands after boundary changes:
|
||||
|
||||
```bash
|
||||
go test ./gatewayauthsession/...
|
||||
go test ./authsessionuser/...
|
||||
go test ./gatewayuser/...
|
||||
go test ./gatewayauthsessionuser/...
|
||||
cd ../gateway && go test ./...
|
||||
cd ../authsession && go test ./... -run GatewayCompatibility
|
||||
cd ../user && go test ./...
|
||||
```
|
||||
|
||||
Do not use `go test ./...` from the repository root. The repository is organized through `go.work`, so verification should stay module-scoped.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package authsessionuser_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAuthsessionUserBlackBoxConfirmCreatesUserWithForwardedRegistrationContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := newAuthsessionUserHarness(t)
|
||||
email := "created@example.com"
|
||||
|
||||
challengeID := h.sendChallenge(t, email)
|
||||
code := lastMailCodeFor(t, h.mailStub, email)
|
||||
|
||||
response := h.confirmCode(t, challengeID, code)
|
||||
var confirmBody struct {
|
||||
DeviceSessionID string `json:"device_session_id"`
|
||||
}
|
||||
requireJSONStatus(t, response, http.StatusOK, &confirmBody)
|
||||
require.True(t, strings.HasPrefix(confirmBody.DeviceSessionID, "device-session-"))
|
||||
|
||||
lookupResponse, account := lookupUserByEmail(t, h.userServiceURL, email)
|
||||
require.Equalf(t, http.StatusOK, lookupResponse.StatusCode, formatStatusError(lookupResponse))
|
||||
require.Equal(t, email, account.User.Email)
|
||||
require.Equal(t, "en", account.User.PreferredLanguage)
|
||||
require.Equal(t, testTimeZone, account.User.TimeZone)
|
||||
require.True(t, strings.HasPrefix(account.User.UserID, "user-"))
|
||||
require.True(t, strings.HasPrefix(account.User.RaceName, "player-"))
|
||||
require.Equal(t, "free", account.User.Entitlement.PlanCode)
|
||||
require.False(t, account.User.Entitlement.IsPaid)
|
||||
require.Empty(t, account.User.ActiveSanctions)
|
||||
require.Empty(t, account.User.ActiveLimits)
|
||||
}
|
||||
|
||||
func TestAuthsessionUserBlackBoxConfirmForExistingUserKeepsCreateOnlySettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := newAuthsessionUserHarness(t)
|
||||
email := "existing@example.com"
|
||||
|
||||
created := postEnsureUser(t, h.userServiceURL, email, "fr-FR", "Europe/Paris")
|
||||
require.Equal(t, "created", created.Outcome)
|
||||
sleepForDistinctCreatedAt()
|
||||
|
||||
challengeID := h.sendChallenge(t, email)
|
||||
code := lastMailCodeFor(t, h.mailStub, email)
|
||||
|
||||
response := h.confirmCode(t, challengeID, code)
|
||||
var confirmBody struct {
|
||||
DeviceSessionID string `json:"device_session_id"`
|
||||
}
|
||||
requireJSONStatus(t, response, http.StatusOK, &confirmBody)
|
||||
require.True(t, strings.HasPrefix(confirmBody.DeviceSessionID, "device-session-"))
|
||||
|
||||
lookupResponse, account := lookupUserByEmail(t, h.userServiceURL, email)
|
||||
require.Equalf(t, http.StatusOK, lookupResponse.StatusCode, formatStatusError(lookupResponse))
|
||||
require.Equal(t, created.UserID, account.User.UserID)
|
||||
require.Equal(t, "fr-FR", account.User.PreferredLanguage)
|
||||
require.Equal(t, "Europe/Paris", account.User.TimeZone)
|
||||
}
|
||||
|
||||
func TestAuthsessionUserBlackBoxBlockedEmailSendIsSuccessShapedAndConfirmIsRejectedWithoutCreatingUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := newAuthsessionUserHarness(t)
|
||||
|
||||
blockedAtSendEmail := "blocked-send@example.com"
|
||||
postBlockByEmail(t, h.userServiceURL, blockedAtSendEmail)
|
||||
|
||||
beforeBlockedSendDeliveries := len(h.mailStub.RecordedDeliveries())
|
||||
blockedChallengeID := h.sendChallenge(t, blockedAtSendEmail)
|
||||
require.NotEmpty(t, blockedChallengeID)
|
||||
require.Len(t, h.mailStub.RecordedDeliveries(), beforeBlockedSendDeliveries)
|
||||
|
||||
blockedAtConfirmEmail := "blocked-confirm@example.com"
|
||||
challengeID := h.sendChallenge(t, blockedAtConfirmEmail)
|
||||
code := lastMailCodeFor(t, h.mailStub, blockedAtConfirmEmail)
|
||||
postBlockByEmail(t, h.userServiceURL, blockedAtConfirmEmail)
|
||||
|
||||
confirmResponse := h.confirmCode(t, challengeID, code)
|
||||
requireJSONStatusRaw(t, confirmResponse, http.StatusForbidden, `{"error":{"code":"blocked_by_policy","message":"authentication is blocked by policy"}}`)
|
||||
|
||||
lookupResponse, _ := lookupUserByEmail(t, h.userServiceURL, blockedAtConfirmEmail)
|
||||
requireLookupNotFound(t, lookupResponse)
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
package authsessionuser_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/integration/internal/harness"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
testClientPublicKey = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
testTimeZone = "Europe/Kaliningrad"
|
||||
)
|
||||
|
||||
type authsessionUserHarness struct {
|
||||
mailStub *harness.MailStub
|
||||
|
||||
authsessionPublicURL string
|
||||
userServiceURL string
|
||||
|
||||
authsessionProcess *harness.Process
|
||||
userServiceProcess *harness.Process
|
||||
}
|
||||
|
||||
func newAuthsessionUserHarness(t *testing.T) *authsessionUserHarness {
|
||||
t.Helper()
|
||||
|
||||
redisServer := harness.StartMiniredis(t)
|
||||
mailStub := harness.NewMailStub(t)
|
||||
|
||||
userServiceAddr := harness.FreeTCPAddress(t)
|
||||
authsessionPublicAddr := harness.FreeTCPAddress(t)
|
||||
authsessionInternalAddr := harness.FreeTCPAddress(t)
|
||||
|
||||
userServiceBinary := harness.BuildBinary(t, "userservice", "./user/cmd/userservice")
|
||||
authsessionBinary := harness.BuildBinary(t, "authsession", "./authsession/cmd/authsession")
|
||||
|
||||
userServiceEnv := map[string]string{
|
||||
"USERSERVICE_LOG_LEVEL": "info",
|
||||
"USERSERVICE_INTERNAL_HTTP_ADDR": userServiceAddr,
|
||||
"USERSERVICE_REDIS_ADDR": redisServer.Addr(),
|
||||
"OTEL_TRACES_EXPORTER": "none",
|
||||
"OTEL_METRICS_EXPORTER": "none",
|
||||
}
|
||||
userServiceProcess := harness.StartProcess(t, "userservice", userServiceBinary, userServiceEnv)
|
||||
waitForUserServiceReady(t, userServiceProcess, "http://"+userServiceAddr)
|
||||
|
||||
authsessionEnv := map[string]string{
|
||||
"AUTHSESSION_LOG_LEVEL": "info",
|
||||
"AUTHSESSION_PUBLIC_HTTP_ADDR": authsessionPublicAddr,
|
||||
"AUTHSESSION_INTERNAL_HTTP_ADDR": authsessionInternalAddr,
|
||||
"AUTHSESSION_REDIS_ADDR": redisServer.Addr(),
|
||||
"AUTHSESSION_USER_SERVICE_MODE": "rest",
|
||||
"AUTHSESSION_USER_SERVICE_BASE_URL": "http://" + userServiceAddr,
|
||||
"AUTHSESSION_MAIL_SERVICE_MODE": "rest",
|
||||
"AUTHSESSION_MAIL_SERVICE_BASE_URL": mailStub.BaseURL(),
|
||||
"OTEL_TRACES_EXPORTER": "none",
|
||||
"OTEL_METRICS_EXPORTER": "none",
|
||||
"AUTHSESSION_PUBLIC_HTTP_REQUEST_TIMEOUT": time.Second.String(),
|
||||
"AUTHSESSION_INTERNAL_HTTP_REQUEST_TIMEOUT": time.Second.String(),
|
||||
}
|
||||
authsessionProcess := harness.StartProcess(t, "authsession", authsessionBinary, authsessionEnv)
|
||||
waitForAuthsessionPublicReady(t, authsessionProcess, "http://"+authsessionPublicAddr)
|
||||
|
||||
return &authsessionUserHarness{
|
||||
mailStub: mailStub,
|
||||
authsessionPublicURL: "http://" + authsessionPublicAddr,
|
||||
userServiceURL: "http://" + userServiceAddr,
|
||||
authsessionProcess: authsessionProcess,
|
||||
userServiceProcess: userServiceProcess,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *authsessionUserHarness) sendChallenge(t *testing.T, email string) string {
|
||||
t.Helper()
|
||||
|
||||
response := postJSONValue(t, h.authsessionPublicURL+"/api/v1/public/auth/send-email-code", map[string]string{
|
||||
"email": email,
|
||||
})
|
||||
require.Equal(t, http.StatusOK, response.StatusCode)
|
||||
|
||||
var body struct {
|
||||
ChallengeID string `json:"challenge_id"`
|
||||
}
|
||||
require.NoError(t, decodeStrictJSONPayload([]byte(response.Body), &body))
|
||||
require.NotEmpty(t, body.ChallengeID)
|
||||
|
||||
return body.ChallengeID
|
||||
}
|
||||
|
||||
func (h *authsessionUserHarness) confirmCode(t *testing.T, challengeID string, code string) httpResponse {
|
||||
t.Helper()
|
||||
|
||||
return postJSONValue(t, h.authsessionPublicURL+"/api/v1/public/auth/confirm-email-code", map[string]string{
|
||||
"challenge_id": challengeID,
|
||||
"code": code,
|
||||
"client_public_key": testClientPublicKey,
|
||||
"time_zone": testTimeZone,
|
||||
})
|
||||
}
|
||||
|
||||
type httpResponse struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
Header http.Header
|
||||
}
|
||||
|
||||
func postJSONValue(t *testing.T, targetURL string, body any) httpResponse {
|
||||
t.Helper()
|
||||
|
||||
payload, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, targetURL, bytes.NewReader(payload))
|
||||
require.NoError(t, err)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 250 * time.Millisecond,
|
||||
Transport: &http.Transport{
|
||||
DisableKeepAlives: true,
|
||||
},
|
||||
}
|
||||
t.Cleanup(client.CloseIdleConnections)
|
||||
|
||||
response, err := client.Do(request)
|
||||
require.NoError(t, err)
|
||||
defer response.Body.Close()
|
||||
|
||||
responseBody, err := io.ReadAll(response.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
return httpResponse{
|
||||
StatusCode: response.StatusCode,
|
||||
Body: string(responseBody),
|
||||
Header: response.Header.Clone(),
|
||||
}
|
||||
}
|
||||
|
||||
func decodeStrictJSONPayload(payload []byte, target any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return errors.New("unexpected trailing JSON input")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func waitForUserServiceReady(t *testing.T, process *harness.Process, baseURL string) {
|
||||
t.Helper()
|
||||
|
||||
client := &http.Client{Timeout: 250 * time.Millisecond}
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
request, err := http.NewRequest(http.MethodGet, baseURL+"/api/v1/internal/users/user-missing/exists", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err == nil {
|
||||
_, _ = io.Copy(io.Discard, response.Body)
|
||||
response.Body.Close()
|
||||
if response.StatusCode == http.StatusOK {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
|
||||
t.Fatalf("wait for userservice readiness: timeout\n%s", process.Logs())
|
||||
}
|
||||
|
||||
func waitForAuthsessionPublicReady(t *testing.T, process *harness.Process, baseURL string) {
|
||||
t.Helper()
|
||||
|
||||
client := &http.Client{Timeout: 250 * time.Millisecond}
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
response, err := postJSONValueMaybe(client, baseURL+"/api/v1/public/auth/send-email-code", map[string]string{
|
||||
"email": "",
|
||||
})
|
||||
if err == nil && response.StatusCode == http.StatusBadRequest {
|
||||
return
|
||||
}
|
||||
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
|
||||
t.Fatalf("wait for authsession public readiness: timeout\n%s", process.Logs())
|
||||
}
|
||||
|
||||
func postJSONValueMaybe(client *http.Client, targetURL string, body any) (httpResponse, error) {
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return httpResponse{}, err
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, targetURL, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return httpResponse{}, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return httpResponse{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
responseBody, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return httpResponse{}, err
|
||||
}
|
||||
|
||||
return httpResponse{
|
||||
StatusCode: response.StatusCode,
|
||||
Body: string(responseBody),
|
||||
Header: response.Header.Clone(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func requireJSONStatus(t *testing.T, response httpResponse, wantStatus int, target any) {
|
||||
t.Helper()
|
||||
|
||||
require.Equal(t, wantStatus, response.StatusCode, "response body: %s", response.Body)
|
||||
require.NoError(t, decodeStrictJSONPayload([]byte(response.Body), target))
|
||||
}
|
||||
|
||||
func requireJSONStatusRaw(t *testing.T, response httpResponse, wantStatus int, wantBody string) {
|
||||
t.Helper()
|
||||
|
||||
require.Equal(t, wantStatus, response.StatusCode, "response body: %s", response.Body)
|
||||
require.JSONEq(t, wantBody, response.Body)
|
||||
}
|
||||
|
||||
func postEnsureUser(t *testing.T, baseURL string, email string, preferredLanguage string, timeZone string) ensureByEmailResponse {
|
||||
t.Helper()
|
||||
|
||||
response := postJSONValue(t, baseURL+"/api/v1/internal/users/ensure-by-email", map[string]any{
|
||||
"email": email,
|
||||
"registration_context": map[string]string{
|
||||
"preferred_language": preferredLanguage,
|
||||
"time_zone": timeZone,
|
||||
},
|
||||
})
|
||||
|
||||
var body ensureByEmailResponse
|
||||
requireJSONStatus(t, response, http.StatusOK, &body)
|
||||
return body
|
||||
}
|
||||
|
||||
func postBlockByEmail(t *testing.T, baseURL string, email string) {
|
||||
t.Helper()
|
||||
|
||||
response := postJSONValue(t, baseURL+"/api/v1/internal/user-blocks/by-email", map[string]string{
|
||||
"email": email,
|
||||
"reason_code": "policy_blocked",
|
||||
})
|
||||
|
||||
var body blockMutationResponse
|
||||
requireJSONStatus(t, response, http.StatusOK, &body)
|
||||
}
|
||||
|
||||
func lookupUserByEmail(t *testing.T, baseURL string, email string) (httpResponse, userLookupResponse) {
|
||||
t.Helper()
|
||||
|
||||
response := postJSONValue(t, baseURL+"/api/v1/internal/user-lookups/by-email", map[string]string{
|
||||
"email": email,
|
||||
})
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return response, userLookupResponse{}
|
||||
}
|
||||
|
||||
var body userLookupResponse
|
||||
require.NoError(t, decodeStrictJSONPayload([]byte(response.Body), &body))
|
||||
return response, body
|
||||
}
|
||||
|
||||
type ensureByEmailResponse struct {
|
||||
Outcome string `json:"outcome"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
type blockMutationResponse struct {
|
||||
Outcome string `json:"outcome"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
type userLookupResponse struct {
|
||||
User accountView `json:"user"`
|
||||
}
|
||||
|
||||
type accountView struct {
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
RaceName string `json:"race_name"`
|
||||
PreferredLanguage string `json:"preferred_language"`
|
||||
TimeZone string `json:"time_zone"`
|
||||
DeclaredCountry string `json:"declared_country,omitempty"`
|
||||
Entitlement entitlementSnapshotView `json:"entitlement"`
|
||||
ActiveSanctions []activeSanctionView `json:"active_sanctions"`
|
||||
ActiveLimits []activeLimitView `json:"active_limits"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type entitlementSnapshotView struct {
|
||||
PlanCode string `json:"plan_code"`
|
||||
IsPaid bool `json:"is_paid"`
|
||||
Source string `json:"source"`
|
||||
Actor actorRefView `json:"actor"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
StartsAt time.Time `json:"starts_at"`
|
||||
EndsAt *time.Time `json:"ends_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type activeSanctionView struct {
|
||||
SanctionCode string `json:"sanction_code"`
|
||||
Scope string `json:"scope"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Actor actorRefView `json:"actor"`
|
||||
AppliedAt time.Time `json:"applied_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
type activeLimitView struct {
|
||||
LimitCode string `json:"limit_code"`
|
||||
Value int `json:"value"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Actor actorRefView `json:"actor"`
|
||||
AppliedAt time.Time `json:"applied_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
type actorRefView struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
func requireLookupNotFound(t *testing.T, response httpResponse) {
|
||||
t.Helper()
|
||||
|
||||
requireJSONStatusRaw(t, response, http.StatusNotFound, `{"error":{"code":"subject_not_found","message":"subject not found"}}`)
|
||||
}
|
||||
|
||||
func lastMailCodeFor(t *testing.T, stub *harness.MailStub, email string) string {
|
||||
t.Helper()
|
||||
|
||||
deliveries := stub.RecordedDeliveries()
|
||||
for index := len(deliveries) - 1; index >= 0; index-- {
|
||||
if deliveries[index].Email == email {
|
||||
return deliveries[index].Code
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("mail stub did not record delivery for %s", email)
|
||||
return ""
|
||||
}
|
||||
|
||||
func sleepForDistinctCreatedAt() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
func formatStatusError(response httpResponse) string {
|
||||
return fmt.Sprintf("status=%d body=%s", response.StatusCode, response.Body)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package gatewayauthsessionuser_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGatewayAuthsessionUserFirstRegistrationCreatesUserAndAllowsAccountRead(t *testing.T) {
|
||||
h := newGatewayAuthsessionUserHarness(t)
|
||||
|
||||
const email = "created@example.com"
|
||||
|
||||
challengeID := h.sendChallenge(t, email)
|
||||
code := lastMailCodeFor(t, h.mailStub, email)
|
||||
clientPrivateKey := newClientPrivateKey("first-registration")
|
||||
|
||||
confirmResponse := h.confirmCode(t, challengeID, code, clientPrivateKey)
|
||||
var confirmBody struct {
|
||||
DeviceSessionID string `json:"device_session_id"`
|
||||
}
|
||||
requireJSONStatus(t, confirmResponse, http.StatusOK, &confirmBody)
|
||||
require.True(t, strings.HasPrefix(confirmBody.DeviceSessionID, "device-session-"))
|
||||
|
||||
sessionRecord := h.waitForGatewaySession(t, confirmBody.DeviceSessionID)
|
||||
accountResponse := h.executeGetMyAccount(t, confirmBody.DeviceSessionID, "request-first-registration", clientPrivateKey)
|
||||
|
||||
require.Equal(t, sessionRecord.UserID, accountResponse.Account.UserID)
|
||||
require.Equal(t, email, accountResponse.Account.Email)
|
||||
require.Equal(t, "en", accountResponse.Account.PreferredLanguage)
|
||||
require.Equal(t, gatewayAuthsessionUserTestTimeZone, accountResponse.Account.TimeZone)
|
||||
|
||||
lookupResponse, lookup := h.lookupUserByEmail(t, email)
|
||||
require.Equalf(t, http.StatusOK, lookupResponse.StatusCode, "status=%d body=%s", lookupResponse.StatusCode, lookupResponse.Body)
|
||||
require.Equal(t, accountResponse.Account.UserID, lookup.User.UserID)
|
||||
}
|
||||
|
||||
func TestGatewayAuthsessionUserExistingAccountKeepsCreateOnlySettings(t *testing.T) {
|
||||
h := newGatewayAuthsessionUserHarness(t)
|
||||
|
||||
const email = "existing@example.com"
|
||||
|
||||
created := h.ensureUser(t, email, "fr-FR", "Europe/Paris")
|
||||
require.Equal(t, "created", created.Outcome)
|
||||
|
||||
challengeID := h.sendChallenge(t, email)
|
||||
code := lastMailCodeFor(t, h.mailStub, email)
|
||||
clientPrivateKey := newClientPrivateKey("existing-account")
|
||||
|
||||
confirmResponse := h.confirmCode(t, challengeID, code, clientPrivateKey)
|
||||
var confirmBody struct {
|
||||
DeviceSessionID string `json:"device_session_id"`
|
||||
}
|
||||
requireJSONStatus(t, confirmResponse, http.StatusOK, &confirmBody)
|
||||
|
||||
accountResponse := h.executeGetMyAccount(t, confirmBody.DeviceSessionID, "request-existing-account", clientPrivateKey)
|
||||
require.Equal(t, created.UserID, accountResponse.Account.UserID)
|
||||
require.Equal(t, "fr-FR", accountResponse.Account.PreferredLanguage)
|
||||
require.Equal(t, "Europe/Paris", accountResponse.Account.TimeZone)
|
||||
}
|
||||
|
||||
func TestGatewayAuthsessionUserBlockedEmailAndUserBehavior(t *testing.T) {
|
||||
h := newGatewayAuthsessionUserHarness(t)
|
||||
|
||||
blockedAtSendEmail := "blocked-send@example.com"
|
||||
h.blockByEmail(t, blockedAtSendEmail)
|
||||
|
||||
beforeBlockedSendDeliveries := len(h.mailStub.RecordedDeliveries())
|
||||
blockedChallengeID := h.sendChallenge(t, blockedAtSendEmail)
|
||||
require.NotEmpty(t, blockedChallengeID)
|
||||
require.Len(t, h.mailStub.RecordedDeliveries(), beforeBlockedSendDeliveries)
|
||||
|
||||
blockedAtConfirmEmail := "blocked-confirm@example.com"
|
||||
challengeID := h.sendChallenge(t, blockedAtConfirmEmail)
|
||||
code := lastMailCodeFor(t, h.mailStub, blockedAtConfirmEmail)
|
||||
h.blockByEmail(t, blockedAtConfirmEmail)
|
||||
|
||||
confirmResponse := h.confirmCode(t, challengeID, code, newClientPrivateKey("blocked-confirm"))
|
||||
require.Equal(t, http.StatusForbidden, confirmResponse.StatusCode)
|
||||
require.JSONEq(t, `{"error":{"code":"blocked_by_policy","message":"authentication is blocked by policy"}}`, confirmResponse.Body)
|
||||
|
||||
lookupResponse, _ := h.lookupUserByEmail(t, blockedAtConfirmEmail)
|
||||
requireLookupNotFound(t, lookupResponse)
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
package gatewayauthsessionuser_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
|
||||
contractsgatewayv1 "galaxy/integration/internal/contracts/gatewayv1"
|
||||
contractsuserv1 "galaxy/integration/internal/contracts/userv1"
|
||||
"galaxy/integration/internal/harness"
|
||||
usermodel "galaxy/model/user"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
const gatewayAuthsessionUserTestTimeZone = "Europe/Kaliningrad"
|
||||
|
||||
type gatewayAuthsessionUserHarness struct {
|
||||
redis *redis.Client
|
||||
|
||||
mailStub *harness.MailStub
|
||||
|
||||
authsessionPublicURL string
|
||||
userServiceURL string
|
||||
gatewayPublicURL string
|
||||
gatewayGRPCAddr string
|
||||
|
||||
responseSignerPublicKey ed25519.PublicKey
|
||||
|
||||
gatewayProcess *harness.Process
|
||||
authsessionProcess *harness.Process
|
||||
userServiceProcess *harness.Process
|
||||
}
|
||||
|
||||
func newGatewayAuthsessionUserHarness(t *testing.T) *gatewayAuthsessionUserHarness {
|
||||
t.Helper()
|
||||
|
||||
redisServer := harness.StartMiniredis(t)
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: redisServer.Addr(),
|
||||
Protocol: 2,
|
||||
DisableIdentity: true,
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, redisClient.Close())
|
||||
})
|
||||
|
||||
mailStub := harness.NewMailStub(t)
|
||||
|
||||
responseSignerPath, responseSignerPublicKey := harness.WriteResponseSignerPEM(t, t.Name())
|
||||
userServiceAddr := harness.FreeTCPAddress(t)
|
||||
authsessionPublicAddr := harness.FreeTCPAddress(t)
|
||||
authsessionInternalAddr := harness.FreeTCPAddress(t)
|
||||
gatewayPublicAddr := harness.FreeTCPAddress(t)
|
||||
gatewayGRPCAddr := harness.FreeTCPAddress(t)
|
||||
|
||||
userServiceBinary := harness.BuildBinary(t, "userservice", "./user/cmd/userservice")
|
||||
authsessionBinary := harness.BuildBinary(t, "authsession", "./authsession/cmd/authsession")
|
||||
gatewayBinary := harness.BuildBinary(t, "gateway", "./gateway/cmd/gateway")
|
||||
|
||||
userServiceEnv := map[string]string{
|
||||
"USERSERVICE_LOG_LEVEL": "info",
|
||||
"USERSERVICE_INTERNAL_HTTP_ADDR": userServiceAddr,
|
||||
"USERSERVICE_REDIS_ADDR": redisServer.Addr(),
|
||||
"OTEL_TRACES_EXPORTER": "none",
|
||||
"OTEL_METRICS_EXPORTER": "none",
|
||||
}
|
||||
userServiceProcess := harness.StartProcess(t, "userservice", userServiceBinary, userServiceEnv)
|
||||
harness.WaitForHTTPStatus(t, userServiceProcess, "http://"+userServiceAddr+"/api/v1/internal/users/user-missing/exists", http.StatusOK)
|
||||
|
||||
authsessionEnv := map[string]string{
|
||||
"AUTHSESSION_LOG_LEVEL": "info",
|
||||
"AUTHSESSION_PUBLIC_HTTP_ADDR": authsessionPublicAddr,
|
||||
"AUTHSESSION_PUBLIC_HTTP_REQUEST_TIMEOUT": time.Second.String(),
|
||||
"AUTHSESSION_INTERNAL_HTTP_ADDR": authsessionInternalAddr,
|
||||
"AUTHSESSION_INTERNAL_HTTP_REQUEST_TIMEOUT": time.Second.String(),
|
||||
"AUTHSESSION_REDIS_ADDR": redisServer.Addr(),
|
||||
"AUTHSESSION_USER_SERVICE_MODE": "rest",
|
||||
"AUTHSESSION_USER_SERVICE_BASE_URL": "http://" + userServiceAddr,
|
||||
"AUTHSESSION_USER_SERVICE_REQUEST_TIMEOUT": time.Second.String(),
|
||||
"AUTHSESSION_MAIL_SERVICE_MODE": "rest",
|
||||
"AUTHSESSION_MAIL_SERVICE_BASE_URL": mailStub.BaseURL(),
|
||||
"AUTHSESSION_MAIL_SERVICE_REQUEST_TIMEOUT": time.Second.String(),
|
||||
"AUTHSESSION_REDIS_GATEWAY_SESSION_CACHE_KEY_PREFIX": "gateway:session:",
|
||||
"AUTHSESSION_REDIS_GATEWAY_SESSION_EVENTS_STREAM": "gateway:session_events",
|
||||
"OTEL_TRACES_EXPORTER": "none",
|
||||
"OTEL_METRICS_EXPORTER": "none",
|
||||
}
|
||||
authsessionProcess := harness.StartProcess(t, "authsession", authsessionBinary, authsessionEnv)
|
||||
waitForAuthsessionPublicReady(t, authsessionProcess, "http://"+authsessionPublicAddr)
|
||||
|
||||
gatewayEnv := map[string]string{
|
||||
"GATEWAY_LOG_LEVEL": "info",
|
||||
"GATEWAY_PUBLIC_HTTP_ADDR": gatewayPublicAddr,
|
||||
"GATEWAY_AUTHENTICATED_GRPC_ADDR": gatewayGRPCAddr,
|
||||
"GATEWAY_AUTH_SERVICE_BASE_URL": "http://" + authsessionPublicAddr,
|
||||
"GATEWAY_USER_SERVICE_BASE_URL": "http://" + userServiceAddr,
|
||||
"GATEWAY_PUBLIC_AUTH_UPSTREAM_TIMEOUT": (500 * time.Millisecond).String(),
|
||||
"GATEWAY_SESSION_CACHE_REDIS_ADDR": redisServer.Addr(),
|
||||
"GATEWAY_SESSION_CACHE_REDIS_KEY_PREFIX": "gateway:session:",
|
||||
"GATEWAY_SESSION_EVENTS_REDIS_STREAM": "gateway:session_events",
|
||||
"GATEWAY_CLIENT_EVENTS_REDIS_STREAM": "gateway:client_events",
|
||||
"GATEWAY_REPLAY_REDIS_KEY_PREFIX": "gateway:replay:",
|
||||
"GATEWAY_RESPONSE_SIGNER_PRIVATE_KEY_PEM_PATH": filepath.Clean(responseSignerPath),
|
||||
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_RATE_LIMIT_REQUESTS": "100",
|
||||
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_RATE_LIMIT_WINDOW": "1s",
|
||||
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_RATE_LIMIT_BURST": "100",
|
||||
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_REQUESTS": "100",
|
||||
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_WINDOW": "1s",
|
||||
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_BURST": "100",
|
||||
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_REQUESTS": "100",
|
||||
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_WINDOW": "1s",
|
||||
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_BURST": "100",
|
||||
"OTEL_TRACES_EXPORTER": "none",
|
||||
"OTEL_METRICS_EXPORTER": "none",
|
||||
}
|
||||
gatewayProcess := harness.StartProcess(t, "gateway", gatewayBinary, gatewayEnv)
|
||||
harness.WaitForHTTPStatus(t, gatewayProcess, "http://"+gatewayPublicAddr+"/healthz", http.StatusOK)
|
||||
harness.WaitForTCP(t, gatewayProcess, gatewayGRPCAddr)
|
||||
|
||||
return &gatewayAuthsessionUserHarness{
|
||||
redis: redisClient,
|
||||
mailStub: mailStub,
|
||||
authsessionPublicURL: "http://" + authsessionPublicAddr,
|
||||
userServiceURL: "http://" + userServiceAddr,
|
||||
gatewayPublicURL: "http://" + gatewayPublicAddr,
|
||||
gatewayGRPCAddr: gatewayGRPCAddr,
|
||||
responseSignerPublicKey: responseSignerPublicKey,
|
||||
gatewayProcess: gatewayProcess,
|
||||
authsessionProcess: authsessionProcess,
|
||||
userServiceProcess: userServiceProcess,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *gatewayAuthsessionUserHarness) sendChallenge(t *testing.T, email string) string {
|
||||
t.Helper()
|
||||
|
||||
response := postJSONValue(t, h.gatewayPublicURL+"/api/v1/public/auth/send-email-code", map[string]string{
|
||||
"email": email,
|
||||
})
|
||||
require.Equal(t, http.StatusOK, response.StatusCode)
|
||||
|
||||
var body struct {
|
||||
ChallengeID string `json:"challenge_id"`
|
||||
}
|
||||
require.NoError(t, decodeStrictJSONPayload([]byte(response.Body), &body))
|
||||
return body.ChallengeID
|
||||
}
|
||||
|
||||
func (h *gatewayAuthsessionUserHarness) confirmCode(t *testing.T, challengeID string, code string, clientPrivateKey ed25519.PrivateKey) httpResponse {
|
||||
t.Helper()
|
||||
|
||||
return postJSONValue(t, h.gatewayPublicURL+"/api/v1/public/auth/confirm-email-code", map[string]string{
|
||||
"challenge_id": challengeID,
|
||||
"code": code,
|
||||
"client_public_key": base64.StdEncoding.EncodeToString(clientPrivateKey.Public().(ed25519.PublicKey)),
|
||||
"time_zone": gatewayAuthsessionUserTestTimeZone,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *gatewayAuthsessionUserHarness) ensureUser(t *testing.T, email string, preferredLanguage string, timeZone string) ensureByEmailResponse {
|
||||
t.Helper()
|
||||
|
||||
response := postJSONValue(t, h.userServiceURL+"/api/v1/internal/users/ensure-by-email", map[string]any{
|
||||
"email": email,
|
||||
"registration_context": map[string]string{
|
||||
"preferred_language": preferredLanguage,
|
||||
"time_zone": timeZone,
|
||||
},
|
||||
})
|
||||
|
||||
var body ensureByEmailResponse
|
||||
requireJSONStatus(t, response, http.StatusOK, &body)
|
||||
return body
|
||||
}
|
||||
|
||||
func (h *gatewayAuthsessionUserHarness) lookupUserByEmail(t *testing.T, email string) (httpResponse, userLookupResponse) {
|
||||
t.Helper()
|
||||
|
||||
response := postJSONValue(t, h.userServiceURL+"/api/v1/internal/user-lookups/by-email", map[string]string{
|
||||
"email": email,
|
||||
})
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return response, userLookupResponse{}
|
||||
}
|
||||
|
||||
var body userLookupResponse
|
||||
require.NoError(t, decodeStrictJSONPayload([]byte(response.Body), &body))
|
||||
return response, body
|
||||
}
|
||||
|
||||
func (h *gatewayAuthsessionUserHarness) blockByEmail(t *testing.T, email string) {
|
||||
t.Helper()
|
||||
|
||||
response := postJSONValue(t, h.userServiceURL+"/api/v1/internal/user-blocks/by-email", map[string]string{
|
||||
"email": email,
|
||||
"reason_code": "policy_blocked",
|
||||
})
|
||||
require.Equal(t, http.StatusOK, response.StatusCode, "response body: %s", response.Body)
|
||||
}
|
||||
|
||||
func (h *gatewayAuthsessionUserHarness) waitForGatewaySession(t *testing.T, deviceSessionID string) gatewaySessionRecord {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
payload, err := h.redis.Get(context.Background(), "gateway:session:"+deviceSessionID).Bytes()
|
||||
if err == nil {
|
||||
var record gatewaySessionRecord
|
||||
require.NoError(t, decodeStrictJSONPayload(payload, &record))
|
||||
return record
|
||||
}
|
||||
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
|
||||
t.Fatalf("gateway session projection for %s was not published in time", deviceSessionID)
|
||||
return gatewaySessionRecord{}
|
||||
}
|
||||
|
||||
func (h *gatewayAuthsessionUserHarness) executeGetMyAccount(t *testing.T, deviceSessionID string, requestID string, clientPrivateKey ed25519.PrivateKey) *usermodel.AccountResponse {
|
||||
t.Helper()
|
||||
|
||||
conn := h.dialGateway(t)
|
||||
client := gatewayv1.NewEdgeGatewayClient(conn)
|
||||
|
||||
payload, err := contractsuserv1.EncodeGetMyAccountRequest()
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
response, err := client.ExecuteCommand(ctx, newExecuteCommandRequest(deviceSessionID, requestID, contractsuserv1.MessageTypeGetMyAccount, payload, clientPrivateKey))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, contractsuserv1.ResultCodeOK, response.GetResultCode())
|
||||
assertSignedExecuteCommandResponse(t, response, h.responseSignerPublicKey)
|
||||
|
||||
accountResponse, err := contractsuserv1.DecodeAccountResponse(response.GetPayloadBytes())
|
||||
require.NoError(t, err)
|
||||
return accountResponse
|
||||
}
|
||||
|
||||
func (h *gatewayAuthsessionUserHarness) dialGateway(t *testing.T) *grpc.ClientConn {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
conn, err := grpc.DialContext(
|
||||
ctx,
|
||||
h.gatewayGRPCAddr,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithBlock(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, conn.Close())
|
||||
})
|
||||
|
||||
return conn
|
||||
}
|
||||
|
||||
type httpResponse struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
Header http.Header
|
||||
}
|
||||
|
||||
type ensureByEmailResponse struct {
|
||||
Outcome string `json:"outcome"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
type gatewaySessionRecord struct {
|
||||
DeviceSessionID string `json:"device_session_id"`
|
||||
UserID string `json:"user_id"`
|
||||
ClientPublicKey string `json:"client_public_key"`
|
||||
Status string `json:"status"`
|
||||
RevokedAtMS *int64 `json:"revoked_at_ms,omitempty"`
|
||||
}
|
||||
|
||||
type userLookupResponse struct {
|
||||
User usermodel.Account `json:"user"`
|
||||
}
|
||||
|
||||
func postJSONValue(t *testing.T, targetURL string, body any) httpResponse {
|
||||
t.Helper()
|
||||
|
||||
payload, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, targetURL, bytes.NewReader(payload))
|
||||
require.NoError(t, err)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
response, err := client.Do(request)
|
||||
require.NoError(t, err)
|
||||
defer response.Body.Close()
|
||||
|
||||
responseBody, err := io.ReadAll(response.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
return httpResponse{
|
||||
StatusCode: response.StatusCode,
|
||||
Body: string(responseBody),
|
||||
Header: response.Header.Clone(),
|
||||
}
|
||||
}
|
||||
|
||||
func decodeStrictJSONPayload(payload []byte, target any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("unexpected trailing JSON input")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func requireJSONStatus(t *testing.T, response httpResponse, wantStatus int, target any) {
|
||||
t.Helper()
|
||||
|
||||
require.Equal(t, wantStatus, response.StatusCode, "response body: %s", response.Body)
|
||||
require.NoError(t, decodeStrictJSONPayload([]byte(response.Body), target))
|
||||
}
|
||||
|
||||
func requireLookupNotFound(t *testing.T, response httpResponse) {
|
||||
t.Helper()
|
||||
|
||||
require.Equal(t, http.StatusNotFound, response.StatusCode, "response body: %s", response.Body)
|
||||
require.JSONEq(t, `{"error":{"code":"subject_not_found","message":"subject not found"}}`, response.Body)
|
||||
}
|
||||
|
||||
func lastMailCodeFor(t *testing.T, stub *harness.MailStub, email string) string {
|
||||
t.Helper()
|
||||
|
||||
deliveries := stub.RecordedDeliveries()
|
||||
for index := len(deliveries) - 1; index >= 0; index-- {
|
||||
if deliveries[index].Email == email {
|
||||
return deliveries[index].Code
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("mail stub did not record delivery for %s", email)
|
||||
return ""
|
||||
}
|
||||
|
||||
func waitForAuthsessionPublicReady(t *testing.T, process *harness.Process, baseURL string) {
|
||||
t.Helper()
|
||||
|
||||
client := &http.Client{Timeout: 250 * time.Millisecond}
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
response, err := postJSONValueMaybe(client, baseURL+"/api/v1/public/auth/send-email-code", map[string]string{
|
||||
"email": "",
|
||||
})
|
||||
if err == nil && response.StatusCode == http.StatusBadRequest {
|
||||
return
|
||||
}
|
||||
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
|
||||
t.Fatalf("wait for authsession public readiness: timeout\n%s", process.Logs())
|
||||
}
|
||||
|
||||
func postJSONValueMaybe(client *http.Client, targetURL string, body any) (httpResponse, error) {
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return httpResponse{}, err
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, targetURL, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return httpResponse{}, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return httpResponse{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
responseBody, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return httpResponse{}, err
|
||||
}
|
||||
|
||||
return httpResponse{
|
||||
StatusCode: response.StatusCode,
|
||||
Body: string(responseBody),
|
||||
Header: response.Header.Clone(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newClientPrivateKey(label string) ed25519.PrivateKey {
|
||||
seed := sha256.Sum256([]byte("galaxy-integration-gateway-authsession-user-client-" + label))
|
||||
return ed25519.NewKeyFromSeed(seed[:])
|
||||
}
|
||||
|
||||
func newExecuteCommandRequest(deviceSessionID string, requestID string, messageType string, payload []byte, clientPrivateKey ed25519.PrivateKey) *gatewayv1.ExecuteCommandRequest {
|
||||
payloadHash := contractsgatewayv1.ComputePayloadHash(payload)
|
||||
|
||||
request := &gatewayv1.ExecuteCommandRequest{
|
||||
ProtocolVersion: contractsgatewayv1.ProtocolVersionV1,
|
||||
DeviceSessionId: deviceSessionID,
|
||||
MessageType: messageType,
|
||||
TimestampMs: time.Now().UnixMilli(),
|
||||
RequestId: requestID,
|
||||
PayloadBytes: payload,
|
||||
PayloadHash: payloadHash,
|
||||
TraceId: "trace-" + requestID,
|
||||
}
|
||||
request.Signature = contractsgatewayv1.SignRequest(clientPrivateKey, contractsgatewayv1.RequestSigningFields{
|
||||
ProtocolVersion: request.GetProtocolVersion(),
|
||||
DeviceSessionID: request.GetDeviceSessionId(),
|
||||
MessageType: request.GetMessageType(),
|
||||
TimestampMS: request.GetTimestampMs(),
|
||||
RequestID: request.GetRequestId(),
|
||||
PayloadHash: request.GetPayloadHash(),
|
||||
})
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
func assertSignedExecuteCommandResponse(t *testing.T, response *gatewayv1.ExecuteCommandResponse, publicKey ed25519.PublicKey) {
|
||||
t.Helper()
|
||||
|
||||
require.NoError(t, contractsgatewayv1.VerifyPayloadHash(response.GetPayloadBytes(), response.GetPayloadHash()))
|
||||
require.NoError(t, contractsgatewayv1.VerifyResponseSignature(publicKey, response.GetSignature(), contractsgatewayv1.ResponseSigningFields{
|
||||
ProtocolVersion: response.GetProtocolVersion(),
|
||||
RequestID: response.GetRequestId(),
|
||||
TimestampMS: response.GetTimestampMs(),
|
||||
ResultCode: response.GetResultCode(),
|
||||
PayloadHash: response.GetPayloadHash(),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package gatewayuser_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
contractsuserv1 "galaxy/integration/internal/contracts/userv1"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGatewayUserGetMyAccountAuthenticated(t *testing.T) {
|
||||
h := newGatewayUserHarness(t)
|
||||
|
||||
const (
|
||||
email = "pilot@example.com"
|
||||
deviceSessionID = "device-session-get-account"
|
||||
requestID = "request-get-account"
|
||||
)
|
||||
|
||||
created := h.ensureUser(t, email, "en", gatewayUserTestTimeZone)
|
||||
require.Equal(t, "created", created.Outcome)
|
||||
|
||||
clientPrivateKey := newClientPrivateKey("get-account")
|
||||
h.seedGatewaySession(t, deviceSessionID, created.UserID, clientPrivateKey)
|
||||
|
||||
payload, err := contractsuserv1.EncodeGetMyAccountRequest()
|
||||
require.NoError(t, err)
|
||||
|
||||
response := h.executeCommand(t, deviceSessionID, requestID, contractsuserv1.MessageTypeGetMyAccount, payload, clientPrivateKey)
|
||||
require.Equal(t, contractsuserv1.ResultCodeOK, response.GetResultCode())
|
||||
|
||||
accountResponse, err := contractsuserv1.DecodeAccountResponse(response.GetPayloadBytes())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, created.UserID, accountResponse.Account.UserID)
|
||||
require.Equal(t, email, accountResponse.Account.Email)
|
||||
require.Equal(t, "en", accountResponse.Account.PreferredLanguage)
|
||||
require.Equal(t, gatewayUserTestTimeZone, accountResponse.Account.TimeZone)
|
||||
}
|
||||
|
||||
func TestGatewayUserUpdateMyProfileSuccess(t *testing.T) {
|
||||
h := newGatewayUserHarness(t)
|
||||
|
||||
const (
|
||||
email = "pilot-profile@example.com"
|
||||
deviceSessionID = "device-session-update-profile"
|
||||
requestID = "request-update-profile"
|
||||
)
|
||||
|
||||
created := h.ensureUser(t, email, "en", gatewayUserTestTimeZone)
|
||||
clientPrivateKey := newClientPrivateKey("update-profile")
|
||||
h.seedGatewaySession(t, deviceSessionID, created.UserID, clientPrivateKey)
|
||||
|
||||
payload, err := contractsuserv1.EncodeUpdateMyProfileRequest("Nova Prime")
|
||||
require.NoError(t, err)
|
||||
|
||||
response := h.executeCommand(t, deviceSessionID, requestID, contractsuserv1.MessageTypeUpdateMyProfile, payload, clientPrivateKey)
|
||||
require.Equal(t, contractsuserv1.ResultCodeOK, response.GetResultCode())
|
||||
|
||||
accountResponse, err := contractsuserv1.DecodeAccountResponse(response.GetPayloadBytes())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Nova Prime", accountResponse.Account.RaceName)
|
||||
|
||||
lookup := h.lookupUserByEmail(t, email)
|
||||
require.Equal(t, "Nova Prime", lookup.User.RaceName)
|
||||
}
|
||||
|
||||
func TestGatewayUserUpdateMySettingsSuccess(t *testing.T) {
|
||||
h := newGatewayUserHarness(t)
|
||||
|
||||
const (
|
||||
email = "pilot-settings@example.com"
|
||||
deviceSessionID = "device-session-update-settings"
|
||||
requestID = "request-update-settings"
|
||||
)
|
||||
|
||||
created := h.ensureUser(t, email, "en", gatewayUserTestTimeZone)
|
||||
clientPrivateKey := newClientPrivateKey("update-settings")
|
||||
h.seedGatewaySession(t, deviceSessionID, created.UserID, clientPrivateKey)
|
||||
|
||||
payload, err := contractsuserv1.EncodeUpdateMySettingsRequest("fr-FR", "Europe/Paris")
|
||||
require.NoError(t, err)
|
||||
|
||||
response := h.executeCommand(t, deviceSessionID, requestID, contractsuserv1.MessageTypeUpdateMySettings, payload, clientPrivateKey)
|
||||
require.Equal(t, contractsuserv1.ResultCodeOK, response.GetResultCode())
|
||||
|
||||
accountResponse, err := contractsuserv1.DecodeAccountResponse(response.GetPayloadBytes())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "fr-FR", accountResponse.Account.PreferredLanguage)
|
||||
require.Equal(t, "Europe/Paris", accountResponse.Account.TimeZone)
|
||||
|
||||
lookup := h.lookupUserByEmail(t, email)
|
||||
require.Equal(t, "fr-FR", lookup.User.PreferredLanguage)
|
||||
require.Equal(t, "Europe/Paris", lookup.User.TimeZone)
|
||||
}
|
||||
|
||||
func TestGatewayUserUpdateMyProfileConflict(t *testing.T) {
|
||||
h := newGatewayUserHarness(t)
|
||||
|
||||
const (
|
||||
email = "pilot-conflict@example.com"
|
||||
deviceSessionID = "device-session-profile-conflict"
|
||||
requestID = "request-profile-conflict"
|
||||
)
|
||||
|
||||
created := h.ensureUser(t, email, "en", gatewayUserTestTimeZone)
|
||||
h.applyProfileUpdateBlock(t, created.UserID)
|
||||
|
||||
clientPrivateKey := newClientPrivateKey("profile-conflict")
|
||||
h.seedGatewaySession(t, deviceSessionID, created.UserID, clientPrivateKey)
|
||||
|
||||
payload, err := contractsuserv1.EncodeUpdateMyProfileRequest("Blocked Nova")
|
||||
require.NoError(t, err)
|
||||
|
||||
response := h.executeCommand(t, deviceSessionID, requestID, contractsuserv1.MessageTypeUpdateMyProfile, payload, clientPrivateKey)
|
||||
require.Equal(t, "conflict", response.GetResultCode())
|
||||
|
||||
errorResponse, err := contractsuserv1.DecodeErrorResponse(response.GetPayloadBytes())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "conflict", errorResponse.Error.Code)
|
||||
require.Equal(t, "request conflicts with current state", errorResponse.Error.Message)
|
||||
}
|
||||
|
||||
func TestGatewayUserUpdateMySettingsInvalidRequest(t *testing.T) {
|
||||
h := newGatewayUserHarness(t)
|
||||
|
||||
const (
|
||||
email = "pilot-invalid@example.com"
|
||||
deviceSessionID = "device-session-settings-invalid"
|
||||
requestID = "request-settings-invalid"
|
||||
)
|
||||
|
||||
created := h.ensureUser(t, email, "en", gatewayUserTestTimeZone)
|
||||
|
||||
clientPrivateKey := newClientPrivateKey("settings-invalid")
|
||||
h.seedGatewaySession(t, deviceSessionID, created.UserID, clientPrivateKey)
|
||||
|
||||
payload, err := contractsuserv1.EncodeUpdateMySettingsRequest("en", "Mars/Base")
|
||||
require.NoError(t, err)
|
||||
|
||||
response := h.executeCommand(t, deviceSessionID, requestID, contractsuserv1.MessageTypeUpdateMySettings, payload, clientPrivateKey)
|
||||
require.Equal(t, "invalid_request", response.GetResultCode())
|
||||
|
||||
errorResponse, err := contractsuserv1.DecodeErrorResponse(response.GetPayloadBytes())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "invalid_request", errorResponse.Error.Code)
|
||||
require.NotEmpty(t, errorResponse.Error.Message)
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package gatewayuser_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
|
||||
contractsgatewayv1 "galaxy/integration/internal/contracts/gatewayv1"
|
||||
"galaxy/integration/internal/harness"
|
||||
usermodel "galaxy/model/user"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
const (
|
||||
gatewayUserDefaultHTTPTimeout = time.Second
|
||||
gatewayUserTestTimeZone = "Europe/Kaliningrad"
|
||||
)
|
||||
|
||||
type gatewayUserHarness struct {
|
||||
redis *redis.Client
|
||||
|
||||
userServiceURL string
|
||||
gatewayGRPCAddr string
|
||||
|
||||
responseSignerPublicKey ed25519.PublicKey
|
||||
|
||||
gatewayProcess *harness.Process
|
||||
userServiceProcess *harness.Process
|
||||
}
|
||||
|
||||
func newGatewayUserHarness(t *testing.T) *gatewayUserHarness {
|
||||
t.Helper()
|
||||
|
||||
redisServer := harness.StartMiniredis(t)
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: redisServer.Addr(),
|
||||
Protocol: 2,
|
||||
DisableIdentity: true,
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, redisClient.Close())
|
||||
})
|
||||
|
||||
responseSignerPath, responseSignerPublicKey := harness.WriteResponseSignerPEM(t, t.Name())
|
||||
userServiceAddr := harness.FreeTCPAddress(t)
|
||||
gatewayPublicAddr := harness.FreeTCPAddress(t)
|
||||
gatewayGRPCAddr := harness.FreeTCPAddress(t)
|
||||
|
||||
userServiceBinary := harness.BuildBinary(t, "userservice", "./user/cmd/userservice")
|
||||
gatewayBinary := harness.BuildBinary(t, "gateway", "./gateway/cmd/gateway")
|
||||
|
||||
userServiceEnv := map[string]string{
|
||||
"USERSERVICE_LOG_LEVEL": "info",
|
||||
"USERSERVICE_INTERNAL_HTTP_ADDR": userServiceAddr,
|
||||
"USERSERVICE_REDIS_ADDR": redisServer.Addr(),
|
||||
"OTEL_TRACES_EXPORTER": "none",
|
||||
"OTEL_METRICS_EXPORTER": "none",
|
||||
}
|
||||
userServiceProcess := harness.StartProcess(t, "userservice", userServiceBinary, userServiceEnv)
|
||||
harness.WaitForHTTPStatus(t, userServiceProcess, "http://"+userServiceAddr+"/api/v1/internal/users/user-missing/exists", http.StatusOK)
|
||||
|
||||
gatewayEnv := map[string]string{
|
||||
"GATEWAY_LOG_LEVEL": "info",
|
||||
"GATEWAY_PUBLIC_HTTP_ADDR": gatewayPublicAddr,
|
||||
"GATEWAY_AUTHENTICATED_GRPC_ADDR": gatewayGRPCAddr,
|
||||
"GATEWAY_USER_SERVICE_BASE_URL": "http://" + userServiceAddr,
|
||||
"GATEWAY_SESSION_CACHE_REDIS_ADDR": redisServer.Addr(),
|
||||
"GATEWAY_SESSION_CACHE_REDIS_KEY_PREFIX": "gateway:session:",
|
||||
"GATEWAY_SESSION_EVENTS_REDIS_STREAM": "gateway:session_events",
|
||||
"GATEWAY_CLIENT_EVENTS_REDIS_STREAM": "gateway:client_events",
|
||||
"GATEWAY_REPLAY_REDIS_KEY_PREFIX": "gateway:replay:",
|
||||
"GATEWAY_RESPONSE_SIGNER_PRIVATE_KEY_PEM_PATH": filepath.Clean(responseSignerPath),
|
||||
"OTEL_TRACES_EXPORTER": "none",
|
||||
"OTEL_METRICS_EXPORTER": "none",
|
||||
}
|
||||
gatewayProcess := harness.StartProcess(t, "gateway", gatewayBinary, gatewayEnv)
|
||||
harness.WaitForHTTPStatus(t, gatewayProcess, "http://"+gatewayPublicAddr+"/healthz", http.StatusOK)
|
||||
harness.WaitForTCP(t, gatewayProcess, gatewayGRPCAddr)
|
||||
|
||||
return &gatewayUserHarness{
|
||||
redis: redisClient,
|
||||
userServiceURL: "http://" + userServiceAddr,
|
||||
gatewayGRPCAddr: gatewayGRPCAddr,
|
||||
responseSignerPublicKey: responseSignerPublicKey,
|
||||
gatewayProcess: gatewayProcess,
|
||||
userServiceProcess: userServiceProcess,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *gatewayUserHarness) dialGateway(t *testing.T) *grpc.ClientConn {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
conn, err := grpc.DialContext(
|
||||
ctx,
|
||||
h.gatewayGRPCAddr,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithBlock(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, conn.Close())
|
||||
})
|
||||
|
||||
return conn
|
||||
}
|
||||
|
||||
func (h *gatewayUserHarness) ensureUser(t *testing.T, email string, preferredLanguage string, timeZone string) ensureByEmailResponse {
|
||||
t.Helper()
|
||||
|
||||
response := postJSONValue(t, h.userServiceURL+"/api/v1/internal/users/ensure-by-email", map[string]any{
|
||||
"email": email,
|
||||
"registration_context": map[string]string{
|
||||
"preferred_language": preferredLanguage,
|
||||
"time_zone": timeZone,
|
||||
},
|
||||
})
|
||||
|
||||
var body ensureByEmailResponse
|
||||
requireJSONStatus(t, response, http.StatusOK, &body)
|
||||
return body
|
||||
}
|
||||
|
||||
func (h *gatewayUserHarness) lookupUserByEmail(t *testing.T, email string) userLookupResponse {
|
||||
t.Helper()
|
||||
|
||||
response := postJSONValue(t, h.userServiceURL+"/api/v1/internal/user-lookups/by-email", map[string]string{
|
||||
"email": email,
|
||||
})
|
||||
|
||||
var body userLookupResponse
|
||||
requireJSONStatus(t, response, http.StatusOK, &body)
|
||||
return body
|
||||
}
|
||||
|
||||
func (h *gatewayUserHarness) applyProfileUpdateBlock(t *testing.T, userID string) {
|
||||
t.Helper()
|
||||
|
||||
response := postJSONValue(t, h.userServiceURL+"/api/v1/internal/users/"+userID+"/sanctions/apply", map[string]any{
|
||||
"sanction_code": "profile_update_block",
|
||||
"scope": "lobby",
|
||||
"reason_code": "manual_block",
|
||||
"actor": map[string]string{
|
||||
"type": "admin",
|
||||
"id": "admin-1",
|
||||
},
|
||||
"applied_at": "2026-04-09T10:00:00Z",
|
||||
})
|
||||
require.Equal(t, http.StatusOK, response.StatusCode, "response body: %s", response.Body)
|
||||
}
|
||||
|
||||
func (h *gatewayUserHarness) seedGatewaySession(t *testing.T, deviceSessionID string, userID string, clientPrivateKey ed25519.PrivateKey) {
|
||||
t.Helper()
|
||||
|
||||
record := gatewaySessionRecord{
|
||||
DeviceSessionID: deviceSessionID,
|
||||
UserID: userID,
|
||||
ClientPublicKey: base64.StdEncoding.EncodeToString(clientPrivateKey.Public().(ed25519.PublicKey)),
|
||||
Status: "active",
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(record)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, h.redis.Set(context.Background(), "gateway:session:"+deviceSessionID, payload, 0).Err())
|
||||
}
|
||||
|
||||
func (h *gatewayUserHarness) executeCommand(t *testing.T, deviceSessionID string, requestID string, messageType string, payload []byte, clientPrivateKey ed25519.PrivateKey) *gatewayv1.ExecuteCommandResponse {
|
||||
t.Helper()
|
||||
|
||||
conn := h.dialGateway(t)
|
||||
client := gatewayv1.NewEdgeGatewayClient(conn)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
response, err := client.ExecuteCommand(ctx, newExecuteCommandRequest(deviceSessionID, requestID, messageType, payload, clientPrivateKey))
|
||||
require.NoError(t, err)
|
||||
assertSignedExecuteCommandResponse(t, response, h.responseSignerPublicKey)
|
||||
return response
|
||||
}
|
||||
|
||||
type httpResponse struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
Header http.Header
|
||||
}
|
||||
|
||||
type gatewaySessionRecord struct {
|
||||
DeviceSessionID string `json:"device_session_id"`
|
||||
UserID string `json:"user_id"`
|
||||
ClientPublicKey string `json:"client_public_key"`
|
||||
Status string `json:"status"`
|
||||
RevokedAtMS *int64 `json:"revoked_at_ms,omitempty"`
|
||||
}
|
||||
|
||||
type ensureByEmailResponse struct {
|
||||
Outcome string `json:"outcome"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
type userLookupResponse struct {
|
||||
User usermodel.Account `json:"user"`
|
||||
}
|
||||
|
||||
func postJSONValue(t *testing.T, targetURL string, body any) httpResponse {
|
||||
t.Helper()
|
||||
|
||||
payload, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
request, err := http.NewRequest(http.MethodPost, targetURL, bytes.NewReader(payload))
|
||||
require.NoError(t, err)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: gatewayUserDefaultHTTPTimeout}
|
||||
response, err := client.Do(request)
|
||||
require.NoError(t, err)
|
||||
defer response.Body.Close()
|
||||
|
||||
responseBody, err := io.ReadAll(response.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
return httpResponse{
|
||||
StatusCode: response.StatusCode,
|
||||
Body: string(responseBody),
|
||||
Header: response.Header.Clone(),
|
||||
}
|
||||
}
|
||||
|
||||
func requireJSONStatus(t *testing.T, response httpResponse, wantStatus int, target any) {
|
||||
t.Helper()
|
||||
|
||||
require.Equal(t, wantStatus, response.StatusCode, "response body: %s", response.Body)
|
||||
require.NoError(t, decodeStrictJSONPayload([]byte(response.Body), target))
|
||||
}
|
||||
|
||||
func decodeStrictJSONPayload(payload []byte, target any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("unexpected trailing JSON input")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newClientPrivateKey(label string) ed25519.PrivateKey {
|
||||
seed := sha256.Sum256([]byte("galaxy-integration-gateway-user-client-" + label))
|
||||
return ed25519.NewKeyFromSeed(seed[:])
|
||||
}
|
||||
|
||||
func newExecuteCommandRequest(deviceSessionID string, requestID string, messageType string, payload []byte, clientPrivateKey ed25519.PrivateKey) *gatewayv1.ExecuteCommandRequest {
|
||||
payloadHash := contractsgatewayv1.ComputePayloadHash(payload)
|
||||
|
||||
request := &gatewayv1.ExecuteCommandRequest{
|
||||
ProtocolVersion: contractsgatewayv1.ProtocolVersionV1,
|
||||
DeviceSessionId: deviceSessionID,
|
||||
MessageType: messageType,
|
||||
TimestampMs: time.Now().UnixMilli(),
|
||||
RequestId: requestID,
|
||||
PayloadBytes: payload,
|
||||
PayloadHash: payloadHash,
|
||||
TraceId: "trace-" + requestID,
|
||||
}
|
||||
request.Signature = contractsgatewayv1.SignRequest(clientPrivateKey, contractsgatewayv1.RequestSigningFields{
|
||||
ProtocolVersion: request.GetProtocolVersion(),
|
||||
DeviceSessionID: request.GetDeviceSessionId(),
|
||||
MessageType: request.GetMessageType(),
|
||||
TimestampMS: request.GetTimestampMs(),
|
||||
RequestID: request.GetRequestId(),
|
||||
PayloadHash: request.GetPayloadHash(),
|
||||
})
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
func assertSignedExecuteCommandResponse(t *testing.T, response *gatewayv1.ExecuteCommandResponse, publicKey ed25519.PublicKey) {
|
||||
t.Helper()
|
||||
|
||||
require.NoError(t, contractsgatewayv1.VerifyPayloadHash(response.GetPayloadBytes(), response.GetPayloadHash()))
|
||||
require.NoError(t, contractsgatewayv1.VerifyResponseSignature(publicKey, response.GetSignature(), contractsgatewayv1.ResponseSigningFields{
|
||||
ProtocolVersion: response.GetProtocolVersion(),
|
||||
RequestID: response.GetRequestId(),
|
||||
TimestampMS: response.GetTimestampMs(),
|
||||
ResultCode: response.GetResultCode(),
|
||||
PayloadHash: response.GetPayloadHash(),
|
||||
}))
|
||||
}
|
||||
+2
-2
@@ -18,8 +18,8 @@ require (
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
go.opentelemetry.io/otel v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
|
||||
+5
-5
@@ -34,11 +34,11 @@ github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
|
||||
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
|
||||
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
|
||||
@@ -38,6 +38,11 @@ var (
|
||||
// ErrInvalidEventSignature reports that one gateway event signature is not
|
||||
// a raw Ed25519 signature for the canonical event signing input.
|
||||
ErrInvalidEventSignature = errors.New("invalid event signature")
|
||||
|
||||
// ErrInvalidResponseSignature reports that one gateway unary response
|
||||
// signature is not a raw Ed25519 signature for the canonical response
|
||||
// signing input.
|
||||
ErrInvalidResponseSignature = errors.New("invalid response signature")
|
||||
)
|
||||
|
||||
// RequestSigningFields stores the canonical public request fields bound into
|
||||
@@ -85,6 +90,25 @@ type EventSigningFields struct {
|
||||
PayloadHash []byte
|
||||
}
|
||||
|
||||
// ResponseSigningFields stores the canonical public unary response fields
|
||||
// bound into one gateway signature input.
|
||||
type ResponseSigningFields struct {
|
||||
// ProtocolVersion identifies the gateway transport envelope version.
|
||||
ProtocolVersion string
|
||||
|
||||
// RequestID is the transport correlation identifier echoed by the gateway.
|
||||
RequestID string
|
||||
|
||||
// TimestampMS carries the gateway response timestamp in milliseconds.
|
||||
TimestampMS int64
|
||||
|
||||
// ResultCode stores the stable opaque gateway result code.
|
||||
ResultCode string
|
||||
|
||||
// PayloadHash stores the raw SHA-256 digest of PayloadBytes.
|
||||
PayloadHash []byte
|
||||
}
|
||||
|
||||
// ComputePayloadHash returns the canonical raw SHA-256 digest for payloadBytes.
|
||||
func ComputePayloadHash(payloadBytes []byte) []byte {
|
||||
sum := sha256.Sum256(payloadBytes)
|
||||
@@ -154,6 +178,28 @@ func BuildEventSigningInput(fields EventSigningFields) []byte {
|
||||
return buf
|
||||
}
|
||||
|
||||
// BuildResponseSigningInput returns the canonical byte sequence the v1
|
||||
// gateway unary response signature covers.
|
||||
func BuildResponseSigningInput(fields ResponseSigningFields) []byte {
|
||||
size := len("galaxy-response-v1") +
|
||||
len(fields.ProtocolVersion) +
|
||||
len(fields.RequestID) +
|
||||
len(fields.ResultCode) +
|
||||
len(fields.PayloadHash) +
|
||||
(5 * binary.MaxVarintLen64) +
|
||||
8
|
||||
|
||||
buf := make([]byte, 0, size)
|
||||
buf = appendLengthPrefixedString(buf, "galaxy-response-v1")
|
||||
buf = appendLengthPrefixedString(buf, fields.ProtocolVersion)
|
||||
buf = appendLengthPrefixedString(buf, fields.RequestID)
|
||||
buf = binary.BigEndian.AppendUint64(buf, uint64(fields.TimestampMS))
|
||||
buf = appendLengthPrefixedString(buf, fields.ResultCode)
|
||||
buf = appendLengthPrefixedBytes(buf, fields.PayloadHash)
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
// SignRequest returns one raw Ed25519 client signature for the canonical v1
|
||||
// request signing input.
|
||||
func SignRequest(privateKey ed25519.PrivateKey, fields RequestSigningFields) []byte {
|
||||
@@ -173,6 +219,19 @@ func VerifyEventSignature(publicKey ed25519.PublicKey, signature []byte, fields
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyResponseSignature reports whether signature authenticates fields under
|
||||
// publicKey using the canonical gateway unary-response signing input.
|
||||
func VerifyResponseSignature(publicKey ed25519.PublicKey, signature []byte, fields ResponseSigningFields) error {
|
||||
if len(publicKey) != ed25519.PublicKeySize || len(signature) != ed25519.SignatureSize {
|
||||
return ErrInvalidResponseSignature
|
||||
}
|
||||
if !ed25519.Verify(publicKey, BuildResponseSigningInput(fields), signature) {
|
||||
return ErrInvalidResponseSignature
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendLengthPrefixedString(dst []byte, value string) []byte {
|
||||
return appendLengthPrefixedBytes(dst, []byte(value))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// Package userv1contract provides public-contract helpers for the
|
||||
// authenticated gateway v1 User Service self-service message types.
|
||||
package userv1contract
|
||||
|
||||
import (
|
||||
usermodel "galaxy/model/user"
|
||||
"galaxy/transcoder"
|
||||
)
|
||||
|
||||
const (
|
||||
// MessageTypeGetMyAccount is the authenticated gateway message type used to
|
||||
// read the current self-service account aggregate.
|
||||
MessageTypeGetMyAccount = usermodel.MessageTypeGetMyAccount
|
||||
|
||||
// MessageTypeUpdateMyProfile is the authenticated gateway message type used
|
||||
// to mutate self-service profile fields.
|
||||
MessageTypeUpdateMyProfile = usermodel.MessageTypeUpdateMyProfile
|
||||
|
||||
// MessageTypeUpdateMySettings is the authenticated gateway message type used
|
||||
// to mutate self-service settings fields.
|
||||
MessageTypeUpdateMySettings = usermodel.MessageTypeUpdateMySettings
|
||||
|
||||
// ResultCodeOK is the success result code projected by gateway for all
|
||||
// successful `user.*` authenticated commands.
|
||||
ResultCodeOK = "ok"
|
||||
)
|
||||
|
||||
// EncodeGetMyAccountRequest returns the FlatBuffers payload for the public
|
||||
// empty get-account request.
|
||||
func EncodeGetMyAccountRequest() ([]byte, error) {
|
||||
return transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{})
|
||||
}
|
||||
|
||||
// EncodeUpdateMyProfileRequest returns the FlatBuffers payload for one public
|
||||
// self-service profile mutation request.
|
||||
func EncodeUpdateMyProfileRequest(raceName string) ([]byte, error) {
|
||||
return transcoder.UpdateMyProfileRequestToPayload(&usermodel.UpdateMyProfileRequest{
|
||||
RaceName: raceName,
|
||||
})
|
||||
}
|
||||
|
||||
// EncodeUpdateMySettingsRequest returns the FlatBuffers payload for one public
|
||||
// self-service settings mutation request.
|
||||
func EncodeUpdateMySettingsRequest(preferredLanguage string, timeZone string) ([]byte, error) {
|
||||
return transcoder.UpdateMySettingsRequestToPayload(&usermodel.UpdateMySettingsRequest{
|
||||
PreferredLanguage: preferredLanguage,
|
||||
TimeZone: timeZone,
|
||||
})
|
||||
}
|
||||
|
||||
// DecodeAccountResponse decodes the public FlatBuffers success payload shared
|
||||
// by all authenticated `user.*` commands.
|
||||
func DecodeAccountResponse(payload []byte) (*usermodel.AccountResponse, error) {
|
||||
return transcoder.PayloadToAccountResponse(payload)
|
||||
}
|
||||
|
||||
// DecodeErrorResponse decodes the public FlatBuffers error payload shared by
|
||||
// all authenticated `user.*` commands.
|
||||
func DecodeErrorResponse(payload []byte) (*usermodel.ErrorResponse, error) {
|
||||
return transcoder.PayloadToErrorResponse(payload)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// Package user defines the public typed command and response payloads exposed
|
||||
// at the authenticated Gateway -> User self-service boundary.
|
||||
package user
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
// MessageTypeGetMyAccount is the authenticated gateway message type used to
|
||||
// read the current regular-user account aggregate.
|
||||
MessageTypeGetMyAccount = "user.account.get"
|
||||
|
||||
// MessageTypeUpdateMyProfile is the authenticated gateway message type used
|
||||
// to mutate self-service profile fields.
|
||||
MessageTypeUpdateMyProfile = "user.profile.update"
|
||||
|
||||
// MessageTypeUpdateMySettings is the authenticated gateway message type used
|
||||
// to mutate self-service settings fields.
|
||||
MessageTypeUpdateMySettings = "user.settings.update"
|
||||
)
|
||||
|
||||
// GetMyAccountRequest stores the authenticated self-service read request for
|
||||
// the current regular-user account aggregate.
|
||||
//
|
||||
// The request body is intentionally empty because gateway derives user
|
||||
// identity from the authenticated device session rather than from client
|
||||
// payload fields.
|
||||
type GetMyAccountRequest struct{}
|
||||
|
||||
// UpdateMyProfileRequest stores the authenticated self-service profile
|
||||
// mutation request.
|
||||
type UpdateMyProfileRequest struct {
|
||||
// RaceName stores the requested exact replacement race name.
|
||||
RaceName string `json:"race_name"`
|
||||
}
|
||||
|
||||
// UpdateMySettingsRequest stores the authenticated self-service settings
|
||||
// mutation request.
|
||||
type UpdateMySettingsRequest struct {
|
||||
// PreferredLanguage stores the requested BCP 47 language tag.
|
||||
PreferredLanguage string `json:"preferred_language"`
|
||||
|
||||
// TimeZone stores the requested IANA time-zone name.
|
||||
TimeZone string `json:"time_zone"`
|
||||
}
|
||||
|
||||
// ActorRef stores transport-ready audit actor metadata projected by User
|
||||
// Service.
|
||||
type ActorRef struct {
|
||||
// Type stores the machine-readable actor type.
|
||||
Type string `json:"type"`
|
||||
|
||||
// ID stores the optional stable actor identifier.
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
// EntitlementSnapshot stores the transport-ready current entitlement snapshot
|
||||
// of one account.
|
||||
type EntitlementSnapshot struct {
|
||||
// PlanCode stores the effective entitlement plan code.
|
||||
PlanCode string `json:"plan_code"`
|
||||
|
||||
// IsPaid reports whether the effective entitlement is currently paid.
|
||||
IsPaid bool `json:"is_paid"`
|
||||
|
||||
// Source stores the machine-readable source that produced the snapshot.
|
||||
Source string `json:"source"`
|
||||
|
||||
// Actor stores the audit actor metadata attached to the current snapshot.
|
||||
Actor ActorRef `json:"actor"`
|
||||
|
||||
// ReasonCode stores the machine-readable reason attached to the snapshot.
|
||||
ReasonCode string `json:"reason_code"`
|
||||
|
||||
// StartsAt stores when the effective state started.
|
||||
StartsAt time.Time `json:"starts_at"`
|
||||
|
||||
// EndsAt stores the optional finite entitlement expiry.
|
||||
EndsAt *time.Time `json:"ends_at,omitempty"`
|
||||
|
||||
// UpdatedAt stores when the snapshot was last recomputed.
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ActiveSanction stores one transport-ready active sanction returned in the
|
||||
// shared account aggregate.
|
||||
type ActiveSanction struct {
|
||||
// SanctionCode stores the active sanction code.
|
||||
SanctionCode string `json:"sanction_code"`
|
||||
|
||||
// Scope stores the machine-readable sanction scope.
|
||||
Scope string `json:"scope"`
|
||||
|
||||
// ReasonCode stores the machine-readable sanction reason.
|
||||
ReasonCode string `json:"reason_code"`
|
||||
|
||||
// Actor stores the audit actor metadata attached to the sanction.
|
||||
Actor ActorRef `json:"actor"`
|
||||
|
||||
// AppliedAt stores when the sanction became active.
|
||||
AppliedAt time.Time `json:"applied_at"`
|
||||
|
||||
// ExpiresAt stores the optional planned sanction expiry.
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// ActiveLimit stores one transport-ready active user-specific limit override
|
||||
// returned in the shared account aggregate.
|
||||
type ActiveLimit struct {
|
||||
// LimitCode stores the active limit code.
|
||||
LimitCode string `json:"limit_code"`
|
||||
|
||||
// Value stores the current override value.
|
||||
Value int `json:"value"`
|
||||
|
||||
// ReasonCode stores the machine-readable limit reason.
|
||||
ReasonCode string `json:"reason_code"`
|
||||
|
||||
// Actor stores the audit actor metadata attached to the limit.
|
||||
Actor ActorRef `json:"actor"`
|
||||
|
||||
// AppliedAt stores when the limit became active.
|
||||
AppliedAt time.Time `json:"applied_at"`
|
||||
|
||||
// ExpiresAt stores the optional planned limit expiry.
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// Account stores the transport-ready account aggregate shared by User Service
|
||||
// self-service read and mutation responses.
|
||||
type Account struct {
|
||||
// UserID stores the durable regular-user identifier.
|
||||
UserID string `json:"user_id"`
|
||||
|
||||
// Email stores the exact-after-trim login e-mail address.
|
||||
Email string `json:"email"`
|
||||
|
||||
// RaceName stores the current user-facing race name.
|
||||
RaceName string `json:"race_name"`
|
||||
|
||||
// PreferredLanguage stores the current BCP 47 language tag.
|
||||
PreferredLanguage string `json:"preferred_language"`
|
||||
|
||||
// TimeZone stores the current IANA time-zone name.
|
||||
TimeZone string `json:"time_zone"`
|
||||
|
||||
// DeclaredCountry stores the optional current effective declared country.
|
||||
DeclaredCountry string `json:"declared_country,omitempty"`
|
||||
|
||||
// Entitlement stores the current entitlement snapshot.
|
||||
Entitlement EntitlementSnapshot `json:"entitlement"`
|
||||
|
||||
// ActiveSanctions stores the current active sanctions sorted by code.
|
||||
ActiveSanctions []ActiveSanction `json:"active_sanctions"`
|
||||
|
||||
// ActiveLimits stores the current active user-specific limits sorted by
|
||||
// code.
|
||||
ActiveLimits []ActiveLimit `json:"active_limits"`
|
||||
|
||||
// CreatedAt stores when the account was created.
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// UpdatedAt stores when the account was last mutated.
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// AccountResponse stores the success payload shared by the authenticated
|
||||
// GetMyAccount, UpdateMyProfile, and UpdateMySettings gateway message types.
|
||||
type AccountResponse struct {
|
||||
// Account stores the current account aggregate.
|
||||
Account Account `json:"account"`
|
||||
}
|
||||
|
||||
// ErrorBody stores the machine-readable and human-readable failure payload
|
||||
// mirrored from the User Service trusted internal error envelope.
|
||||
type ErrorBody struct {
|
||||
// Code stores the stable machine-readable failure code.
|
||||
Code string `json:"code"`
|
||||
|
||||
// Message stores the client-safe failure message.
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ErrorResponse stores the error payload returned by the authenticated
|
||||
// Gateway -> User boundary when User Service rejects a request semantically.
|
||||
type ErrorResponse struct {
|
||||
// Error stores the mirrored error envelope body.
|
||||
Error ErrorBody `json:"error"`
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// user contains FlatBuffers payloads used by the authenticated gateway
|
||||
// self-service boundary for User Service.
|
||||
namespace user;
|
||||
|
||||
table GetMyAccountRequest {
|
||||
}
|
||||
|
||||
table UpdateMyProfileRequest {
|
||||
race_name:string;
|
||||
}
|
||||
|
||||
table UpdateMySettingsRequest {
|
||||
preferred_language:string;
|
||||
time_zone:string;
|
||||
}
|
||||
|
||||
table ActorRef {
|
||||
type:string;
|
||||
id:string;
|
||||
}
|
||||
|
||||
table EntitlementSnapshot {
|
||||
plan_code:string;
|
||||
is_paid:bool;
|
||||
source:string;
|
||||
actor:ActorRef;
|
||||
reason_code:string;
|
||||
starts_at_ms:int64;
|
||||
ends_at_ms:int64;
|
||||
updated_at_ms:int64;
|
||||
}
|
||||
|
||||
table ActiveSanction {
|
||||
sanction_code:string;
|
||||
scope:string;
|
||||
reason_code:string;
|
||||
actor:ActorRef;
|
||||
applied_at_ms:int64;
|
||||
expires_at_ms:int64;
|
||||
}
|
||||
|
||||
table ActiveLimit {
|
||||
limit_code:string;
|
||||
value:int64;
|
||||
reason_code:string;
|
||||
actor:ActorRef;
|
||||
applied_at_ms:int64;
|
||||
expires_at_ms:int64;
|
||||
}
|
||||
|
||||
table AccountView {
|
||||
user_id:string;
|
||||
email:string;
|
||||
race_name:string;
|
||||
preferred_language:string;
|
||||
time_zone:string;
|
||||
declared_country:string;
|
||||
entitlement:EntitlementSnapshot;
|
||||
active_sanctions:[ActiveSanction];
|
||||
active_limits:[ActiveLimit];
|
||||
created_at_ms:int64;
|
||||
updated_at_ms:int64;
|
||||
}
|
||||
|
||||
table AccountResponse {
|
||||
account:AccountView;
|
||||
}
|
||||
|
||||
table ErrorBody {
|
||||
code:string;
|
||||
message:string;
|
||||
}
|
||||
|
||||
table ErrorResponse {
|
||||
error:ErrorBody;
|
||||
}
|
||||
|
||||
root_type AccountResponse;
|
||||
@@ -0,0 +1,65 @@
|
||||
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
)
|
||||
|
||||
type AccountResponse struct {
|
||||
_tab flatbuffers.Table
|
||||
}
|
||||
|
||||
func GetRootAsAccountResponse(buf []byte, offset flatbuffers.UOffsetT) *AccountResponse {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||
x := &AccountResponse{}
|
||||
x.Init(buf, n+offset)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishAccountResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.Finish(offset)
|
||||
}
|
||||
|
||||
func GetSizePrefixedRootAsAccountResponse(buf []byte, offset flatbuffers.UOffsetT) *AccountResponse {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||
x := &AccountResponse{}
|
||||
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishSizePrefixedAccountResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.FinishSizePrefixed(offset)
|
||||
}
|
||||
|
||||
func (rcv *AccountResponse) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||
rcv._tab.Bytes = buf
|
||||
rcv._tab.Pos = i
|
||||
}
|
||||
|
||||
func (rcv *AccountResponse) Table() flatbuffers.Table {
|
||||
return rcv._tab
|
||||
}
|
||||
|
||||
func (rcv *AccountResponse) Account(obj *AccountView) *AccountView {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||
if o != 0 {
|
||||
x := rcv._tab.Indirect(o + rcv._tab.Pos)
|
||||
if obj == nil {
|
||||
obj = new(AccountView)
|
||||
}
|
||||
obj.Init(rcv._tab.Bytes, x)
|
||||
return obj
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func AccountResponseStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(1)
|
||||
}
|
||||
func AccountResponseAddAccount(builder *flatbuffers.Builder, account flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(account), 0)
|
||||
}
|
||||
func AccountResponseEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
)
|
||||
|
||||
type AccountView struct {
|
||||
_tab flatbuffers.Table
|
||||
}
|
||||
|
||||
func GetRootAsAccountView(buf []byte, offset flatbuffers.UOffsetT) *AccountView {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||
x := &AccountView{}
|
||||
x.Init(buf, n+offset)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishAccountViewBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.Finish(offset)
|
||||
}
|
||||
|
||||
func GetSizePrefixedRootAsAccountView(buf []byte, offset flatbuffers.UOffsetT) *AccountView {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||
x := &AccountView{}
|
||||
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishSizePrefixedAccountViewBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.FinishSizePrefixed(offset)
|
||||
}
|
||||
|
||||
func (rcv *AccountView) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||
rcv._tab.Bytes = buf
|
||||
rcv._tab.Pos = i
|
||||
}
|
||||
|
||||
func (rcv *AccountView) Table() flatbuffers.Table {
|
||||
return rcv._tab
|
||||
}
|
||||
|
||||
func (rcv *AccountView) UserId() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *AccountView) Email() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *AccountView) RaceName() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *AccountView) PreferredLanguage() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *AccountView) TimeZone() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(12))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *AccountView) DeclaredCountry() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *AccountView) Entitlement(obj *EntitlementSnapshot) *EntitlementSnapshot {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(16))
|
||||
if o != 0 {
|
||||
x := rcv._tab.Indirect(o + rcv._tab.Pos)
|
||||
if obj == nil {
|
||||
obj = new(EntitlementSnapshot)
|
||||
}
|
||||
obj.Init(rcv._tab.Bytes, x)
|
||||
return obj
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *AccountView) ActiveSanctions(obj *ActiveSanction, j int) bool {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(18))
|
||||
if o != 0 {
|
||||
x := rcv._tab.Vector(o)
|
||||
x += flatbuffers.UOffsetT(j) * 4
|
||||
x = rcv._tab.Indirect(x)
|
||||
obj.Init(rcv._tab.Bytes, x)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (rcv *AccountView) ActiveSanctionsLength() int {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(18))
|
||||
if o != 0 {
|
||||
return rcv._tab.VectorLen(o)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *AccountView) ActiveLimits(obj *ActiveLimit, j int) bool {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(20))
|
||||
if o != 0 {
|
||||
x := rcv._tab.Vector(o)
|
||||
x += flatbuffers.UOffsetT(j) * 4
|
||||
x = rcv._tab.Indirect(x)
|
||||
obj.Init(rcv._tab.Bytes, x)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (rcv *AccountView) ActiveLimitsLength() int {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(20))
|
||||
if o != 0 {
|
||||
return rcv._tab.VectorLen(o)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *AccountView) CreatedAtMs() int64 {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(22))
|
||||
if o != 0 {
|
||||
return rcv._tab.GetInt64(o + rcv._tab.Pos)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *AccountView) MutateCreatedAtMs(n int64) bool {
|
||||
return rcv._tab.MutateInt64Slot(22, n)
|
||||
}
|
||||
|
||||
func (rcv *AccountView) UpdatedAtMs() int64 {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(24))
|
||||
if o != 0 {
|
||||
return rcv._tab.GetInt64(o + rcv._tab.Pos)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *AccountView) MutateUpdatedAtMs(n int64) bool {
|
||||
return rcv._tab.MutateInt64Slot(24, n)
|
||||
}
|
||||
|
||||
func AccountViewStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(11)
|
||||
}
|
||||
func AccountViewAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0)
|
||||
}
|
||||
func AccountViewAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(email), 0)
|
||||
}
|
||||
func AccountViewAddRaceName(builder *flatbuffers.Builder, raceName flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(raceName), 0)
|
||||
}
|
||||
func AccountViewAddPreferredLanguage(builder *flatbuffers.Builder, preferredLanguage flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(preferredLanguage), 0)
|
||||
}
|
||||
func AccountViewAddTimeZone(builder *flatbuffers.Builder, timeZone flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(timeZone), 0)
|
||||
}
|
||||
func AccountViewAddDeclaredCountry(builder *flatbuffers.Builder, declaredCountry flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(declaredCountry), 0)
|
||||
}
|
||||
func AccountViewAddEntitlement(builder *flatbuffers.Builder, entitlement flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(6, flatbuffers.UOffsetT(entitlement), 0)
|
||||
}
|
||||
func AccountViewAddActiveSanctions(builder *flatbuffers.Builder, activeSanctions flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(7, flatbuffers.UOffsetT(activeSanctions), 0)
|
||||
}
|
||||
func AccountViewStartActiveSanctionsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||
return builder.StartVector(4, numElems, 4)
|
||||
}
|
||||
func AccountViewAddActiveLimits(builder *flatbuffers.Builder, activeLimits flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(8, flatbuffers.UOffsetT(activeLimits), 0)
|
||||
}
|
||||
func AccountViewStartActiveLimitsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||
return builder.StartVector(4, numElems, 4)
|
||||
}
|
||||
func AccountViewAddCreatedAtMs(builder *flatbuffers.Builder, createdAtMs int64) {
|
||||
builder.PrependInt64Slot(9, createdAtMs, 0)
|
||||
}
|
||||
func AccountViewAddUpdatedAtMs(builder *flatbuffers.Builder, updatedAtMs int64) {
|
||||
builder.PrependInt64Slot(10, updatedAtMs, 0)
|
||||
}
|
||||
func AccountViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
)
|
||||
|
||||
type ActiveLimit struct {
|
||||
_tab flatbuffers.Table
|
||||
}
|
||||
|
||||
func GetRootAsActiveLimit(buf []byte, offset flatbuffers.UOffsetT) *ActiveLimit {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||
x := &ActiveLimit{}
|
||||
x.Init(buf, n+offset)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishActiveLimitBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.Finish(offset)
|
||||
}
|
||||
|
||||
func GetSizePrefixedRootAsActiveLimit(buf []byte, offset flatbuffers.UOffsetT) *ActiveLimit {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||
x := &ActiveLimit{}
|
||||
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishSizePrefixedActiveLimitBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.FinishSizePrefixed(offset)
|
||||
}
|
||||
|
||||
func (rcv *ActiveLimit) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||
rcv._tab.Bytes = buf
|
||||
rcv._tab.Pos = i
|
||||
}
|
||||
|
||||
func (rcv *ActiveLimit) Table() flatbuffers.Table {
|
||||
return rcv._tab
|
||||
}
|
||||
|
||||
func (rcv *ActiveLimit) LimitCode() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *ActiveLimit) Value() int64 {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
return rcv._tab.GetInt64(o + rcv._tab.Pos)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *ActiveLimit) MutateValue(n int64) bool {
|
||||
return rcv._tab.MutateInt64Slot(6, n)
|
||||
}
|
||||
|
||||
func (rcv *ActiveLimit) ReasonCode() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *ActiveLimit) Actor(obj *ActorRef) *ActorRef {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
|
||||
if o != 0 {
|
||||
x := rcv._tab.Indirect(o + rcv._tab.Pos)
|
||||
if obj == nil {
|
||||
obj = new(ActorRef)
|
||||
}
|
||||
obj.Init(rcv._tab.Bytes, x)
|
||||
return obj
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *ActiveLimit) AppliedAtMs() int64 {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(12))
|
||||
if o != 0 {
|
||||
return rcv._tab.GetInt64(o + rcv._tab.Pos)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *ActiveLimit) MutateAppliedAtMs(n int64) bool {
|
||||
return rcv._tab.MutateInt64Slot(12, n)
|
||||
}
|
||||
|
||||
func (rcv *ActiveLimit) ExpiresAtMs() int64 {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
|
||||
if o != 0 {
|
||||
return rcv._tab.GetInt64(o + rcv._tab.Pos)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *ActiveLimit) MutateExpiresAtMs(n int64) bool {
|
||||
return rcv._tab.MutateInt64Slot(14, n)
|
||||
}
|
||||
|
||||
func ActiveLimitStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(6)
|
||||
}
|
||||
func ActiveLimitAddLimitCode(builder *flatbuffers.Builder, limitCode flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(limitCode), 0)
|
||||
}
|
||||
func ActiveLimitAddValue(builder *flatbuffers.Builder, value int64) {
|
||||
builder.PrependInt64Slot(1, value, 0)
|
||||
}
|
||||
func ActiveLimitAddReasonCode(builder *flatbuffers.Builder, reasonCode flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(reasonCode), 0)
|
||||
}
|
||||
func ActiveLimitAddActor(builder *flatbuffers.Builder, actor flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(actor), 0)
|
||||
}
|
||||
func ActiveLimitAddAppliedAtMs(builder *flatbuffers.Builder, appliedAtMs int64) {
|
||||
builder.PrependInt64Slot(4, appliedAtMs, 0)
|
||||
}
|
||||
func ActiveLimitAddExpiresAtMs(builder *flatbuffers.Builder, expiresAtMs int64) {
|
||||
builder.PrependInt64Slot(5, expiresAtMs, 0)
|
||||
}
|
||||
func ActiveLimitEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
)
|
||||
|
||||
type ActiveSanction struct {
|
||||
_tab flatbuffers.Table
|
||||
}
|
||||
|
||||
func GetRootAsActiveSanction(buf []byte, offset flatbuffers.UOffsetT) *ActiveSanction {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||
x := &ActiveSanction{}
|
||||
x.Init(buf, n+offset)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishActiveSanctionBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.Finish(offset)
|
||||
}
|
||||
|
||||
func GetSizePrefixedRootAsActiveSanction(buf []byte, offset flatbuffers.UOffsetT) *ActiveSanction {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||
x := &ActiveSanction{}
|
||||
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishSizePrefixedActiveSanctionBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.FinishSizePrefixed(offset)
|
||||
}
|
||||
|
||||
func (rcv *ActiveSanction) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||
rcv._tab.Bytes = buf
|
||||
rcv._tab.Pos = i
|
||||
}
|
||||
|
||||
func (rcv *ActiveSanction) Table() flatbuffers.Table {
|
||||
return rcv._tab
|
||||
}
|
||||
|
||||
func (rcv *ActiveSanction) SanctionCode() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *ActiveSanction) Scope() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *ActiveSanction) ReasonCode() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *ActiveSanction) Actor(obj *ActorRef) *ActorRef {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
|
||||
if o != 0 {
|
||||
x := rcv._tab.Indirect(o + rcv._tab.Pos)
|
||||
if obj == nil {
|
||||
obj = new(ActorRef)
|
||||
}
|
||||
obj.Init(rcv._tab.Bytes, x)
|
||||
return obj
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *ActiveSanction) AppliedAtMs() int64 {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(12))
|
||||
if o != 0 {
|
||||
return rcv._tab.GetInt64(o + rcv._tab.Pos)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *ActiveSanction) MutateAppliedAtMs(n int64) bool {
|
||||
return rcv._tab.MutateInt64Slot(12, n)
|
||||
}
|
||||
|
||||
func (rcv *ActiveSanction) ExpiresAtMs() int64 {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
|
||||
if o != 0 {
|
||||
return rcv._tab.GetInt64(o + rcv._tab.Pos)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *ActiveSanction) MutateExpiresAtMs(n int64) bool {
|
||||
return rcv._tab.MutateInt64Slot(14, n)
|
||||
}
|
||||
|
||||
func ActiveSanctionStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(6)
|
||||
}
|
||||
func ActiveSanctionAddSanctionCode(builder *flatbuffers.Builder, sanctionCode flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(sanctionCode), 0)
|
||||
}
|
||||
func ActiveSanctionAddScope(builder *flatbuffers.Builder, scope flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(scope), 0)
|
||||
}
|
||||
func ActiveSanctionAddReasonCode(builder *flatbuffers.Builder, reasonCode flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(reasonCode), 0)
|
||||
}
|
||||
func ActiveSanctionAddActor(builder *flatbuffers.Builder, actor flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(actor), 0)
|
||||
}
|
||||
func ActiveSanctionAddAppliedAtMs(builder *flatbuffers.Builder, appliedAtMs int64) {
|
||||
builder.PrependInt64Slot(4, appliedAtMs, 0)
|
||||
}
|
||||
func ActiveSanctionAddExpiresAtMs(builder *flatbuffers.Builder, expiresAtMs int64) {
|
||||
builder.PrependInt64Slot(5, expiresAtMs, 0)
|
||||
}
|
||||
func ActiveSanctionEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
)
|
||||
|
||||
type ActorRef struct {
|
||||
_tab flatbuffers.Table
|
||||
}
|
||||
|
||||
func GetRootAsActorRef(buf []byte, offset flatbuffers.UOffsetT) *ActorRef {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||
x := &ActorRef{}
|
||||
x.Init(buf, n+offset)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishActorRefBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.Finish(offset)
|
||||
}
|
||||
|
||||
func GetSizePrefixedRootAsActorRef(buf []byte, offset flatbuffers.UOffsetT) *ActorRef {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||
x := &ActorRef{}
|
||||
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishSizePrefixedActorRefBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.FinishSizePrefixed(offset)
|
||||
}
|
||||
|
||||
func (rcv *ActorRef) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||
rcv._tab.Bytes = buf
|
||||
rcv._tab.Pos = i
|
||||
}
|
||||
|
||||
func (rcv *ActorRef) Table() flatbuffers.Table {
|
||||
return rcv._tab
|
||||
}
|
||||
|
||||
func (rcv *ActorRef) Type() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *ActorRef) Id() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ActorRefStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(2)
|
||||
}
|
||||
func ActorRefAddType(builder *flatbuffers.Builder, type_ flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(type_), 0)
|
||||
}
|
||||
func ActorRefAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(id), 0)
|
||||
}
|
||||
func ActorRefEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
)
|
||||
|
||||
type EntitlementSnapshot struct {
|
||||
_tab flatbuffers.Table
|
||||
}
|
||||
|
||||
func GetRootAsEntitlementSnapshot(buf []byte, offset flatbuffers.UOffsetT) *EntitlementSnapshot {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||
x := &EntitlementSnapshot{}
|
||||
x.Init(buf, n+offset)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishEntitlementSnapshotBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.Finish(offset)
|
||||
}
|
||||
|
||||
func GetSizePrefixedRootAsEntitlementSnapshot(buf []byte, offset flatbuffers.UOffsetT) *EntitlementSnapshot {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||
x := &EntitlementSnapshot{}
|
||||
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishSizePrefixedEntitlementSnapshotBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.FinishSizePrefixed(offset)
|
||||
}
|
||||
|
||||
func (rcv *EntitlementSnapshot) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||
rcv._tab.Bytes = buf
|
||||
rcv._tab.Pos = i
|
||||
}
|
||||
|
||||
func (rcv *EntitlementSnapshot) Table() flatbuffers.Table {
|
||||
return rcv._tab
|
||||
}
|
||||
|
||||
func (rcv *EntitlementSnapshot) PlanCode() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *EntitlementSnapshot) IsPaid() bool {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
return rcv._tab.GetBool(o + rcv._tab.Pos)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (rcv *EntitlementSnapshot) MutateIsPaid(n bool) bool {
|
||||
return rcv._tab.MutateBoolSlot(6, n)
|
||||
}
|
||||
|
||||
func (rcv *EntitlementSnapshot) Source() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *EntitlementSnapshot) Actor(obj *ActorRef) *ActorRef {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
|
||||
if o != 0 {
|
||||
x := rcv._tab.Indirect(o + rcv._tab.Pos)
|
||||
if obj == nil {
|
||||
obj = new(ActorRef)
|
||||
}
|
||||
obj.Init(rcv._tab.Bytes, x)
|
||||
return obj
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *EntitlementSnapshot) ReasonCode() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(12))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *EntitlementSnapshot) StartsAtMs() int64 {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
|
||||
if o != 0 {
|
||||
return rcv._tab.GetInt64(o + rcv._tab.Pos)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *EntitlementSnapshot) MutateStartsAtMs(n int64) bool {
|
||||
return rcv._tab.MutateInt64Slot(14, n)
|
||||
}
|
||||
|
||||
func (rcv *EntitlementSnapshot) EndsAtMs() int64 {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(16))
|
||||
if o != 0 {
|
||||
return rcv._tab.GetInt64(o + rcv._tab.Pos)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *EntitlementSnapshot) MutateEndsAtMs(n int64) bool {
|
||||
return rcv._tab.MutateInt64Slot(16, n)
|
||||
}
|
||||
|
||||
func (rcv *EntitlementSnapshot) UpdatedAtMs() int64 {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(18))
|
||||
if o != 0 {
|
||||
return rcv._tab.GetInt64(o + rcv._tab.Pos)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *EntitlementSnapshot) MutateUpdatedAtMs(n int64) bool {
|
||||
return rcv._tab.MutateInt64Slot(18, n)
|
||||
}
|
||||
|
||||
func EntitlementSnapshotStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(8)
|
||||
}
|
||||
func EntitlementSnapshotAddPlanCode(builder *flatbuffers.Builder, planCode flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(planCode), 0)
|
||||
}
|
||||
func EntitlementSnapshotAddIsPaid(builder *flatbuffers.Builder, isPaid bool) {
|
||||
builder.PrependBoolSlot(1, isPaid, false)
|
||||
}
|
||||
func EntitlementSnapshotAddSource(builder *flatbuffers.Builder, source flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(source), 0)
|
||||
}
|
||||
func EntitlementSnapshotAddActor(builder *flatbuffers.Builder, actor flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(actor), 0)
|
||||
}
|
||||
func EntitlementSnapshotAddReasonCode(builder *flatbuffers.Builder, reasonCode flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(reasonCode), 0)
|
||||
}
|
||||
func EntitlementSnapshotAddStartsAtMs(builder *flatbuffers.Builder, startsAtMs int64) {
|
||||
builder.PrependInt64Slot(5, startsAtMs, 0)
|
||||
}
|
||||
func EntitlementSnapshotAddEndsAtMs(builder *flatbuffers.Builder, endsAtMs int64) {
|
||||
builder.PrependInt64Slot(6, endsAtMs, 0)
|
||||
}
|
||||
func EntitlementSnapshotAddUpdatedAtMs(builder *flatbuffers.Builder, updatedAtMs int64) {
|
||||
builder.PrependInt64Slot(7, updatedAtMs, 0)
|
||||
}
|
||||
func EntitlementSnapshotEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
)
|
||||
|
||||
type ErrorBody struct {
|
||||
_tab flatbuffers.Table
|
||||
}
|
||||
|
||||
func GetRootAsErrorBody(buf []byte, offset flatbuffers.UOffsetT) *ErrorBody {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||
x := &ErrorBody{}
|
||||
x.Init(buf, n+offset)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishErrorBodyBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.Finish(offset)
|
||||
}
|
||||
|
||||
func GetSizePrefixedRootAsErrorBody(buf []byte, offset flatbuffers.UOffsetT) *ErrorBody {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||
x := &ErrorBody{}
|
||||
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishSizePrefixedErrorBodyBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.FinishSizePrefixed(offset)
|
||||
}
|
||||
|
||||
func (rcv *ErrorBody) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||
rcv._tab.Bytes = buf
|
||||
rcv._tab.Pos = i
|
||||
}
|
||||
|
||||
func (rcv *ErrorBody) Table() flatbuffers.Table {
|
||||
return rcv._tab
|
||||
}
|
||||
|
||||
func (rcv *ErrorBody) Code() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *ErrorBody) Message() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ErrorBodyStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(2)
|
||||
}
|
||||
func ErrorBodyAddCode(builder *flatbuffers.Builder, code flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(code), 0)
|
||||
}
|
||||
func ErrorBodyAddMessage(builder *flatbuffers.Builder, message flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(message), 0)
|
||||
}
|
||||
func ErrorBodyEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
)
|
||||
|
||||
type ErrorResponse struct {
|
||||
_tab flatbuffers.Table
|
||||
}
|
||||
|
||||
func GetRootAsErrorResponse(buf []byte, offset flatbuffers.UOffsetT) *ErrorResponse {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||
x := &ErrorResponse{}
|
||||
x.Init(buf, n+offset)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishErrorResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.Finish(offset)
|
||||
}
|
||||
|
||||
func GetSizePrefixedRootAsErrorResponse(buf []byte, offset flatbuffers.UOffsetT) *ErrorResponse {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||
x := &ErrorResponse{}
|
||||
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishSizePrefixedErrorResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.FinishSizePrefixed(offset)
|
||||
}
|
||||
|
||||
func (rcv *ErrorResponse) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||
rcv._tab.Bytes = buf
|
||||
rcv._tab.Pos = i
|
||||
}
|
||||
|
||||
func (rcv *ErrorResponse) Table() flatbuffers.Table {
|
||||
return rcv._tab
|
||||
}
|
||||
|
||||
func (rcv *ErrorResponse) Error(obj *ErrorBody) *ErrorBody {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||
if o != 0 {
|
||||
x := rcv._tab.Indirect(o + rcv._tab.Pos)
|
||||
if obj == nil {
|
||||
obj = new(ErrorBody)
|
||||
}
|
||||
obj.Init(rcv._tab.Bytes, x)
|
||||
return obj
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ErrorResponseStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(1)
|
||||
}
|
||||
func ErrorResponseAddError(builder *flatbuffers.Builder, error flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(error), 0)
|
||||
}
|
||||
func ErrorResponseEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
)
|
||||
|
||||
type GetMyAccountRequest struct {
|
||||
_tab flatbuffers.Table
|
||||
}
|
||||
|
||||
func GetRootAsGetMyAccountRequest(buf []byte, offset flatbuffers.UOffsetT) *GetMyAccountRequest {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||
x := &GetMyAccountRequest{}
|
||||
x.Init(buf, n+offset)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishGetMyAccountRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.Finish(offset)
|
||||
}
|
||||
|
||||
func GetSizePrefixedRootAsGetMyAccountRequest(buf []byte, offset flatbuffers.UOffsetT) *GetMyAccountRequest {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||
x := &GetMyAccountRequest{}
|
||||
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishSizePrefixedGetMyAccountRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.FinishSizePrefixed(offset)
|
||||
}
|
||||
|
||||
func (rcv *GetMyAccountRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||
rcv._tab.Bytes = buf
|
||||
rcv._tab.Pos = i
|
||||
}
|
||||
|
||||
func (rcv *GetMyAccountRequest) Table() flatbuffers.Table {
|
||||
return rcv._tab
|
||||
}
|
||||
|
||||
func GetMyAccountRequestStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(0)
|
||||
}
|
||||
func GetMyAccountRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
)
|
||||
|
||||
type UpdateMyProfileRequest struct {
|
||||
_tab flatbuffers.Table
|
||||
}
|
||||
|
||||
func GetRootAsUpdateMyProfileRequest(buf []byte, offset flatbuffers.UOffsetT) *UpdateMyProfileRequest {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||
x := &UpdateMyProfileRequest{}
|
||||
x.Init(buf, n+offset)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishUpdateMyProfileRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.Finish(offset)
|
||||
}
|
||||
|
||||
func GetSizePrefixedRootAsUpdateMyProfileRequest(buf []byte, offset flatbuffers.UOffsetT) *UpdateMyProfileRequest {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||
x := &UpdateMyProfileRequest{}
|
||||
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishSizePrefixedUpdateMyProfileRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.FinishSizePrefixed(offset)
|
||||
}
|
||||
|
||||
func (rcv *UpdateMyProfileRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||
rcv._tab.Bytes = buf
|
||||
rcv._tab.Pos = i
|
||||
}
|
||||
|
||||
func (rcv *UpdateMyProfileRequest) Table() flatbuffers.Table {
|
||||
return rcv._tab
|
||||
}
|
||||
|
||||
func (rcv *UpdateMyProfileRequest) RaceName() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func UpdateMyProfileRequestStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(1)
|
||||
}
|
||||
func UpdateMyProfileRequestAddRaceName(builder *flatbuffers.Builder, raceName flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(raceName), 0)
|
||||
}
|
||||
func UpdateMyProfileRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
)
|
||||
|
||||
type UpdateMySettingsRequest struct {
|
||||
_tab flatbuffers.Table
|
||||
}
|
||||
|
||||
func GetRootAsUpdateMySettingsRequest(buf []byte, offset flatbuffers.UOffsetT) *UpdateMySettingsRequest {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||
x := &UpdateMySettingsRequest{}
|
||||
x.Init(buf, n+offset)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishUpdateMySettingsRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.Finish(offset)
|
||||
}
|
||||
|
||||
func GetSizePrefixedRootAsUpdateMySettingsRequest(buf []byte, offset flatbuffers.UOffsetT) *UpdateMySettingsRequest {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||
x := &UpdateMySettingsRequest{}
|
||||
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishSizePrefixedUpdateMySettingsRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.FinishSizePrefixed(offset)
|
||||
}
|
||||
|
||||
func (rcv *UpdateMySettingsRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||
rcv._tab.Bytes = buf
|
||||
rcv._tab.Pos = i
|
||||
}
|
||||
|
||||
func (rcv *UpdateMySettingsRequest) Table() flatbuffers.Table {
|
||||
return rcv._tab
|
||||
}
|
||||
|
||||
func (rcv *UpdateMySettingsRequest) PreferredLanguage() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *UpdateMySettingsRequest) TimeZone() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func UpdateMySettingsRequestStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(2)
|
||||
}
|
||||
func UpdateMySettingsRequestAddPreferredLanguage(builder *flatbuffers.Builder, preferredLanguage flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(preferredLanguage), 0)
|
||||
}
|
||||
func UpdateMySettingsRequestAddTimeZone(builder *flatbuffers.Builder, timeZone flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(timeZone), 0)
|
||||
}
|
||||
func UpdateMySettingsRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
package transcoder
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
usermodel "galaxy/model/user"
|
||||
userfbs "galaxy/schema/fbs/user"
|
||||
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
)
|
||||
|
||||
// GetMyAccountRequestToPayload converts usermodel.GetMyAccountRequest to
|
||||
// FlatBuffers bytes suitable for the authenticated gateway transport.
|
||||
func GetMyAccountRequestToPayload(request *usermodel.GetMyAccountRequest) ([]byte, error) {
|
||||
if request == nil {
|
||||
return nil, errors.New("encode get my account request payload: request is nil")
|
||||
}
|
||||
|
||||
builder := flatbuffers.NewBuilder(32)
|
||||
userfbs.GetMyAccountRequestStart(builder)
|
||||
offset := userfbs.GetMyAccountRequestEnd(builder)
|
||||
userfbs.FinishGetMyAccountRequestBuffer(builder, offset)
|
||||
|
||||
return builder.FinishedBytes(), nil
|
||||
}
|
||||
|
||||
// PayloadToGetMyAccountRequest converts FlatBuffers payload bytes into
|
||||
// usermodel.GetMyAccountRequest.
|
||||
func PayloadToGetMyAccountRequest(data []byte) (result *usermodel.GetMyAccountRequest, err error) {
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("decode get my account request payload: data is empty")
|
||||
}
|
||||
|
||||
defer recoverUserDecodePanic("decode get my account request payload", &result, &err)
|
||||
|
||||
_ = userfbs.GetRootAsGetMyAccountRequest(data, 0)
|
||||
return &usermodel.GetMyAccountRequest{}, nil
|
||||
}
|
||||
|
||||
// UpdateMyProfileRequestToPayload converts usermodel.UpdateMyProfileRequest to
|
||||
// FlatBuffers bytes suitable for the authenticated gateway transport.
|
||||
func UpdateMyProfileRequestToPayload(request *usermodel.UpdateMyProfileRequest) ([]byte, error) {
|
||||
if request == nil {
|
||||
return nil, errors.New("encode update my profile request payload: request is nil")
|
||||
}
|
||||
|
||||
builder := flatbuffers.NewBuilder(128)
|
||||
raceName := builder.CreateString(request.RaceName)
|
||||
|
||||
userfbs.UpdateMyProfileRequestStart(builder)
|
||||
userfbs.UpdateMyProfileRequestAddRaceName(builder, raceName)
|
||||
offset := userfbs.UpdateMyProfileRequestEnd(builder)
|
||||
userfbs.FinishUpdateMyProfileRequestBuffer(builder, offset)
|
||||
|
||||
return builder.FinishedBytes(), nil
|
||||
}
|
||||
|
||||
// PayloadToUpdateMyProfileRequest converts FlatBuffers payload bytes into
|
||||
// usermodel.UpdateMyProfileRequest.
|
||||
func PayloadToUpdateMyProfileRequest(data []byte) (result *usermodel.UpdateMyProfileRequest, err error) {
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("decode update my profile request payload: data is empty")
|
||||
}
|
||||
|
||||
defer recoverUserDecodePanic("decode update my profile request payload", &result, &err)
|
||||
|
||||
request := userfbs.GetRootAsUpdateMyProfileRequest(data, 0)
|
||||
return &usermodel.UpdateMyProfileRequest{
|
||||
RaceName: string(request.RaceName()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateMySettingsRequestToPayload converts
|
||||
// usermodel.UpdateMySettingsRequest to FlatBuffers bytes suitable for the
|
||||
// authenticated gateway transport.
|
||||
func UpdateMySettingsRequestToPayload(request *usermodel.UpdateMySettingsRequest) ([]byte, error) {
|
||||
if request == nil {
|
||||
return nil, errors.New("encode update my settings request payload: request is nil")
|
||||
}
|
||||
|
||||
builder := flatbuffers.NewBuilder(128)
|
||||
preferredLanguage := builder.CreateString(request.PreferredLanguage)
|
||||
timeZone := builder.CreateString(request.TimeZone)
|
||||
|
||||
userfbs.UpdateMySettingsRequestStart(builder)
|
||||
userfbs.UpdateMySettingsRequestAddPreferredLanguage(builder, preferredLanguage)
|
||||
userfbs.UpdateMySettingsRequestAddTimeZone(builder, timeZone)
|
||||
offset := userfbs.UpdateMySettingsRequestEnd(builder)
|
||||
userfbs.FinishUpdateMySettingsRequestBuffer(builder, offset)
|
||||
|
||||
return builder.FinishedBytes(), nil
|
||||
}
|
||||
|
||||
// PayloadToUpdateMySettingsRequest converts FlatBuffers payload bytes into
|
||||
// usermodel.UpdateMySettingsRequest.
|
||||
func PayloadToUpdateMySettingsRequest(data []byte) (result *usermodel.UpdateMySettingsRequest, err error) {
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("decode update my settings request payload: data is empty")
|
||||
}
|
||||
|
||||
defer recoverUserDecodePanic("decode update my settings request payload", &result, &err)
|
||||
|
||||
request := userfbs.GetRootAsUpdateMySettingsRequest(data, 0)
|
||||
return &usermodel.UpdateMySettingsRequest{
|
||||
PreferredLanguage: string(request.PreferredLanguage()),
|
||||
TimeZone: string(request.TimeZone()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AccountResponseToPayload converts usermodel.AccountResponse to FlatBuffers
|
||||
// bytes suitable for the authenticated gateway transport.
|
||||
func AccountResponseToPayload(response *usermodel.AccountResponse) ([]byte, error) {
|
||||
if response == nil {
|
||||
return nil, errors.New("encode account response payload: response is nil")
|
||||
}
|
||||
|
||||
builder := flatbuffers.NewBuilder(512)
|
||||
accountOffset, err := encodeAccount(builder, response.Account)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode account response payload: %w", err)
|
||||
}
|
||||
|
||||
userfbs.AccountResponseStart(builder)
|
||||
userfbs.AccountResponseAddAccount(builder, accountOffset)
|
||||
offset := userfbs.AccountResponseEnd(builder)
|
||||
userfbs.FinishAccountResponseBuffer(builder, offset)
|
||||
|
||||
return builder.FinishedBytes(), nil
|
||||
}
|
||||
|
||||
// PayloadToAccountResponse converts FlatBuffers payload bytes into
|
||||
// usermodel.AccountResponse.
|
||||
func PayloadToAccountResponse(data []byte) (result *usermodel.AccountResponse, err error) {
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("decode account response payload: data is empty")
|
||||
}
|
||||
|
||||
defer recoverUserDecodePanic("decode account response payload", &result, &err)
|
||||
|
||||
response := userfbs.GetRootAsAccountResponse(data, 0)
|
||||
account := response.Account(nil)
|
||||
if account == nil {
|
||||
return nil, errors.New("decode account response payload: account is missing")
|
||||
}
|
||||
|
||||
decodedAccount, err := decodeAccount(account)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode account response payload: %w", err)
|
||||
}
|
||||
|
||||
return &usermodel.AccountResponse{Account: decodedAccount}, nil
|
||||
}
|
||||
|
||||
// ErrorResponseToPayload converts usermodel.ErrorResponse to FlatBuffers bytes
|
||||
// suitable for the authenticated gateway transport.
|
||||
func ErrorResponseToPayload(response *usermodel.ErrorResponse) ([]byte, error) {
|
||||
if response == nil {
|
||||
return nil, errors.New("encode error response payload: response is nil")
|
||||
}
|
||||
|
||||
builder := flatbuffers.NewBuilder(128)
|
||||
errorOffset := encodeErrorBody(builder, response.Error)
|
||||
|
||||
userfbs.ErrorResponseStart(builder)
|
||||
userfbs.ErrorResponseAddError(builder, errorOffset)
|
||||
offset := userfbs.ErrorResponseEnd(builder)
|
||||
userfbs.FinishErrorResponseBuffer(builder, offset)
|
||||
|
||||
return builder.FinishedBytes(), nil
|
||||
}
|
||||
|
||||
// PayloadToErrorResponse converts FlatBuffers payload bytes into
|
||||
// usermodel.ErrorResponse.
|
||||
func PayloadToErrorResponse(data []byte) (result *usermodel.ErrorResponse, err error) {
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("decode error response payload: data is empty")
|
||||
}
|
||||
|
||||
defer recoverUserDecodePanic("decode error response payload", &result, &err)
|
||||
|
||||
response := userfbs.GetRootAsErrorResponse(data, 0)
|
||||
errorBody := response.Error(nil)
|
||||
if errorBody == nil {
|
||||
return nil, errors.New("decode error response payload: error is missing")
|
||||
}
|
||||
|
||||
return &usermodel.ErrorResponse{
|
||||
Error: usermodel.ErrorBody{
|
||||
Code: string(errorBody.Code()),
|
||||
Message: string(errorBody.Message()),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func encodeAccount(builder *flatbuffers.Builder, account usermodel.Account) (flatbuffers.UOffsetT, error) {
|
||||
entitlementOffset, err := encodeEntitlementSnapshot(builder, account.Entitlement)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("encode account: %w", err)
|
||||
}
|
||||
|
||||
activeSanctionOffsets := make([]flatbuffers.UOffsetT, len(account.ActiveSanctions))
|
||||
for index := range account.ActiveSanctions {
|
||||
activeSanctionOffsets[index], err = encodeActiveSanction(builder, account.ActiveSanctions[index])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("encode account active sanction %d: %w", index, err)
|
||||
}
|
||||
}
|
||||
|
||||
var activeSanctionsVector flatbuffers.UOffsetT
|
||||
if len(activeSanctionOffsets) > 0 {
|
||||
userfbs.AccountViewStartActiveSanctionsVector(builder, len(activeSanctionOffsets))
|
||||
for index := len(activeSanctionOffsets) - 1; index >= 0; index-- {
|
||||
builder.PrependUOffsetT(activeSanctionOffsets[index])
|
||||
}
|
||||
activeSanctionsVector = builder.EndVector(len(activeSanctionOffsets))
|
||||
}
|
||||
|
||||
activeLimitOffsets := make([]flatbuffers.UOffsetT, len(account.ActiveLimits))
|
||||
for index := range account.ActiveLimits {
|
||||
activeLimitOffsets[index], err = encodeActiveLimit(builder, account.ActiveLimits[index])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("encode account active limit %d: %w", index, err)
|
||||
}
|
||||
}
|
||||
|
||||
var activeLimitsVector flatbuffers.UOffsetT
|
||||
if len(activeLimitOffsets) > 0 {
|
||||
userfbs.AccountViewStartActiveLimitsVector(builder, len(activeLimitOffsets))
|
||||
for index := len(activeLimitOffsets) - 1; index >= 0; index-- {
|
||||
builder.PrependUOffsetT(activeLimitOffsets[index])
|
||||
}
|
||||
activeLimitsVector = builder.EndVector(len(activeLimitOffsets))
|
||||
}
|
||||
|
||||
userID := builder.CreateString(account.UserID)
|
||||
email := builder.CreateString(account.Email)
|
||||
raceName := builder.CreateString(account.RaceName)
|
||||
preferredLanguage := builder.CreateString(account.PreferredLanguage)
|
||||
timeZone := builder.CreateString(account.TimeZone)
|
||||
|
||||
var declaredCountry flatbuffers.UOffsetT
|
||||
if account.DeclaredCountry != "" {
|
||||
declaredCountry = builder.CreateString(account.DeclaredCountry)
|
||||
}
|
||||
|
||||
userfbs.AccountViewStart(builder)
|
||||
userfbs.AccountViewAddUserId(builder, userID)
|
||||
userfbs.AccountViewAddEmail(builder, email)
|
||||
userfbs.AccountViewAddRaceName(builder, raceName)
|
||||
userfbs.AccountViewAddPreferredLanguage(builder, preferredLanguage)
|
||||
userfbs.AccountViewAddTimeZone(builder, timeZone)
|
||||
if declaredCountry != 0 {
|
||||
userfbs.AccountViewAddDeclaredCountry(builder, declaredCountry)
|
||||
}
|
||||
userfbs.AccountViewAddEntitlement(builder, entitlementOffset)
|
||||
if activeSanctionsVector != 0 {
|
||||
userfbs.AccountViewAddActiveSanctions(builder, activeSanctionsVector)
|
||||
}
|
||||
if activeLimitsVector != 0 {
|
||||
userfbs.AccountViewAddActiveLimits(builder, activeLimitsVector)
|
||||
}
|
||||
userfbs.AccountViewAddCreatedAtMs(builder, account.CreatedAt.UTC().UnixMilli())
|
||||
userfbs.AccountViewAddUpdatedAtMs(builder, account.UpdatedAt.UTC().UnixMilli())
|
||||
|
||||
return userfbs.AccountViewEnd(builder), nil
|
||||
}
|
||||
|
||||
func decodeAccount(account *userfbs.AccountView) (usermodel.Account, error) {
|
||||
entitlement := account.Entitlement(nil)
|
||||
if entitlement == nil {
|
||||
return usermodel.Account{}, errors.New("account entitlement is missing")
|
||||
}
|
||||
|
||||
decodedEntitlement, err := decodeEntitlementSnapshot(entitlement)
|
||||
if err != nil {
|
||||
return usermodel.Account{}, fmt.Errorf("decode account entitlement: %w", err)
|
||||
}
|
||||
|
||||
createdAt := time.UnixMilli(account.CreatedAtMs()).UTC()
|
||||
updatedAt := time.UnixMilli(account.UpdatedAtMs()).UTC()
|
||||
|
||||
result := usermodel.Account{
|
||||
UserID: string(account.UserId()),
|
||||
Email: string(account.Email()),
|
||||
RaceName: string(account.RaceName()),
|
||||
PreferredLanguage: string(account.PreferredLanguage()),
|
||||
TimeZone: string(account.TimeZone()),
|
||||
DeclaredCountry: string(account.DeclaredCountry()),
|
||||
Entitlement: decodedEntitlement,
|
||||
ActiveSanctions: make([]usermodel.ActiveSanction, 0, account.ActiveSanctionsLength()),
|
||||
ActiveLimits: make([]usermodel.ActiveLimit, 0, account.ActiveLimitsLength()),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
|
||||
activeSanction := new(userfbs.ActiveSanction)
|
||||
for index := 0; index < account.ActiveSanctionsLength(); index++ {
|
||||
if !account.ActiveSanctions(activeSanction, index) {
|
||||
return usermodel.Account{}, fmt.Errorf("account active sanction %d is missing", index)
|
||||
}
|
||||
|
||||
decodedSanction, err := decodeActiveSanction(activeSanction)
|
||||
if err != nil {
|
||||
return usermodel.Account{}, fmt.Errorf("decode account active sanction %d: %w", index, err)
|
||||
}
|
||||
result.ActiveSanctions = append(result.ActiveSanctions, decodedSanction)
|
||||
}
|
||||
|
||||
activeLimit := new(userfbs.ActiveLimit)
|
||||
for index := 0; index < account.ActiveLimitsLength(); index++ {
|
||||
if !account.ActiveLimits(activeLimit, index) {
|
||||
return usermodel.Account{}, fmt.Errorf("account active limit %d is missing", index)
|
||||
}
|
||||
|
||||
decodedLimit, err := decodeActiveLimit(activeLimit)
|
||||
if err != nil {
|
||||
return usermodel.Account{}, fmt.Errorf("decode account active limit %d: %w", index, err)
|
||||
}
|
||||
result.ActiveLimits = append(result.ActiveLimits, decodedLimit)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func encodeEntitlementSnapshot(builder *flatbuffers.Builder, snapshot usermodel.EntitlementSnapshot) (flatbuffers.UOffsetT, error) {
|
||||
actorOffset := encodeActorRef(builder, snapshot.Actor)
|
||||
planCode := builder.CreateString(snapshot.PlanCode)
|
||||
source := builder.CreateString(snapshot.Source)
|
||||
reasonCode := builder.CreateString(snapshot.ReasonCode)
|
||||
|
||||
userfbs.EntitlementSnapshotStart(builder)
|
||||
userfbs.EntitlementSnapshotAddPlanCode(builder, planCode)
|
||||
userfbs.EntitlementSnapshotAddIsPaid(builder, snapshot.IsPaid)
|
||||
userfbs.EntitlementSnapshotAddSource(builder, source)
|
||||
userfbs.EntitlementSnapshotAddActor(builder, actorOffset)
|
||||
userfbs.EntitlementSnapshotAddReasonCode(builder, reasonCode)
|
||||
userfbs.EntitlementSnapshotAddStartsAtMs(builder, snapshot.StartsAt.UTC().UnixMilli())
|
||||
if snapshot.EndsAt != nil {
|
||||
userfbs.EntitlementSnapshotAddEndsAtMs(builder, snapshot.EndsAt.UTC().UnixMilli())
|
||||
}
|
||||
userfbs.EntitlementSnapshotAddUpdatedAtMs(builder, snapshot.UpdatedAt.UTC().UnixMilli())
|
||||
|
||||
return userfbs.EntitlementSnapshotEnd(builder), nil
|
||||
}
|
||||
|
||||
func decodeEntitlementSnapshot(snapshot *userfbs.EntitlementSnapshot) (usermodel.EntitlementSnapshot, error) {
|
||||
actor := snapshot.Actor(nil)
|
||||
if actor == nil {
|
||||
return usermodel.EntitlementSnapshot{}, errors.New("entitlement actor is missing")
|
||||
}
|
||||
|
||||
decodedActor, err := decodeActorRef(actor)
|
||||
if err != nil {
|
||||
return usermodel.EntitlementSnapshot{}, fmt.Errorf("decode entitlement actor: %w", err)
|
||||
}
|
||||
|
||||
return usermodel.EntitlementSnapshot{
|
||||
PlanCode: string(snapshot.PlanCode()),
|
||||
IsPaid: snapshot.IsPaid(),
|
||||
Source: string(snapshot.Source()),
|
||||
Actor: decodedActor,
|
||||
ReasonCode: string(snapshot.ReasonCode()),
|
||||
StartsAt: time.UnixMilli(snapshot.StartsAtMs()).UTC(),
|
||||
EndsAt: optionalUnixMilli(snapshot.EndsAtMs()),
|
||||
UpdatedAt: time.UnixMilli(snapshot.UpdatedAtMs()).UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func encodeActiveSanction(builder *flatbuffers.Builder, sanction usermodel.ActiveSanction) (flatbuffers.UOffsetT, error) {
|
||||
actorOffset := encodeActorRef(builder, sanction.Actor)
|
||||
sanctionCode := builder.CreateString(sanction.SanctionCode)
|
||||
scope := builder.CreateString(sanction.Scope)
|
||||
reasonCode := builder.CreateString(sanction.ReasonCode)
|
||||
|
||||
userfbs.ActiveSanctionStart(builder)
|
||||
userfbs.ActiveSanctionAddSanctionCode(builder, sanctionCode)
|
||||
userfbs.ActiveSanctionAddScope(builder, scope)
|
||||
userfbs.ActiveSanctionAddReasonCode(builder, reasonCode)
|
||||
userfbs.ActiveSanctionAddActor(builder, actorOffset)
|
||||
userfbs.ActiveSanctionAddAppliedAtMs(builder, sanction.AppliedAt.UTC().UnixMilli())
|
||||
if sanction.ExpiresAt != nil {
|
||||
userfbs.ActiveSanctionAddExpiresAtMs(builder, sanction.ExpiresAt.UTC().UnixMilli())
|
||||
}
|
||||
|
||||
return userfbs.ActiveSanctionEnd(builder), nil
|
||||
}
|
||||
|
||||
func decodeActiveSanction(sanction *userfbs.ActiveSanction) (usermodel.ActiveSanction, error) {
|
||||
actor := sanction.Actor(nil)
|
||||
if actor == nil {
|
||||
return usermodel.ActiveSanction{}, errors.New("sanction actor is missing")
|
||||
}
|
||||
|
||||
decodedActor, err := decodeActorRef(actor)
|
||||
if err != nil {
|
||||
return usermodel.ActiveSanction{}, fmt.Errorf("decode sanction actor: %w", err)
|
||||
}
|
||||
|
||||
return usermodel.ActiveSanction{
|
||||
SanctionCode: string(sanction.SanctionCode()),
|
||||
Scope: string(sanction.Scope()),
|
||||
ReasonCode: string(sanction.ReasonCode()),
|
||||
Actor: decodedActor,
|
||||
AppliedAt: time.UnixMilli(sanction.AppliedAtMs()).UTC(),
|
||||
ExpiresAt: optionalUnixMilli(sanction.ExpiresAtMs()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func encodeActiveLimit(builder *flatbuffers.Builder, limit usermodel.ActiveLimit) (flatbuffers.UOffsetT, error) {
|
||||
actorOffset := encodeActorRef(builder, limit.Actor)
|
||||
limitCode := builder.CreateString(limit.LimitCode)
|
||||
reasonCode := builder.CreateString(limit.ReasonCode)
|
||||
|
||||
userfbs.ActiveLimitStart(builder)
|
||||
userfbs.ActiveLimitAddLimitCode(builder, limitCode)
|
||||
userfbs.ActiveLimitAddValue(builder, int64(limit.Value))
|
||||
userfbs.ActiveLimitAddReasonCode(builder, reasonCode)
|
||||
userfbs.ActiveLimitAddActor(builder, actorOffset)
|
||||
userfbs.ActiveLimitAddAppliedAtMs(builder, limit.AppliedAt.UTC().UnixMilli())
|
||||
if limit.ExpiresAt != nil {
|
||||
userfbs.ActiveLimitAddExpiresAtMs(builder, limit.ExpiresAt.UTC().UnixMilli())
|
||||
}
|
||||
|
||||
return userfbs.ActiveLimitEnd(builder), nil
|
||||
}
|
||||
|
||||
func decodeActiveLimit(limit *userfbs.ActiveLimit) (usermodel.ActiveLimit, error) {
|
||||
actor := limit.Actor(nil)
|
||||
if actor == nil {
|
||||
return usermodel.ActiveLimit{}, errors.New("limit actor is missing")
|
||||
}
|
||||
|
||||
decodedActor, err := decodeActorRef(actor)
|
||||
if err != nil {
|
||||
return usermodel.ActiveLimit{}, fmt.Errorf("decode limit actor: %w", err)
|
||||
}
|
||||
|
||||
value, err := int64ToInt(limit.Value(), "value")
|
||||
if err != nil {
|
||||
return usermodel.ActiveLimit{}, err
|
||||
}
|
||||
|
||||
return usermodel.ActiveLimit{
|
||||
LimitCode: string(limit.LimitCode()),
|
||||
Value: value,
|
||||
ReasonCode: string(limit.ReasonCode()),
|
||||
Actor: decodedActor,
|
||||
AppliedAt: time.UnixMilli(limit.AppliedAtMs()).UTC(),
|
||||
ExpiresAt: optionalUnixMilli(limit.ExpiresAtMs()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func encodeActorRef(builder *flatbuffers.Builder, actor usermodel.ActorRef) flatbuffers.UOffsetT {
|
||||
actorType := builder.CreateString(actor.Type)
|
||||
|
||||
var actorID flatbuffers.UOffsetT
|
||||
if actor.ID != "" {
|
||||
actorID = builder.CreateString(actor.ID)
|
||||
}
|
||||
|
||||
userfbs.ActorRefStart(builder)
|
||||
userfbs.ActorRefAddType(builder, actorType)
|
||||
if actorID != 0 {
|
||||
userfbs.ActorRefAddId(builder, actorID)
|
||||
}
|
||||
|
||||
return userfbs.ActorRefEnd(builder)
|
||||
}
|
||||
|
||||
func decodeActorRef(actor *userfbs.ActorRef) (usermodel.ActorRef, error) {
|
||||
return usermodel.ActorRef{
|
||||
Type: string(actor.Type()),
|
||||
ID: string(actor.Id()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func encodeErrorBody(builder *flatbuffers.Builder, errorBody usermodel.ErrorBody) flatbuffers.UOffsetT {
|
||||
code := builder.CreateString(errorBody.Code)
|
||||
message := builder.CreateString(errorBody.Message)
|
||||
|
||||
userfbs.ErrorBodyStart(builder)
|
||||
userfbs.ErrorBodyAddCode(builder, code)
|
||||
userfbs.ErrorBodyAddMessage(builder, message)
|
||||
|
||||
return userfbs.ErrorBodyEnd(builder)
|
||||
}
|
||||
|
||||
func optionalUnixMilli(value int64) *time.Time {
|
||||
if value == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
decoded := time.UnixMilli(value).UTC()
|
||||
return &decoded
|
||||
}
|
||||
|
||||
func recoverUserDecodePanic[T any](message string, result **T, err *error) {
|
||||
if recovered := recover(); recovered != nil {
|
||||
*result = nil
|
||||
*err = fmt.Errorf("%s: panic recovered: %v", message, recovered)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
package transcoder
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
usermodel "galaxy/model/user"
|
||||
userfbs "galaxy/schema/fbs/user"
|
||||
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
)
|
||||
|
||||
func TestUserRequestPayloadRoundTrips(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
getPayload, err := GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("encode get my account request: %v", err)
|
||||
}
|
||||
|
||||
getDecoded, err := PayloadToGetMyAccountRequest(getPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("decode get my account request: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(&usermodel.GetMyAccountRequest{}, getDecoded) {
|
||||
t.Fatalf("get my account request mismatch: %#v", getDecoded)
|
||||
}
|
||||
|
||||
profileSource := &usermodel.UpdateMyProfileRequest{RaceName: "Nova Prime"}
|
||||
profilePayload, err := UpdateMyProfileRequestToPayload(profileSource)
|
||||
if err != nil {
|
||||
t.Fatalf("encode update my profile request: %v", err)
|
||||
}
|
||||
|
||||
profileDecoded, err := PayloadToUpdateMyProfileRequest(profilePayload)
|
||||
if err != nil {
|
||||
t.Fatalf("decode update my profile request: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(profileSource, profileDecoded) {
|
||||
t.Fatalf("update my profile request mismatch\nsource: %#v\ndecoded:%#v", profileSource, profileDecoded)
|
||||
}
|
||||
|
||||
settingsSource := &usermodel.UpdateMySettingsRequest{
|
||||
PreferredLanguage: "en-US",
|
||||
TimeZone: "Europe/Kaliningrad",
|
||||
}
|
||||
settingsPayload, err := UpdateMySettingsRequestToPayload(settingsSource)
|
||||
if err != nil {
|
||||
t.Fatalf("encode update my settings request: %v", err)
|
||||
}
|
||||
|
||||
settingsDecoded, err := PayloadToUpdateMySettingsRequest(settingsPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("decode update my settings request: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(settingsSource, settingsDecoded) {
|
||||
t.Fatalf("update my settings request mismatch\nsource: %#v\ndecoded:%#v", settingsSource, settingsDecoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountResponsePayloadRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, time.April, 9, 10, 0, 0, 0, time.UTC)
|
||||
expiresAt := now.Add(30 * 24 * time.Hour)
|
||||
limitExpiresAt := now.Add(90 * 24 * time.Hour)
|
||||
|
||||
source := &usermodel.AccountResponse{
|
||||
Account: usermodel.Account{
|
||||
UserID: "user-123",
|
||||
Email: "pilot@example.com",
|
||||
RaceName: "Pilot Nova",
|
||||
PreferredLanguage: "en",
|
||||
TimeZone: "Europe/Kaliningrad",
|
||||
DeclaredCountry: "DE",
|
||||
Entitlement: usermodel.EntitlementSnapshot{
|
||||
PlanCode: "paid_monthly",
|
||||
IsPaid: true,
|
||||
Source: "billing",
|
||||
Actor: usermodel.ActorRef{Type: "billing", ID: "invoice-1"},
|
||||
ReasonCode: "renewal",
|
||||
StartsAt: now,
|
||||
EndsAt: &expiresAt,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
ActiveSanctions: []usermodel.ActiveSanction{
|
||||
{
|
||||
SanctionCode: "profile_update_block",
|
||||
Scope: "lobby",
|
||||
ReasonCode: "manual_block",
|
||||
Actor: usermodel.ActorRef{Type: "admin", ID: "admin-1"},
|
||||
AppliedAt: now,
|
||||
ExpiresAt: &expiresAt,
|
||||
},
|
||||
},
|
||||
ActiveLimits: []usermodel.ActiveLimit{
|
||||
{
|
||||
LimitCode: "max_owned_private_games",
|
||||
Value: 3,
|
||||
ReasonCode: "manual_override",
|
||||
Actor: usermodel.ActorRef{Type: "admin", ID: "admin-1"},
|
||||
AppliedAt: now,
|
||||
ExpiresAt: &limitExpiresAt,
|
||||
},
|
||||
},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now.Add(time.Hour),
|
||||
},
|
||||
}
|
||||
|
||||
payload, err := AccountResponseToPayload(source)
|
||||
if err != nil {
|
||||
t.Fatalf("encode account response: %v", err)
|
||||
}
|
||||
|
||||
decoded, err := PayloadToAccountResponse(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("decode account response: %v", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(source, decoded) {
|
||||
t.Fatalf("account response mismatch\nsource: %#v\ndecoded:%#v", source, decoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorResponsePayloadRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
source := &usermodel.ErrorResponse{
|
||||
Error: usermodel.ErrorBody{
|
||||
Code: "conflict",
|
||||
Message: "request conflicts with current state",
|
||||
},
|
||||
}
|
||||
|
||||
payload, err := ErrorResponseToPayload(source)
|
||||
if err != nil {
|
||||
t.Fatalf("encode error response: %v", err)
|
||||
}
|
||||
|
||||
decoded, err := PayloadToErrorResponse(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("decode error response: %v", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(source, decoded) {
|
||||
t.Fatalf("error response mismatch\nsource: %#v\ndecoded:%#v", source, decoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserPayloadEncodersRejectNilInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
call func() error
|
||||
}{
|
||||
{
|
||||
name: "get my account request",
|
||||
call: func() error {
|
||||
_, err := GetMyAccountRequestToPayload(nil)
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update my profile request",
|
||||
call: func() error {
|
||||
_, err := UpdateMyProfileRequestToPayload(nil)
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update my settings request",
|
||||
call: func() error {
|
||||
_, err := UpdateMySettingsRequestToPayload(nil)
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "account response",
|
||||
call: func() error {
|
||||
_, err := AccountResponseToPayload(nil)
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "error response",
|
||||
call: func() error {
|
||||
_, err := ErrorResponseToPayload(nil)
|
||||
return err
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if err := tt.call(); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserPayloadDecodersRejectEmptyPayloads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
call func() error
|
||||
}{
|
||||
{
|
||||
name: "get my account request",
|
||||
call: func() error {
|
||||
_, err := PayloadToGetMyAccountRequest(nil)
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update my profile request",
|
||||
call: func() error {
|
||||
_, err := PayloadToUpdateMyProfileRequest(nil)
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update my settings request",
|
||||
call: func() error {
|
||||
_, err := PayloadToUpdateMySettingsRequest(nil)
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "account response",
|
||||
call: func() error {
|
||||
_, err := PayloadToAccountResponse(nil)
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "error response",
|
||||
call: func() error {
|
||||
_, err := PayloadToErrorResponse(nil)
|
||||
return err
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if err := tt.call(); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserPayloadDecodersRecoverFromGarbagePayloads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
call func() error
|
||||
}{
|
||||
{
|
||||
name: "get my account request",
|
||||
call: func() error {
|
||||
_, err := PayloadToGetMyAccountRequest([]byte{0x01, 0x02, 0x03})
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update my profile request",
|
||||
call: func() error {
|
||||
_, err := PayloadToUpdateMyProfileRequest([]byte{0x01, 0x02, 0x03})
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update my settings request",
|
||||
call: func() error {
|
||||
_, err := PayloadToUpdateMySettingsRequest([]byte{0x01, 0x02, 0x03})
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "account response",
|
||||
call: func() error {
|
||||
_, err := PayloadToAccountResponse([]byte{0x01, 0x02, 0x03})
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "error response",
|
||||
call: func() error {
|
||||
_, err := PayloadToErrorResponse([]byte{0x01, 0x02, 0x03})
|
||||
return err
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if err := tt.call(); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayloadToAccountResponseRejectsMissingAccount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
builder := flatbuffers.NewBuilder(64)
|
||||
userfbs.AccountResponseStart(builder)
|
||||
offset := userfbs.AccountResponseEnd(builder)
|
||||
userfbs.FinishAccountResponseBuffer(builder, offset)
|
||||
|
||||
_, err := PayloadToAccountResponse(builder.FinishedBytes())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing account")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "account is missing") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayloadToAccountResponseRejectsMissingEntitlement(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
payload := buildAccountResponsePayload(func(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
userID := builder.CreateString("user-123")
|
||||
email := builder.CreateString("pilot@example.com")
|
||||
raceName := builder.CreateString("Pilot Nova")
|
||||
preferredLanguage := builder.CreateString("en")
|
||||
timeZone := builder.CreateString("Europe/Kaliningrad")
|
||||
|
||||
userfbs.AccountViewStart(builder)
|
||||
userfbs.AccountViewAddUserId(builder, userID)
|
||||
userfbs.AccountViewAddEmail(builder, email)
|
||||
userfbs.AccountViewAddRaceName(builder, raceName)
|
||||
userfbs.AccountViewAddPreferredLanguage(builder, preferredLanguage)
|
||||
userfbs.AccountViewAddTimeZone(builder, timeZone)
|
||||
userfbs.AccountViewAddCreatedAtMs(builder, 1)
|
||||
userfbs.AccountViewAddUpdatedAtMs(builder, 2)
|
||||
return userfbs.AccountViewEnd(builder)
|
||||
})
|
||||
|
||||
_, err := PayloadToAccountResponse(payload)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing entitlement")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "entitlement is missing") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayloadToAccountResponseRejectsOverflowLimitValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if strconv.IntSize == 64 {
|
||||
t.Skip("int overflow from int64 is not possible on 64-bit runtime")
|
||||
}
|
||||
|
||||
maxInt := int64(int(^uint(0) >> 1))
|
||||
overflow := maxInt + 1
|
||||
nowMS := int64(1)
|
||||
|
||||
payload := buildAccountResponsePayload(func(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
actorType := builder.CreateString("admin")
|
||||
userfbs.ActorRefStart(builder)
|
||||
userfbs.ActorRefAddType(builder, actorType)
|
||||
actorOffset := userfbs.ActorRefEnd(builder)
|
||||
|
||||
planCode := builder.CreateString("free")
|
||||
source := builder.CreateString("auth_registration")
|
||||
reasonCode := builder.CreateString("initial_free_entitlement")
|
||||
userfbs.EntitlementSnapshotStart(builder)
|
||||
userfbs.EntitlementSnapshotAddPlanCode(builder, planCode)
|
||||
userfbs.EntitlementSnapshotAddSource(builder, source)
|
||||
userfbs.EntitlementSnapshotAddActor(builder, actorOffset)
|
||||
userfbs.EntitlementSnapshotAddReasonCode(builder, reasonCode)
|
||||
userfbs.EntitlementSnapshotAddStartsAtMs(builder, nowMS)
|
||||
userfbs.EntitlementSnapshotAddUpdatedAtMs(builder, nowMS)
|
||||
entitlementOffset := userfbs.EntitlementSnapshotEnd(builder)
|
||||
|
||||
limitCode := builder.CreateString("max_owned_private_games")
|
||||
limitReasonCode := builder.CreateString("manual_override")
|
||||
userfbs.ActiveLimitStart(builder)
|
||||
userfbs.ActiveLimitAddLimitCode(builder, limitCode)
|
||||
userfbs.ActiveLimitAddValue(builder, overflow)
|
||||
userfbs.ActiveLimitAddReasonCode(builder, limitReasonCode)
|
||||
userfbs.ActiveLimitAddActor(builder, actorOffset)
|
||||
userfbs.ActiveLimitAddAppliedAtMs(builder, nowMS)
|
||||
limitOffset := userfbs.ActiveLimitEnd(builder)
|
||||
|
||||
userfbs.AccountViewStartActiveLimitsVector(builder, 1)
|
||||
builder.PrependUOffsetT(limitOffset)
|
||||
limitsVector := builder.EndVector(1)
|
||||
|
||||
userID := builder.CreateString("user-123")
|
||||
email := builder.CreateString("pilot@example.com")
|
||||
raceName := builder.CreateString("Pilot Nova")
|
||||
preferredLanguage := builder.CreateString("en")
|
||||
timeZone := builder.CreateString("Europe/Kaliningrad")
|
||||
|
||||
userfbs.AccountViewStart(builder)
|
||||
userfbs.AccountViewAddUserId(builder, userID)
|
||||
userfbs.AccountViewAddEmail(builder, email)
|
||||
userfbs.AccountViewAddRaceName(builder, raceName)
|
||||
userfbs.AccountViewAddPreferredLanguage(builder, preferredLanguage)
|
||||
userfbs.AccountViewAddTimeZone(builder, timeZone)
|
||||
userfbs.AccountViewAddEntitlement(builder, entitlementOffset)
|
||||
userfbs.AccountViewAddActiveLimits(builder, limitsVector)
|
||||
userfbs.AccountViewAddCreatedAtMs(builder, nowMS)
|
||||
userfbs.AccountViewAddUpdatedAtMs(builder, nowMS)
|
||||
return userfbs.AccountViewEnd(builder)
|
||||
})
|
||||
|
||||
_, err := PayloadToAccountResponse(payload)
|
||||
if err == nil {
|
||||
t.Fatal("expected overflow error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "overflows int") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayloadToErrorResponseRejectsMissingError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
builder := flatbuffers.NewBuilder(64)
|
||||
userfbs.ErrorResponseStart(builder)
|
||||
offset := userfbs.ErrorResponseEnd(builder)
|
||||
userfbs.FinishErrorResponseBuffer(builder, offset)
|
||||
|
||||
_, err := PayloadToErrorResponse(builder.FinishedBytes())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing error body")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "error is missing") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func buildAccountResponsePayload(accountBuilder func(*flatbuffers.Builder) flatbuffers.UOffsetT) []byte {
|
||||
builder := flatbuffers.NewBuilder(256)
|
||||
|
||||
accountOffset := accountBuilder(builder)
|
||||
|
||||
userfbs.AccountResponseStart(builder)
|
||||
userfbs.AccountResponseAddAccount(builder, accountOffset)
|
||||
responseOffset := userfbs.AccountResponseEnd(builder)
|
||||
userfbs.FinishAccountResponseBuffer(builder, responseOffset)
|
||||
|
||||
return builder.FinishedBytes()
|
||||
}
|
||||
+103
-32
@@ -1,5 +1,9 @@
|
||||
# User Service Implementation Plan
|
||||
|
||||
This plan has been already implemented and stays here for historical reasons.
|
||||
|
||||
It should NOT be threated as source of truth for service functionality.
|
||||
|
||||
## Planning Principles
|
||||
|
||||
This plan is aligned with the current repository architecture and is written
|
||||
@@ -17,7 +21,9 @@ Execution priorities:
|
||||
- keep the first version storage-agnostic at the domain boundary even if Redis
|
||||
is the initial backend
|
||||
|
||||
## Stage 01 — Freeze Vocabulary, Contracts, and Cross-Service Ownership
|
||||
## ~~Stage 01~~ — Freeze Vocabulary, Contracts, and Cross-Service Ownership
|
||||
|
||||
Status: implemented.
|
||||
|
||||
### Goal
|
||||
|
||||
@@ -38,8 +44,10 @@ Remove naming ambiguity and freeze the service boundary before implementation.
|
||||
- workflow and history in `Geo Profile Service`
|
||||
- Freeze the auth-facing internal REST endpoints already reserved by
|
||||
`Auth / Session Service`.
|
||||
- Freeze the need for create-only registration context on
|
||||
`EnsureUserByEmail`.
|
||||
- Freeze the exact create-only registration context shape on
|
||||
`EnsureUserByEmail`:
|
||||
- `preferred_language`
|
||||
- `time_zone`
|
||||
|
||||
### Deliverables
|
||||
|
||||
@@ -58,7 +66,9 @@ Remove naming ambiguity and freeze the service boundary before implementation.
|
||||
|
||||
- none yet beyond documentation review
|
||||
|
||||
## Stage 02 — Define Domain Entities and Redis-Backed Logical State
|
||||
## ~~Stage 02~~ — Define Domain Entities and Redis-Backed Logical State
|
||||
|
||||
Status: implemented.
|
||||
|
||||
### Goal
|
||||
|
||||
@@ -97,7 +107,9 @@ without revisiting core semantics.
|
||||
- domain validation tests for required fields
|
||||
- tests for effective-state evaluation of active versus expired records
|
||||
|
||||
## Stage 03 — Implement Auth-Facing Resolution, Ensure, Existence, and E-Mail Blocking
|
||||
## ~~Stage 03~~ — Implement Auth-Facing Resolution, Ensure, Existence, and E-Mail Blocking
|
||||
|
||||
Status: implemented.
|
||||
|
||||
### Goal
|
||||
|
||||
@@ -122,6 +134,12 @@ Provide the minimum trusted API needed by `Auth / Session Service`.
|
||||
- trusted internal REST handlers for auth-facing endpoints
|
||||
- domain services for resolution and block behavior
|
||||
- Redis-backed storage for user existence and blocked-email subjects
|
||||
- runnable `cmd/userservice` process using `Gin` and `go-redis/v9`
|
||||
- durable create path that already materializes:
|
||||
- opaque `user_id`
|
||||
- generated `player-<shortid>` race name
|
||||
- stored `preferred_language` and `time_zone`
|
||||
- initial free entitlement snapshot
|
||||
|
||||
### Exit Criteria
|
||||
|
||||
@@ -137,22 +155,26 @@ Provide the minimum trusted API needed by `Auth / Session Service`.
|
||||
- block by user id on unknown user returns not found
|
||||
- repeated block calls stay idempotent
|
||||
|
||||
## Stage 04 — Add New-User Creation Context from Auth
|
||||
## ~~Stage 04~~ — Implement New-User Creation Context from Auth
|
||||
|
||||
Status: implemented.
|
||||
|
||||
### Goal
|
||||
|
||||
Support first-login user creation with initial settings captured at confirm
|
||||
time.
|
||||
Tighten the already-implemented first-login create path with stricter semantic
|
||||
validation.
|
||||
|
||||
### Tasks
|
||||
|
||||
- Extend `EnsureUserByEmail` contract with create-only registration context:
|
||||
- Preserve the already-frozen create-only `EnsureUserByEmail`
|
||||
registration context with:
|
||||
- `preferred_language`
|
||||
- `time_zone`
|
||||
- Validate `preferred_language` as BCP 47.
|
||||
- Validate `time_zone` as IANA TZ name.
|
||||
- Generate initial `race_name` in `player-<shortid>` form during creation.
|
||||
- Initialize the newly created user with:
|
||||
- Tighten `preferred_language` validation to BCP 47 semantics.
|
||||
- Tighten `time_zone` validation to IANA TZ semantics.
|
||||
- Preserve generated initial `race_name` in `player-<shortid>` form during
|
||||
creation.
|
||||
- Preserve the newly created user initialization with:
|
||||
- free entitlement
|
||||
- no active sanctions
|
||||
- no custom limits
|
||||
@@ -161,9 +183,9 @@ time.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- extended ensure-by-email request model
|
||||
- create-user domain service
|
||||
- create-user domain service using the frozen ensure-by-email request model
|
||||
- generated-race-name helper
|
||||
- create-path validation for `preferred_language` and `time_zone`
|
||||
|
||||
### Exit Criteria
|
||||
|
||||
@@ -177,7 +199,9 @@ time.
|
||||
- existing user ensure ignores create-only registration context
|
||||
- invalid BCP 47 or IANA inputs are rejected on create path
|
||||
|
||||
## Stage 05 — Implement Self-Service Account Read and Split Profile/Settings Mutations
|
||||
## ~~Stage 05~~ — Implement Self-Service Account Read and Split Profile/Settings Mutations
|
||||
|
||||
Status: implemented.
|
||||
|
||||
### Goal
|
||||
|
||||
@@ -220,7 +244,9 @@ Expose the minimal authenticated account surface routed by `Edge Gateway`.
|
||||
- `UpdateMySettings` validates BCP 47 and IANA values
|
||||
- active `profile_update_block` denies both update flows
|
||||
|
||||
## Stage 06 — Implement race_name Uniqueness Policy Behind a Dedicated Interface
|
||||
## ~~Stage 06~~ — Implement race_name Uniqueness Policy Behind a Dedicated Interface
|
||||
|
||||
Status: implemented.
|
||||
|
||||
### Goal
|
||||
|
||||
@@ -256,7 +282,9 @@ Keep `race_name` uniqueness strict and replaceable.
|
||||
- rename releases the old reservation only after the new one is secured
|
||||
- failed reservation backend causes mutation to fail closed
|
||||
|
||||
## Stage 07 — Implement Entitlement History Plus Materialized Current Snapshot
|
||||
## ~~Stage 07~~ — Implement Entitlement History Plus Materialized Current Snapshot
|
||||
|
||||
Status: implemented.
|
||||
|
||||
### Goal
|
||||
|
||||
@@ -298,7 +326,9 @@ Support both auditability and fast synchronous entitlement reads.
|
||||
- free default is created for new users
|
||||
- extending or revoking access preserves deterministic current-state behavior
|
||||
|
||||
## Stage 08 — Implement Sanctions and Limit Records with Active/Effective Evaluation
|
||||
## ~~Stage 08~~ — Implement Sanctions and Limit Records with Active/Effective Evaluation
|
||||
|
||||
Status: implemented.
|
||||
|
||||
### Goal
|
||||
|
||||
@@ -317,11 +347,23 @@ consumers.
|
||||
- `profile_update_block`
|
||||
- Freeze v1 limit catalog:
|
||||
- `max_owned_private_games`
|
||||
- `max_active_private_games`
|
||||
- `max_pending_public_applications`
|
||||
- `max_pending_private_join_requests`
|
||||
- `max_pending_private_invites_sent`
|
||||
- `max_active_game_memberships`
|
||||
- Freeze supported v1 limit semantics:
|
||||
- paid effective defaults:
|
||||
- `max_owned_private_games=3`
|
||||
- `max_pending_public_applications=10`
|
||||
- `max_active_game_memberships=10`
|
||||
- free effective defaults:
|
||||
- `max_owned_private_games` is omitted
|
||||
- `max_pending_public_applications=3`
|
||||
- `max_active_game_memberships=3`
|
||||
- `max_active_game_memberships` applies only to public games
|
||||
- `max_pending_public_applications` is the total public-games budget and is
|
||||
interpreted by `Game Lobby` together with current active public
|
||||
memberships
|
||||
- Keep legacy retired limit codes backward-compatible on reads, but reject
|
||||
them for new trusted limit commands.
|
||||
- Implement active/effective evaluation with current time.
|
||||
- Implement trusted explicit commands to apply/remove sanctions and set/remove
|
||||
limits.
|
||||
@@ -343,9 +385,14 @@ consumers.
|
||||
|
||||
- active sanctions appear in account reads
|
||||
- expired sanctions and limits stop affecting effective state
|
||||
- retired legacy limit records are ignored during reads and effective
|
||||
evaluation
|
||||
- retired legacy limit codes are rejected by trusted limit commands
|
||||
- applying and removing sanctions/limits is idempotent where appropriate
|
||||
|
||||
## Stage 09 — Implement Lobby Eligibility Snapshot API
|
||||
## ~~Stage 09~~ — Implement Lobby Eligibility Snapshot API
|
||||
|
||||
Status: implemented.
|
||||
|
||||
### Goal
|
||||
|
||||
@@ -361,6 +408,16 @@ user-level access decisions.
|
||||
- active lobby-relevant sanctions
|
||||
- effective lobby-relevant limits
|
||||
- derived booleans for lobby decisions
|
||||
- Freeze the lobby-facing effective limit catalog:
|
||||
- paid users receive `max_owned_private_games=3`,
|
||||
`max_pending_public_applications=10`, and
|
||||
`max_active_game_memberships=10`
|
||||
- free users omit `max_owned_private_games` and receive
|
||||
`max_pending_public_applications=3` and
|
||||
`max_active_game_memberships=3`
|
||||
- `max_pending_public_applications` remains the total public-games budget
|
||||
consumed together with current active public memberships inside
|
||||
`Game Lobby`
|
||||
- Keep the response read-optimized so lobby does not need multiple dependent
|
||||
calls back into `User Service`.
|
||||
- Define deterministic not-found behavior.
|
||||
@@ -381,8 +438,12 @@ user-level access decisions.
|
||||
- lobby eligibility snapshot reflects paid status, sanctions, and limits
|
||||
- unknown user returns stable not-found behavior
|
||||
- derived booleans remain consistent with raw effective state
|
||||
- free and paid snapshots materialize the reduced three-code effective limit
|
||||
catalog correctly
|
||||
|
||||
## Stage 10 — Implement Geo declared_country Sync Command
|
||||
## ~~Stage 10~~ — Implement Geo declared_country Sync Command
|
||||
|
||||
Status: implemented.
|
||||
|
||||
### Goal
|
||||
|
||||
@@ -416,7 +477,9 @@ Support the current-country denormalization path owned by `Geo Profile Service`.
|
||||
- invalid country codes are rejected
|
||||
- country sync emits the correct auxiliary event after commit
|
||||
|
||||
## Stage 11 — Implement Admin Lookup, Filtered Listing, and Explicit Trusted Mutations
|
||||
## ~~Stage 11~~ — Implement Admin Lookup, Filtered Listing, and Explicit Trusted Mutations
|
||||
|
||||
Status: implemented.
|
||||
|
||||
### Goal
|
||||
|
||||
@@ -462,7 +525,9 @@ operations.
|
||||
- exact lookups by `user_id`, email, and `race_name` resolve the correct user
|
||||
- every trusted mutation preserves actor and reason metadata
|
||||
|
||||
## Stage 12 — Add Per-Domain-Area Async Events and Observability
|
||||
## ~~Stage 12~~ — Add Per-Domain-Area Async Events and Observability
|
||||
|
||||
Status: implemented.
|
||||
|
||||
### Goal
|
||||
|
||||
@@ -505,7 +570,9 @@ truth.
|
||||
- event payloads include minimum required metadata
|
||||
- observability hooks do not change business behavior
|
||||
|
||||
## Stage 13 — Add Contract Tests Against Auth, Lobby, and Geo Expectations
|
||||
## ~~Stage 13~~ — Add Contract Tests Against Auth, Lobby, and Geo Expectations
|
||||
|
||||
Status: implemented.
|
||||
|
||||
### Goal
|
||||
|
||||
@@ -542,7 +609,9 @@ must satisfy for other services.
|
||||
- lobby eligibility snapshot reflects paid status, sanctions, and limits
|
||||
- geo country sync changes only current `declared_country`
|
||||
|
||||
## Stage 14 — Add Rollout Notes for Gateway/Auth/OpenAPI Updates and Shared geoip
|
||||
## ~~Stage 14~~ — Add Rollout Notes for Gateway/Auth/OpenAPI Updates and Shared geoip
|
||||
|
||||
Status: implemented.
|
||||
|
||||
### Goal
|
||||
|
||||
@@ -551,11 +620,13 @@ its intended end-to-end form.
|
||||
|
||||
### Tasks
|
||||
|
||||
- Document the required `gateway` public `confirm-email-code` addition of
|
||||
- Document the required `gateway` public `confirm-email-code` dependency on
|
||||
`time_zone`.
|
||||
- Document the required `authsession` public OpenAPI preservation of the same
|
||||
`time_zone` requirement.
|
||||
- Document that the frozen `authsession -> user` ensure contract requires
|
||||
create-only `registration_context` with `preferred_language` and
|
||||
`time_zone`.
|
||||
- Document the required `authsession` public OpenAPI mirror change.
|
||||
- Document the required `authsession -> user` ensure contract extension for
|
||||
create-only registration context.
|
||||
- Document the required shared `pkg/geoip` package for gateway and geo.
|
||||
- Document README follow-up updates needed in `gateway` and `geoprofile`.
|
||||
- Define rollout order so the cross-service contract changes do not land in an
|
||||
|
||||
+267
-689
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"galaxy/user/internal/app"
|
||||
"galaxy/user/internal/config"
|
||||
"galaxy/user/internal/logging"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "userservice: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
cfg, err := config.LoadFromEnv()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger, err := logging.New(cfg.Logging.Level)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rootCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
runtime, err := app.NewRuntime(rootCtx, cfg, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = runtime.Close()
|
||||
}()
|
||||
|
||||
return runtime.Run(rootCtx)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# User Service Docs
|
||||
|
||||
This directory keeps service-local documentation that is more operational or
|
||||
more example-heavy than [`../README.md`](../README.md).
|
||||
|
||||
Sections:
|
||||
|
||||
- [Runtime and components](runtime.md)
|
||||
- [Main flows and boundaries](flows.md)
|
||||
- [Operator runbook](runbook.md)
|
||||
- [Contract examples](examples.md)
|
||||
|
||||
Primary references:
|
||||
|
||||
- [`../README.md`](../README.md) for stable service scope and business rules
|
||||
- [`../openapi.yaml`](../openapi.yaml) for the trusted internal REST contract
|
||||
- [`../../ARCHITECTURE.md`](../../ARCHITECTURE.md) for system-level transport
|
||||
and ownership rules
|
||||
- [`../../TESTING.md`](../../TESTING.md) for the cross-service testing matrix
|
||||
@@ -0,0 +1,206 @@
|
||||
# Contract Examples
|
||||
|
||||
## ensure-by-email
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "pilot@example.com",
|
||||
"registration_context": {
|
||||
"preferred_language": "en",
|
||||
"time_zone": "Europe/Kaliningrad"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Created response:
|
||||
|
||||
```json
|
||||
{
|
||||
"outcome": "created",
|
||||
"user_id": "user-123"
|
||||
}
|
||||
```
|
||||
|
||||
Existing response:
|
||||
|
||||
```json
|
||||
{
|
||||
"outcome": "existing",
|
||||
"user_id": "user-123"
|
||||
}
|
||||
```
|
||||
|
||||
Blocked response:
|
||||
|
||||
```json
|
||||
{
|
||||
"outcome": "blocked",
|
||||
"block_reason_code": "policy_blocked"
|
||||
}
|
||||
```
|
||||
|
||||
## account aggregate
|
||||
|
||||
```json
|
||||
{
|
||||
"account": {
|
||||
"user_id": "user-123",
|
||||
"email": "pilot@example.com",
|
||||
"race_name": "Pilot Nova",
|
||||
"preferred_language": "en",
|
||||
"time_zone": "Europe/Kaliningrad",
|
||||
"declared_country": "DE",
|
||||
"entitlement": {
|
||||
"plan_code": "free",
|
||||
"is_paid": false,
|
||||
"source": "auth_registration",
|
||||
"actor": {
|
||||
"type": "service",
|
||||
"id": "user-service"
|
||||
},
|
||||
"reason_code": "initial_free_entitlement",
|
||||
"starts_at": "2026-04-09T10:00:00Z",
|
||||
"updated_at": "2026-04-09T10:00:00Z"
|
||||
},
|
||||
"active_sanctions": [],
|
||||
"active_limits": [],
|
||||
"created_at": "2026-04-09T10:00:00Z",
|
||||
"updated_at": "2026-04-09T10:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## update profile
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"race_name": "Nova Prime"
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"account": {
|
||||
"user_id": "user-123",
|
||||
"email": "pilot@example.com",
|
||||
"race_name": "Nova Prime",
|
||||
"preferred_language": "en",
|
||||
"time_zone": "Europe/Kaliningrad",
|
||||
"entitlement": {
|
||||
"plan_code": "free",
|
||||
"is_paid": false,
|
||||
"source": "auth_registration",
|
||||
"actor": {
|
||||
"type": "service",
|
||||
"id": "user-service"
|
||||
},
|
||||
"reason_code": "initial_free_entitlement",
|
||||
"starts_at": "2026-04-09T10:00:00Z",
|
||||
"updated_at": "2026-04-09T10:00:00Z"
|
||||
},
|
||||
"active_sanctions": [],
|
||||
"active_limits": [],
|
||||
"created_at": "2026-04-09T10:00:00Z",
|
||||
"updated_at": "2026-04-09T10:05:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Conflict:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "conflict",
|
||||
"message": "request conflicts with current state"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## update settings
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"preferred_language": "fr-FR",
|
||||
"time_zone": "Europe/Paris"
|
||||
}
|
||||
```
|
||||
|
||||
## admin lookup by e-mail
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "pilot@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"user_id": "user-123",
|
||||
"email": "pilot@example.com",
|
||||
"race_name": "Pilot Nova",
|
||||
"preferred_language": "en",
|
||||
"time_zone": "Europe/Kaliningrad",
|
||||
"entitlement": {
|
||||
"plan_code": "free",
|
||||
"is_paid": false,
|
||||
"source": "auth_registration",
|
||||
"actor": {
|
||||
"type": "service",
|
||||
"id": "user-service"
|
||||
},
|
||||
"reason_code": "initial_free_entitlement",
|
||||
"starts_at": "2026-04-09T10:00:00Z",
|
||||
"updated_at": "2026-04-09T10:00:00Z"
|
||||
},
|
||||
"active_sanctions": [],
|
||||
"active_limits": [],
|
||||
"created_at": "2026-04-09T10:00:00Z",
|
||||
"updated_at": "2026-04-09T10:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## declared-country sync
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"declared_country": "DE"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": "user-123",
|
||||
"declared_country": "DE",
|
||||
"updated_at": "2026-04-09T10:10:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## shared error envelope
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "invalid_request",
|
||||
"message": "request is invalid"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,163 @@
|
||||
# Main Flows and Boundaries
|
||||
|
||||
## Auth / Session -> User
|
||||
|
||||
`Auth / Session Service` uses synchronous REST calls for user ownership
|
||||
decisions during public auth.
|
||||
|
||||
### Resolve by e-mail
|
||||
|
||||
`POST /api/v1/internal/user-resolutions/by-email`
|
||||
|
||||
Outcome vocabulary:
|
||||
|
||||
- `creatable`
|
||||
- `existing`
|
||||
- `blocked`
|
||||
|
||||
The decision is based on exact-after-trim e-mail matching plus the current
|
||||
block state for that subject.
|
||||
|
||||
### Ensure by e-mail
|
||||
|
||||
`POST /api/v1/internal/users/ensure-by-email`
|
||||
|
||||
Rules:
|
||||
|
||||
- `registration_context` is required
|
||||
- `registration_context` is create-only
|
||||
- existing users ignore the supplied registration context
|
||||
- blocked subjects return `blocked` rather than creating a user
|
||||
- the current rollout sends temporary `preferred_language="en"` from
|
||||
authsession and forwards the public confirm `time_zone`
|
||||
|
||||
Create side effects:
|
||||
|
||||
- generate opaque `user_id`
|
||||
- generate default `player-*` race name
|
||||
- store initial preferred language and time zone
|
||||
- materialize the initial free entitlement snapshot
|
||||
- publish initialization-style profile, settings, and entitlement events
|
||||
|
||||
## Gateway -> User
|
||||
|
||||
Gateway owns the external authenticated gRPC contract and transcodes to this
|
||||
service's internal REST API.
|
||||
|
||||
External authenticated message types:
|
||||
|
||||
- `user.account.get`
|
||||
- `user.profile.update`
|
||||
- `user.settings.update`
|
||||
|
||||
Internal REST routes:
|
||||
|
||||
- `GET /api/v1/internal/users/{user_id}/account`
|
||||
- `POST /api/v1/internal/users/{user_id}/profile`
|
||||
- `POST /api/v1/internal/users/{user_id}/settings`
|
||||
|
||||
Rules:
|
||||
|
||||
- gateway derives `user_id` from authenticated session context only
|
||||
- success returns the shared account aggregate
|
||||
- business errors return stable `code` and `message`
|
||||
- timeout or upstream `503` stay transport-level unavailable at gateway
|
||||
|
||||
### Profile update
|
||||
|
||||
`UpdateMyProfile` changes only `race_name`.
|
||||
|
||||
Rules:
|
||||
|
||||
- preserve stored casing on success
|
||||
- enforce canonical reservation uniqueness
|
||||
- reject conflicts as `409 conflict`
|
||||
- reject writes while `profile_update_block` is active
|
||||
- return current aggregate on no-op rename
|
||||
|
||||
### Settings update
|
||||
|
||||
`UpdateMySettings` changes only:
|
||||
|
||||
- `preferred_language`
|
||||
- `time_zone`
|
||||
|
||||
Rules:
|
||||
|
||||
- validate BCP 47 and IANA semantics
|
||||
- reject writes while `profile_update_block` is active
|
||||
- return the refreshed account aggregate
|
||||
|
||||
## Lobby -> User
|
||||
|
||||
`Game Lobby Service` reads one synchronous eligibility snapshot through:
|
||||
|
||||
- `GET /api/v1/internal/users/{user_id}/eligibility`
|
||||
|
||||
Rules:
|
||||
|
||||
- unknown users return `exists=false`
|
||||
- current entitlement is expiry-repaired lazily
|
||||
- active sanctions are filtered to the lobby-relevant set
|
||||
- effective limits combine default catalog values plus active overrides
|
||||
- markers are derived from sanctions, entitlement, and limits
|
||||
|
||||
## Geo -> User
|
||||
|
||||
`Geo Profile Service` synchronizes the latest approved effective declared
|
||||
country through:
|
||||
|
||||
- `POST /api/v1/internal/users/{user_id}/declared-country/sync`
|
||||
|
||||
Rules:
|
||||
|
||||
- input must be uppercase ISO 3166-1 alpha-2
|
||||
- syncing the stored value is a no-op
|
||||
- `User Service` stores only the current effective value
|
||||
- geo owns review workflow and history
|
||||
- successful updates publish `user.declared_country.changed`
|
||||
|
||||
## Admin Reads And Commands
|
||||
|
||||
Trusted admin callers use:
|
||||
|
||||
- exact reads by `user_id`, e-mail, and race name
|
||||
- deterministic filtered listing
|
||||
- explicit entitlement commands
|
||||
- explicit sanction commands
|
||||
- explicit limit commands
|
||||
|
||||
Listing rules:
|
||||
|
||||
- order by `created_at desc`, then `user_id desc`
|
||||
- combine filters with `AND`
|
||||
- `page_token` is opaque and filter-bound
|
||||
|
||||
## Domain Events
|
||||
|
||||
The shared auxiliary event stream contains post-commit state propagation for:
|
||||
|
||||
- `user.profile.changed`
|
||||
- `user.settings.changed`
|
||||
- `user.entitlement.changed`
|
||||
- `user.sanction.changed`
|
||||
- `user.limit.changed`
|
||||
- `user.declared_country.changed`
|
||||
|
||||
Operation vocabularies:
|
||||
|
||||
- profile and settings:
|
||||
- `initialized`
|
||||
- `updated`
|
||||
- entitlement:
|
||||
- `initialized`
|
||||
- `granted`
|
||||
- `extended`
|
||||
- `revoked`
|
||||
- `expired_repaired`
|
||||
- sanction:
|
||||
- `applied`
|
||||
- `removed`
|
||||
- limit:
|
||||
- `set`
|
||||
- `removed`
|
||||
@@ -0,0 +1,106 @@
|
||||
# Runbook
|
||||
|
||||
## Startup Checklist
|
||||
|
||||
Before starting `userservice`, verify:
|
||||
|
||||
- `USERSERVICE_REDIS_ADDR` points to the intended Redis instance
|
||||
- internal HTTP bind address is free
|
||||
- optional admin metrics listener does not collide with another process
|
||||
- domain-events stream settings match the environment that consumes them
|
||||
|
||||
Expected startup behavior:
|
||||
|
||||
- configuration is loaded and validated first
|
||||
- Redis-backed stores and publishers are constructed
|
||||
- startup fails fast on Redis misconfiguration or connectivity failure
|
||||
|
||||
## Health And Readiness
|
||||
|
||||
`userservice` does not expose public health endpoints.
|
||||
|
||||
Operational readiness is typically checked through one trusted internal route,
|
||||
for example:
|
||||
|
||||
- `GET /api/v1/internal/users/{user_id}/exists`
|
||||
|
||||
with a guaranteed-missing `user_id`. A healthy process returns `200` with
|
||||
`{"exists":false}`.
|
||||
|
||||
If admin metrics are enabled, `/metrics` on the admin listener is the
|
||||
additional process-level operational endpoint.
|
||||
|
||||
## Common Failure Modes
|
||||
|
||||
### Redis unavailable
|
||||
|
||||
Symptoms:
|
||||
|
||||
- process fails during startup
|
||||
- internal API returns `503 service_unavailable`
|
||||
- domain events stop being published
|
||||
|
||||
Checks:
|
||||
|
||||
- connectivity to `USERSERVICE_REDIS_ADDR`
|
||||
- Redis ACL credentials
|
||||
- Redis DB number
|
||||
- TLS setting mismatch
|
||||
|
||||
### Invalid registration context
|
||||
|
||||
Symptoms:
|
||||
|
||||
- `ensure-by-email` returns `400 invalid_request`
|
||||
|
||||
Checks:
|
||||
|
||||
- `preferred_language` is a valid BCP 47 tag
|
||||
- `time_zone` is a valid IANA time-zone name
|
||||
|
||||
### race_name conflict
|
||||
|
||||
Symptoms:
|
||||
|
||||
- profile update returns `409 conflict`
|
||||
|
||||
Checks:
|
||||
|
||||
- desired race name is not already reserved under canonical uniqueness rules
|
||||
- user is not currently blocked by `profile_update_block`
|
||||
|
||||
### declared-country sync rejected
|
||||
|
||||
Symptoms:
|
||||
|
||||
- geo sync returns `400 invalid_request`
|
||||
|
||||
Checks:
|
||||
|
||||
- country code is uppercase ISO 3166-1 alpha-2
|
||||
- trusted caller is using the intended internal route
|
||||
|
||||
## Safe Rollout Notes
|
||||
|
||||
- Keep `Auth / Session Service` and `User Service` aligned on the current
|
||||
`registration_context` shape.
|
||||
- During the current rollout, treat authsession-provided
|
||||
`preferred_language="en"` as the active create-path contract.
|
||||
- Gateway direct `user.*` self-service routing depends on the internal REST
|
||||
routes staying stable.
|
||||
- Do not roll out billing-driven entitlement mutations assuming another
|
||||
service owns current entitlement state. `User Service` remains the source of
|
||||
truth for current entitlement.
|
||||
|
||||
## Debugging Data Mismatches
|
||||
|
||||
When a caller reports mismatched user state:
|
||||
|
||||
1. Read the current account aggregate through the trusted internal route.
|
||||
2. Confirm whether the discrepancy is in source-of-truth state or in a
|
||||
downstream projection.
|
||||
3. If the issue concerns declared-country workflow history, switch to `Geo
|
||||
Profile Service`; `User Service` stores only the current effective value.
|
||||
4. If the issue concerns authenticated edge transport, verify the same user
|
||||
through gateway `user.account.get` to distinguish transport problems from
|
||||
source-of-truth problems.
|
||||
@@ -0,0 +1,151 @@
|
||||
# Runtime and Components
|
||||
|
||||
The diagram below focuses on the deployed `galaxy/user` process and its
|
||||
runtime dependencies.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Callers
|
||||
Auth["Auth / Session Service"]
|
||||
Gateway["Edge Gateway"]
|
||||
Lobby["Game Lobby Service"]
|
||||
Geo["Geo Profile Service"]
|
||||
Admin["Trusted admin callers"]
|
||||
end
|
||||
|
||||
subgraph User["User Service process"]
|
||||
InternalHTTP["Trusted internal HTTP listener\n/api/v1/internal/*"]
|
||||
AdminHTTP["Optional admin HTTP listener\n/metrics"]
|
||||
Services["Application services"]
|
||||
Telemetry["Logs, traces, metrics"]
|
||||
end
|
||||
|
||||
Redis["Redis\nkeyspace + domain-events stream"]
|
||||
|
||||
Auth --> InternalHTTP
|
||||
Gateway --> InternalHTTP
|
||||
Lobby --> InternalHTTP
|
||||
Geo --> InternalHTTP
|
||||
Admin --> InternalHTTP
|
||||
InternalHTTP --> Services
|
||||
Services --> Redis
|
||||
InternalHTTP --> Telemetry
|
||||
AdminHTTP --> Telemetry
|
||||
```
|
||||
|
||||
## Listeners
|
||||
|
||||
`userservice` exposes two HTTP listeners:
|
||||
|
||||
| Listener | Default addr | Purpose |
|
||||
| --- | --- | --- |
|
||||
| Internal HTTP | `:8091` | Trusted business API under `/api/v1/internal/*` |
|
||||
| Admin HTTP | disabled | Optional Prometheus metrics on `/metrics` |
|
||||
|
||||
Shared listener defaults:
|
||||
|
||||
- read-header timeout: `2s`
|
||||
- read timeout: `10s`
|
||||
- idle timeout: `1m`
|
||||
|
||||
The internal application timeout is configured separately through
|
||||
`USERSERVICE_INTERNAL_HTTP_REQUEST_TIMEOUT`.
|
||||
|
||||
Intentional omissions:
|
||||
|
||||
- no public listener
|
||||
- no authenticated edge gRPC listener
|
||||
- no built-in `/healthz`
|
||||
- no built-in `/readyz`
|
||||
|
||||
## Startup Wiring
|
||||
|
||||
`cmd/userservice` loads config, constructs logging and telemetry, and then
|
||||
creates the runtime through `internal/app.NewRuntime`.
|
||||
|
||||
The runtime wires:
|
||||
|
||||
- Redis-backed stores for accounts, entitlement snapshots, sanctions, limits,
|
||||
and listing indexes
|
||||
- the trusted internal HTTP router
|
||||
- the optional admin metrics listener
|
||||
- the optional Redis-backed domain-event publishers
|
||||
- service-local helpers for clock, IDs, and validation/policy adapters
|
||||
|
||||
Startup fails fast when Redis connectivity is unavailable or configuration is
|
||||
invalid.
|
||||
|
||||
## Redis Namespaces
|
||||
|
||||
The service uses one Redis keyspace prefix plus one auxiliary domain-events
|
||||
stream.
|
||||
|
||||
Configuration:
|
||||
|
||||
- `USERSERVICE_REDIS_KEYSPACE_PREFIX`
|
||||
- `USERSERVICE_REDIS_DOMAIN_EVENTS_STREAM`
|
||||
- `USERSERVICE_REDIS_DOMAIN_EVENTS_STREAM_MAX_LEN`
|
||||
|
||||
The keyspace stores source-of-truth business state. The stream carries
|
||||
post-commit auxiliary domain events and must not be treated as the source of
|
||||
truth.
|
||||
|
||||
## Configuration Groups
|
||||
|
||||
Required for all process starts:
|
||||
|
||||
- `USERSERVICE_REDIS_ADDR`
|
||||
|
||||
Core process config:
|
||||
|
||||
- `USERSERVICE_SHUTDOWN_TIMEOUT`
|
||||
- `USERSERVICE_LOG_LEVEL`
|
||||
|
||||
Internal HTTP config:
|
||||
|
||||
- `USERSERVICE_INTERNAL_HTTP_ADDR`
|
||||
- `USERSERVICE_INTERNAL_HTTP_READ_HEADER_TIMEOUT`
|
||||
- `USERSERVICE_INTERNAL_HTTP_READ_TIMEOUT`
|
||||
- `USERSERVICE_INTERNAL_HTTP_IDLE_TIMEOUT`
|
||||
- `USERSERVICE_INTERNAL_HTTP_REQUEST_TIMEOUT`
|
||||
|
||||
Admin HTTP config:
|
||||
|
||||
- `USERSERVICE_ADMIN_HTTP_ADDR`
|
||||
- `USERSERVICE_ADMIN_HTTP_READ_HEADER_TIMEOUT`
|
||||
- `USERSERVICE_ADMIN_HTTP_READ_TIMEOUT`
|
||||
- `USERSERVICE_ADMIN_HTTP_IDLE_TIMEOUT`
|
||||
|
||||
Redis connectivity and namespace config:
|
||||
|
||||
- `USERSERVICE_REDIS_USERNAME`
|
||||
- `USERSERVICE_REDIS_PASSWORD`
|
||||
- `USERSERVICE_REDIS_DB`
|
||||
- `USERSERVICE_REDIS_TLS_ENABLED`
|
||||
- `USERSERVICE_REDIS_OPERATION_TIMEOUT`
|
||||
- `USERSERVICE_REDIS_KEYSPACE_PREFIX`
|
||||
- `USERSERVICE_REDIS_DOMAIN_EVENTS_STREAM`
|
||||
- `USERSERVICE_REDIS_DOMAIN_EVENTS_STREAM_MAX_LEN`
|
||||
|
||||
Telemetry:
|
||||
|
||||
- `OTEL_SERVICE_NAME`
|
||||
- `OTEL_TRACES_EXPORTER`
|
||||
- `OTEL_METRICS_EXPORTER`
|
||||
- `OTEL_EXPORTER_OTLP_PROTOCOL`
|
||||
- `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL`
|
||||
- `OTEL_EXPORTER_OTLP_METRICS_PROTOCOL`
|
||||
- `USERSERVICE_OTEL_STDOUT_TRACES_ENABLED`
|
||||
- `USERSERVICE_OTEL_STDOUT_METRICS_ENABLED`
|
||||
|
||||
## Runtime Notes
|
||||
|
||||
- The service remains internal REST only; gateway owns external authenticated
|
||||
gRPC and FlatBuffers.
|
||||
- Gateway self-service traffic reaches this service over REST/JSON after
|
||||
gateway-side authentication and FlatBuffers transcoding.
|
||||
- Current direct synchronous callers are `Auth / Session Service`,
|
||||
`Edge Gateway`, `Game Lobby Service`, `Geo Profile Service`, and trusted
|
||||
admin callers.
|
||||
- Domain-event publication is auxiliary. A failed auxiliary consumer must not
|
||||
become the source of truth for current account state.
|
||||
+89
@@ -1,3 +1,92 @@
|
||||
module galaxy/user
|
||||
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/alicebob/miniredis/v2 v2.37.0
|
||||
github.com/disciplinedware/go-confusables v0.1.1
|
||||
github.com/getkin/kin-openapi v0.135.0
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0
|
||||
go.opentelemetry.io/otel v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.65.0
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0
|
||||
go.opentelemetry.io/otel/metric v1.43.0
|
||||
go.opentelemetry.io/otel/sdk v1.43.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0
|
||||
go.opentelemetry.io/otel/trace v1.43.0
|
||||
golang.org/x/text v0.35.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.2 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/oasdiff/yaml v0.0.9 // indirect
|
||||
github.com/oasdiff/yaml3 v0.0.9 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
|
||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/otlptranslator v1.0.0 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/woodsbury/decimal128 v1.3.0 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
golang.org/x/arch v0.25.0 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/grpc v1.80.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
|
||||
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
||||
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/disciplinedware/go-confusables v0.1.1 h1:l/JVOsdrEDHo7nvL+tQfRO1F14UyuuDm1Uvv3Nqmq9Q=
|
||||
github.com/disciplinedware/go-confusables v0.1.1/go.mod h1:2hAXIAtpSqx+tMKdCzgRNv4J/kmz/oGfSHTBGJjVgfc=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/getkin/kin-openapi v0.135.0 h1:751SjYfbiwqukYuVjwYEIKNfrSwS5YpA7DZnKSwQgtg=
|
||||
github.com/getkin/kin-openapi v0.135.0/go.mod h1:6dd5FJl6RdX4usBtFBaQhk9q62Yb2J0Mk5IhUO/QqFI=
|
||||
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
||||
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
|
||||
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
|
||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48=
|
||||
github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM=
|
||||
github.com/oasdiff/yaml3 v0.0.9 h1:rWPrKccrdUm8J0F3sGuU+fuh9+1K/RdJlWF7O/9yw2g=
|
||||
github.com/oasdiff/yaml3 v0.0.9/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
||||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
|
||||
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
|
||||
github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0 h1:5FXSL2s6afUC1bzNzl1iedZZ8yqR7GOhbCoEXtyeK6Q=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0/go.mod h1:MdHW7tLtkeGJnR4TyOrnd5D0zUGZQB1l84uHCe8hRpE=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.43.0 h1:CETqV3QLLPTy5yNrqyMr41VnAOOD4lsRved7n4QG00A=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.43.0/go.mod h1:Q4mCiCdziYzpNR0g+6UqVotAlCDZdzz6L8jwY4knOrw=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
|
||||
golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,13 @@
|
||||
// Package local provides small in-process runtime adapters used by the user
|
||||
// service process.
|
||||
package local
|
||||
|
||||
import "time"
|
||||
|
||||
// Clock returns the current wall-clock time.
|
||||
type Clock struct{}
|
||||
|
||||
// Now returns the current time.
|
||||
func (Clock) Now() time.Time {
|
||||
return time.Now()
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"galaxy/user/internal/ports"
|
||||
)
|
||||
|
||||
// NoopDeclaredCountryChangedPublisher validates and discards auxiliary
|
||||
// declared-country change events.
|
||||
type NoopDeclaredCountryChangedPublisher struct{}
|
||||
|
||||
// PublishDeclaredCountryChanged validates event and discards it.
|
||||
func (NoopDeclaredCountryChangedPublisher) PublishDeclaredCountryChanged(
|
||||
ctx context.Context,
|
||||
event ports.DeclaredCountryChangedEvent,
|
||||
) error {
|
||||
if ctx == nil {
|
||||
return fmt.Errorf("publish declared-country changed event: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return event.Validate()
|
||||
}
|
||||
|
||||
var _ ports.DeclaredCountryChangedPublisher = NoopDeclaredCountryChangedPublisher{}
|
||||
@@ -0,0 +1,62 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"galaxy/user/internal/ports"
|
||||
)
|
||||
|
||||
// NoopDomainEventPublisher validates and discards auxiliary user-domain
|
||||
// events.
|
||||
type NoopDomainEventPublisher struct{}
|
||||
|
||||
// PublishProfileChanged validates event and discards it.
|
||||
func (NoopDomainEventPublisher) PublishProfileChanged(ctx context.Context, event ports.ProfileChangedEvent) error {
|
||||
return validateNoopPublish(ctx, "publish profile changed event", event.Validate)
|
||||
}
|
||||
|
||||
// PublishSettingsChanged validates event and discards it.
|
||||
func (NoopDomainEventPublisher) PublishSettingsChanged(ctx context.Context, event ports.SettingsChangedEvent) error {
|
||||
return validateNoopPublish(ctx, "publish settings changed event", event.Validate)
|
||||
}
|
||||
|
||||
// PublishEntitlementChanged validates event and discards it.
|
||||
func (NoopDomainEventPublisher) PublishEntitlementChanged(ctx context.Context, event ports.EntitlementChangedEvent) error {
|
||||
return validateNoopPublish(ctx, "publish entitlement changed event", event.Validate)
|
||||
}
|
||||
|
||||
// PublishSanctionChanged validates event and discards it.
|
||||
func (NoopDomainEventPublisher) PublishSanctionChanged(ctx context.Context, event ports.SanctionChangedEvent) error {
|
||||
return validateNoopPublish(ctx, "publish sanction changed event", event.Validate)
|
||||
}
|
||||
|
||||
// PublishLimitChanged validates event and discards it.
|
||||
func (NoopDomainEventPublisher) PublishLimitChanged(ctx context.Context, event ports.LimitChangedEvent) error {
|
||||
return validateNoopPublish(ctx, "publish limit changed event", event.Validate)
|
||||
}
|
||||
|
||||
// PublishDeclaredCountryChanged validates event and discards it.
|
||||
func (NoopDomainEventPublisher) PublishDeclaredCountryChanged(ctx context.Context, event ports.DeclaredCountryChangedEvent) error {
|
||||
return validateNoopPublish(ctx, "publish declared-country changed event", event.Validate)
|
||||
}
|
||||
|
||||
func validateNoopPublish(ctx context.Context, operation string, validate func() error) error {
|
||||
if ctx == nil {
|
||||
return fmt.Errorf("%s: nil context", operation)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return validate()
|
||||
}
|
||||
|
||||
var (
|
||||
_ ports.ProfileChangedPublisher = NoopDomainEventPublisher{}
|
||||
_ ports.SettingsChangedPublisher = NoopDomainEventPublisher{}
|
||||
_ ports.EntitlementChangedPublisher = NoopDomainEventPublisher{}
|
||||
_ ports.SanctionChangedPublisher = NoopDomainEventPublisher{}
|
||||
_ ports.LimitChangedPublisher = NoopDomainEventPublisher{}
|
||||
_ ports.DeclaredCountryChangedPublisher = NoopDomainEventPublisher{}
|
||||
)
|
||||
@@ -0,0 +1,105 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
)
|
||||
|
||||
var base32NoPadding = base32.StdEncoding.WithPadding(base32.NoPadding)
|
||||
|
||||
// IDGenerator creates opaque stable user identifiers and generated initial
|
||||
// race names.
|
||||
type IDGenerator struct{}
|
||||
|
||||
// NewUserID returns one newly generated opaque user identifier.
|
||||
func (IDGenerator) NewUserID() (common.UserID, error) {
|
||||
token, err := randomToken(10)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate user id: %w", err)
|
||||
}
|
||||
|
||||
userID := common.UserID("user-" + token)
|
||||
if err := userID.Validate(); err != nil {
|
||||
return "", fmt.Errorf("generate user id: %w", err)
|
||||
}
|
||||
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// NewInitialRaceName returns one generated race name in the `player-<shortid>`
|
||||
// form.
|
||||
func (IDGenerator) NewInitialRaceName() (common.RaceName, error) {
|
||||
token, err := randomToken(5)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate initial race name: %w", err)
|
||||
}
|
||||
|
||||
raceName := common.RaceName("player-" + token)
|
||||
if err := raceName.Validate(); err != nil {
|
||||
return "", fmt.Errorf("generate initial race name: %w", err)
|
||||
}
|
||||
|
||||
return raceName, nil
|
||||
}
|
||||
|
||||
// NewEntitlementRecordID returns one generated entitlement history record
|
||||
// identifier.
|
||||
func (IDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
|
||||
token, err := randomToken(10)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate entitlement record id: %w", err)
|
||||
}
|
||||
|
||||
recordID := entitlement.EntitlementRecordID("entitlement-" + token)
|
||||
if err := recordID.Validate(); err != nil {
|
||||
return "", fmt.Errorf("generate entitlement record id: %w", err)
|
||||
}
|
||||
|
||||
return recordID, nil
|
||||
}
|
||||
|
||||
// NewSanctionRecordID returns one generated sanction history record
|
||||
// identifier.
|
||||
func (IDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
|
||||
token, err := randomToken(10)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate sanction record id: %w", err)
|
||||
}
|
||||
|
||||
recordID := policy.SanctionRecordID("sanction-" + token)
|
||||
if err := recordID.Validate(); err != nil {
|
||||
return "", fmt.Errorf("generate sanction record id: %w", err)
|
||||
}
|
||||
|
||||
return recordID, nil
|
||||
}
|
||||
|
||||
// NewLimitRecordID returns one generated limit history record identifier.
|
||||
func (IDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
|
||||
token, err := randomToken(10)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate limit record id: %w", err)
|
||||
}
|
||||
|
||||
recordID := policy.LimitRecordID("limit-" + token)
|
||||
if err := recordID.Validate(); err != nil {
|
||||
return "", fmt.Errorf("generate limit record id: %w", err)
|
||||
}
|
||||
|
||||
return recordID, nil
|
||||
}
|
||||
|
||||
func randomToken(size int) (string, error) {
|
||||
buffer := make([]byte, size)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return strings.ToLower(base32NoPadding.EncodeToString(buffer)), nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/ports"
|
||||
|
||||
confusables "github.com/disciplinedware/go-confusables"
|
||||
"golang.org/x/text/cases"
|
||||
)
|
||||
|
||||
type confusableSkeletoner interface {
|
||||
Skeleton(string) string
|
||||
}
|
||||
|
||||
type raceNamePolicy struct {
|
||||
caseFolder cases.Caser
|
||||
skeletoner confusableSkeletoner
|
||||
}
|
||||
|
||||
var raceNameAntiFraudReplacer = strings.NewReplacer(
|
||||
"1", "i",
|
||||
"0", "o",
|
||||
"8", "b",
|
||||
)
|
||||
|
||||
// NewRaceNamePolicy returns the local Stage 06 race-name canonicalization
|
||||
// policy backed by Unicode case folding, explicit ASCII anti-fraud mappings,
|
||||
// and a TR39 confusable skeleton.
|
||||
func NewRaceNamePolicy() (ports.RaceNamePolicy, error) {
|
||||
policy := &raceNamePolicy{
|
||||
caseFolder: cases.Fold(),
|
||||
skeletoner: confusables.Default(),
|
||||
}
|
||||
if policy.skeletoner == nil {
|
||||
return nil, fmt.Errorf("new race-name policy: nil confusable skeletoner")
|
||||
}
|
||||
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
// CanonicalKey returns the stable uniqueness key for raceName.
|
||||
func (policy *raceNamePolicy) CanonicalKey(raceName common.RaceName) (account.RaceNameCanonicalKey, error) {
|
||||
switch {
|
||||
case policy == nil:
|
||||
return "", fmt.Errorf("canonicalize race name: nil policy")
|
||||
case policy.skeletoner == nil:
|
||||
return "", fmt.Errorf("canonicalize race name: nil confusable skeletoner")
|
||||
}
|
||||
if err := raceName.Validate(); err != nil {
|
||||
return "", fmt.Errorf("canonicalize race name: %w", err)
|
||||
}
|
||||
|
||||
folded := policy.caseFolder.String(raceName.String())
|
||||
antiFraudMapped := raceNameAntiFraudReplacer.Replace(folded)
|
||||
key := account.RaceNameCanonicalKey(policy.skeletoner.Skeleton(antiFraudMapped))
|
||||
if err := key.Validate(); err != nil {
|
||||
return "", fmt.Errorf("canonicalize race name: %w", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/service/shared"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRaceNamePolicyCanonicalKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
policy, err := NewRaceNamePolicy()
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
left common.RaceName
|
||||
right common.RaceName
|
||||
}{
|
||||
{
|
||||
name: "case insensitive collision",
|
||||
left: common.RaceName("Pilot Nova"),
|
||||
right: common.RaceName("pilot nova"),
|
||||
},
|
||||
{
|
||||
name: "ascii anti fraud collision",
|
||||
left: common.RaceName("Pilot Nova"),
|
||||
right: common.RaceName("P1lot N0va"),
|
||||
},
|
||||
{
|
||||
name: "unicode confusable collision",
|
||||
left: common.RaceName("paypal"),
|
||||
right: common.RaceName("раураl"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
leftKey, err := policy.CanonicalKey(tt.left)
|
||||
require.NoError(t, err)
|
||||
rightKey, err := policy.CanonicalKey(tt.right)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, rightKey, leftKey)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRaceNameReservationPreservesOriginalDisplayValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
policy, err := NewRaceNamePolicy()
|
||||
require.NoError(t, err)
|
||||
|
||||
record, err := shared.BuildRaceNameReservation(
|
||||
policy,
|
||||
common.UserID("user-123"),
|
||||
common.RaceName("P1lot Nova"),
|
||||
time.Unix(1_775_240_000, 0).UTC(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, common.RaceName("P1lot Nova"), record.RaceName)
|
||||
require.NotEqual(t, account.RaceNameCanonicalKey(""), record.CanonicalKey)
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
// Package domainevents implements Redis Stream-backed auxiliary user-domain
|
||||
// event publishers.
|
||||
package domainevents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/ports"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// Config configures one Redis-backed user domain-event publisher.
|
||||
type Config struct {
|
||||
// Addr is the Redis network address in host:port form.
|
||||
Addr string
|
||||
|
||||
// Username is the optional Redis ACL username.
|
||||
Username string
|
||||
|
||||
// Password is the optional Redis ACL password.
|
||||
Password string
|
||||
|
||||
// DB is the Redis logical database index.
|
||||
DB int
|
||||
|
||||
// TLSEnabled enables TLS with a conservative minimum protocol version.
|
||||
TLSEnabled bool
|
||||
|
||||
// Stream identifies the Redis Stream key used for domain events.
|
||||
Stream string
|
||||
|
||||
// StreamMaxLen bounds the stream with approximate trimming via
|
||||
// `XADD MAXLEN ~`.
|
||||
StreamMaxLen int64
|
||||
|
||||
// OperationTimeout bounds each Redis round trip performed by the adapter.
|
||||
OperationTimeout time.Duration
|
||||
}
|
||||
|
||||
// Publisher publishes auxiliary user-domain events into one Redis Stream.
|
||||
type Publisher struct {
|
||||
client *redis.Client
|
||||
stream string
|
||||
streamMaxLen int64
|
||||
operationTimeout time.Duration
|
||||
}
|
||||
|
||||
// New constructs a Redis-backed domain-event publisher from cfg.
|
||||
func New(cfg Config) (*Publisher, error) {
|
||||
switch {
|
||||
case strings.TrimSpace(cfg.Addr) == "":
|
||||
return nil, errors.New("new redis domain-event publisher: redis addr must not be empty")
|
||||
case cfg.DB < 0:
|
||||
return nil, errors.New("new redis domain-event publisher: redis db must not be negative")
|
||||
case strings.TrimSpace(cfg.Stream) == "":
|
||||
return nil, errors.New("new redis domain-event publisher: stream must not be empty")
|
||||
case cfg.StreamMaxLen <= 0:
|
||||
return nil, errors.New("new redis domain-event publisher: stream max len must be positive")
|
||||
case cfg.OperationTimeout <= 0:
|
||||
return nil, errors.New("new redis domain-event publisher: operation timeout must be positive")
|
||||
}
|
||||
|
||||
options := &redis.Options{
|
||||
Addr: cfg.Addr,
|
||||
Username: cfg.Username,
|
||||
Password: cfg.Password,
|
||||
DB: cfg.DB,
|
||||
Protocol: 2,
|
||||
DisableIdentity: true,
|
||||
}
|
||||
if cfg.TLSEnabled {
|
||||
options.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
}
|
||||
|
||||
return &Publisher{
|
||||
client: redis.NewClient(options),
|
||||
stream: cfg.Stream,
|
||||
streamMaxLen: cfg.StreamMaxLen,
|
||||
operationTimeout: cfg.OperationTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close releases the underlying Redis client resources.
|
||||
func (publisher *Publisher) Close() error {
|
||||
if publisher == nil || publisher.client == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return publisher.client.Close()
|
||||
}
|
||||
|
||||
// Ping verifies that the configured Redis backend is reachable within the
|
||||
// adapter operation timeout budget.
|
||||
func (publisher *Publisher) Ping(ctx context.Context) error {
|
||||
operationCtx, cancel, err := publisher.operationContext(ctx, "ping redis domain-event publisher")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
if err := publisher.client.Ping(operationCtx).Err(); err != nil {
|
||||
return fmt.Errorf("ping redis domain-event publisher: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PublishProfileChanged publishes one committed profile-change event.
|
||||
func (publisher *Publisher) PublishProfileChanged(ctx context.Context, event ports.ProfileChangedEvent) error {
|
||||
if err := event.Validate(); err != nil {
|
||||
return fmt.Errorf("publish profile changed event: %w", err)
|
||||
}
|
||||
|
||||
values := buildEnvelope(ports.ProfileChangedEventType, event.UserID.String(), event.OccurredAt, event.Source.String(), traceIDFromContext(ctx, event.TraceID))
|
||||
values["operation"] = string(event.Operation)
|
||||
values["race_name"] = event.RaceName.String()
|
||||
|
||||
return publisher.publish(ctx, "publish profile changed event", values)
|
||||
}
|
||||
|
||||
// PublishSettingsChanged publishes one committed settings-change event.
|
||||
func (publisher *Publisher) PublishSettingsChanged(ctx context.Context, event ports.SettingsChangedEvent) error {
|
||||
if err := event.Validate(); err != nil {
|
||||
return fmt.Errorf("publish settings changed event: %w", err)
|
||||
}
|
||||
|
||||
values := buildEnvelope(ports.SettingsChangedEventType, event.UserID.String(), event.OccurredAt, event.Source.String(), traceIDFromContext(ctx, event.TraceID))
|
||||
values["operation"] = string(event.Operation)
|
||||
values["preferred_language"] = event.PreferredLanguage.String()
|
||||
values["time_zone"] = event.TimeZone.String()
|
||||
|
||||
return publisher.publish(ctx, "publish settings changed event", values)
|
||||
}
|
||||
|
||||
// PublishEntitlementChanged publishes one committed entitlement-change event.
|
||||
func (publisher *Publisher) PublishEntitlementChanged(ctx context.Context, event ports.EntitlementChangedEvent) error {
|
||||
if err := event.Validate(); err != nil {
|
||||
return fmt.Errorf("publish entitlement changed event: %w", err)
|
||||
}
|
||||
|
||||
values := buildEnvelope(ports.EntitlementChangedEventType, event.UserID.String(), event.OccurredAt, event.Source.String(), traceIDFromContext(ctx, event.TraceID))
|
||||
values["operation"] = string(event.Operation)
|
||||
values["plan_code"] = string(event.PlanCode)
|
||||
values["is_paid"] = strconv.FormatBool(event.IsPaid)
|
||||
values["starts_at_ms"] = strconv.FormatInt(event.StartsAt.UTC().UnixMilli(), 10)
|
||||
values["reason_code"] = event.ReasonCode.String()
|
||||
values["actor_type"] = event.Actor.Type.String()
|
||||
values["updated_at_ms"] = strconv.FormatInt(event.UpdatedAt.UTC().UnixMilli(), 10)
|
||||
if !event.Actor.ID.IsZero() {
|
||||
values["actor_id"] = event.Actor.ID.String()
|
||||
}
|
||||
if event.EndsAt != nil {
|
||||
values["ends_at_ms"] = strconv.FormatInt(event.EndsAt.UTC().UnixMilli(), 10)
|
||||
}
|
||||
|
||||
return publisher.publish(ctx, "publish entitlement changed event", values)
|
||||
}
|
||||
|
||||
// PublishSanctionChanged publishes one committed sanction-change event.
|
||||
func (publisher *Publisher) PublishSanctionChanged(ctx context.Context, event ports.SanctionChangedEvent) error {
|
||||
if err := event.Validate(); err != nil {
|
||||
return fmt.Errorf("publish sanction changed event: %w", err)
|
||||
}
|
||||
|
||||
values := buildEnvelope(ports.SanctionChangedEventType, event.UserID.String(), event.OccurredAt, event.Source.String(), traceIDFromContext(ctx, event.TraceID))
|
||||
values["operation"] = string(event.Operation)
|
||||
values["sanction_code"] = string(event.SanctionCode)
|
||||
values["scope"] = event.Scope.String()
|
||||
values["reason_code"] = event.ReasonCode.String()
|
||||
values["actor_type"] = event.Actor.Type.String()
|
||||
values["applied_at_ms"] = strconv.FormatInt(event.AppliedAt.UTC().UnixMilli(), 10)
|
||||
if !event.Actor.ID.IsZero() {
|
||||
values["actor_id"] = event.Actor.ID.String()
|
||||
}
|
||||
if event.ExpiresAt != nil {
|
||||
values["expires_at_ms"] = strconv.FormatInt(event.ExpiresAt.UTC().UnixMilli(), 10)
|
||||
}
|
||||
if event.RemovedAt != nil {
|
||||
values["removed_at_ms"] = strconv.FormatInt(event.RemovedAt.UTC().UnixMilli(), 10)
|
||||
}
|
||||
|
||||
return publisher.publish(ctx, "publish sanction changed event", values)
|
||||
}
|
||||
|
||||
// PublishLimitChanged publishes one committed limit-change event.
|
||||
func (publisher *Publisher) PublishLimitChanged(ctx context.Context, event ports.LimitChangedEvent) error {
|
||||
if err := event.Validate(); err != nil {
|
||||
return fmt.Errorf("publish limit changed event: %w", err)
|
||||
}
|
||||
|
||||
values := buildEnvelope(ports.LimitChangedEventType, event.UserID.String(), event.OccurredAt, event.Source.String(), traceIDFromContext(ctx, event.TraceID))
|
||||
values["operation"] = string(event.Operation)
|
||||
values["limit_code"] = string(event.LimitCode)
|
||||
values["reason_code"] = event.ReasonCode.String()
|
||||
values["actor_type"] = event.Actor.Type.String()
|
||||
values["applied_at_ms"] = strconv.FormatInt(event.AppliedAt.UTC().UnixMilli(), 10)
|
||||
if event.Value != nil {
|
||||
values["value"] = strconv.Itoa(*event.Value)
|
||||
}
|
||||
if !event.Actor.ID.IsZero() {
|
||||
values["actor_id"] = event.Actor.ID.String()
|
||||
}
|
||||
if event.ExpiresAt != nil {
|
||||
values["expires_at_ms"] = strconv.FormatInt(event.ExpiresAt.UTC().UnixMilli(), 10)
|
||||
}
|
||||
if event.RemovedAt != nil {
|
||||
values["removed_at_ms"] = strconv.FormatInt(event.RemovedAt.UTC().UnixMilli(), 10)
|
||||
}
|
||||
|
||||
return publisher.publish(ctx, "publish limit changed event", values)
|
||||
}
|
||||
|
||||
// PublishDeclaredCountryChanged publishes one committed declared-country change
|
||||
// event.
|
||||
func (publisher *Publisher) PublishDeclaredCountryChanged(ctx context.Context, event ports.DeclaredCountryChangedEvent) error {
|
||||
if err := event.Validate(); err != nil {
|
||||
return fmt.Errorf("publish declared-country changed event: %w", err)
|
||||
}
|
||||
|
||||
values := buildEnvelope(
|
||||
ports.DeclaredCountryChangedEventType,
|
||||
event.UserID.String(),
|
||||
event.UpdatedAt,
|
||||
event.Source.String(),
|
||||
traceIDFromContext(ctx, event.TraceID),
|
||||
)
|
||||
values["declared_country"] = event.DeclaredCountry.String()
|
||||
values["updated_at_ms"] = strconv.FormatInt(event.UpdatedAt.UTC().UnixMilli(), 10)
|
||||
|
||||
return publisher.publish(ctx, "publish declared-country changed event", values)
|
||||
}
|
||||
|
||||
func (publisher *Publisher) publish(ctx context.Context, operation string, values map[string]any) error {
|
||||
operationCtx, cancel, err := publisher.operationContext(ctx, operation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
if err := publisher.client.XAdd(operationCtx, &redis.XAddArgs{
|
||||
Stream: publisher.stream,
|
||||
MaxLen: publisher.streamMaxLen,
|
||||
Approx: true,
|
||||
Values: values,
|
||||
}).Err(); err != nil {
|
||||
return fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (publisher *Publisher) operationContext(ctx context.Context, operation string) (context.Context, context.CancelFunc, error) {
|
||||
if publisher == nil || publisher.client == nil {
|
||||
return nil, nil, fmt.Errorf("%s: nil publisher", operation)
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, nil, fmt.Errorf("%s: nil context", operation)
|
||||
}
|
||||
|
||||
operationCtx, cancel := context.WithTimeout(ctx, publisher.operationTimeout)
|
||||
return operationCtx, cancel, nil
|
||||
}
|
||||
|
||||
func buildEnvelope(eventType string, userID string, occurredAt time.Time, source string, traceID string) map[string]any {
|
||||
values := map[string]any{
|
||||
"event_type": eventType,
|
||||
"user_id": userID,
|
||||
"occurred_at_ms": strconv.FormatInt(occurredAt.UTC().UnixMilli(), 10),
|
||||
"source": source,
|
||||
}
|
||||
if traceID != "" {
|
||||
values["trace_id"] = traceID
|
||||
}
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
func traceIDFromContext(ctx context.Context, fallback string) string {
|
||||
if strings.TrimSpace(fallback) != "" {
|
||||
return fallback
|
||||
}
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
spanContext := trace.SpanContextFromContext(ctx)
|
||||
if !spanContext.IsValid() {
|
||||
return ""
|
||||
}
|
||||
|
||||
return spanContext.TraceID().String()
|
||||
}
|
||||
|
||||
var (
|
||||
_ interface{ Close() error } = (*Publisher)(nil)
|
||||
_ interface{ Ping(context.Context) error } = (*Publisher)(nil)
|
||||
_ ports.ProfileChangedPublisher = (*Publisher)(nil)
|
||||
_ ports.SettingsChangedPublisher = (*Publisher)(nil)
|
||||
_ ports.EntitlementChangedPublisher = (*Publisher)(nil)
|
||||
_ ports.SanctionChangedPublisher = (*Publisher)(nil)
|
||||
_ ports.LimitChangedPublisher = (*Publisher)(nil)
|
||||
_ ports.DeclaredCountryChangedPublisher = (*Publisher)(nil)
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
package domainevents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/ports"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPublisherPublishesFlatRedisStreamEntry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
publisher, err := New(Config{
|
||||
Addr: server.Addr(),
|
||||
Stream: "user:test_events",
|
||||
StreamMaxLen: 5,
|
||||
OperationTimeout: time.Second,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
occurredAt := time.Unix(1_775_240_000, 0).UTC()
|
||||
err = publisher.PublishProfileChanged(context.Background(), ports.ProfileChangedEvent{
|
||||
UserID: common.UserID("user-123"),
|
||||
OccurredAt: occurredAt,
|
||||
Source: common.Source("gateway_self_service"),
|
||||
TraceID: "4bf92f3577b34da6a3ce929d0e0e4736",
|
||||
Operation: ports.ProfileChangedOperationUpdated,
|
||||
RaceName: common.RaceName("Nova Prime"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
entries, err := publisher.client.XRange(context.Background(), publisher.stream, "-", "+").Result()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1)
|
||||
require.Equal(t, ports.ProfileChangedEventType, entries[0].Values["event_type"])
|
||||
require.Equal(t, "user-123", entries[0].Values["user_id"])
|
||||
require.Equal(t, strconv.FormatInt(occurredAt.UnixMilli(), 10), entries[0].Values["occurred_at_ms"])
|
||||
require.Equal(t, "gateway_self_service", entries[0].Values["source"])
|
||||
require.Equal(t, "4bf92f3577b34da6a3ce929d0e0e4736", entries[0].Values["trace_id"])
|
||||
require.Equal(t, string(ports.ProfileChangedOperationUpdated), entries[0].Values["operation"])
|
||||
require.Equal(t, "Nova Prime", entries[0].Values["race_name"])
|
||||
|
||||
for index := 0; index < 20; index++ {
|
||||
err = publisher.PublishSettingsChanged(context.Background(), ports.SettingsChangedEvent{
|
||||
UserID: common.UserID("user-123"),
|
||||
OccurredAt: occurredAt.Add(time.Duration(index+1) * time.Second),
|
||||
Source: common.Source("gateway_self_service"),
|
||||
Operation: ports.SettingsChangedOperationUpdated,
|
||||
PreferredLanguage: common.LanguageTag("en-US"),
|
||||
TimeZone: common.TimeZoneName("UTC"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
length, err := publisher.client.XLen(context.Background(), publisher.stream).Result()
|
||||
require.NoError(t, err)
|
||||
require.LessOrEqual(t, length, int64(20))
|
||||
}
|
||||
|
||||
func TestPublisherRejectsInvalidEventBeforeXAdd(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
publisher, err := New(Config{
|
||||
Addr: server.Addr(),
|
||||
Stream: "user:test_events",
|
||||
StreamMaxLen: 5,
|
||||
OperationTimeout: time.Second,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = publisher.PublishProfileChanged(context.Background(), ports.ProfileChangedEvent{
|
||||
UserID: common.UserID("user-123"),
|
||||
OccurredAt: time.Unix(1_775_240_000, 0).UTC(),
|
||||
Operation: ports.ProfileChangedOperationUpdated,
|
||||
RaceName: common.RaceName("Nova Prime"),
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
length, xLenErr := publisher.client.XLen(context.Background(), publisher.stream).Result()
|
||||
require.NoError(t, xLenErr)
|
||||
require.Zero(t, length)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package userstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"galaxy/user/internal/adapters/redisstate"
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
"galaxy/user/internal/ports"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
var knownSanctionCodes = []policy.SanctionCode{
|
||||
policy.SanctionCodeLoginBlock,
|
||||
policy.SanctionCodePrivateGameCreateBlock,
|
||||
policy.SanctionCodePrivateGameManageBlock,
|
||||
policy.SanctionCodeGameJoinBlock,
|
||||
policy.SanctionCodeProfileUpdateBlock,
|
||||
}
|
||||
|
||||
var knownLimitCodes = []policy.LimitCode{
|
||||
policy.LimitCodeMaxOwnedPrivateGames,
|
||||
policy.LimitCodeMaxPendingPublicApplications,
|
||||
policy.LimitCodeMaxActiveGameMemberships,
|
||||
}
|
||||
|
||||
var knownEligibilityMarkers = []policy.EligibilityMarker{
|
||||
policy.EligibilityMarkerCanLogin,
|
||||
policy.EligibilityMarkerCanCreatePrivateGame,
|
||||
policy.EligibilityMarkerCanManagePrivateGame,
|
||||
policy.EligibilityMarkerCanJoinGame,
|
||||
policy.EligibilityMarkerCanUpdateProfile,
|
||||
}
|
||||
|
||||
func (store *Store) addCreatedAtIndex(
|
||||
pipe redis.Pipeliner,
|
||||
ctx context.Context,
|
||||
record account.UserAccount,
|
||||
) {
|
||||
pipe.ZAdd(ctx, store.keyspace.CreatedAtIndex(), redis.Z{
|
||||
Score: redisstate.CreatedAtScore(record.CreatedAt),
|
||||
Member: record.UserID.String(),
|
||||
})
|
||||
}
|
||||
|
||||
func (store *Store) syncDeclaredCountryIndex(
|
||||
pipe redis.Pipeliner,
|
||||
ctx context.Context,
|
||||
previous account.UserAccount,
|
||||
current account.UserAccount,
|
||||
) {
|
||||
if !previous.DeclaredCountry.IsZero() {
|
||||
pipe.SRem(ctx, store.keyspace.DeclaredCountryIndex(previous.DeclaredCountry), current.UserID.String())
|
||||
}
|
||||
if !current.DeclaredCountry.IsZero() {
|
||||
pipe.SAdd(ctx, store.keyspace.DeclaredCountryIndex(current.DeclaredCountry), current.UserID.String())
|
||||
}
|
||||
}
|
||||
|
||||
func (store *Store) syncEntitlementIndexes(
|
||||
pipe redis.Pipeliner,
|
||||
ctx context.Context,
|
||||
snapshot entitlement.CurrentSnapshot,
|
||||
) {
|
||||
pipe.SRem(ctx, store.keyspace.PaidStateIndex(entitlement.PaidStateFree), snapshot.UserID.String())
|
||||
pipe.SRem(ctx, store.keyspace.PaidStateIndex(entitlement.PaidStatePaid), snapshot.UserID.String())
|
||||
pipe.SAdd(ctx, store.keyspace.PaidStateIndex(paidStateFromSnapshot(snapshot)), snapshot.UserID.String())
|
||||
|
||||
pipe.ZRem(ctx, store.keyspace.FinitePaidExpiryIndex(), snapshot.UserID.String())
|
||||
if snapshot.HasFiniteExpiry() {
|
||||
pipe.ZAdd(ctx, store.keyspace.FinitePaidExpiryIndex(), redis.Z{
|
||||
Score: redisstate.ExpiryScore(*snapshot.EndsAt),
|
||||
Member: snapshot.UserID.String(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (store *Store) syncActiveSanctionCodeIndexes(
|
||||
pipe redis.Pipeliner,
|
||||
ctx context.Context,
|
||||
userID common.UserID,
|
||||
activeCodes map[policy.SanctionCode]struct{},
|
||||
) {
|
||||
for _, code := range knownSanctionCodes {
|
||||
pipe.SRem(ctx, store.keyspace.ActiveSanctionCodeIndex(code), userID.String())
|
||||
if _, ok := activeCodes[code]; ok {
|
||||
pipe.SAdd(ctx, store.keyspace.ActiveSanctionCodeIndex(code), userID.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (store *Store) syncActiveLimitCodeIndexes(
|
||||
pipe redis.Pipeliner,
|
||||
ctx context.Context,
|
||||
userID common.UserID,
|
||||
activeCodes map[policy.LimitCode]struct{},
|
||||
) {
|
||||
for _, code := range knownLimitCodes {
|
||||
pipe.SRem(ctx, store.keyspace.ActiveLimitCodeIndex(code), userID.String())
|
||||
if _, ok := activeCodes[code]; ok {
|
||||
pipe.SAdd(ctx, store.keyspace.ActiveLimitCodeIndex(code), userID.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (store *Store) syncEligibilityMarkerIndexes(
|
||||
pipe redis.Pipeliner,
|
||||
ctx context.Context,
|
||||
userID common.UserID,
|
||||
isPaid bool,
|
||||
activeSanctionCodes map[policy.SanctionCode]struct{},
|
||||
) {
|
||||
values := deriveEligibilityMarkerValues(isPaid, activeSanctionCodes)
|
||||
|
||||
for _, marker := range knownEligibilityMarkers {
|
||||
pipe.SRem(ctx, store.keyspace.EligibilityMarkerIndex(marker, true), userID.String())
|
||||
pipe.SRem(ctx, store.keyspace.EligibilityMarkerIndex(marker, false), userID.String())
|
||||
pipe.SAdd(ctx, store.keyspace.EligibilityMarkerIndex(marker, values[marker]), userID.String())
|
||||
}
|
||||
}
|
||||
|
||||
func (store *Store) loadActiveSanctionCodeSet(
|
||||
ctx context.Context,
|
||||
getter bytesGetter,
|
||||
userID common.UserID,
|
||||
) (map[policy.SanctionCode]struct{}, error) {
|
||||
activeCodes := make(map[policy.SanctionCode]struct{}, len(knownSanctionCodes))
|
||||
|
||||
for _, code := range knownSanctionCodes {
|
||||
_, err := store.loadActiveSanctionRecordID(ctx, getter, store.keyspace.ActiveSanction(userID, code))
|
||||
switch {
|
||||
case err == nil:
|
||||
activeCodes[code] = struct{}{}
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
continue
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return activeCodes, nil
|
||||
}
|
||||
|
||||
func (store *Store) loadActiveLimitCodeSet(
|
||||
ctx context.Context,
|
||||
getter bytesGetter,
|
||||
userID common.UserID,
|
||||
) (map[policy.LimitCode]struct{}, error) {
|
||||
activeCodes := make(map[policy.LimitCode]struct{}, len(knownLimitCodes))
|
||||
|
||||
for _, code := range knownLimitCodes {
|
||||
_, err := store.loadActiveLimitRecordID(ctx, getter, store.keyspace.ActiveLimit(userID, code))
|
||||
switch {
|
||||
case err == nil:
|
||||
activeCodes[code] = struct{}{}
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
continue
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return activeCodes, nil
|
||||
}
|
||||
|
||||
func (store *Store) activeSanctionWatchKeys(userID common.UserID) []string {
|
||||
keys := make([]string, 0, len(knownSanctionCodes))
|
||||
for _, code := range knownSanctionCodes {
|
||||
keys = append(keys, store.keyspace.ActiveSanction(userID, code))
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
func (store *Store) activeLimitWatchKeys(userID common.UserID) []string {
|
||||
keys := make([]string, 0, len(knownLimitCodes))
|
||||
for _, code := range knownLimitCodes {
|
||||
keys = append(keys, store.keyspace.ActiveLimit(userID, code))
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
func deriveEligibilityMarkerValues(
|
||||
isPaid bool,
|
||||
activeSanctionCodes map[policy.SanctionCode]struct{},
|
||||
) map[policy.EligibilityMarker]bool {
|
||||
_, loginBlocked := activeSanctionCodes[policy.SanctionCodeLoginBlock]
|
||||
_, createBlocked := activeSanctionCodes[policy.SanctionCodePrivateGameCreateBlock]
|
||||
_, manageBlocked := activeSanctionCodes[policy.SanctionCodePrivateGameManageBlock]
|
||||
_, joinBlocked := activeSanctionCodes[policy.SanctionCodeGameJoinBlock]
|
||||
_, profileBlocked := activeSanctionCodes[policy.SanctionCodeProfileUpdateBlock]
|
||||
|
||||
canLogin := !loginBlocked
|
||||
|
||||
return map[policy.EligibilityMarker]bool{
|
||||
policy.EligibilityMarkerCanLogin: canLogin,
|
||||
policy.EligibilityMarkerCanCreatePrivateGame: canLogin && isPaid && !createBlocked,
|
||||
policy.EligibilityMarkerCanManagePrivateGame: canLogin && isPaid && !manageBlocked,
|
||||
policy.EligibilityMarkerCanJoinGame: canLogin && !joinBlocked,
|
||||
policy.EligibilityMarkerCanUpdateProfile: canLogin && !profileBlocked,
|
||||
}
|
||||
}
|
||||
|
||||
func paidStateFromSnapshot(snapshot entitlement.CurrentSnapshot) entitlement.PaidState {
|
||||
if snapshot.IsPaid {
|
||||
return entitlement.PaidStatePaid
|
||||
}
|
||||
|
||||
return entitlement.PaidStateFree
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
package userstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/adapters/redisstate"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
"galaxy/user/internal/ports"
|
||||
"galaxy/user/internal/service/adminusers"
|
||||
"galaxy/user/internal/service/entitlementsvc"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestListUserIDsCreatedAtPagination(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
base := time.Unix(1_775_240_000, 0).UTC()
|
||||
|
||||
first := validAccountRecord()
|
||||
first.UserID = common.UserID("user-100")
|
||||
first.Email = common.Email("u100@example.com")
|
||||
first.RaceName = common.RaceName("User 100")
|
||||
first.CreatedAt = base.Add(-time.Hour)
|
||||
first.UpdatedAt = first.CreatedAt
|
||||
|
||||
second := validAccountRecord()
|
||||
second.UserID = common.UserID("user-200")
|
||||
second.Email = common.Email("u200@example.com")
|
||||
second.RaceName = common.RaceName("User 200")
|
||||
second.CreatedAt = base
|
||||
second.UpdatedAt = second.CreatedAt
|
||||
|
||||
third := validAccountRecord()
|
||||
third.UserID = common.UserID("user-300")
|
||||
third.Email = common.Email("u300@example.com")
|
||||
third.RaceName = common.RaceName("User 300")
|
||||
third.CreatedAt = base
|
||||
third.UpdatedAt = third.CreatedAt
|
||||
|
||||
require.NoError(t, store.Create(context.Background(), createAccountInput(first)))
|
||||
require.NoError(t, store.Create(context.Background(), createAccountInput(second)))
|
||||
require.NoError(t, store.Create(context.Background(), createAccountInput(third)))
|
||||
|
||||
firstPage, err := store.ListUserIDs(context.Background(), ports.ListUsersInput{
|
||||
PageSize: 2,
|
||||
Filters: ports.UserListFilters{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []common.UserID{third.UserID, second.UserID}, firstPage.UserIDs)
|
||||
require.NotEmpty(t, firstPage.NextPageToken)
|
||||
|
||||
secondPage, err := store.ListUserIDs(context.Background(), ports.ListUsersInput{
|
||||
PageSize: 2,
|
||||
PageToken: firstPage.NextPageToken,
|
||||
Filters: ports.UserListFilters{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []common.UserID{first.UserID}, secondPage.UserIDs)
|
||||
require.Empty(t, secondPage.NextPageToken)
|
||||
}
|
||||
|
||||
func TestEnsureByEmailInitialAdminIndexes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
record := validAccountRecord()
|
||||
record.DeclaredCountry = common.CountryCode("DE")
|
||||
record.CreatedAt = now
|
||||
record.UpdatedAt = now
|
||||
|
||||
result, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
|
||||
Email: record.Email,
|
||||
Account: record,
|
||||
Entitlement: validEntitlementSnapshot(record.UserID, now),
|
||||
EntitlementRecord: validEntitlementRecord(record.UserID, now),
|
||||
Reservation: raceNameReservation(record.UserID, record.RaceName, now),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.EnsureByEmailOutcomeCreated, result.Outcome)
|
||||
|
||||
requireSortedSetScore(t, store, store.keyspace.CreatedAtIndex(), record.UserID.String(), redisstate.CreatedAtScore(record.CreatedAt))
|
||||
requireSetContains(t, store, store.keyspace.PaidStateIndex(entitlement.PaidStateFree), record.UserID.String())
|
||||
requireSetNotContains(t, store, store.keyspace.PaidStateIndex(entitlement.PaidStatePaid), record.UserID.String())
|
||||
requireSetContains(t, store, store.keyspace.DeclaredCountryIndex(record.DeclaredCountry), record.UserID.String())
|
||||
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanLogin, true), record.UserID.String())
|
||||
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanCreatePrivateGame, false), record.UserID.String())
|
||||
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanJoinGame, true), record.UserID.String())
|
||||
}
|
||||
|
||||
func TestAccountUpdateSyncsDeclaredCountryIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
accountStore := store.Accounts()
|
||||
record := validAccountRecord()
|
||||
record.DeclaredCountry = common.CountryCode("DE")
|
||||
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record)))
|
||||
|
||||
updated := record
|
||||
updated.DeclaredCountry = common.CountryCode("FR")
|
||||
updated.UpdatedAt = record.UpdatedAt.Add(time.Minute)
|
||||
require.NoError(t, accountStore.Update(context.Background(), updated))
|
||||
|
||||
requireSetNotContains(t, store, store.keyspace.DeclaredCountryIndex(common.CountryCode("DE")), record.UserID.String())
|
||||
requireSetContains(t, store, store.keyspace.DeclaredCountryIndex(common.CountryCode("FR")), record.UserID.String())
|
||||
}
|
||||
|
||||
func TestEntitlementLifecycleSyncsAdminIndexes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
record := validAccountRecord()
|
||||
record.CreatedAt = now
|
||||
record.UpdatedAt = now
|
||||
_, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
|
||||
Email: record.Email,
|
||||
Account: record,
|
||||
Entitlement: validEntitlementSnapshot(record.UserID, now),
|
||||
EntitlementRecord: validEntitlementRecord(record.UserID, now),
|
||||
Reservation: raceNameReservation(record.UserID, record.RaceName, now),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
lifecycleStore := store.EntitlementLifecycle()
|
||||
freeRecord := validEntitlementRecord(record.UserID, now)
|
||||
freeSnapshot := validEntitlementSnapshot(record.UserID, now)
|
||||
|
||||
grantStartsAt := now.Add(time.Hour)
|
||||
grantEndsAt := grantStartsAt.Add(30 * 24 * time.Hour)
|
||||
grantedRecord := paidEntitlementRecord(
|
||||
entitlement.EntitlementRecordID("entitlement-paid-1"),
|
||||
record.UserID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
grantStartsAt,
|
||||
grantEndsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_grant"),
|
||||
)
|
||||
grantedSnapshot := paidEntitlementSnapshot(
|
||||
record.UserID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
grantStartsAt,
|
||||
grantEndsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_grant"),
|
||||
)
|
||||
closedFreeRecord := freeRecord
|
||||
closedFreeRecord.ClosedAt = timePointer(grantStartsAt)
|
||||
closedFreeRecord.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
|
||||
closedFreeRecord.ClosedReasonCode = common.ReasonCode("manual_grant")
|
||||
|
||||
require.NoError(t, lifecycleStore.Grant(context.Background(), ports.GrantEntitlementInput{
|
||||
ExpectedCurrentSnapshot: freeSnapshot,
|
||||
ExpectedCurrentRecord: freeRecord,
|
||||
UpdatedCurrentRecord: closedFreeRecord,
|
||||
NewRecord: grantedRecord,
|
||||
NewSnapshot: grantedSnapshot,
|
||||
}))
|
||||
|
||||
requireSetContains(t, store, store.keyspace.PaidStateIndex(entitlement.PaidStatePaid), record.UserID.String())
|
||||
requireSetNotContains(t, store, store.keyspace.PaidStateIndex(entitlement.PaidStateFree), record.UserID.String())
|
||||
requireSortedSetScore(t, store, store.keyspace.FinitePaidExpiryIndex(), record.UserID.String(), redisstate.ExpiryScore(grantEndsAt))
|
||||
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanCreatePrivateGame, true), record.UserID.String())
|
||||
|
||||
extendedEndsAt := grantEndsAt.Add(30 * 24 * time.Hour)
|
||||
extensionRecord := paidEntitlementRecord(
|
||||
entitlement.EntitlementRecordID("entitlement-paid-2"),
|
||||
record.UserID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
grantEndsAt,
|
||||
extendedEndsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_extend"),
|
||||
)
|
||||
extendedSnapshot := paidEntitlementSnapshot(
|
||||
record.UserID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
grantStartsAt,
|
||||
extendedEndsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_extend"),
|
||||
)
|
||||
require.NoError(t, lifecycleStore.Extend(context.Background(), ports.ExtendEntitlementInput{
|
||||
ExpectedCurrentSnapshot: grantedSnapshot,
|
||||
NewRecord: extensionRecord,
|
||||
NewSnapshot: extendedSnapshot,
|
||||
}))
|
||||
|
||||
requireSortedSetScore(t, store, store.keyspace.FinitePaidExpiryIndex(), record.UserID.String(), redisstate.ExpiryScore(extendedEndsAt))
|
||||
|
||||
revokeAt := grantEndsAt.Add(12 * time.Hour)
|
||||
revokedCurrentRecord := extensionRecord
|
||||
revokedCurrentRecord.ClosedAt = timePointer(revokeAt)
|
||||
revokedCurrentRecord.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
|
||||
revokedCurrentRecord.ClosedReasonCode = common.ReasonCode("manual_revoke")
|
||||
freeAfterRevokeRecord := entitlement.PeriodRecord{
|
||||
RecordID: entitlement.EntitlementRecordID("entitlement-free-2"),
|
||||
UserID: record.UserID,
|
||||
PlanCode: entitlement.PlanCodeFree,
|
||||
Source: common.Source("admin"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: common.ReasonCode("manual_revoke"),
|
||||
StartsAt: revokeAt,
|
||||
CreatedAt: revokeAt,
|
||||
}
|
||||
freeAfterRevokeSnapshot := entitlement.CurrentSnapshot{
|
||||
UserID: record.UserID,
|
||||
PlanCode: entitlement.PlanCodeFree,
|
||||
IsPaid: false,
|
||||
StartsAt: revokeAt,
|
||||
Source: common.Source("admin"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: common.ReasonCode("manual_revoke"),
|
||||
UpdatedAt: revokeAt,
|
||||
}
|
||||
require.NoError(t, lifecycleStore.Revoke(context.Background(), ports.RevokeEntitlementInput{
|
||||
ExpectedCurrentSnapshot: extendedSnapshot,
|
||||
ExpectedCurrentRecord: extensionRecord,
|
||||
UpdatedCurrentRecord: revokedCurrentRecord,
|
||||
NewRecord: freeAfterRevokeRecord,
|
||||
NewSnapshot: freeAfterRevokeSnapshot,
|
||||
}))
|
||||
|
||||
requireSetContains(t, store, store.keyspace.PaidStateIndex(entitlement.PaidStateFree), record.UserID.String())
|
||||
requireSetNotContains(t, store, store.keyspace.PaidStateIndex(entitlement.PaidStatePaid), record.UserID.String())
|
||||
requireSortedSetMissing(t, store, store.keyspace.FinitePaidExpiryIndex(), record.UserID.String())
|
||||
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanCreatePrivateGame, false), record.UserID.String())
|
||||
}
|
||||
|
||||
func TestPolicyLifecycleSyncsAdminIndexes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
record := validAccountRecord()
|
||||
record.CreatedAt = now
|
||||
record.UpdatedAt = now
|
||||
_, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
|
||||
Email: record.Email,
|
||||
Account: record,
|
||||
Entitlement: validEntitlementSnapshot(record.UserID, now),
|
||||
EntitlementRecord: validEntitlementRecord(record.UserID, now),
|
||||
Reservation: raceNameReservation(record.UserID, record.RaceName, now),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
lifecycleStore := store.PolicyLifecycle()
|
||||
sanctionRecord := policy.SanctionRecord{
|
||||
RecordID: policy.SanctionRecordID("sanction-1"),
|
||||
UserID: record.UserID,
|
||||
SanctionCode: policy.SanctionCodeLoginBlock,
|
||||
Scope: common.Scope("auth"),
|
||||
ReasonCode: common.ReasonCode("manual_block"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
AppliedAt: now,
|
||||
}
|
||||
require.NoError(t, lifecycleStore.ApplySanction(context.Background(), ports.ApplySanctionInput{
|
||||
NewRecord: sanctionRecord,
|
||||
}))
|
||||
|
||||
requireSetContains(t, store, store.keyspace.ActiveSanctionCodeIndex(policy.SanctionCodeLoginBlock), record.UserID.String())
|
||||
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanLogin, false), record.UserID.String())
|
||||
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanJoinGame, false), record.UserID.String())
|
||||
|
||||
removedSanction := sanctionRecord
|
||||
removedAt := now.Add(time.Minute)
|
||||
removedSanction.RemovedAt = &removedAt
|
||||
removedSanction.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")}
|
||||
removedSanction.RemovedReasonCode = common.ReasonCode("manual_remove")
|
||||
require.NoError(t, lifecycleStore.RemoveSanction(context.Background(), ports.RemoveSanctionInput{
|
||||
ExpectedActiveRecord: sanctionRecord,
|
||||
UpdatedRecord: removedSanction,
|
||||
}))
|
||||
|
||||
requireSetNotContains(t, store, store.keyspace.ActiveSanctionCodeIndex(policy.SanctionCodeLoginBlock), record.UserID.String())
|
||||
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanLogin, true), record.UserID.String())
|
||||
|
||||
limitRecord := policy.LimitRecord{
|
||||
RecordID: policy.LimitRecordID("limit-1"),
|
||||
UserID: record.UserID,
|
||||
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
|
||||
Value: 5,
|
||||
ReasonCode: common.ReasonCode("manual_override"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
AppliedAt: now.Add(2 * time.Minute),
|
||||
}
|
||||
require.NoError(t, lifecycleStore.SetLimit(context.Background(), ports.SetLimitInput{
|
||||
NewRecord: limitRecord,
|
||||
}))
|
||||
|
||||
requireSetContains(t, store, store.keyspace.ActiveLimitCodeIndex(policy.LimitCodeMaxOwnedPrivateGames), record.UserID.String())
|
||||
|
||||
removedLimit := limitRecord
|
||||
limitRemovedAt := now.Add(3 * time.Minute)
|
||||
removedLimit.RemovedAt = &limitRemovedAt
|
||||
removedLimit.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")}
|
||||
removedLimit.RemovedReasonCode = common.ReasonCode("manual_remove")
|
||||
require.NoError(t, lifecycleStore.RemoveLimit(context.Background(), ports.RemoveLimitInput{
|
||||
ExpectedActiveRecord: limitRecord,
|
||||
UpdatedRecord: removedLimit,
|
||||
}))
|
||||
|
||||
requireSetNotContains(t, store, store.keyspace.ActiveLimitCodeIndex(policy.LimitCodeMaxOwnedPrivateGames), record.UserID.String())
|
||||
}
|
||||
|
||||
func TestAdminListerReevaluatesExpiredPaidSnapshots(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
userID := common.UserID("user-123")
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
record := validAccountRecord()
|
||||
record.CreatedAt = now.Add(-2 * time.Hour)
|
||||
record.UpdatedAt = record.CreatedAt
|
||||
_, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
|
||||
Email: record.Email,
|
||||
Account: record,
|
||||
Entitlement: validEntitlementSnapshot(userID, record.CreatedAt),
|
||||
EntitlementRecord: validEntitlementRecord(userID, record.CreatedAt),
|
||||
Reservation: raceNameReservation(userID, record.RaceName, record.CreatedAt),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
grantStartsAt := now.Add(-90 * time.Minute)
|
||||
grantEndsAt := now.Add(-30 * time.Minute)
|
||||
freeRecord := validEntitlementRecord(userID, record.CreatedAt)
|
||||
freeSnapshot := validEntitlementSnapshot(userID, record.CreatedAt)
|
||||
grantedRecord := paidEntitlementRecord(
|
||||
entitlement.EntitlementRecordID("entitlement-paid-expired"),
|
||||
userID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
grantStartsAt,
|
||||
grantEndsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_grant"),
|
||||
)
|
||||
grantedSnapshot := paidEntitlementSnapshot(
|
||||
userID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
grantStartsAt,
|
||||
grantEndsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_grant"),
|
||||
)
|
||||
closedFreeRecord := freeRecord
|
||||
closedFreeRecord.ClosedAt = timePointer(grantStartsAt)
|
||||
closedFreeRecord.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
|
||||
closedFreeRecord.ClosedReasonCode = common.ReasonCode("manual_grant")
|
||||
require.NoError(t, store.EntitlementLifecycle().Grant(context.Background(), ports.GrantEntitlementInput{
|
||||
ExpectedCurrentSnapshot: freeSnapshot,
|
||||
ExpectedCurrentRecord: freeRecord,
|
||||
UpdatedCurrentRecord: closedFreeRecord,
|
||||
NewRecord: grantedRecord,
|
||||
NewSnapshot: grantedSnapshot,
|
||||
}))
|
||||
|
||||
reader, err := entitlementsvc.NewReader(
|
||||
store.EntitlementSnapshots(),
|
||||
store.EntitlementLifecycle(),
|
||||
adminStoreClock{now: now},
|
||||
adminStoreIDGenerator{entitlementRecordID: entitlement.EntitlementRecordID("entitlement-free-after-expiry")},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
lister, err := adminusers.NewLister(store.Accounts(), reader, store.Sanctions(), store.Limits(), adminStoreClock{now: now}, store)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := lister.Execute(context.Background(), adminusers.ListUsersInput{PaidState: "free"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result.Items, 1)
|
||||
require.Equal(t, "user-123", result.Items[0].UserID)
|
||||
require.Equal(t, "free", result.Items[0].Entitlement.PlanCode)
|
||||
require.False(t, result.Items[0].Entitlement.IsPaid)
|
||||
|
||||
storedSnapshot, err := store.EntitlementSnapshots().GetByUserID(context.Background(), userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, entitlement.PlanCodeFree, storedSnapshot.PlanCode)
|
||||
require.False(t, storedSnapshot.IsPaid)
|
||||
}
|
||||
|
||||
type adminStoreClock struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (clock adminStoreClock) Now() time.Time {
|
||||
return clock.now
|
||||
}
|
||||
|
||||
type adminStoreIDGenerator struct {
|
||||
entitlementRecordID entitlement.EntitlementRecordID
|
||||
}
|
||||
|
||||
func (generator adminStoreIDGenerator) NewUserID() (common.UserID, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (generator adminStoreIDGenerator) NewInitialRaceName() (common.RaceName, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (generator adminStoreIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
|
||||
return generator.entitlementRecordID, nil
|
||||
}
|
||||
|
||||
func (generator adminStoreIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (generator adminStoreIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func requireSetContains(t *testing.T, store *Store, key string, member string) {
|
||||
t.Helper()
|
||||
|
||||
exists, err := store.client.SIsMember(context.Background(), key, member).Result()
|
||||
require.NoError(t, err)
|
||||
require.True(t, exists, "expected %q to contain %q", key, member)
|
||||
}
|
||||
|
||||
func requireSetNotContains(t *testing.T, store *Store, key string, member string) {
|
||||
t.Helper()
|
||||
|
||||
exists, err := store.client.SIsMember(context.Background(), key, member).Result()
|
||||
require.NoError(t, err)
|
||||
require.False(t, exists, "expected %q not to contain %q", key, member)
|
||||
}
|
||||
|
||||
func requireSortedSetScore(t *testing.T, store *Store, key string, member string, want float64) {
|
||||
t.Helper()
|
||||
|
||||
got, err := store.client.ZScore(context.Background(), key, member).Result()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, want, got)
|
||||
}
|
||||
|
||||
func requireSortedSetMissing(t *testing.T, store *Store, key string, member string) {
|
||||
t.Helper()
|
||||
|
||||
_, err := store.client.ZScore(context.Background(), key, member).Result()
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,752 @@
|
||||
package userstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/ports"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type entitlementPeriodRecord struct {
|
||||
RecordID string `json:"record_id"`
|
||||
UserID string `json:"user_id"`
|
||||
PlanCode string `json:"plan_code"`
|
||||
Source string `json:"source"`
|
||||
ActorType string `json:"actor_type"`
|
||||
ActorID *string `json:"actor_id,omitempty"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
StartsAt string `json:"starts_at"`
|
||||
EndsAt *string `json:"ends_at,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ClosedAt *string `json:"closed_at,omitempty"`
|
||||
ClosedByType *string `json:"closed_by_type,omitempty"`
|
||||
ClosedByID *string `json:"closed_by_id,omitempty"`
|
||||
ClosedReasonCode *string `json:"closed_reason_code,omitempty"`
|
||||
}
|
||||
|
||||
// CreateEntitlementRecord stores one new entitlement history record.
|
||||
func (store *Store) CreateEntitlementRecord(ctx context.Context, record entitlement.PeriodRecord) error {
|
||||
if err := record.Validate(); err != nil {
|
||||
return fmt.Errorf("create entitlement record in redis: %w", err)
|
||||
}
|
||||
|
||||
payload, err := marshalEntitlementPeriodRecord(record)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create entitlement record in redis: %w", err)
|
||||
}
|
||||
|
||||
recordKey := store.keyspace.EntitlementRecord(record.RecordID)
|
||||
historyKey := store.keyspace.EntitlementHistory(record.UserID)
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "create entitlement record in redis")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
||||
if err := ensureKeyAbsent(operationCtx, tx, recordKey); err != nil {
|
||||
return fmt.Errorf("create entitlement record %q in redis: %w", record.RecordID, err)
|
||||
}
|
||||
|
||||
_, err := tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(operationCtx, recordKey, payload, 0)
|
||||
pipe.ZAdd(operationCtx, historyKey, redis.Z{
|
||||
Score: float64(record.StartsAt.UTC().UnixMicro()),
|
||||
Member: record.RecordID.String(),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create entitlement record %q in redis: %w", record.RecordID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, recordKey, historyKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("create entitlement record %q in redis: %w", record.RecordID, ports.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// GetEntitlementRecordByRecordID returns the entitlement history record
|
||||
// identified by recordID.
|
||||
func (store *Store) GetEntitlementRecordByRecordID(
|
||||
ctx context.Context,
|
||||
recordID entitlement.EntitlementRecordID,
|
||||
) (entitlement.PeriodRecord, error) {
|
||||
if err := recordID.Validate(); err != nil {
|
||||
return entitlement.PeriodRecord{}, fmt.Errorf("get entitlement record by record id from redis: %w", err)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "get entitlement record by record id from redis")
|
||||
if err != nil {
|
||||
return entitlement.PeriodRecord{}, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
record, err := store.loadEntitlementRecord(operationCtx, store.client, recordID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ports.ErrNotFound):
|
||||
return entitlement.PeriodRecord{}, fmt.Errorf("get entitlement record by record id %q from redis: %w", recordID, ports.ErrNotFound)
|
||||
default:
|
||||
return entitlement.PeriodRecord{}, fmt.Errorf("get entitlement record by record id %q from redis: %w", recordID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// ListEntitlementRecordsByUserID returns every entitlement history record
|
||||
// owned by userID.
|
||||
func (store *Store) ListEntitlementRecordsByUserID(
|
||||
ctx context.Context,
|
||||
userID common.UserID,
|
||||
) ([]entitlement.PeriodRecord, error) {
|
||||
if err := userID.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("list entitlement records by user id from redis: %w", err)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "list entitlement records by user id from redis")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
recordIDs, err := store.client.ZRange(operationCtx, store.keyspace.EntitlementHistory(userID), 0, -1).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list entitlement records by user id %q from redis: %w", userID, err)
|
||||
}
|
||||
|
||||
records := make([]entitlement.PeriodRecord, 0, len(recordIDs))
|
||||
for _, rawRecordID := range recordIDs {
|
||||
record, err := store.loadEntitlementRecord(operationCtx, store.client, entitlement.EntitlementRecordID(rawRecordID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list entitlement records by user id %q from redis: %w", userID, err)
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// UpdateEntitlementRecord replaces one stored entitlement history record.
|
||||
func (store *Store) UpdateEntitlementRecord(ctx context.Context, record entitlement.PeriodRecord) error {
|
||||
if err := record.Validate(); err != nil {
|
||||
return fmt.Errorf("update entitlement record in redis: %w", err)
|
||||
}
|
||||
|
||||
payload, err := marshalEntitlementPeriodRecord(record)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update entitlement record in redis: %w", err)
|
||||
}
|
||||
|
||||
recordKey := store.keyspace.EntitlementRecord(record.RecordID)
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "update entitlement record in redis")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
||||
if _, err := store.loadEntitlementRecord(operationCtx, tx, record.RecordID); err != nil {
|
||||
return fmt.Errorf("update entitlement record %q in redis: %w", record.RecordID, err)
|
||||
}
|
||||
|
||||
_, err := tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(operationCtx, recordKey, payload, 0)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("update entitlement record %q in redis: %w", record.RecordID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, recordKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("update entitlement record %q in redis: %w", record.RecordID, ports.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// GrantEntitlement atomically closes the current free history record, creates
|
||||
// one paid history record, and replaces the current snapshot.
|
||||
func (store *Store) GrantEntitlement(ctx context.Context, input ports.GrantEntitlementInput) error {
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("grant entitlement in redis: %w", err)
|
||||
}
|
||||
|
||||
updatedCurrentRecordPayload, err := marshalEntitlementPeriodRecord(input.UpdatedCurrentRecord)
|
||||
if err != nil {
|
||||
return fmt.Errorf("grant entitlement in redis: %w", err)
|
||||
}
|
||||
newRecordPayload, err := marshalEntitlementPeriodRecord(input.NewRecord)
|
||||
if err != nil {
|
||||
return fmt.Errorf("grant entitlement in redis: %w", err)
|
||||
}
|
||||
newSnapshotPayload, err := marshalEntitlementSnapshotRecord(input.NewSnapshot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("grant entitlement in redis: %w", err)
|
||||
}
|
||||
|
||||
currentRecordKey := store.keyspace.EntitlementRecord(input.ExpectedCurrentRecord.RecordID)
|
||||
newRecordKey := store.keyspace.EntitlementRecord(input.NewRecord.RecordID)
|
||||
historyKey := store.keyspace.EntitlementHistory(input.NewRecord.UserID)
|
||||
snapshotKey := store.keyspace.EntitlementSnapshot(input.NewSnapshot.UserID)
|
||||
watchedKeys := append(
|
||||
[]string{currentRecordKey, newRecordKey, historyKey, snapshotKey},
|
||||
store.activeSanctionWatchKeys(input.NewSnapshot.UserID)...,
|
||||
)
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "grant entitlement in redis")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
||||
storedSnapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedCurrentSnapshot.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
||||
}
|
||||
if !equalEntitlementSnapshots(storedSnapshot, input.ExpectedCurrentSnapshot) {
|
||||
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
|
||||
}
|
||||
|
||||
storedCurrentRecord, err := store.loadEntitlementRecord(operationCtx, tx, input.ExpectedCurrentRecord.RecordID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
||||
}
|
||||
if !equalEntitlementPeriodRecords(storedCurrentRecord, input.ExpectedCurrentRecord) {
|
||||
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
|
||||
}
|
||||
if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil {
|
||||
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
||||
}
|
||||
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewSnapshot.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
||||
}
|
||||
|
||||
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(operationCtx, currentRecordKey, updatedCurrentRecordPayload, 0)
|
||||
pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0)
|
||||
pipe.ZAdd(operationCtx, historyKey, redis.Z{
|
||||
Score: float64(input.NewRecord.StartsAt.UTC().UnixMicro()),
|
||||
Member: input.NewRecord.RecordID.String(),
|
||||
})
|
||||
pipe.Set(operationCtx, snapshotKey, newSnapshotPayload, 0)
|
||||
store.syncEntitlementIndexes(pipe, operationCtx, input.NewSnapshot)
|
||||
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewSnapshot.UserID, input.NewSnapshot.IsPaid, activeSanctionCodes)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, watchedKeys...)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ExtendEntitlement atomically appends one paid history segment and replaces
|
||||
// the current paid snapshot.
|
||||
func (store *Store) ExtendEntitlement(ctx context.Context, input ports.ExtendEntitlementInput) error {
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("extend entitlement in redis: %w", err)
|
||||
}
|
||||
|
||||
newRecordPayload, err := marshalEntitlementPeriodRecord(input.NewRecord)
|
||||
if err != nil {
|
||||
return fmt.Errorf("extend entitlement in redis: %w", err)
|
||||
}
|
||||
newSnapshotPayload, err := marshalEntitlementSnapshotRecord(input.NewSnapshot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("extend entitlement in redis: %w", err)
|
||||
}
|
||||
|
||||
newRecordKey := store.keyspace.EntitlementRecord(input.NewRecord.RecordID)
|
||||
historyKey := store.keyspace.EntitlementHistory(input.NewRecord.UserID)
|
||||
snapshotKey := store.keyspace.EntitlementSnapshot(input.NewSnapshot.UserID)
|
||||
watchedKeys := append(
|
||||
[]string{newRecordKey, historyKey, snapshotKey},
|
||||
store.activeSanctionWatchKeys(input.NewSnapshot.UserID)...,
|
||||
)
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "extend entitlement in redis")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
||||
storedSnapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedCurrentSnapshot.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
||||
}
|
||||
if !equalEntitlementSnapshots(storedSnapshot, input.ExpectedCurrentSnapshot) {
|
||||
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
|
||||
}
|
||||
if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil {
|
||||
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
||||
}
|
||||
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewSnapshot.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
||||
}
|
||||
|
||||
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0)
|
||||
pipe.ZAdd(operationCtx, historyKey, redis.Z{
|
||||
Score: float64(input.NewRecord.StartsAt.UTC().UnixMicro()),
|
||||
Member: input.NewRecord.RecordID.String(),
|
||||
})
|
||||
pipe.Set(operationCtx, snapshotKey, newSnapshotPayload, 0)
|
||||
store.syncEntitlementIndexes(pipe, operationCtx, input.NewSnapshot)
|
||||
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewSnapshot.UserID, input.NewSnapshot.IsPaid, activeSanctionCodes)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, watchedKeys...)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// RevokeEntitlement atomically closes the current paid history record,
|
||||
// creates one free history record, and replaces the current snapshot.
|
||||
func (store *Store) RevokeEntitlement(ctx context.Context, input ports.RevokeEntitlementInput) error {
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("revoke entitlement in redis: %w", err)
|
||||
}
|
||||
|
||||
updatedCurrentRecordPayload, err := marshalEntitlementPeriodRecord(input.UpdatedCurrentRecord)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke entitlement in redis: %w", err)
|
||||
}
|
||||
newRecordPayload, err := marshalEntitlementPeriodRecord(input.NewRecord)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke entitlement in redis: %w", err)
|
||||
}
|
||||
newSnapshotPayload, err := marshalEntitlementSnapshotRecord(input.NewSnapshot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke entitlement in redis: %w", err)
|
||||
}
|
||||
|
||||
currentRecordKey := store.keyspace.EntitlementRecord(input.ExpectedCurrentRecord.RecordID)
|
||||
newRecordKey := store.keyspace.EntitlementRecord(input.NewRecord.RecordID)
|
||||
historyKey := store.keyspace.EntitlementHistory(input.NewRecord.UserID)
|
||||
snapshotKey := store.keyspace.EntitlementSnapshot(input.NewSnapshot.UserID)
|
||||
watchedKeys := append(
|
||||
[]string{currentRecordKey, newRecordKey, historyKey, snapshotKey},
|
||||
store.activeSanctionWatchKeys(input.NewSnapshot.UserID)...,
|
||||
)
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "revoke entitlement in redis")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
||||
storedSnapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedCurrentSnapshot.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
||||
}
|
||||
if !equalEntitlementSnapshots(storedSnapshot, input.ExpectedCurrentSnapshot) {
|
||||
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
|
||||
}
|
||||
|
||||
storedCurrentRecord, err := store.loadEntitlementRecord(operationCtx, tx, input.ExpectedCurrentRecord.RecordID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
||||
}
|
||||
if !equalEntitlementPeriodRecords(storedCurrentRecord, input.ExpectedCurrentRecord) {
|
||||
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
|
||||
}
|
||||
if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil {
|
||||
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
||||
}
|
||||
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewSnapshot.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
||||
}
|
||||
|
||||
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(operationCtx, currentRecordKey, updatedCurrentRecordPayload, 0)
|
||||
pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0)
|
||||
pipe.ZAdd(operationCtx, historyKey, redis.Z{
|
||||
Score: float64(input.NewRecord.StartsAt.UTC().UnixMicro()),
|
||||
Member: input.NewRecord.RecordID.String(),
|
||||
})
|
||||
pipe.Set(operationCtx, snapshotKey, newSnapshotPayload, 0)
|
||||
store.syncEntitlementIndexes(pipe, operationCtx, input.NewSnapshot)
|
||||
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewSnapshot.UserID, input.NewSnapshot.IsPaid, activeSanctionCodes)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, watchedKeys...)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// RepairExpiredEntitlement atomically replaces one expired finite paid
|
||||
// snapshot with a materialized free state.
|
||||
func (store *Store) RepairExpiredEntitlement(ctx context.Context, input ports.RepairExpiredEntitlementInput) error {
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("repair expired entitlement in redis: %w", err)
|
||||
}
|
||||
|
||||
newRecordPayload, err := marshalEntitlementPeriodRecord(input.NewRecord)
|
||||
if err != nil {
|
||||
return fmt.Errorf("repair expired entitlement in redis: %w", err)
|
||||
}
|
||||
newSnapshotPayload, err := marshalEntitlementSnapshotRecord(input.NewSnapshot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("repair expired entitlement in redis: %w", err)
|
||||
}
|
||||
|
||||
newRecordKey := store.keyspace.EntitlementRecord(input.NewRecord.RecordID)
|
||||
historyKey := store.keyspace.EntitlementHistory(input.NewRecord.UserID)
|
||||
snapshotKey := store.keyspace.EntitlementSnapshot(input.NewSnapshot.UserID)
|
||||
watchedKeys := append(
|
||||
[]string{newRecordKey, historyKey, snapshotKey},
|
||||
store.activeSanctionWatchKeys(input.NewSnapshot.UserID)...,
|
||||
)
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "repair expired entitlement in redis")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
||||
storedSnapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedExpiredSnapshot.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, err)
|
||||
}
|
||||
if !equalEntitlementSnapshots(storedSnapshot, input.ExpectedExpiredSnapshot) {
|
||||
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, ports.ErrConflict)
|
||||
}
|
||||
if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil {
|
||||
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, err)
|
||||
}
|
||||
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewSnapshot.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, err)
|
||||
}
|
||||
|
||||
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0)
|
||||
pipe.ZAdd(operationCtx, historyKey, redis.Z{
|
||||
Score: float64(input.NewRecord.StartsAt.UTC().UnixMicro()),
|
||||
Member: input.NewRecord.RecordID.String(),
|
||||
})
|
||||
pipe.Set(operationCtx, snapshotKey, newSnapshotPayload, 0)
|
||||
store.syncEntitlementIndexes(pipe, operationCtx, input.NewSnapshot)
|
||||
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewSnapshot.UserID, input.NewSnapshot.IsPaid, activeSanctionCodes)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, watchedKeys...)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, ports.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (store *Store) loadEntitlementRecord(
|
||||
ctx context.Context,
|
||||
getter bytesGetter,
|
||||
recordID entitlement.EntitlementRecordID,
|
||||
) (entitlement.PeriodRecord, error) {
|
||||
payload, err := getter.Get(ctx, store.keyspace.EntitlementRecord(recordID)).Bytes()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return entitlement.PeriodRecord{}, ports.ErrNotFound
|
||||
case err != nil:
|
||||
return entitlement.PeriodRecord{}, err
|
||||
}
|
||||
|
||||
return decodeEntitlementPeriodRecord(payload)
|
||||
}
|
||||
|
||||
func marshalEntitlementPeriodRecord(record entitlement.PeriodRecord) ([]byte, error) {
|
||||
encoded := entitlementPeriodRecord{
|
||||
RecordID: record.RecordID.String(),
|
||||
UserID: record.UserID.String(),
|
||||
PlanCode: string(record.PlanCode),
|
||||
Source: record.Source.String(),
|
||||
ActorType: record.Actor.Type.String(),
|
||||
ReasonCode: record.ReasonCode.String(),
|
||||
StartsAt: record.StartsAt.UTC().Format(time.RFC3339Nano),
|
||||
CreatedAt: record.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
if !record.Actor.ID.IsZero() {
|
||||
value := record.Actor.ID.String()
|
||||
encoded.ActorID = &value
|
||||
}
|
||||
if record.EndsAt != nil {
|
||||
value := record.EndsAt.UTC().Format(time.RFC3339Nano)
|
||||
encoded.EndsAt = &value
|
||||
}
|
||||
if record.ClosedAt != nil {
|
||||
value := record.ClosedAt.UTC().Format(time.RFC3339Nano)
|
||||
encoded.ClosedAt = &value
|
||||
}
|
||||
if !record.ClosedBy.Type.IsZero() {
|
||||
value := record.ClosedBy.Type.String()
|
||||
encoded.ClosedByType = &value
|
||||
}
|
||||
if !record.ClosedBy.ID.IsZero() {
|
||||
value := record.ClosedBy.ID.String()
|
||||
encoded.ClosedByID = &value
|
||||
}
|
||||
if !record.ClosedReasonCode.IsZero() {
|
||||
value := record.ClosedReasonCode.String()
|
||||
encoded.ClosedReasonCode = &value
|
||||
}
|
||||
|
||||
return json.Marshal(encoded)
|
||||
}
|
||||
|
||||
func decodeEntitlementPeriodRecord(payload []byte) (entitlement.PeriodRecord, error) {
|
||||
var encoded entitlementPeriodRecord
|
||||
if err := decodeJSONPayload(payload, &encoded); err != nil {
|
||||
return entitlement.PeriodRecord{}, err
|
||||
}
|
||||
|
||||
startsAt, err := time.Parse(time.RFC3339Nano, encoded.StartsAt)
|
||||
if err != nil {
|
||||
return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record starts_at: %w", err)
|
||||
}
|
||||
createdAt, err := time.Parse(time.RFC3339Nano, encoded.CreatedAt)
|
||||
if err != nil {
|
||||
return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record created_at: %w", err)
|
||||
}
|
||||
|
||||
record := entitlement.PeriodRecord{
|
||||
RecordID: entitlement.EntitlementRecordID(encoded.RecordID),
|
||||
UserID: common.UserID(encoded.UserID),
|
||||
PlanCode: entitlement.PlanCode(encoded.PlanCode),
|
||||
Source: common.Source(encoded.Source),
|
||||
Actor: common.ActorRef{Type: common.ActorType(encoded.ActorType)},
|
||||
ReasonCode: common.ReasonCode(encoded.ReasonCode),
|
||||
StartsAt: startsAt.UTC(),
|
||||
CreatedAt: createdAt.UTC(),
|
||||
}
|
||||
if encoded.ActorID != nil {
|
||||
record.Actor.ID = common.ActorID(*encoded.ActorID)
|
||||
}
|
||||
if encoded.EndsAt != nil {
|
||||
value, err := time.Parse(time.RFC3339Nano, *encoded.EndsAt)
|
||||
if err != nil {
|
||||
return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record ends_at: %w", err)
|
||||
}
|
||||
value = value.UTC()
|
||||
record.EndsAt = &value
|
||||
}
|
||||
if encoded.ClosedAt != nil {
|
||||
value, err := time.Parse(time.RFC3339Nano, *encoded.ClosedAt)
|
||||
if err != nil {
|
||||
return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record closed_at: %w", err)
|
||||
}
|
||||
value = value.UTC()
|
||||
record.ClosedAt = &value
|
||||
}
|
||||
if encoded.ClosedByType != nil {
|
||||
record.ClosedBy.Type = common.ActorType(*encoded.ClosedByType)
|
||||
}
|
||||
if encoded.ClosedByID != nil {
|
||||
record.ClosedBy.ID = common.ActorID(*encoded.ClosedByID)
|
||||
}
|
||||
if encoded.ClosedReasonCode != nil {
|
||||
record.ClosedReasonCode = common.ReasonCode(*encoded.ClosedReasonCode)
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record: %w", err)
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func equalEntitlementSnapshots(left entitlement.CurrentSnapshot, right entitlement.CurrentSnapshot) bool {
|
||||
return left.UserID == right.UserID &&
|
||||
left.PlanCode == right.PlanCode &&
|
||||
left.IsPaid == right.IsPaid &&
|
||||
left.StartsAt.Equal(right.StartsAt) &&
|
||||
equalOptionalTime(left.EndsAt, right.EndsAt) &&
|
||||
left.Source == right.Source &&
|
||||
left.Actor == right.Actor &&
|
||||
left.ReasonCode == right.ReasonCode &&
|
||||
left.UpdatedAt.Equal(right.UpdatedAt)
|
||||
}
|
||||
|
||||
func equalEntitlementPeriodRecords(left entitlement.PeriodRecord, right entitlement.PeriodRecord) bool {
|
||||
return left.RecordID == right.RecordID &&
|
||||
left.UserID == right.UserID &&
|
||||
left.PlanCode == right.PlanCode &&
|
||||
left.Source == right.Source &&
|
||||
left.Actor == right.Actor &&
|
||||
left.ReasonCode == right.ReasonCode &&
|
||||
left.StartsAt.Equal(right.StartsAt) &&
|
||||
equalOptionalTime(left.EndsAt, right.EndsAt) &&
|
||||
left.CreatedAt.Equal(right.CreatedAt) &&
|
||||
equalOptionalTime(left.ClosedAt, right.ClosedAt) &&
|
||||
left.ClosedBy == right.ClosedBy &&
|
||||
left.ClosedReasonCode == right.ClosedReasonCode
|
||||
}
|
||||
|
||||
func equalOptionalTime(left *time.Time, right *time.Time) bool {
|
||||
switch {
|
||||
case left == nil && right == nil:
|
||||
return true
|
||||
case left == nil || right == nil:
|
||||
return false
|
||||
default:
|
||||
return left.Equal(*right)
|
||||
}
|
||||
}
|
||||
|
||||
// EntitlementHistoryStore adapts Store to the existing
|
||||
// EntitlementHistoryStore port.
|
||||
type EntitlementHistoryStore struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
// EntitlementHistory returns one adapter that exposes the entitlement-history
|
||||
// store port over Store.
|
||||
func (store *Store) EntitlementHistory() *EntitlementHistoryStore {
|
||||
if store == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &EntitlementHistoryStore{store: store}
|
||||
}
|
||||
|
||||
// Create stores one new entitlement history record.
|
||||
func (adapter *EntitlementHistoryStore) Create(ctx context.Context, record entitlement.PeriodRecord) error {
|
||||
return adapter.store.CreateEntitlementRecord(ctx, record)
|
||||
}
|
||||
|
||||
// GetByRecordID returns the entitlement history record identified by recordID.
|
||||
func (adapter *EntitlementHistoryStore) GetByRecordID(
|
||||
ctx context.Context,
|
||||
recordID entitlement.EntitlementRecordID,
|
||||
) (entitlement.PeriodRecord, error) {
|
||||
return adapter.store.GetEntitlementRecordByRecordID(ctx, recordID)
|
||||
}
|
||||
|
||||
// ListByUserID returns every entitlement history record owned by userID.
|
||||
func (adapter *EntitlementHistoryStore) ListByUserID(
|
||||
ctx context.Context,
|
||||
userID common.UserID,
|
||||
) ([]entitlement.PeriodRecord, error) {
|
||||
return adapter.store.ListEntitlementRecordsByUserID(ctx, userID)
|
||||
}
|
||||
|
||||
// Update replaces one stored entitlement history record.
|
||||
func (adapter *EntitlementHistoryStore) Update(ctx context.Context, record entitlement.PeriodRecord) error {
|
||||
return adapter.store.UpdateEntitlementRecord(ctx, record)
|
||||
}
|
||||
|
||||
var _ ports.EntitlementHistoryStore = (*EntitlementHistoryStore)(nil)
|
||||
|
||||
// EntitlementLifecycleStore adapts Store to the existing
|
||||
// EntitlementLifecycleStore port.
|
||||
type EntitlementLifecycleStore struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
// EntitlementLifecycle returns one adapter that exposes the atomic
|
||||
// entitlement-lifecycle store port over Store.
|
||||
func (store *Store) EntitlementLifecycle() *EntitlementLifecycleStore {
|
||||
if store == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &EntitlementLifecycleStore{store: store}
|
||||
}
|
||||
|
||||
// Grant atomically applies one free-to-paid transition.
|
||||
func (adapter *EntitlementLifecycleStore) Grant(ctx context.Context, input ports.GrantEntitlementInput) error {
|
||||
return adapter.store.GrantEntitlement(ctx, input)
|
||||
}
|
||||
|
||||
// Extend atomically appends one paid extension segment and updates the current
|
||||
// snapshot.
|
||||
func (adapter *EntitlementLifecycleStore) Extend(ctx context.Context, input ports.ExtendEntitlementInput) error {
|
||||
return adapter.store.ExtendEntitlement(ctx, input)
|
||||
}
|
||||
|
||||
// Revoke atomically applies one paid-to-free transition.
|
||||
func (adapter *EntitlementLifecycleStore) Revoke(ctx context.Context, input ports.RevokeEntitlementInput) error {
|
||||
return adapter.store.RevokeEntitlement(ctx, input)
|
||||
}
|
||||
|
||||
// RepairExpired atomically repairs one expired finite paid snapshot.
|
||||
func (adapter *EntitlementLifecycleStore) RepairExpired(
|
||||
ctx context.Context,
|
||||
input ports.RepairExpiredEntitlementInput,
|
||||
) error {
|
||||
return adapter.store.RepairExpiredEntitlement(ctx, input)
|
||||
}
|
||||
|
||||
var _ ports.EntitlementLifecycleStore = (*EntitlementLifecycleStore)(nil)
|
||||
@@ -0,0 +1,137 @@
|
||||
package userstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/adapters/redisstate"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/ports"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// ListUserIDs returns one deterministic page of user identifiers ordered by
|
||||
// `created_at desc`, then `user_id desc`.
|
||||
func (store *Store) ListUserIDs(ctx context.Context, input ports.ListUsersInput) (ports.ListUsersResult, error) {
|
||||
if err := input.Validate(); err != nil {
|
||||
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "list users in redis")
|
||||
if err != nil {
|
||||
return ports.ListUsersResult{}, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
startIndex := int64(0)
|
||||
filters := userListFiltersFromPorts(input.Filters)
|
||||
if input.PageToken != "" {
|
||||
cursor, err := redisstate.DecodePageToken(input.PageToken, filters)
|
||||
if err != nil {
|
||||
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", ports.ErrInvalidPageToken)
|
||||
}
|
||||
|
||||
score, err := store.client.ZScore(operationCtx, store.keyspace.CreatedAtIndex(), cursor.UserID.String()).Result()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", ports.ErrInvalidPageToken)
|
||||
case err != nil:
|
||||
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
|
||||
}
|
||||
if !time.UnixMicro(int64(score)).UTC().Equal(cursor.CreatedAt.UTC()) {
|
||||
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", ports.ErrInvalidPageToken)
|
||||
}
|
||||
|
||||
rank, err := store.client.ZRevRank(operationCtx, store.keyspace.CreatedAtIndex(), cursor.UserID.String()).Result()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", ports.ErrInvalidPageToken)
|
||||
case err != nil:
|
||||
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
|
||||
}
|
||||
|
||||
startIndex = rank + 1
|
||||
}
|
||||
|
||||
rawPage, err := store.client.ZRevRangeWithScores(
|
||||
operationCtx,
|
||||
store.keyspace.CreatedAtIndex(),
|
||||
startIndex,
|
||||
startIndex+int64(input.PageSize),
|
||||
).Result()
|
||||
if err != nil {
|
||||
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
|
||||
}
|
||||
|
||||
result := ports.ListUsersResult{
|
||||
UserIDs: make([]common.UserID, 0, min(len(rawPage), input.PageSize)),
|
||||
}
|
||||
|
||||
visibleCount := min(len(rawPage), input.PageSize)
|
||||
for index := 0; index < visibleCount; index++ {
|
||||
userID, err := memberUserID(rawPage[index].Member)
|
||||
if err != nil {
|
||||
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
|
||||
}
|
||||
result.UserIDs = append(result.UserIDs, userID)
|
||||
}
|
||||
|
||||
if len(rawPage) > input.PageSize {
|
||||
lastVisible := rawPage[input.PageSize-1]
|
||||
lastUserID, err := memberUserID(lastVisible.Member)
|
||||
if err != nil {
|
||||
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
|
||||
}
|
||||
token, err := redisstate.EncodePageToken(redisstate.PageCursor{
|
||||
CreatedAt: time.UnixMicro(int64(lastVisible.Score)).UTC(),
|
||||
UserID: lastUserID,
|
||||
}, filters)
|
||||
if err != nil {
|
||||
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
|
||||
}
|
||||
result.NextPageToken = token
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func userListFiltersFromPorts(filters ports.UserListFilters) redisstate.UserListFilters {
|
||||
return redisstate.UserListFilters{
|
||||
PaidState: filters.PaidState,
|
||||
PaidExpiresBefore: filters.PaidExpiresBefore,
|
||||
PaidExpiresAfter: filters.PaidExpiresAfter,
|
||||
DeclaredCountry: filters.DeclaredCountry,
|
||||
SanctionCode: filters.SanctionCode,
|
||||
LimitCode: filters.LimitCode,
|
||||
CanLogin: filters.CanLogin,
|
||||
CanCreatePrivateGame: filters.CanCreatePrivateGame,
|
||||
CanJoinGame: filters.CanJoinGame,
|
||||
}
|
||||
}
|
||||
|
||||
func memberUserID(member any) (common.UserID, error) {
|
||||
value, ok := member.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unexpected created-at index member type %T", member)
|
||||
}
|
||||
|
||||
userID := common.UserID(value)
|
||||
if err := userID.Validate(); err != nil {
|
||||
return "", fmt.Errorf("created-at index member user id: %w", err)
|
||||
}
|
||||
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func min(left int, right int) int {
|
||||
if left < right {
|
||||
return left
|
||||
}
|
||||
|
||||
return right
|
||||
}
|
||||
|
||||
var _ ports.UserListStore = (*Store)(nil)
|
||||
@@ -0,0 +1,445 @@
|
||||
package userstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/policy"
|
||||
"galaxy/user/internal/ports"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// ApplySanction atomically creates one new active sanction record.
|
||||
func (store *Store) ApplySanction(ctx context.Context, input ports.ApplySanctionInput) error {
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("apply sanction in redis: %w", err)
|
||||
}
|
||||
|
||||
recordPayload, err := marshalSanctionRecord(input.NewRecord)
|
||||
if err != nil {
|
||||
return fmt.Errorf("apply sanction in redis: %w", err)
|
||||
}
|
||||
|
||||
recordKey := store.keyspace.SanctionRecord(input.NewRecord.RecordID)
|
||||
historyKey := store.keyspace.SanctionHistory(input.NewRecord.UserID)
|
||||
activeKey := store.keyspace.ActiveSanction(input.NewRecord.UserID, input.NewRecord.SanctionCode)
|
||||
snapshotKey := store.keyspace.EntitlementSnapshot(input.NewRecord.UserID)
|
||||
watchedKeys := append(
|
||||
[]string{recordKey, historyKey, activeKey, snapshotKey},
|
||||
store.activeSanctionWatchKeys(input.NewRecord.UserID)...,
|
||||
)
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "apply sanction in redis")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
||||
if err := ensureKeyAbsent(operationCtx, tx, recordKey); err != nil {
|
||||
return fmt.Errorf("apply sanction for user %q in redis: %w", input.NewRecord.UserID, err)
|
||||
}
|
||||
if err := ensureKeyAbsent(operationCtx, tx, activeKey); err != nil {
|
||||
return fmt.Errorf("apply sanction for user %q in redis: %w", input.NewRecord.UserID, err)
|
||||
}
|
||||
snapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.NewRecord.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("apply sanction for user %q in redis: %w", input.NewRecord.UserID, err)
|
||||
}
|
||||
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewRecord.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("apply sanction for user %q in redis: %w", input.NewRecord.UserID, err)
|
||||
}
|
||||
activeSanctionCodes[input.NewRecord.SanctionCode] = struct{}{}
|
||||
|
||||
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(operationCtx, recordKey, recordPayload, 0)
|
||||
pipe.ZAdd(operationCtx, historyKey, redis.Z{
|
||||
Score: float64(input.NewRecord.AppliedAt.UTC().UnixMicro()),
|
||||
Member: input.NewRecord.RecordID.String(),
|
||||
})
|
||||
setActiveSlot(pipe, operationCtx, activeKey, input.NewRecord.RecordID.String(), input.NewRecord.ExpiresAt)
|
||||
store.syncActiveSanctionCodeIndexes(pipe, operationCtx, input.NewRecord.UserID, activeSanctionCodes)
|
||||
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewRecord.UserID, snapshot.IsPaid, activeSanctionCodes)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("apply sanction for user %q in redis: %w", input.NewRecord.UserID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, watchedKeys...)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("apply sanction for user %q in redis: %w", input.NewRecord.UserID, ports.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveSanction atomically removes one active sanction record.
|
||||
func (store *Store) RemoveSanction(ctx context.Context, input ports.RemoveSanctionInput) error {
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("remove sanction in redis: %w", err)
|
||||
}
|
||||
|
||||
updatedPayload, err := marshalSanctionRecord(input.UpdatedRecord)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove sanction in redis: %w", err)
|
||||
}
|
||||
|
||||
recordKey := store.keyspace.SanctionRecord(input.ExpectedActiveRecord.RecordID)
|
||||
activeKey := store.keyspace.ActiveSanction(input.ExpectedActiveRecord.UserID, input.ExpectedActiveRecord.SanctionCode)
|
||||
snapshotKey := store.keyspace.EntitlementSnapshot(input.ExpectedActiveRecord.UserID)
|
||||
watchedKeys := append(
|
||||
[]string{recordKey, activeKey, snapshotKey},
|
||||
store.activeSanctionWatchKeys(input.ExpectedActiveRecord.UserID)...,
|
||||
)
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "remove sanction in redis")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
||||
activeRecordID, err := store.loadActiveSanctionRecordID(operationCtx, tx, activeKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
|
||||
}
|
||||
if activeRecordID != input.ExpectedActiveRecord.RecordID {
|
||||
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, ports.ErrConflict)
|
||||
}
|
||||
|
||||
storedRecord, err := store.loadSanctionRecord(operationCtx, tx, input.ExpectedActiveRecord.RecordID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
|
||||
}
|
||||
if !equalSanctionRecords(storedRecord, input.ExpectedActiveRecord) {
|
||||
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, ports.ErrConflict)
|
||||
}
|
||||
snapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedActiveRecord.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
|
||||
}
|
||||
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.ExpectedActiveRecord.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
|
||||
}
|
||||
delete(activeSanctionCodes, input.ExpectedActiveRecord.SanctionCode)
|
||||
|
||||
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(operationCtx, recordKey, updatedPayload, 0)
|
||||
pipe.Del(operationCtx, activeKey)
|
||||
store.syncActiveSanctionCodeIndexes(pipe, operationCtx, input.ExpectedActiveRecord.UserID, activeSanctionCodes)
|
||||
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.ExpectedActiveRecord.UserID, snapshot.IsPaid, activeSanctionCodes)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, watchedKeys...)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, ports.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// SetLimit atomically creates or replaces one active limit record.
|
||||
func (store *Store) SetLimit(ctx context.Context, input ports.SetLimitInput) error {
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("set limit in redis: %w", err)
|
||||
}
|
||||
|
||||
newRecordPayload, err := marshalLimitRecord(input.NewRecord)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set limit in redis: %w", err)
|
||||
}
|
||||
|
||||
newRecordKey := store.keyspace.LimitRecord(input.NewRecord.RecordID)
|
||||
historyKey := store.keyspace.LimitHistory(input.NewRecord.UserID)
|
||||
activeKey := store.keyspace.ActiveLimit(input.NewRecord.UserID, input.NewRecord.LimitCode)
|
||||
watchedKeys := append(
|
||||
[]string{newRecordKey, historyKey, activeKey},
|
||||
store.activeLimitWatchKeys(input.NewRecord.UserID)...,
|
||||
)
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "set limit in redis")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
if input.ExpectedActiveRecord != nil {
|
||||
watchedKeys = append(watchedKeys, store.keyspace.LimitRecord(input.ExpectedActiveRecord.RecordID))
|
||||
}
|
||||
|
||||
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
||||
if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil {
|
||||
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
|
||||
}
|
||||
|
||||
var updatedPayload []byte
|
||||
if input.ExpectedActiveRecord == nil {
|
||||
if err := ensureKeyAbsent(operationCtx, tx, activeKey); err != nil {
|
||||
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
|
||||
}
|
||||
} else {
|
||||
activeRecordID, err := store.loadActiveLimitRecordID(operationCtx, tx, activeKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
|
||||
}
|
||||
if activeRecordID != input.ExpectedActiveRecord.RecordID {
|
||||
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, ports.ErrConflict)
|
||||
}
|
||||
|
||||
storedRecord, err := store.loadLimitRecord(operationCtx, tx, input.ExpectedActiveRecord.RecordID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
|
||||
}
|
||||
if !equalLimitRecords(storedRecord, *input.ExpectedActiveRecord) {
|
||||
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, ports.ErrConflict)
|
||||
}
|
||||
|
||||
updatedPayload, err = marshalLimitRecord(*input.UpdatedActiveRecord)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
|
||||
}
|
||||
}
|
||||
activeLimitCodes, err := store.loadActiveLimitCodeSet(operationCtx, tx, input.NewRecord.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
|
||||
}
|
||||
activeLimitCodes[input.NewRecord.LimitCode] = struct{}{}
|
||||
|
||||
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
||||
if input.ExpectedActiveRecord != nil {
|
||||
pipe.Set(operationCtx, store.keyspace.LimitRecord(input.ExpectedActiveRecord.RecordID), updatedPayload, 0)
|
||||
}
|
||||
pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0)
|
||||
pipe.ZAdd(operationCtx, historyKey, redis.Z{
|
||||
Score: float64(input.NewRecord.AppliedAt.UTC().UnixMicro()),
|
||||
Member: input.NewRecord.RecordID.String(),
|
||||
})
|
||||
setActiveSlot(pipe, operationCtx, activeKey, input.NewRecord.RecordID.String(), input.NewRecord.ExpiresAt)
|
||||
store.syncActiveLimitCodeIndexes(pipe, operationCtx, input.NewRecord.UserID, activeLimitCodes)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, watchedKeys...)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, ports.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveLimit atomically removes one active limit record.
|
||||
func (store *Store) RemoveLimit(ctx context.Context, input ports.RemoveLimitInput) error {
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("remove limit in redis: %w", err)
|
||||
}
|
||||
|
||||
updatedPayload, err := marshalLimitRecord(input.UpdatedRecord)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove limit in redis: %w", err)
|
||||
}
|
||||
|
||||
recordKey := store.keyspace.LimitRecord(input.ExpectedActiveRecord.RecordID)
|
||||
activeKey := store.keyspace.ActiveLimit(input.ExpectedActiveRecord.UserID, input.ExpectedActiveRecord.LimitCode)
|
||||
watchedKeys := append(
|
||||
[]string{recordKey, activeKey},
|
||||
store.activeLimitWatchKeys(input.ExpectedActiveRecord.UserID)...,
|
||||
)
|
||||
|
||||
operationCtx, cancel, err := store.operationContext(ctx, "remove limit in redis")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
||||
activeRecordID, err := store.loadActiveLimitRecordID(operationCtx, tx, activeKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
|
||||
}
|
||||
if activeRecordID != input.ExpectedActiveRecord.RecordID {
|
||||
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, ports.ErrConflict)
|
||||
}
|
||||
|
||||
storedRecord, err := store.loadLimitRecord(operationCtx, tx, input.ExpectedActiveRecord.RecordID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
|
||||
}
|
||||
if !equalLimitRecords(storedRecord, input.ExpectedActiveRecord) {
|
||||
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, ports.ErrConflict)
|
||||
}
|
||||
activeLimitCodes, err := store.loadActiveLimitCodeSet(operationCtx, tx, input.ExpectedActiveRecord.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
|
||||
}
|
||||
delete(activeLimitCodes, input.ExpectedActiveRecord.LimitCode)
|
||||
|
||||
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(operationCtx, recordKey, updatedPayload, 0)
|
||||
pipe.Del(operationCtx, activeKey)
|
||||
store.syncActiveLimitCodeIndexes(pipe, operationCtx, input.ExpectedActiveRecord.UserID, activeLimitCodes)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, watchedKeys...)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, ports.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (store *Store) loadActiveSanctionRecordID(
|
||||
ctx context.Context,
|
||||
getter bytesGetter,
|
||||
key string,
|
||||
) (policy.SanctionRecordID, error) {
|
||||
value, err := getter.Get(ctx, key).Result()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return "", ports.ErrNotFound
|
||||
case err != nil:
|
||||
return "", err
|
||||
}
|
||||
|
||||
recordID := policy.SanctionRecordID(value)
|
||||
if err := recordID.Validate(); err != nil {
|
||||
return "", fmt.Errorf("active sanction record id: %w", err)
|
||||
}
|
||||
|
||||
return recordID, nil
|
||||
}
|
||||
|
||||
func (store *Store) loadActiveLimitRecordID(
|
||||
ctx context.Context,
|
||||
getter bytesGetter,
|
||||
key string,
|
||||
) (policy.LimitRecordID, error) {
|
||||
value, err := getter.Get(ctx, key).Result()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return "", ports.ErrNotFound
|
||||
case err != nil:
|
||||
return "", err
|
||||
}
|
||||
|
||||
recordID := policy.LimitRecordID(value)
|
||||
if err := recordID.Validate(); err != nil {
|
||||
return "", fmt.Errorf("active limit record id: %w", err)
|
||||
}
|
||||
|
||||
return recordID, nil
|
||||
}
|
||||
|
||||
func setActiveSlot(
|
||||
pipe redis.Pipeliner,
|
||||
ctx context.Context,
|
||||
key string,
|
||||
recordID string,
|
||||
expiresAt *time.Time,
|
||||
) {
|
||||
pipe.Set(ctx, key, recordID, 0)
|
||||
if expiresAt != nil {
|
||||
pipe.PExpireAt(ctx, key, expiresAt.UTC())
|
||||
}
|
||||
}
|
||||
|
||||
func equalSanctionRecords(left policy.SanctionRecord, right policy.SanctionRecord) bool {
|
||||
return left.RecordID == right.RecordID &&
|
||||
left.UserID == right.UserID &&
|
||||
left.SanctionCode == right.SanctionCode &&
|
||||
left.Scope == right.Scope &&
|
||||
left.ReasonCode == right.ReasonCode &&
|
||||
left.Actor == right.Actor &&
|
||||
left.AppliedAt.Equal(right.AppliedAt) &&
|
||||
equalOptionalTime(left.ExpiresAt, right.ExpiresAt) &&
|
||||
equalOptionalTime(left.RemovedAt, right.RemovedAt) &&
|
||||
left.RemovedBy == right.RemovedBy &&
|
||||
left.RemovedReasonCode == right.RemovedReasonCode
|
||||
}
|
||||
|
||||
func equalLimitRecords(left policy.LimitRecord, right policy.LimitRecord) bool {
|
||||
return left.RecordID == right.RecordID &&
|
||||
left.UserID == right.UserID &&
|
||||
left.LimitCode == right.LimitCode &&
|
||||
left.Value == right.Value &&
|
||||
left.ReasonCode == right.ReasonCode &&
|
||||
left.Actor == right.Actor &&
|
||||
left.AppliedAt.Equal(right.AppliedAt) &&
|
||||
equalOptionalTime(left.ExpiresAt, right.ExpiresAt) &&
|
||||
equalOptionalTime(left.RemovedAt, right.RemovedAt) &&
|
||||
left.RemovedBy == right.RemovedBy &&
|
||||
left.RemovedReasonCode == right.RemovedReasonCode
|
||||
}
|
||||
|
||||
// PolicyLifecycleStore adapts Store to the existing PolicyLifecycleStore
|
||||
// port.
|
||||
type PolicyLifecycleStore struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
// PolicyLifecycle returns one adapter that exposes the atomic policy-lifecycle
|
||||
// store port over Store.
|
||||
func (store *Store) PolicyLifecycle() *PolicyLifecycleStore {
|
||||
if store == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &PolicyLifecycleStore{store: store}
|
||||
}
|
||||
|
||||
// ApplySanction atomically creates one new active sanction record.
|
||||
func (adapter *PolicyLifecycleStore) ApplySanction(ctx context.Context, input ports.ApplySanctionInput) error {
|
||||
return adapter.store.ApplySanction(ctx, input)
|
||||
}
|
||||
|
||||
// RemoveSanction atomically removes one active sanction record.
|
||||
func (adapter *PolicyLifecycleStore) RemoveSanction(ctx context.Context, input ports.RemoveSanctionInput) error {
|
||||
return adapter.store.RemoveSanction(ctx, input)
|
||||
}
|
||||
|
||||
// SetLimit atomically creates or replaces one active limit record.
|
||||
func (adapter *PolicyLifecycleStore) SetLimit(ctx context.Context, input ports.SetLimitInput) error {
|
||||
return adapter.store.SetLimit(ctx, input)
|
||||
}
|
||||
|
||||
// RemoveLimit atomically removes one active limit record.
|
||||
func (adapter *PolicyLifecycleStore) RemoveLimit(ctx context.Context, input ports.RemoveLimitInput) error {
|
||||
return adapter.store.RemoveLimit(ctx, input)
|
||||
}
|
||||
|
||||
var _ ports.PolicyLifecycleStore = (*PolicyLifecycleStore)(nil)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,930 @@
|
||||
package userstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/authblock"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
"galaxy/user/internal/ports"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAccountStoreCreateAndLookups(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
accountStore := store.Accounts()
|
||||
|
||||
record := validAccountRecord()
|
||||
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record)))
|
||||
|
||||
byUserID, err := accountStore.GetByUserID(context.Background(), record.UserID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record, byUserID)
|
||||
|
||||
byEmail, err := accountStore.GetByEmail(context.Background(), record.Email)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record, byEmail)
|
||||
|
||||
byRaceName, err := accountStore.GetByRaceName(context.Background(), record.RaceName)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record, byRaceName)
|
||||
|
||||
exists, err := accountStore.ExistsByUserID(context.Background(), record.UserID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, exists)
|
||||
|
||||
reservation, err := store.loadRaceNameReservation(context.Background(), store.client, canonicalKey(record.RaceName))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record.UserID, reservation.UserID)
|
||||
require.Equal(t, record.RaceName, reservation.RaceName)
|
||||
}
|
||||
|
||||
func TestBlockedEmailStoreUpsertAndGet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
blockedEmailStore := store.BlockedEmails()
|
||||
|
||||
record := authblock.BlockedEmailSubject{
|
||||
Email: common.Email("blocked@example.com"),
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
BlockedAt: time.Unix(1_775_240_100, 0).UTC(),
|
||||
ResolvedUserID: common.UserID("user-123"),
|
||||
}
|
||||
require.NoError(t, blockedEmailStore.Upsert(context.Background(), record))
|
||||
|
||||
got, err := blockedEmailStore.GetByEmail(context.Background(), record.Email)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record, got)
|
||||
}
|
||||
|
||||
func TestEnsureResolveAndBlockFlows(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
accountRecord := validAccountRecord()
|
||||
entitlementSnapshot := validEntitlementSnapshot(accountRecord.UserID, now)
|
||||
|
||||
created, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
|
||||
Email: accountRecord.Email,
|
||||
Account: accountRecord,
|
||||
Entitlement: entitlementSnapshot,
|
||||
EntitlementRecord: validEntitlementRecord(accountRecord.UserID, now),
|
||||
Reservation: raceNameReservation(accountRecord.UserID, accountRecord.RaceName, accountRecord.UpdatedAt),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.EnsureByEmailOutcomeCreated, created.Outcome)
|
||||
|
||||
reservation, err := store.loadRaceNameReservation(context.Background(), store.client, canonicalKey(accountRecord.RaceName))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, accountRecord.UserID, reservation.UserID)
|
||||
|
||||
entitlementHistory, err := store.ListEntitlementRecordsByUserID(context.Background(), accountRecord.UserID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entitlementHistory, 1)
|
||||
require.Equal(t, validEntitlementRecord(accountRecord.UserID, now), entitlementHistory[0])
|
||||
|
||||
resolved, err := store.ResolveByEmail(context.Background(), accountRecord.Email)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.AuthResolutionKindExisting, resolved.Kind)
|
||||
|
||||
blockedByUserID, err := store.BlockByUserID(context.Background(), ports.BlockByUserIDInput{
|
||||
UserID: accountRecord.UserID,
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
BlockedAt: now.Add(time.Minute),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.AuthBlockOutcomeBlocked, blockedByUserID.Outcome)
|
||||
|
||||
repeatedBlock, err := store.BlockByEmail(context.Background(), ports.BlockByEmailInput{
|
||||
Email: accountRecord.Email,
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
BlockedAt: now.Add(2 * time.Minute),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.AuthBlockOutcomeAlreadyBlocked, repeatedBlock.Outcome)
|
||||
require.Equal(t, accountRecord.UserID, repeatedBlock.UserID)
|
||||
|
||||
blockedResolution, err := store.ResolveByEmail(context.Background(), accountRecord.Email)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.AuthResolutionKindBlocked, blockedResolution.Kind)
|
||||
|
||||
ensureBlocked, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
|
||||
Email: accountRecord.Email,
|
||||
Account: accountRecord,
|
||||
Entitlement: entitlementSnapshot,
|
||||
EntitlementRecord: validEntitlementRecord(accountRecord.UserID, now),
|
||||
Reservation: raceNameReservation(accountRecord.UserID, accountRecord.RaceName, accountRecord.UpdatedAt),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.EnsureByEmailOutcomeBlocked, ensureBlocked.Outcome)
|
||||
}
|
||||
|
||||
func TestBlockedEmailWithoutUserPreventsEnsureCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
accountRecord := validAccountRecord()
|
||||
entitlementSnapshot := validEntitlementSnapshot(accountRecord.UserID, now)
|
||||
|
||||
blocked, err := store.BlockByEmail(context.Background(), ports.BlockByEmailInput{
|
||||
Email: accountRecord.Email,
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
BlockedAt: now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.AuthBlockOutcomeBlocked, blocked.Outcome)
|
||||
require.True(t, blocked.UserID.IsZero())
|
||||
|
||||
resolved, err := store.ResolveByEmail(context.Background(), accountRecord.Email)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.AuthResolutionKindBlocked, resolved.Kind)
|
||||
|
||||
ensured, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
|
||||
Email: accountRecord.Email,
|
||||
Account: accountRecord,
|
||||
Entitlement: entitlementSnapshot,
|
||||
EntitlementRecord: validEntitlementRecord(accountRecord.UserID, now),
|
||||
Reservation: raceNameReservation(accountRecord.UserID, accountRecord.RaceName, accountRecord.UpdatedAt),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.EnsureByEmailOutcomeBlocked, ensured.Outcome)
|
||||
|
||||
exists, err := store.ExistsByUserID(context.Background(), accountRecord.UserID)
|
||||
require.NoError(t, err)
|
||||
require.False(t, exists)
|
||||
}
|
||||
|
||||
func TestEnsureByEmailExistingDoesNotOverwriteStoredSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
createdAt := time.Unix(1_775_240_000, 0).UTC()
|
||||
existingAccount := account.UserAccount{
|
||||
UserID: common.UserID("user-existing"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
RaceName: common.RaceName("Pilot Nova"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
}
|
||||
require.NoError(t, store.Create(context.Background(), createAccountInput(existingAccount)))
|
||||
|
||||
result, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
|
||||
Email: existingAccount.Email,
|
||||
Account: account.UserAccount{
|
||||
UserID: common.UserID("user-created"),
|
||||
Email: existingAccount.Email,
|
||||
RaceName: common.RaceName("player-new123"),
|
||||
PreferredLanguage: common.LanguageTag("fr-FR"),
|
||||
TimeZone: common.TimeZoneName("UTC"),
|
||||
CreatedAt: createdAt.Add(time.Minute),
|
||||
UpdatedAt: createdAt.Add(time.Minute),
|
||||
},
|
||||
Entitlement: validEntitlementSnapshot(common.UserID("user-created"), createdAt.Add(time.Minute)),
|
||||
EntitlementRecord: validEntitlementRecord(common.UserID("user-created"), createdAt.Add(time.Minute)),
|
||||
Reservation: raceNameReservation(common.UserID("user-created"), common.RaceName("player-new123"), createdAt.Add(time.Minute)),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.EnsureByEmailOutcomeExisting, result.Outcome)
|
||||
require.Equal(t, existingAccount.UserID, result.UserID)
|
||||
|
||||
storedAccount, err := store.GetByEmail(context.Background(), existingAccount.Email)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, existingAccount, storedAccount)
|
||||
}
|
||||
|
||||
func TestAccountStoreRenameRaceNameSwapsLookupAtomically(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
accountStore := store.Accounts()
|
||||
record := validAccountRecord()
|
||||
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record)))
|
||||
|
||||
updatedAt := record.UpdatedAt.Add(time.Minute)
|
||||
require.NoError(t, accountStore.RenameRaceName(context.Background(), renameRaceNameInput(record, common.RaceName("Nova Prime"), updatedAt)))
|
||||
|
||||
stored, err := accountStore.GetByUserID(context.Background(), record.UserID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, common.RaceName("Nova Prime"), stored.RaceName)
|
||||
require.True(t, stored.UpdatedAt.Equal(updatedAt))
|
||||
|
||||
_, err = accountStore.GetByRaceName(context.Background(), record.RaceName)
|
||||
require.ErrorIs(t, err, ports.ErrNotFound)
|
||||
|
||||
renamed, err := accountStore.GetByRaceName(context.Background(), common.RaceName("Nova Prime"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record.UserID, renamed.UserID)
|
||||
|
||||
_, err = store.loadRaceNameReservation(context.Background(), store.client, canonicalKey(record.RaceName))
|
||||
require.ErrorIs(t, err, ports.ErrNotFound)
|
||||
|
||||
reservation, err := store.loadRaceNameReservation(context.Background(), store.client, canonicalKey(common.RaceName("Nova Prime")))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, common.RaceName("Nova Prime"), reservation.RaceName)
|
||||
}
|
||||
|
||||
func TestAccountStoreRenameRaceNameAllowsSameOwnerCanonicalSlot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
accountStore := store.Accounts()
|
||||
|
||||
record := validAccountRecord()
|
||||
record.RaceName = common.RaceName("Pilot Nova")
|
||||
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record)))
|
||||
|
||||
updatedAt := record.UpdatedAt.Add(time.Minute)
|
||||
require.NoError(t, accountStore.RenameRaceName(context.Background(), renameRaceNameInput(record, common.RaceName("P1lot Nova"), updatedAt)))
|
||||
|
||||
reservation, err := store.loadRaceNameReservation(context.Background(), store.client, canonicalKey(common.RaceName("P1lot Nova")))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, common.RaceName("P1lot Nova"), reservation.RaceName)
|
||||
}
|
||||
|
||||
func TestAccountStoreRenameRaceNameReturnsConflictWhenTargetExists(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
accountStore := store.Accounts()
|
||||
|
||||
first := validAccountRecord()
|
||||
second := validAccountRecord()
|
||||
second.UserID = common.UserID("user-456")
|
||||
second.Email = common.Email("other@example.com")
|
||||
second.RaceName = common.RaceName("Taken Name")
|
||||
|
||||
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(first)))
|
||||
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(second)))
|
||||
|
||||
err := accountStore.RenameRaceName(context.Background(), renameRaceNameInput(first, second.RaceName, first.UpdatedAt.Add(time.Minute)))
|
||||
require.ErrorIs(t, err, ports.ErrConflict)
|
||||
|
||||
stored, err := accountStore.GetByUserID(context.Background(), first.UserID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, first.RaceName, stored.RaceName)
|
||||
}
|
||||
|
||||
func TestAccountStoreUpdateDeclaredCountryPreservesLookups(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
accountStore := store.Accounts()
|
||||
|
||||
record := validAccountRecord()
|
||||
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record)))
|
||||
|
||||
updated := record
|
||||
updated.DeclaredCountry = common.CountryCode("FR")
|
||||
updated.UpdatedAt = record.UpdatedAt.Add(time.Minute)
|
||||
|
||||
require.NoError(t, accountStore.Update(context.Background(), updated))
|
||||
|
||||
byUserID, err := accountStore.GetByUserID(context.Background(), record.UserID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, updated, byUserID)
|
||||
|
||||
byEmail, err := accountStore.GetByEmail(context.Background(), record.Email)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, updated, byEmail)
|
||||
|
||||
byRaceName, err := accountStore.GetByRaceName(context.Background(), record.RaceName)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, updated, byRaceName)
|
||||
}
|
||||
|
||||
func TestAccountStoreCreateReturnsConflictWhenCanonicalReservationExists(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
accountStore := store.Accounts()
|
||||
|
||||
first := validAccountRecord()
|
||||
second := validAccountRecord()
|
||||
second.UserID = common.UserID("user-456")
|
||||
second.Email = common.Email("other@example.com")
|
||||
second.RaceName = common.RaceName("P1lot Nova")
|
||||
|
||||
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(first)))
|
||||
|
||||
err := accountStore.Create(context.Background(), createAccountInput(second))
|
||||
require.ErrorIs(t, err, ports.ErrConflict)
|
||||
}
|
||||
|
||||
func TestBlockByUserIDRepeatedCallsStayIdempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
accountRecord := validAccountRecord()
|
||||
|
||||
require.NoError(t, store.Create(context.Background(), createAccountInput(accountRecord)))
|
||||
|
||||
first, err := store.BlockByUserID(context.Background(), ports.BlockByUserIDInput{
|
||||
UserID: accountRecord.UserID,
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
BlockedAt: now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.AuthBlockOutcomeBlocked, first.Outcome)
|
||||
|
||||
second, err := store.BlockByUserID(context.Background(), ports.BlockByUserIDInput{
|
||||
UserID: accountRecord.UserID,
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
BlockedAt: now.Add(time.Minute),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports.AuthBlockOutcomeAlreadyBlocked, second.Outcome)
|
||||
require.Equal(t, accountRecord.UserID, second.UserID)
|
||||
}
|
||||
|
||||
func TestBlockByUserIDUnknownUserReturnsNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
|
||||
_, err := store.BlockByUserID(context.Background(), ports.BlockByUserIDInput{
|
||||
UserID: common.UserID("user-missing"),
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
BlockedAt: time.Unix(1_775_240_000, 0).UTC(),
|
||||
})
|
||||
require.ErrorIs(t, err, ports.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestSanctionAndLimitStoresRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
sanctionStore := store.Sanctions()
|
||||
limitStore := store.Limits()
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
|
||||
sanctionRecord := policy.SanctionRecord{
|
||||
RecordID: policy.SanctionRecordID("sanction-1"),
|
||||
UserID: common.UserID("user-123"),
|
||||
SanctionCode: policy.SanctionCodeLoginBlock,
|
||||
Scope: common.Scope("self_service"),
|
||||
ReasonCode: common.ReasonCode("policy_enforced"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
|
||||
AppliedAt: now,
|
||||
}
|
||||
require.NoError(t, sanctionStore.Create(context.Background(), sanctionRecord))
|
||||
|
||||
gotSanction, err := sanctionStore.GetByRecordID(context.Background(), sanctionRecord.RecordID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, sanctionRecord, gotSanction)
|
||||
|
||||
sanctions, err := sanctionStore.ListByUserID(context.Background(), sanctionRecord.UserID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, sanctions, 1)
|
||||
|
||||
expiresAt := now.Add(time.Hour)
|
||||
sanctionRecord.ExpiresAt = &expiresAt
|
||||
require.NoError(t, sanctionStore.Update(context.Background(), sanctionRecord))
|
||||
|
||||
gotSanction, err = sanctionStore.GetByRecordID(context.Background(), sanctionRecord.RecordID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, sanctionRecord.RecordID, gotSanction.RecordID)
|
||||
require.Equal(t, sanctionRecord.UserID, gotSanction.UserID)
|
||||
require.Equal(t, sanctionRecord.SanctionCode, gotSanction.SanctionCode)
|
||||
require.Equal(t, sanctionRecord.Scope, gotSanction.Scope)
|
||||
require.Equal(t, sanctionRecord.ReasonCode, gotSanction.ReasonCode)
|
||||
require.Equal(t, sanctionRecord.Actor, gotSanction.Actor)
|
||||
require.True(t, gotSanction.AppliedAt.Equal(sanctionRecord.AppliedAt))
|
||||
require.NotNil(t, gotSanction.ExpiresAt)
|
||||
require.True(t, gotSanction.ExpiresAt.Equal(*sanctionRecord.ExpiresAt))
|
||||
|
||||
limitRecord := policy.LimitRecord{
|
||||
RecordID: policy.LimitRecordID("limit-1"),
|
||||
UserID: common.UserID("user-123"),
|
||||
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
|
||||
Value: 3,
|
||||
ReasonCode: common.ReasonCode("policy_enforced"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
|
||||
AppliedAt: now,
|
||||
}
|
||||
require.NoError(t, limitStore.Create(context.Background(), limitRecord))
|
||||
|
||||
gotLimit, err := limitStore.GetByRecordID(context.Background(), limitRecord.RecordID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, limitRecord, gotLimit)
|
||||
|
||||
limits, err := limitStore.ListByUserID(context.Background(), limitRecord.UserID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, limits, 1)
|
||||
|
||||
limitRecord.Value = 5
|
||||
require.NoError(t, limitStore.Update(context.Background(), limitRecord))
|
||||
|
||||
gotLimit, err = limitStore.GetByRecordID(context.Background(), limitRecord.RecordID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, limitRecord, gotLimit)
|
||||
}
|
||||
|
||||
func TestPolicyLifecycleApplyAndRemoveSanction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
lifecycleStore := store.PolicyLifecycle()
|
||||
sanctionStore := store.Sanctions()
|
||||
snapshotStore := store.EntitlementSnapshots()
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
require.NoError(t, snapshotStore.Put(context.Background(), validEntitlementSnapshot(userID, now)))
|
||||
|
||||
record := policy.SanctionRecord{
|
||||
RecordID: policy.SanctionRecordID("sanction-1"),
|
||||
UserID: userID,
|
||||
SanctionCode: policy.SanctionCodeLoginBlock,
|
||||
Scope: common.Scope("auth"),
|
||||
ReasonCode: common.ReasonCode("manual_block"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
AppliedAt: now,
|
||||
}
|
||||
require.NoError(t, lifecycleStore.ApplySanction(context.Background(), ports.ApplySanctionInput{
|
||||
NewRecord: record,
|
||||
}))
|
||||
|
||||
activeRecordID, err := store.loadActiveSanctionRecordID(
|
||||
context.Background(),
|
||||
store.client,
|
||||
store.keyspace.ActiveSanction(userID, policy.SanctionCodeLoginBlock),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record.RecordID, activeRecordID)
|
||||
|
||||
err = lifecycleStore.ApplySanction(context.Background(), ports.ApplySanctionInput{
|
||||
NewRecord: policy.SanctionRecord{
|
||||
RecordID: policy.SanctionRecordID("sanction-2"),
|
||||
UserID: userID,
|
||||
SanctionCode: policy.SanctionCodeLoginBlock,
|
||||
Scope: common.Scope("auth"),
|
||||
ReasonCode: common.ReasonCode("manual_block"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")},
|
||||
AppliedAt: now.Add(time.Minute),
|
||||
},
|
||||
})
|
||||
require.ErrorIs(t, err, ports.ErrConflict)
|
||||
|
||||
removed := record
|
||||
removedAt := now.Add(30 * time.Minute)
|
||||
removed.RemovedAt = &removedAt
|
||||
removed.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")}
|
||||
removed.RemovedReasonCode = common.ReasonCode("manual_remove")
|
||||
require.NoError(t, lifecycleStore.RemoveSanction(context.Background(), ports.RemoveSanctionInput{
|
||||
ExpectedActiveRecord: record,
|
||||
UpdatedRecord: removed,
|
||||
}))
|
||||
|
||||
stored, err := sanctionStore.GetByRecordID(context.Background(), record.RecordID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, removed, stored)
|
||||
|
||||
_, err = store.loadActiveSanctionRecordID(
|
||||
context.Background(),
|
||||
store.client,
|
||||
store.keyspace.ActiveSanction(userID, policy.SanctionCodeLoginBlock),
|
||||
)
|
||||
require.ErrorIs(t, err, ports.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestPolicyLifecycleSetAndRemoveLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
lifecycleStore := store.PolicyLifecycle()
|
||||
limitStore := store.Limits()
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
userID := common.UserID("user-123")
|
||||
|
||||
first := policy.LimitRecord{
|
||||
RecordID: policy.LimitRecordID("limit-1"),
|
||||
UserID: userID,
|
||||
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
|
||||
Value: 3,
|
||||
ReasonCode: common.ReasonCode("manual_override"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
AppliedAt: now,
|
||||
}
|
||||
require.NoError(t, lifecycleStore.SetLimit(context.Background(), ports.SetLimitInput{
|
||||
NewRecord: first,
|
||||
}))
|
||||
|
||||
activeRecordID, err := store.loadActiveLimitRecordID(
|
||||
context.Background(),
|
||||
store.client,
|
||||
store.keyspace.ActiveLimit(userID, policy.LimitCodeMaxOwnedPrivateGames),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, first.RecordID, activeRecordID)
|
||||
|
||||
second := policy.LimitRecord{
|
||||
RecordID: policy.LimitRecordID("limit-2"),
|
||||
UserID: userID,
|
||||
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
|
||||
Value: 5,
|
||||
ReasonCode: common.ReasonCode("manual_override"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")},
|
||||
AppliedAt: now.Add(time.Hour),
|
||||
}
|
||||
updatedFirst := first
|
||||
removedAt := second.AppliedAt
|
||||
updatedFirst.RemovedAt = &removedAt
|
||||
updatedFirst.RemovedBy = second.Actor
|
||||
updatedFirst.RemovedReasonCode = second.ReasonCode
|
||||
require.NoError(t, lifecycleStore.SetLimit(context.Background(), ports.SetLimitInput{
|
||||
ExpectedActiveRecord: &first,
|
||||
UpdatedActiveRecord: &updatedFirst,
|
||||
NewRecord: second,
|
||||
}))
|
||||
|
||||
storedFirst, err := limitStore.GetByRecordID(context.Background(), first.RecordID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, updatedFirst, storedFirst)
|
||||
|
||||
activeRecordID, err = store.loadActiveLimitRecordID(
|
||||
context.Background(),
|
||||
store.client,
|
||||
store.keyspace.ActiveLimit(userID, policy.LimitCodeMaxOwnedPrivateGames),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, second.RecordID, activeRecordID)
|
||||
|
||||
removedSecond := second
|
||||
removeAt := now.Add(90 * time.Minute)
|
||||
removedSecond.RemovedAt = &removeAt
|
||||
removedSecond.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-3")}
|
||||
removedSecond.RemovedReasonCode = common.ReasonCode("manual_remove")
|
||||
require.NoError(t, lifecycleStore.RemoveLimit(context.Background(), ports.RemoveLimitInput{
|
||||
ExpectedActiveRecord: second,
|
||||
UpdatedRecord: removedSecond,
|
||||
}))
|
||||
|
||||
storedSecond, err := limitStore.GetByRecordID(context.Background(), second.RecordID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, removedSecond, storedSecond)
|
||||
|
||||
_, err = store.loadActiveLimitRecordID(
|
||||
context.Background(),
|
||||
store.client,
|
||||
store.keyspace.ActiveLimit(userID, policy.LimitCodeMaxOwnedPrivateGames),
|
||||
)
|
||||
require.ErrorIs(t, err, ports.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestEntitlementLifecycleTransitions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
historyStore := store.EntitlementHistory()
|
||||
snapshotStore := store.EntitlementSnapshots()
|
||||
lifecycleStore := store.EntitlementLifecycle()
|
||||
userID := common.UserID("user-123")
|
||||
startedFreeAt := time.Unix(1_775_240_000, 0).UTC()
|
||||
|
||||
freeRecord := validEntitlementRecord(userID, startedFreeAt)
|
||||
freeSnapshot := validEntitlementSnapshot(userID, startedFreeAt)
|
||||
require.NoError(t, historyStore.Create(context.Background(), freeRecord))
|
||||
require.NoError(t, snapshotStore.Put(context.Background(), freeSnapshot))
|
||||
|
||||
grantStartsAt := startedFreeAt.Add(24 * time.Hour)
|
||||
grantEndsAt := grantStartsAt.Add(30 * 24 * time.Hour)
|
||||
grantedRecord := paidEntitlementRecord(
|
||||
entitlement.EntitlementRecordID("entitlement-paid-1"),
|
||||
userID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
grantStartsAt,
|
||||
grantEndsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_grant"),
|
||||
)
|
||||
grantedSnapshot := paidEntitlementSnapshot(
|
||||
userID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
grantStartsAt,
|
||||
grantEndsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_grant"),
|
||||
)
|
||||
closedFreeRecord := freeRecord
|
||||
closedFreeRecord.ClosedAt = timePointer(grantStartsAt)
|
||||
closedFreeRecord.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
|
||||
closedFreeRecord.ClosedReasonCode = common.ReasonCode("manual_grant")
|
||||
|
||||
require.NoError(t, lifecycleStore.Grant(context.Background(), ports.GrantEntitlementInput{
|
||||
ExpectedCurrentSnapshot: freeSnapshot,
|
||||
ExpectedCurrentRecord: freeRecord,
|
||||
UpdatedCurrentRecord: closedFreeRecord,
|
||||
NewRecord: grantedRecord,
|
||||
NewSnapshot: grantedSnapshot,
|
||||
}))
|
||||
|
||||
storedSnapshot, err := snapshotStore.GetByUserID(context.Background(), userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, grantedSnapshot, storedSnapshot)
|
||||
|
||||
storedFreeRecord, err := historyStore.GetByRecordID(context.Background(), freeRecord.RecordID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, closedFreeRecord, storedFreeRecord)
|
||||
|
||||
extendedEndsAt := grantEndsAt.Add(30 * 24 * time.Hour)
|
||||
extensionRecord := paidEntitlementRecord(
|
||||
entitlement.EntitlementRecordID("entitlement-paid-2"),
|
||||
userID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
grantEndsAt,
|
||||
extendedEndsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_extend"),
|
||||
)
|
||||
extendedSnapshot := paidEntitlementSnapshot(
|
||||
userID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
grantStartsAt,
|
||||
extendedEndsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_extend"),
|
||||
)
|
||||
|
||||
require.NoError(t, lifecycleStore.Extend(context.Background(), ports.ExtendEntitlementInput{
|
||||
ExpectedCurrentSnapshot: grantedSnapshot,
|
||||
NewRecord: extensionRecord,
|
||||
NewSnapshot: extendedSnapshot,
|
||||
}))
|
||||
|
||||
storedSnapshot, err = snapshotStore.GetByUserID(context.Background(), userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, extendedSnapshot, storedSnapshot)
|
||||
|
||||
revokeAt := grantEndsAt.Add(12 * time.Hour)
|
||||
revokedCurrentRecord := extensionRecord
|
||||
revokedCurrentRecord.ClosedAt = timePointer(revokeAt)
|
||||
revokedCurrentRecord.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
|
||||
revokedCurrentRecord.ClosedReasonCode = common.ReasonCode("manual_revoke")
|
||||
|
||||
freeAfterRevokeRecord := entitlement.PeriodRecord{
|
||||
RecordID: entitlement.EntitlementRecordID("entitlement-free-2"),
|
||||
UserID: userID,
|
||||
PlanCode: entitlement.PlanCodeFree,
|
||||
Source: common.Source("admin"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: common.ReasonCode("manual_revoke"),
|
||||
StartsAt: revokeAt,
|
||||
CreatedAt: revokeAt,
|
||||
}
|
||||
freeAfterRevokeSnapshot := entitlement.CurrentSnapshot{
|
||||
UserID: userID,
|
||||
PlanCode: entitlement.PlanCodeFree,
|
||||
IsPaid: false,
|
||||
StartsAt: revokeAt,
|
||||
Source: common.Source("admin"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: common.ReasonCode("manual_revoke"),
|
||||
UpdatedAt: revokeAt,
|
||||
}
|
||||
|
||||
require.NoError(t, lifecycleStore.Revoke(context.Background(), ports.RevokeEntitlementInput{
|
||||
ExpectedCurrentSnapshot: extendedSnapshot,
|
||||
ExpectedCurrentRecord: extensionRecord,
|
||||
UpdatedCurrentRecord: revokedCurrentRecord,
|
||||
NewRecord: freeAfterRevokeRecord,
|
||||
NewSnapshot: freeAfterRevokeSnapshot,
|
||||
}))
|
||||
|
||||
storedSnapshot, err = snapshotStore.GetByUserID(context.Background(), userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, freeAfterRevokeSnapshot, storedSnapshot)
|
||||
|
||||
historyRecords, err := historyStore.ListByUserID(context.Background(), userID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, historyRecords, 4)
|
||||
}
|
||||
|
||||
func TestRepairExpiredEntitlementMaterializesFreeSnapshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newTestStore(t)
|
||||
historyStore := store.EntitlementHistory()
|
||||
snapshotStore := store.EntitlementSnapshots()
|
||||
lifecycleStore := store.EntitlementLifecycle()
|
||||
userID := common.UserID("user-123")
|
||||
startsAt := time.Unix(1_775_240_000, 0).UTC()
|
||||
endsAt := startsAt.Add(24 * time.Hour)
|
||||
expiredSnapshot := paidEntitlementSnapshot(
|
||||
userID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
startsAt,
|
||||
endsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_grant"),
|
||||
)
|
||||
expiredSnapshot.UpdatedAt = endsAt.Add(24 * time.Hour)
|
||||
expiredRecord := paidEntitlementRecord(
|
||||
entitlement.EntitlementRecordID("entitlement-paid-1"),
|
||||
userID,
|
||||
entitlement.PlanCodePaidMonthly,
|
||||
startsAt,
|
||||
endsAt,
|
||||
common.Source("admin"),
|
||||
common.ReasonCode("manual_grant"),
|
||||
)
|
||||
require.NoError(t, historyStore.Create(context.Background(), expiredRecord))
|
||||
require.NoError(t, snapshotStore.Put(context.Background(), expiredSnapshot))
|
||||
|
||||
repairedAt := endsAt.Add(2 * time.Hour)
|
||||
freeRecord := entitlement.PeriodRecord{
|
||||
RecordID: entitlement.EntitlementRecordID("entitlement-free-after-expiry"),
|
||||
UserID: userID,
|
||||
PlanCode: entitlement.PlanCodeFree,
|
||||
Source: common.Source("entitlement_expiry_repair"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
|
||||
ReasonCode: common.ReasonCode("paid_entitlement_expired"),
|
||||
StartsAt: endsAt,
|
||||
CreatedAt: repairedAt,
|
||||
}
|
||||
freeSnapshot := entitlement.CurrentSnapshot{
|
||||
UserID: userID,
|
||||
PlanCode: entitlement.PlanCodeFree,
|
||||
IsPaid: false,
|
||||
StartsAt: endsAt,
|
||||
Source: common.Source("entitlement_expiry_repair"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
|
||||
ReasonCode: common.ReasonCode("paid_entitlement_expired"),
|
||||
UpdatedAt: repairedAt,
|
||||
}
|
||||
|
||||
require.NoError(t, lifecycleStore.RepairExpired(context.Background(), ports.RepairExpiredEntitlementInput{
|
||||
ExpectedExpiredSnapshot: expiredSnapshot,
|
||||
NewRecord: freeRecord,
|
||||
NewSnapshot: freeSnapshot,
|
||||
}))
|
||||
|
||||
storedSnapshot, err := snapshotStore.GetByUserID(context.Background(), userID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, freeSnapshot, storedSnapshot)
|
||||
|
||||
historyRecords, err := historyStore.ListByUserID(context.Background(), userID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, historyRecords, 2)
|
||||
require.Equal(t, freeRecord, historyRecords[1])
|
||||
}
|
||||
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
store, err := New(Config{
|
||||
Addr: server.Addr(),
|
||||
DB: 0,
|
||||
KeyspacePrefix: "user:test:",
|
||||
OperationTimeout: 250 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = store.Close()
|
||||
})
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func validAccountRecord() account.UserAccount {
|
||||
createdAt := time.Unix(1_775_240_000, 0).UTC()
|
||||
return account.UserAccount{
|
||||
UserID: common.UserID("user-123"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
RaceName: common.RaceName("Pilot Nova"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
func validEntitlementSnapshot(userID common.UserID, now time.Time) entitlement.CurrentSnapshot {
|
||||
return entitlement.CurrentSnapshot{
|
||||
UserID: userID,
|
||||
PlanCode: entitlement.PlanCodeFree,
|
||||
IsPaid: false,
|
||||
StartsAt: now,
|
||||
Source: common.Source("auth_registration"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
|
||||
ReasonCode: common.ReasonCode("initial_free_entitlement"),
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func validEntitlementRecord(userID common.UserID, now time.Time) entitlement.PeriodRecord {
|
||||
return entitlement.PeriodRecord{
|
||||
RecordID: entitlement.EntitlementRecordID("entitlement-" + userID.String()),
|
||||
UserID: userID,
|
||||
PlanCode: entitlement.PlanCodeFree,
|
||||
Source: common.Source("auth_registration"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
|
||||
ReasonCode: common.ReasonCode("initial_free_entitlement"),
|
||||
StartsAt: now,
|
||||
CreatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func paidEntitlementRecord(
|
||||
recordID entitlement.EntitlementRecordID,
|
||||
userID common.UserID,
|
||||
planCode entitlement.PlanCode,
|
||||
startsAt time.Time,
|
||||
endsAt time.Time,
|
||||
source common.Source,
|
||||
reasonCode common.ReasonCode,
|
||||
) entitlement.PeriodRecord {
|
||||
return entitlement.PeriodRecord{
|
||||
RecordID: recordID,
|
||||
UserID: userID,
|
||||
PlanCode: planCode,
|
||||
Source: source,
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: reasonCode,
|
||||
StartsAt: startsAt,
|
||||
EndsAt: timePointer(endsAt),
|
||||
CreatedAt: startsAt,
|
||||
}
|
||||
}
|
||||
|
||||
func paidEntitlementSnapshot(
|
||||
userID common.UserID,
|
||||
planCode entitlement.PlanCode,
|
||||
startsAt time.Time,
|
||||
endsAt time.Time,
|
||||
source common.Source,
|
||||
reasonCode common.ReasonCode,
|
||||
) entitlement.CurrentSnapshot {
|
||||
return entitlement.CurrentSnapshot{
|
||||
UserID: userID,
|
||||
PlanCode: planCode,
|
||||
IsPaid: true,
|
||||
StartsAt: startsAt,
|
||||
EndsAt: timePointer(endsAt),
|
||||
Source: source,
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: reasonCode,
|
||||
UpdatedAt: startsAt,
|
||||
}
|
||||
}
|
||||
|
||||
func timePointer(value time.Time) *time.Time {
|
||||
utcValue := value.UTC()
|
||||
return &utcValue
|
||||
}
|
||||
|
||||
func createAccountInput(record account.UserAccount) ports.CreateAccountInput {
|
||||
return ports.CreateAccountInput{
|
||||
Account: record,
|
||||
Reservation: raceNameReservation(record.UserID, record.RaceName, record.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func renameRaceNameInput(
|
||||
record account.UserAccount,
|
||||
newRaceName common.RaceName,
|
||||
updatedAt time.Time,
|
||||
) ports.RenameRaceNameInput {
|
||||
return ports.RenameRaceNameInput{
|
||||
UserID: record.UserID,
|
||||
CurrentCanonicalKey: canonicalKey(record.RaceName),
|
||||
NewRaceName: newRaceName,
|
||||
NewReservation: raceNameReservation(record.UserID, newRaceName, updatedAt),
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func raceNameReservation(
|
||||
userID common.UserID,
|
||||
raceName common.RaceName,
|
||||
reservedAt time.Time,
|
||||
) account.RaceNameReservation {
|
||||
return account.RaceNameReservation{
|
||||
CanonicalKey: canonicalKey(raceName),
|
||||
UserID: userID,
|
||||
RaceName: raceName,
|
||||
ReservedAt: reservedAt.UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalKey(raceName common.RaceName) account.RaceNameCanonicalKey {
|
||||
return account.RaceNameCanonicalKey(strings.NewReplacer(
|
||||
"1", "i",
|
||||
"0", "o",
|
||||
"8", "b",
|
||||
).Replace(strings.ToLower(raceName.String())))
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
// Package redisstate defines the frozen Redis logical keyspace and pagination
|
||||
// helpers used by future User Service storage adapters.
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
)
|
||||
|
||||
const defaultPrefix = "user:"
|
||||
|
||||
// Keyspace builds the frozen Redis logical keys used by future storage
|
||||
// adapters. The package intentionally exposes key construction only and does
|
||||
// not depend on any Redis client.
|
||||
type Keyspace struct {
|
||||
// Prefix stores the namespace prefix applied to every key. The zero value
|
||||
// uses `user:`.
|
||||
Prefix string
|
||||
}
|
||||
|
||||
// Account returns the primary user-account key for userID.
|
||||
func (k Keyspace) Account(userID common.UserID) string {
|
||||
return k.prefix() + "account:" + encodeKeyComponent(userID.String())
|
||||
}
|
||||
|
||||
// EmailLookup returns the exact normalized e-mail lookup key.
|
||||
func (k Keyspace) EmailLookup(email common.Email) string {
|
||||
return k.prefix() + "lookup:email:" + encodeKeyComponent(email.String())
|
||||
}
|
||||
|
||||
// RaceNameLookup returns the exact stored race-name lookup key.
|
||||
func (k Keyspace) RaceNameLookup(raceName common.RaceName) string {
|
||||
return k.prefix() + "lookup:race-name:" + encodeKeyComponent(raceName.String())
|
||||
}
|
||||
|
||||
// RaceNameReservation returns the replaceable canonical race-name reservation
|
||||
// key.
|
||||
func (k Keyspace) RaceNameReservation(key account.RaceNameCanonicalKey) string {
|
||||
return k.prefix() + "reservation:race-name:" + encodeKeyComponent(key.String())
|
||||
}
|
||||
|
||||
// BlockedEmailSubject returns the dedicated blocked-email-subject key.
|
||||
func (k Keyspace) BlockedEmailSubject(email common.Email) string {
|
||||
return k.prefix() + "blocked-email:" + encodeKeyComponent(email.String())
|
||||
}
|
||||
|
||||
// EntitlementRecord returns the primary entitlement history-record key.
|
||||
func (k Keyspace) EntitlementRecord(recordID entitlement.EntitlementRecordID) string {
|
||||
return k.prefix() + "entitlement:record:" + encodeKeyComponent(recordID.String())
|
||||
}
|
||||
|
||||
// EntitlementHistory returns the per-user entitlement-history index key.
|
||||
func (k Keyspace) EntitlementHistory(userID common.UserID) string {
|
||||
return k.prefix() + "entitlement:history:" + encodeKeyComponent(userID.String())
|
||||
}
|
||||
|
||||
// EntitlementSnapshot returns the current entitlement-snapshot key.
|
||||
func (k Keyspace) EntitlementSnapshot(userID common.UserID) string {
|
||||
return k.prefix() + "entitlement:snapshot:" + encodeKeyComponent(userID.String())
|
||||
}
|
||||
|
||||
// SanctionRecord returns the primary sanction history-record key.
|
||||
func (k Keyspace) SanctionRecord(recordID policy.SanctionRecordID) string {
|
||||
return k.prefix() + "sanction:record:" + encodeKeyComponent(recordID.String())
|
||||
}
|
||||
|
||||
// SanctionHistory returns the per-user sanction-history index key.
|
||||
func (k Keyspace) SanctionHistory(userID common.UserID) string {
|
||||
return k.prefix() + "sanction:history:" + encodeKeyComponent(userID.String())
|
||||
}
|
||||
|
||||
// ActiveSanction returns the per-user active-sanction slot for one sanction
|
||||
// code. The slot guarantees at most one active sanction per `user_id +
|
||||
// sanction_code`.
|
||||
func (k Keyspace) ActiveSanction(userID common.UserID, code policy.SanctionCode) string {
|
||||
return k.prefix() + "sanction:active:" + encodeKeyComponent(userID.String()) + ":" + encodeKeyComponent(string(code))
|
||||
}
|
||||
|
||||
// LimitRecord returns the primary limit history-record key.
|
||||
func (k Keyspace) LimitRecord(recordID policy.LimitRecordID) string {
|
||||
return k.prefix() + "limit:record:" + encodeKeyComponent(recordID.String())
|
||||
}
|
||||
|
||||
// LimitHistory returns the per-user limit-history index key.
|
||||
func (k Keyspace) LimitHistory(userID common.UserID) string {
|
||||
return k.prefix() + "limit:history:" + encodeKeyComponent(userID.String())
|
||||
}
|
||||
|
||||
// ActiveLimit returns the per-user active-limit slot for one limit code. The
|
||||
// slot guarantees at most one active limit per `user_id + limit_code`.
|
||||
func (k Keyspace) ActiveLimit(userID common.UserID, code policy.LimitCode) string {
|
||||
return k.prefix() + "limit:active:" + encodeKeyComponent(userID.String()) + ":" + encodeKeyComponent(string(code))
|
||||
}
|
||||
|
||||
// CreatedAtIndex returns the deterministic newest-first user-ordering index.
|
||||
func (k Keyspace) CreatedAtIndex() string {
|
||||
return k.prefix() + "index:created-at"
|
||||
}
|
||||
|
||||
// PaidStateIndex returns the coarse free-versus-paid index key.
|
||||
func (k Keyspace) PaidStateIndex(state entitlement.PaidState) string {
|
||||
return k.prefix() + "index:paid-state:" + encodeKeyComponent(string(state))
|
||||
}
|
||||
|
||||
// FinitePaidExpiryIndex returns the finite paid-expiry index key. Lifetime
|
||||
// plans intentionally do not participate in this index.
|
||||
func (k Keyspace) FinitePaidExpiryIndex() string {
|
||||
return k.prefix() + "index:paid-expiry:finite"
|
||||
}
|
||||
|
||||
// DeclaredCountryIndex returns the current declared-country reverse-lookup
|
||||
// index key.
|
||||
func (k Keyspace) DeclaredCountryIndex(code common.CountryCode) string {
|
||||
return k.prefix() + "index:declared-country:" + encodeKeyComponent(code.String())
|
||||
}
|
||||
|
||||
// ActiveSanctionCodeIndex returns the reverse-lookup index key for users with
|
||||
// an active sanction code.
|
||||
func (k Keyspace) ActiveSanctionCodeIndex(code policy.SanctionCode) string {
|
||||
return k.prefix() + "index:active-sanction:" + encodeKeyComponent(string(code))
|
||||
}
|
||||
|
||||
// ActiveLimitCodeIndex returns the reverse-lookup index key for users with an
|
||||
// active limit code.
|
||||
func (k Keyspace) ActiveLimitCodeIndex(code policy.LimitCode) string {
|
||||
return k.prefix() + "index:active-limit:" + encodeKeyComponent(string(code))
|
||||
}
|
||||
|
||||
// EligibilityMarkerIndex returns the reverse-lookup index key for one derived
|
||||
// eligibility marker boolean.
|
||||
func (k Keyspace) EligibilityMarkerIndex(marker policy.EligibilityMarker, value bool) string {
|
||||
return fmt.Sprintf("%sindex:eligibility:%s:%t", k.prefix(), encodeKeyComponent(string(marker)), value)
|
||||
}
|
||||
|
||||
// CreatedAtScore returns the frozen ZSET score representation for created-at
|
||||
// ordering and deterministic pagination.
|
||||
func CreatedAtScore(createdAt time.Time) float64 {
|
||||
return float64(createdAt.UTC().UnixMicro())
|
||||
}
|
||||
|
||||
// ExpiryScore returns the frozen ZSET score representation for finite paid
|
||||
// expiry ordering.
|
||||
func ExpiryScore(expiresAt time.Time) float64 {
|
||||
return float64(expiresAt.UTC().UnixMicro())
|
||||
}
|
||||
|
||||
// PageCursor identifies the last seen `(created_at, user_id)` tuple used by
|
||||
// deterministic newest-first pagination.
|
||||
type PageCursor struct {
|
||||
// CreatedAt stores the created-at component of the last seen row.
|
||||
CreatedAt time.Time
|
||||
|
||||
// UserID stores the user-id tiebreaker component of the last seen row.
|
||||
UserID common.UserID
|
||||
}
|
||||
|
||||
// Validate reports whether PageCursor contains a complete cursor tuple.
|
||||
func (cursor PageCursor) Validate() error {
|
||||
if err := common.ValidateTimestamp("page cursor created at", cursor.CreatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cursor.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("page cursor user id: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ComparePageOrder compares two listing positions using the frozen ordering:
|
||||
// `created_at desc`, then `user_id desc`.
|
||||
func ComparePageOrder(left PageCursor, right PageCursor) int {
|
||||
switch {
|
||||
case left.CreatedAt.After(right.CreatedAt):
|
||||
return -1
|
||||
case left.CreatedAt.Before(right.CreatedAt):
|
||||
return 1
|
||||
default:
|
||||
return -strings.Compare(left.UserID.String(), right.UserID.String())
|
||||
}
|
||||
}
|
||||
|
||||
func (k Keyspace) prefix() string {
|
||||
prefix := strings.TrimSpace(k.Prefix)
|
||||
if prefix == "" {
|
||||
return defaultPrefix
|
||||
}
|
||||
|
||||
return prefix
|
||||
}
|
||||
|
||||
func encodeKeyComponent(value string) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(value))
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestKeyspaceBuildsStableKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
keyspace := Keyspace{Prefix: "custom:"}
|
||||
|
||||
require.Equal(t, "custom:account:dXNlci0xMjM", keyspace.Account(common.UserID("user-123")))
|
||||
require.Equal(t, "custom:lookup:email:cGlsb3RAZXhhbXBsZS5jb20", keyspace.EmailLookup(common.Email("pilot@example.com")))
|
||||
require.Equal(t, "custom:lookup:race-name:UGlsb3QgTm92YQ", keyspace.RaceNameLookup(common.RaceName("Pilot Nova")))
|
||||
require.Equal(t, "custom:reservation:race-name:cGlsb3Qtbm92YQ", keyspace.RaceNameReservation(account.RaceNameCanonicalKey("pilot-nova")))
|
||||
require.Equal(t, "custom:blocked-email:cGlsb3RAZXhhbXBsZS5jb20", keyspace.BlockedEmailSubject(common.Email("pilot@example.com")))
|
||||
require.Equal(t, "custom:entitlement:record:ZW50aXRsZW1lbnQtMTIz", keyspace.EntitlementRecord(entitlement.EntitlementRecordID("entitlement-123")))
|
||||
require.Equal(t, "custom:sanction:record:c2FuY3Rpb24tMQ", keyspace.SanctionRecord(policy.SanctionRecordID("sanction-1")))
|
||||
require.Equal(t, "custom:limit:record:bGltaXQtMQ", keyspace.LimitRecord(policy.LimitRecordID("limit-1")))
|
||||
require.Equal(t, "custom:sanction:active:dXNlci0xMjM:bG9naW5fYmxvY2s", keyspace.ActiveSanction(common.UserID("user-123"), policy.SanctionCodeLoginBlock))
|
||||
require.Equal(t, "custom:limit:active:dXNlci0xMjM:bWF4X293bmVkX3ByaXZhdGVfZ2FtZXM", keyspace.ActiveLimit(common.UserID("user-123"), policy.LimitCodeMaxOwnedPrivateGames))
|
||||
require.Equal(t, "custom:index:created-at", keyspace.CreatedAtIndex())
|
||||
require.Equal(t, "custom:index:paid-state:cGFpZA", keyspace.PaidStateIndex(entitlement.PaidStatePaid))
|
||||
require.Equal(t, "custom:index:paid-expiry:finite", keyspace.FinitePaidExpiryIndex())
|
||||
require.Equal(t, "custom:index:declared-country:REU", keyspace.DeclaredCountryIndex(common.CountryCode("DE")))
|
||||
require.Equal(t, "custom:index:active-sanction:bG9naW5fYmxvY2s", keyspace.ActiveSanctionCodeIndex(policy.SanctionCodeLoginBlock))
|
||||
require.Equal(t, "custom:index:active-limit:bWF4X293bmVkX3ByaXZhdGVfZ2FtZXM", keyspace.ActiveLimitCodeIndex(policy.LimitCodeMaxOwnedPrivateGames))
|
||||
require.Equal(t, "custom:index:eligibility:Y2FuX2xvZ2lu:true", keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanLogin, true))
|
||||
}
|
||||
|
||||
func TestComparePageOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
newer := PageCursor{CreatedAt: time.Unix(20, 0).UTC(), UserID: common.UserID("user-200")}
|
||||
older := PageCursor{CreatedAt: time.Unix(10, 0).UTC(), UserID: common.UserID("user-100")}
|
||||
sameTimeHigherUserID := PageCursor{CreatedAt: time.Unix(20, 0).UTC(), UserID: common.UserID("user-300")}
|
||||
|
||||
require.Negative(t, ComparePageOrder(newer, older))
|
||||
require.Positive(t, ComparePageOrder(older, newer))
|
||||
require.Negative(t, ComparePageOrder(sameTimeHigherUserID, newer))
|
||||
}
|
||||
|
||||
func TestScoresUseUnixMicro(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
value := time.Unix(1_775_240_000, 123_000).UTC()
|
||||
want := float64(value.UnixMicro())
|
||||
|
||||
require.Equal(t, want, CreatedAtScore(value))
|
||||
require.Equal(t, want, ExpiryScore(value))
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrPageTokenFiltersMismatch reports that a supplied page token was created
|
||||
// for a different normalized filter set.
|
||||
ErrPageTokenFiltersMismatch = errors.New("page token filters do not match current filters")
|
||||
)
|
||||
|
||||
// UserListFilters stores the frozen admin-listing filter set that becomes part
|
||||
// of the opaque page token fingerprint.
|
||||
type UserListFilters struct {
|
||||
// PaidState stores the coarse free-versus-paid filter.
|
||||
PaidState entitlement.PaidState
|
||||
|
||||
// PaidExpiresBefore stores the optional finite-paid expiry upper bound.
|
||||
PaidExpiresBefore *time.Time
|
||||
|
||||
// PaidExpiresAfter stores the optional finite-paid expiry lower bound.
|
||||
PaidExpiresAfter *time.Time
|
||||
|
||||
// DeclaredCountry stores the optional declared-country filter.
|
||||
DeclaredCountry common.CountryCode
|
||||
|
||||
// SanctionCode stores the optional active-sanction filter.
|
||||
SanctionCode policy.SanctionCode
|
||||
|
||||
// LimitCode stores the optional active-limit filter.
|
||||
LimitCode policy.LimitCode
|
||||
|
||||
// CanLogin stores the optional login-eligibility filter.
|
||||
CanLogin *bool
|
||||
|
||||
// CanCreatePrivateGame stores the optional private-game-create eligibility
|
||||
// filter.
|
||||
CanCreatePrivateGame *bool
|
||||
|
||||
// CanJoinGame stores the optional join-game eligibility filter.
|
||||
CanJoinGame *bool
|
||||
}
|
||||
|
||||
// Validate reports whether UserListFilters is structurally valid.
|
||||
func (filters UserListFilters) Validate() error {
|
||||
if !filters.PaidState.IsKnown() {
|
||||
return fmt.Errorf("paid state %q is unsupported", filters.PaidState)
|
||||
}
|
||||
if filters.PaidExpiresBefore != nil && filters.PaidExpiresBefore.IsZero() {
|
||||
return fmt.Errorf("paid expires before must not be zero")
|
||||
}
|
||||
if filters.PaidExpiresAfter != nil && filters.PaidExpiresAfter.IsZero() {
|
||||
return fmt.Errorf("paid expires after must not be zero")
|
||||
}
|
||||
if !filters.DeclaredCountry.IsZero() {
|
||||
if err := filters.DeclaredCountry.Validate(); err != nil {
|
||||
return fmt.Errorf("declared country: %w", err)
|
||||
}
|
||||
}
|
||||
if filters.SanctionCode != "" && !filters.SanctionCode.IsKnown() {
|
||||
return fmt.Errorf("sanction code %q is unsupported", filters.SanctionCode)
|
||||
}
|
||||
if filters.LimitCode != "" && !filters.LimitCode.IsKnown() {
|
||||
return fmt.Errorf("limit code %q is unsupported", filters.LimitCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodePageToken encodes cursor and filters into the frozen opaque page token
|
||||
// format.
|
||||
func EncodePageToken(cursor PageCursor, filters UserListFilters) (string, error) {
|
||||
if err := cursor.Validate(); err != nil {
|
||||
return "", fmt.Errorf("encode page token: %w", err)
|
||||
}
|
||||
fingerprint, err := normalizeFilters(filters)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode page token: %w", err)
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(pageTokenPayload{
|
||||
CreatedAt: cursor.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
UserID: cursor.UserID.String(),
|
||||
Filters: fingerprint,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode page token: %w", err)
|
||||
}
|
||||
|
||||
return base64.RawURLEncoding.EncodeToString(payload), nil
|
||||
}
|
||||
|
||||
// DecodePageToken decodes raw into the frozen page cursor and verifies that
|
||||
// the embedded normalized filter set matches expectedFilters.
|
||||
func DecodePageToken(raw string, expectedFilters UserListFilters) (PageCursor, error) {
|
||||
fingerprint, err := normalizeFilters(expectedFilters)
|
||||
if err != nil {
|
||||
return PageCursor{}, fmt.Errorf("decode page token: %w", err)
|
||||
}
|
||||
|
||||
payload, err := base64.RawURLEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return PageCursor{}, fmt.Errorf("decode page token: %w", err)
|
||||
}
|
||||
|
||||
var token pageTokenPayload
|
||||
if err := json.Unmarshal(payload, &token); err != nil {
|
||||
return PageCursor{}, fmt.Errorf("decode page token: %w", err)
|
||||
}
|
||||
if token.Filters != fingerprint {
|
||||
return PageCursor{}, ErrPageTokenFiltersMismatch
|
||||
}
|
||||
|
||||
createdAt, err := time.Parse(time.RFC3339Nano, token.CreatedAt)
|
||||
if err != nil {
|
||||
return PageCursor{}, fmt.Errorf("decode page token: parse created_at: %w", err)
|
||||
}
|
||||
|
||||
cursor := PageCursor{
|
||||
CreatedAt: createdAt.UTC(),
|
||||
UserID: common.UserID(token.UserID),
|
||||
}
|
||||
if err := cursor.Validate(); err != nil {
|
||||
return PageCursor{}, fmt.Errorf("decode page token: %w", err)
|
||||
}
|
||||
|
||||
return cursor, nil
|
||||
}
|
||||
|
||||
type pageTokenPayload struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
UserID string `json:"user_id"`
|
||||
Filters normalizedFilterPayload `json:"filters"`
|
||||
}
|
||||
|
||||
type normalizedFilterPayload struct {
|
||||
PaidState string `json:"paid_state,omitempty"`
|
||||
PaidExpiresBeforeUTC string `json:"paid_expires_before_utc,omitempty"`
|
||||
PaidExpiresAfterUTC string `json:"paid_expires_after_utc,omitempty"`
|
||||
DeclaredCountry string `json:"declared_country,omitempty"`
|
||||
SanctionCode string `json:"sanction_code,omitempty"`
|
||||
LimitCode string `json:"limit_code,omitempty"`
|
||||
CanLogin string `json:"can_login,omitempty"`
|
||||
CanCreatePrivateGame string `json:"can_create_private_game,omitempty"`
|
||||
CanJoinGame string `json:"can_join_game,omitempty"`
|
||||
}
|
||||
|
||||
func normalizeFilters(filters UserListFilters) (normalizedFilterPayload, error) {
|
||||
if err := filters.Validate(); err != nil {
|
||||
return normalizedFilterPayload{}, err
|
||||
}
|
||||
|
||||
return normalizedFilterPayload{
|
||||
PaidState: string(filters.PaidState),
|
||||
PaidExpiresBeforeUTC: formatOptionalTime(filters.PaidExpiresBefore),
|
||||
PaidExpiresAfterUTC: formatOptionalTime(filters.PaidExpiresAfter),
|
||||
DeclaredCountry: filters.DeclaredCountry.String(),
|
||||
SanctionCode: string(filters.SanctionCode),
|
||||
LimitCode: string(filters.LimitCode),
|
||||
CanLogin: formatOptionalBool(filters.CanLogin),
|
||||
CanCreatePrivateGame: formatOptionalBool(filters.CanCreatePrivateGame),
|
||||
CanJoinGame: formatOptionalBool(filters.CanJoinGame),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func formatOptionalTime(value *time.Time) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return value.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
func formatOptionalBool(value *bool) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
if *value {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
"galaxy/user/internal/domain/entitlement"
|
||||
"galaxy/user/internal/domain/policy"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEncodeDecodePageToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
before := time.Unix(1_775_250_000, 0).UTC()
|
||||
after := time.Unix(1_775_240_000, 0).UTC()
|
||||
canLogin := true
|
||||
canCreate := false
|
||||
canJoin := true
|
||||
|
||||
filters := UserListFilters{
|
||||
PaidState: entitlement.PaidStatePaid,
|
||||
PaidExpiresBefore: &before,
|
||||
PaidExpiresAfter: &after,
|
||||
DeclaredCountry: common.CountryCode("DE"),
|
||||
SanctionCode: policy.SanctionCodeLoginBlock,
|
||||
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
|
||||
CanLogin: &canLogin,
|
||||
CanCreatePrivateGame: &canCreate,
|
||||
CanJoinGame: &canJoin,
|
||||
}
|
||||
cursor := PageCursor{
|
||||
CreatedAt: time.Unix(1_775_240_100, 987_000_000).UTC(),
|
||||
UserID: common.UserID("user-123"),
|
||||
}
|
||||
|
||||
token, err := EncodePageToken(cursor, filters)
|
||||
require.NoError(t, err)
|
||||
|
||||
decoded, err := DecodePageToken(token, filters)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, cursor, decoded)
|
||||
}
|
||||
|
||||
func TestDecodePageTokenFilterMismatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cursor := PageCursor{
|
||||
CreatedAt: time.Unix(1_775_240_100, 0).UTC(),
|
||||
UserID: common.UserID("user-123"),
|
||||
}
|
||||
filters := UserListFilters{
|
||||
PaidState: entitlement.PaidStatePaid,
|
||||
}
|
||||
|
||||
token, err := EncodePageToken(cursor, filters)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = DecodePageToken(token, UserListFilters{PaidState: entitlement.PaidStateFree})
|
||||
require.ErrorIs(t, err, ErrPageTokenFiltersMismatch)
|
||||
}
|
||||
|
||||
func TestDecodePageTokenRejectsInvalidInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := DecodePageToken("%%%not-base64%%%", UserListFilters{})
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Package adminapi exposes the optional private admin HTTP listener used for
|
||||
// operational endpoints such as Prometheus metrics.
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"galaxy/user/internal/config"
|
||||
)
|
||||
|
||||
// Server owns the optional admin HTTP listener exposed by the user service.
|
||||
type Server struct {
|
||||
cfg config.AdminHTTPConfig
|
||||
handler http.Handler
|
||||
logger *slog.Logger
|
||||
|
||||
stateMu sync.RWMutex
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
}
|
||||
|
||||
// NewServer constructs an admin HTTP server for cfg and handler.
|
||||
func NewServer(cfg config.AdminHTTPConfig, handler http.Handler, logger *slog.Logger) *Server {
|
||||
if handler == nil {
|
||||
handler = http.NotFoundHandler()
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("GET /metrics", handler)
|
||||
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
handler: mux,
|
||||
logger: logger.With("component", "admin_http"),
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled reports whether the admin listener should run.
|
||||
func (server *Server) Enabled() bool {
|
||||
return server != nil && server.cfg.Addr != ""
|
||||
}
|
||||
|
||||
// Run binds the configured listener and serves the admin HTTP surface until
|
||||
// Shutdown closes the server. A disabled admin server returns when ctx is
|
||||
// canceled.
|
||||
func (server *Server) Run(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("run admin HTTP server: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !server.Enabled() {
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", server.cfg.Addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("run admin HTTP server: listen on %q: %w", server.cfg.Addr, err)
|
||||
}
|
||||
|
||||
httpServer := &http.Server{
|
||||
Handler: server.handler,
|
||||
ReadHeaderTimeout: server.cfg.ReadHeaderTimeout,
|
||||
ReadTimeout: server.cfg.ReadTimeout,
|
||||
IdleTimeout: server.cfg.IdleTimeout,
|
||||
}
|
||||
|
||||
server.stateMu.Lock()
|
||||
server.server = httpServer
|
||||
server.listener = listener
|
||||
server.stateMu.Unlock()
|
||||
|
||||
server.logger.Info("admin HTTP server started", "addr", listener.Addr().String())
|
||||
|
||||
shutdownDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(shutdownDone)
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), server.cfg.ReadTimeout)
|
||||
defer cancel()
|
||||
_ = server.Shutdown(shutdownCtx)
|
||||
}()
|
||||
|
||||
defer func() {
|
||||
server.stateMu.Lock()
|
||||
server.server = nil
|
||||
server.listener = nil
|
||||
server.stateMu.Unlock()
|
||||
<-shutdownDone
|
||||
}()
|
||||
|
||||
err = httpServer.Serve(listener)
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, http.ErrServerClosed):
|
||||
server.logger.Info("admin HTTP server stopped")
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("run admin HTTP server: serve on %q: %w", server.cfg.Addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown gracefully stops the admin HTTP server within ctx.
|
||||
func (server *Server) Shutdown(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("shutdown admin HTTP server: nil context")
|
||||
}
|
||||
|
||||
server.stateMu.RLock()
|
||||
httpServer := server.server
|
||||
server.stateMu.RUnlock()
|
||||
|
||||
if httpServer == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := httpServer.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return fmt.Errorf("shutdown admin HTTP server: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/config"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestServerRunDisabledWaitsForContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := NewServer(config.AdminHTTPConfig{}, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
t.Fatal("disabled admin server must not serve requests")
|
||||
}), nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- server.Run(ctx)
|
||||
}()
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("disabled admin server did not stop after context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerRunServesMetricsOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := NewServer(config.AdminHTTPConfig{
|
||||
Addr: "127.0.0.1:0",
|
||||
ReadHeaderTimeout: 2 * time.Second,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
IdleTimeout: time.Minute,
|
||||
}, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("sample_metric 1\n"))
|
||||
}), nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- server.Run(ctx)
|
||||
}()
|
||||
|
||||
addr := waitForListener(t, server)
|
||||
|
||||
metricsResponse, err := http.Get("http://" + addr + "/metrics")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = metricsResponse.Body.Close() })
|
||||
require.Equal(t, http.StatusOK, metricsResponse.StatusCode)
|
||||
|
||||
rootResponse, err := http.Get("http://" + addr + "/")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = rootResponse.Body.Close() })
|
||||
require.Equal(t, http.StatusNotFound, rootResponse.StatusCode)
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
require.NoError(t, err)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("admin server did not stop after context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func waitForListener(t *testing.T, server *Server) string {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
server.stateMu.RLock()
|
||||
listener := server.listener
|
||||
server.stateMu.RUnlock()
|
||||
if listener != nil {
|
||||
return listener.Addr().String()
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
t.Fatal("admin server listener did not start")
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/service/adminusers"
|
||||
"galaxy/user/internal/service/shared"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type getUserByEmailRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type getUserByRaceNameRequest struct {
|
||||
RaceName string `json:"race_name"`
|
||||
}
|
||||
|
||||
func handleGetUserByID(useCase GetUserByIDUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, adminusers.GetUserByIDInput{
|
||||
UserID: c.Param("user_id"),
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetUserByEmail(useCase GetUserByEmailUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request getUserByEmailRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, adminusers.GetUserByEmailInput{
|
||||
Email: request.Email,
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetUserByRaceName(useCase GetUserByRaceNameUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request getUserByRaceNameRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, adminusers.GetUserByRaceNameInput{
|
||||
RaceName: request.RaceName,
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
func handleListUsers(useCase ListUsersUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
input, err := buildListUsersInput(c)
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, input)
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
func buildListUsersInput(c *gin.Context) (adminusers.ListUsersInput, error) {
|
||||
pageSize, err := parseOptionalPageSize(c, "page_size")
|
||||
if err != nil {
|
||||
return adminusers.ListUsersInput{}, err
|
||||
}
|
||||
pageToken, err := parseOptionalPageToken(c, "page_token")
|
||||
if err != nil {
|
||||
return adminusers.ListUsersInput{}, err
|
||||
}
|
||||
paidExpiresBefore, err := parseOptionalRFC3339Query(c, "paid_expires_before")
|
||||
if err != nil {
|
||||
return adminusers.ListUsersInput{}, err
|
||||
}
|
||||
paidExpiresAfter, err := parseOptionalRFC3339Query(c, "paid_expires_after")
|
||||
if err != nil {
|
||||
return adminusers.ListUsersInput{}, err
|
||||
}
|
||||
canLogin, err := parseOptionalBoolQuery(c, "can_login")
|
||||
if err != nil {
|
||||
return adminusers.ListUsersInput{}, err
|
||||
}
|
||||
canCreatePrivateGame, err := parseOptionalBoolQuery(c, "can_create_private_game")
|
||||
if err != nil {
|
||||
return adminusers.ListUsersInput{}, err
|
||||
}
|
||||
canJoinGame, err := parseOptionalBoolQuery(c, "can_join_game")
|
||||
if err != nil {
|
||||
return adminusers.ListUsersInput{}, err
|
||||
}
|
||||
|
||||
return adminusers.ListUsersInput{
|
||||
PageSize: pageSize,
|
||||
PageToken: pageToken,
|
||||
PaidState: c.Query("paid_state"),
|
||||
PaidExpiresBefore: paidExpiresBefore,
|
||||
PaidExpiresAfter: paidExpiresAfter,
|
||||
DeclaredCountry: c.Query("declared_country"),
|
||||
SanctionCode: c.Query("sanction_code"),
|
||||
LimitCode: c.Query("limit_code"),
|
||||
CanLogin: canLogin,
|
||||
CanCreatePrivateGame: canCreatePrivateGame,
|
||||
CanJoinGame: canJoinGame,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseOptionalPageSize(c *gin.Context, name string) (int, error) {
|
||||
raw, present := c.GetQuery(name)
|
||||
if !present {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
value, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil || value < 1 || value > 200 {
|
||||
return 0, shared.InvalidRequest("page_size must be between 1 and 200")
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func parseOptionalPageToken(c *gin.Context, name string) (string, error) {
|
||||
raw, present := c.GetQuery(name)
|
||||
if !present {
|
||||
return "", nil
|
||||
}
|
||||
if strings.TrimSpace(raw) != raw {
|
||||
return "", shared.InvalidRequest("page_token must not contain surrounding whitespace")
|
||||
}
|
||||
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func parseOptionalRFC3339Query(c *gin.Context, name string) (*time.Time, error) {
|
||||
raw, present := c.GetQuery(name)
|
||||
if !present {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return nil, shared.InvalidRequest(name + " must be a valid RFC 3339 timestamp")
|
||||
}
|
||||
|
||||
return &parsed, nil
|
||||
}
|
||||
|
||||
func parseOptionalBoolQuery(c *gin.Context, name string) (*bool, error) {
|
||||
raw, present := c.GetQuery(name)
|
||||
if !present {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return nil, shared.InvalidRequest(name + " must be a valid boolean")
|
||||
}
|
||||
|
||||
return &parsed, nil
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/service/accountview"
|
||||
"galaxy/user/internal/service/adminusers"
|
||||
"galaxy/user/internal/service/shared"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAdminReadHandlersSuccessCases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := mustNewHandler(t, Dependencies{
|
||||
GetUserByID: getUserByIDFunc(func(_ context.Context, input adminusers.GetUserByIDInput) (adminusers.LookupResult, error) {
|
||||
require.Equal(t, "user-123", input.UserID)
|
||||
return adminusers.LookupResult{User: sampleAccountView()}, nil
|
||||
}),
|
||||
GetUserByEmail: getUserByEmailFunc(func(_ context.Context, input adminusers.GetUserByEmailInput) (adminusers.LookupResult, error) {
|
||||
require.Equal(t, "pilot@example.com", input.Email)
|
||||
return adminusers.LookupResult{User: sampleAccountView()}, nil
|
||||
}),
|
||||
GetUserByRaceName: getUserByRaceNameFunc(func(_ context.Context, input adminusers.GetUserByRaceNameInput) (adminusers.LookupResult, error) {
|
||||
require.Equal(t, "Pilot Nova", input.RaceName)
|
||||
return adminusers.LookupResult{User: sampleAccountView()}, nil
|
||||
}),
|
||||
ListUsers: listUsersFunc(func(_ context.Context, input adminusers.ListUsersInput) (adminusers.ListUsersResult, error) {
|
||||
require.Equal(t, 2, input.PageSize)
|
||||
require.Equal(t, "cursor-1", input.PageToken)
|
||||
require.Equal(t, "paid", input.PaidState)
|
||||
require.Equal(t, "DE", input.DeclaredCountry)
|
||||
require.Equal(t, "login_block", input.SanctionCode)
|
||||
require.Equal(t, "max_owned_private_games", input.LimitCode)
|
||||
require.NotNil(t, input.PaidExpiresBefore)
|
||||
require.NotNil(t, input.PaidExpiresAfter)
|
||||
require.NotNil(t, input.CanLogin)
|
||||
require.NotNil(t, input.CanCreatePrivateGame)
|
||||
require.NotNil(t, input.CanJoinGame)
|
||||
require.False(t, *input.CanLogin)
|
||||
require.True(t, *input.CanCreatePrivateGame)
|
||||
require.True(t, *input.CanJoinGame)
|
||||
require.Equal(t, time.Date(2026, time.April, 10, 12, 0, 0, 0, time.UTC), input.PaidExpiresBefore.UTC())
|
||||
require.Equal(t, time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC), input.PaidExpiresAfter.UTC())
|
||||
|
||||
other := sampleAccountView()
|
||||
other.UserID = "user-234"
|
||||
other.Email = "second@example.com"
|
||||
other.RaceName = "Second Pilot"
|
||||
|
||||
return adminusers.ListUsersResult{
|
||||
Items: []accountview.AccountView{sampleAccountView(), other},
|
||||
NextPageToken: "cursor-2",
|
||||
}, nil
|
||||
}),
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
wantStatus int
|
||||
wantBody string
|
||||
}{
|
||||
{
|
||||
name: "get user by id",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/users/user-123",
|
||||
wantStatus: http.StatusOK,
|
||||
wantBody: `{"user":{"user_id":"user-123","email":"pilot@example.com","race_name":"Pilot Nova","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}}`,
|
||||
},
|
||||
{
|
||||
name: "get user by email",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/user-lookups/by-email",
|
||||
body: `{"email":"pilot@example.com"}`,
|
||||
wantStatus: http.StatusOK,
|
||||
wantBody: `{"user":{"user_id":"user-123","email":"pilot@example.com","race_name":"Pilot Nova","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}}`,
|
||||
},
|
||||
{
|
||||
name: "get user by race name",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/user-lookups/by-race-name",
|
||||
body: `{"race_name":"Pilot Nova"}`,
|
||||
wantStatus: http.StatusOK,
|
||||
wantBody: `{"user":{"user_id":"user-123","email":"pilot@example.com","race_name":"Pilot Nova","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}}`,
|
||||
},
|
||||
{
|
||||
name: "list users",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/users?page_size=2&page_token=cursor-1&paid_state=paid&paid_expires_before=2026-04-10T12:00:00Z&paid_expires_after=2026-04-01T12:00:00Z&declared_country=DE&sanction_code=login_block&limit_code=max_owned_private_games&can_login=false&can_create_private_game=true&can_join_game=true",
|
||||
wantStatus: http.StatusOK,
|
||||
wantBody: `{"items":[{"user_id":"user-123","email":"pilot@example.com","race_name":"Pilot Nova","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},{"user_id":"user-234","email":"second@example.com","race_name":"Second Pilot","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}],"next_page_token":"cursor-2"}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var body *bytes.Buffer
|
||||
if tt.body != "" {
|
||||
body = bytes.NewBufferString(tt.body)
|
||||
} else {
|
||||
body = &bytes.Buffer{}
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(tt.method, tt.path, body)
|
||||
if tt.body != "" {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, tt.wantStatus, recorder.Code)
|
||||
assertJSONEq(t, recorder.Body.String(), tt.wantBody)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminReadHandlersErrorCases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := mustNewHandler(t, Dependencies{
|
||||
GetUserByID: getUserByIDFunc(func(context.Context, adminusers.GetUserByIDInput) (adminusers.LookupResult, error) {
|
||||
return adminusers.LookupResult{}, shared.SubjectNotFound()
|
||||
}),
|
||||
GetUserByEmail: getUserByEmailFunc(func(context.Context, adminusers.GetUserByEmailInput) (adminusers.LookupResult, error) {
|
||||
return adminusers.LookupResult{}, shared.SubjectNotFound()
|
||||
}),
|
||||
GetUserByRaceName: getUserByRaceNameFunc(func(context.Context, adminusers.GetUserByRaceNameInput) (adminusers.LookupResult, error) {
|
||||
return adminusers.LookupResult{}, shared.SubjectNotFound()
|
||||
}),
|
||||
ListUsers: listUsersFunc(func(context.Context, adminusers.ListUsersInput) (adminusers.ListUsersResult, error) {
|
||||
return adminusers.ListUsersResult{}, shared.InvalidRequest("page_token is invalid or does not match current filters")
|
||||
}),
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
wantStatus int
|
||||
wantBody string
|
||||
}{
|
||||
{
|
||||
name: "get user by id not found",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/users/user-missing",
|
||||
wantStatus: http.StatusNotFound,
|
||||
wantBody: `{"error":{"code":"subject_not_found","message":"subject not found"}}`,
|
||||
},
|
||||
{
|
||||
name: "get user by email malformed json",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/user-lookups/by-email",
|
||||
body: `{"email":"pilot@example.com","extra":true}`,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: `{"error":{"code":"invalid_request","message":"request body contains unknown field \"extra\""}}`,
|
||||
},
|
||||
{
|
||||
name: "get user by race name not found",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/user-lookups/by-race-name",
|
||||
body: `{"race_name":"Missing Pilot"}`,
|
||||
wantStatus: http.StatusNotFound,
|
||||
wantBody: `{"error":{"code":"subject_not_found","message":"subject not found"}}`,
|
||||
},
|
||||
{
|
||||
name: "list users invalid page size",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/users?page_size=201",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: `{"error":{"code":"invalid_request","message":"page_size must be between 1 and 200"}}`,
|
||||
},
|
||||
{
|
||||
name: "list users invalid timestamp",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/users?paid_expires_before=not-a-time",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: `{"error":{"code":"invalid_request","message":"paid_expires_before must be a valid RFC 3339 timestamp"}}`,
|
||||
},
|
||||
{
|
||||
name: "list users invalid boolean",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/users?can_login=maybe",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: `{"error":{"code":"invalid_request","message":"can_login must be a valid boolean"}}`,
|
||||
},
|
||||
{
|
||||
name: "list users invalid page token",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/users?page_token=cursor-1",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: `{"error":{"code":"invalid_request","message":"page_token is invalid or does not match current filters"}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var body *bytes.Buffer
|
||||
if tt.body != "" {
|
||||
body = bytes.NewBufferString(tt.body)
|
||||
} else {
|
||||
body = &bytes.Buffer{}
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(tt.method, tt.path, body)
|
||||
if tt.body != "" {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, tt.wantStatus, recorder.Code)
|
||||
assertJSONEq(t, recorder.Body.String(), tt.wantBody)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,841 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/logging"
|
||||
"galaxy/user/internal/service/authdirectory"
|
||||
"galaxy/user/internal/service/entitlementsvc"
|
||||
"galaxy/user/internal/service/geosync"
|
||||
"galaxy/user/internal/service/lobbyeligibility"
|
||||
"galaxy/user/internal/service/policysvc"
|
||||
"galaxy/user/internal/service/selfservice"
|
||||
"galaxy/user/internal/service/shared"
|
||||
"galaxy/user/internal/telemetry"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
)
|
||||
|
||||
const internalHTTPServiceName = "galaxy-user-internal"
|
||||
|
||||
type errorResponse struct {
|
||||
Error errorBody `json:"error"`
|
||||
}
|
||||
|
||||
type errorBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type resolveByEmailRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type resolveByEmailResponse struct {
|
||||
Kind string `json:"kind"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
BlockReasonCode string `json:"block_reason_code,omitempty"`
|
||||
}
|
||||
|
||||
type existsByUserIDResponse struct {
|
||||
Exists bool `json:"exists"`
|
||||
}
|
||||
|
||||
type ensureByEmailRequest struct {
|
||||
Email string `json:"email"`
|
||||
RegistrationContext *ensureRegistrationContextDTO `json:"registration_context"`
|
||||
}
|
||||
|
||||
type ensureRegistrationContextDTO struct {
|
||||
PreferredLanguage string `json:"preferred_language"`
|
||||
TimeZone string `json:"time_zone"`
|
||||
}
|
||||
|
||||
type ensureByEmailResponse struct {
|
||||
Outcome string `json:"outcome"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
BlockReasonCode string `json:"block_reason_code,omitempty"`
|
||||
}
|
||||
|
||||
type blockByUserIDRequest struct {
|
||||
ReasonCode string `json:"reason_code"`
|
||||
}
|
||||
|
||||
type blockByEmailRequest struct {
|
||||
Email string `json:"email"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
}
|
||||
|
||||
type blockResponse struct {
|
||||
Outcome string `json:"outcome"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
type getMyAccountResponse struct {
|
||||
Account selfservice.AccountView `json:"account"`
|
||||
}
|
||||
|
||||
type updateMyProfileRequest struct {
|
||||
RaceName string `json:"race_name"`
|
||||
}
|
||||
|
||||
type updateMySettingsRequest struct {
|
||||
PreferredLanguage string `json:"preferred_language"`
|
||||
TimeZone string `json:"time_zone"`
|
||||
}
|
||||
|
||||
type syncDeclaredCountryRequest struct {
|
||||
DeclaredCountry string `json:"declared_country"`
|
||||
}
|
||||
|
||||
type syncDeclaredCountryResponse struct {
|
||||
UserID string `json:"user_id"`
|
||||
DeclaredCountry string `json:"declared_country"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type actorDTO struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
type grantEntitlementRequest struct {
|
||||
PlanCode string `json:"plan_code"`
|
||||
Source string `json:"source"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Actor actorDTO `json:"actor"`
|
||||
StartsAt string `json:"starts_at"`
|
||||
EndsAt string `json:"ends_at,omitempty"`
|
||||
}
|
||||
|
||||
type extendEntitlementRequest struct {
|
||||
Source string `json:"source"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Actor actorDTO `json:"actor"`
|
||||
EndsAt string `json:"ends_at"`
|
||||
}
|
||||
|
||||
type revokeEntitlementRequest struct {
|
||||
Source string `json:"source"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Actor actorDTO `json:"actor"`
|
||||
}
|
||||
|
||||
type applySanctionRequest struct {
|
||||
SanctionCode string `json:"sanction_code"`
|
||||
Scope string `json:"scope"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Actor actorDTO `json:"actor"`
|
||||
AppliedAt string `json:"applied_at"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
type removeSanctionRequest struct {
|
||||
SanctionCode string `json:"sanction_code"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Actor actorDTO `json:"actor"`
|
||||
}
|
||||
|
||||
type setLimitRequest struct {
|
||||
LimitCode string `json:"limit_code"`
|
||||
Value int `json:"value"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Actor actorDTO `json:"actor"`
|
||||
AppliedAt string `json:"applied_at"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
type removeLimitRequest struct {
|
||||
LimitCode string `json:"limit_code"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Actor actorDTO `json:"actor"`
|
||||
}
|
||||
|
||||
type entitlementSnapshotResponse struct {
|
||||
PlanCode string `json:"plan_code"`
|
||||
IsPaid bool `json:"is_paid"`
|
||||
Source string `json:"source"`
|
||||
Actor actorDTO `json:"actor"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
StartsAt time.Time `json:"starts_at"`
|
||||
EndsAt *time.Time `json:"ends_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type entitlementCommandResponse struct {
|
||||
UserID string `json:"user_id"`
|
||||
Entitlement entitlementSnapshotResponse `json:"entitlement"`
|
||||
}
|
||||
|
||||
func newHandlerWithConfig(cfg Config, deps Dependencies) (http.Handler, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
normalizedDeps, err := normalizeDependencies(deps)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
configureGinModeOnce.Do(func() {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
})
|
||||
|
||||
engine := gin.New()
|
||||
engine.Use(newOTelMiddleware(normalizedDeps.Telemetry))
|
||||
engine.Use(withObservability(normalizedDeps.Logger, normalizedDeps.Telemetry))
|
||||
engine.POST("/api/v1/internal/user-resolutions/by-email", handleResolveByEmail(normalizedDeps.ResolveByEmail, cfg.RequestTimeout))
|
||||
engine.GET("/api/v1/internal/users/:user_id/exists", handleExistsByUserID(normalizedDeps.ExistsByUserID, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/users/ensure-by-email", handleEnsureByEmail(normalizedDeps.EnsureByEmail, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/users/:user_id/block", handleBlockByUserID(normalizedDeps.BlockByUserID, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/user-blocks/by-email", handleBlockByEmail(normalizedDeps.BlockByEmail, cfg.RequestTimeout))
|
||||
engine.GET("/api/v1/internal/users/:user_id/account", handleGetMyAccount(normalizedDeps.GetMyAccount, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/users/:user_id/profile", handleUpdateMyProfile(normalizedDeps.UpdateMyProfile, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/users/:user_id/settings", handleUpdateMySettings(normalizedDeps.UpdateMySettings, cfg.RequestTimeout))
|
||||
engine.GET("/api/v1/internal/users/:user_id", handleGetUserByID(normalizedDeps.GetUserByID, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/user-lookups/by-email", handleGetUserByEmail(normalizedDeps.GetUserByEmail, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/user-lookups/by-race-name", handleGetUserByRaceName(normalizedDeps.GetUserByRaceName, cfg.RequestTimeout))
|
||||
engine.GET("/api/v1/internal/users", handleListUsers(normalizedDeps.ListUsers, cfg.RequestTimeout))
|
||||
engine.GET("/api/v1/internal/users/:user_id/eligibility", handleGetUserEligibility(normalizedDeps.GetUserEligibility, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/users/:user_id/declared-country/sync", handleSyncDeclaredCountry(normalizedDeps.SyncDeclaredCountry, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/users/:user_id/entitlements/grant", handleGrantEntitlement(normalizedDeps.GrantEntitlement, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/users/:user_id/entitlements/extend", handleExtendEntitlement(normalizedDeps.ExtendEntitlement, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/users/:user_id/entitlements/revoke", handleRevokeEntitlement(normalizedDeps.RevokeEntitlement, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/users/:user_id/sanctions/apply", handleApplySanction(normalizedDeps.ApplySanction, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/users/:user_id/sanctions/remove", handleRemoveSanction(normalizedDeps.RemoveSanction, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/users/:user_id/limits/set", handleSetLimit(normalizedDeps.SetLimit, cfg.RequestTimeout))
|
||||
engine.POST("/api/v1/internal/users/:user_id/limits/remove", handleRemoveLimit(normalizedDeps.RemoveLimit, cfg.RequestTimeout))
|
||||
|
||||
return engine, nil
|
||||
}
|
||||
|
||||
func handleResolveByEmail(useCase ResolveByEmailUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request resolveByEmailRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, authdirectory.ResolveByEmailInput{
|
||||
Email: request.Email,
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resolveByEmailResponse{
|
||||
Kind: result.Kind,
|
||||
UserID: result.UserID,
|
||||
BlockReasonCode: result.BlockReasonCode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleExistsByUserID(useCase ExistsByUserIDUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, authdirectory.ExistsByUserIDInput{
|
||||
UserID: c.Param("user_id"),
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, existsByUserIDResponse{Exists: result.Exists})
|
||||
}
|
||||
}
|
||||
|
||||
func handleEnsureByEmail(useCase EnsureByEmailUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request ensureByEmailRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
if request.RegistrationContext == nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest("registration_context must be present")))
|
||||
return
|
||||
}
|
||||
|
||||
var registrationContext *authdirectory.RegistrationContext
|
||||
registrationContext = &authdirectory.RegistrationContext{
|
||||
PreferredLanguage: request.RegistrationContext.PreferredLanguage,
|
||||
TimeZone: request.RegistrationContext.TimeZone,
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, authdirectory.EnsureByEmailInput{
|
||||
Email: request.Email,
|
||||
RegistrationContext: registrationContext,
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, ensureByEmailResponse{
|
||||
Outcome: result.Outcome,
|
||||
UserID: result.UserID,
|
||||
BlockReasonCode: result.BlockReasonCode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleBlockByUserID(useCase BlockByUserIDUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request blockByUserIDRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, authdirectory.BlockByUserIDInput{
|
||||
UserID: c.Param("user_id"),
|
||||
ReasonCode: request.ReasonCode,
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, blockResponse{
|
||||
Outcome: result.Outcome,
|
||||
UserID: result.UserID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleBlockByEmail(useCase BlockByEmailUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request blockByEmailRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, authdirectory.BlockByEmailInput{
|
||||
Email: request.Email,
|
||||
ReasonCode: request.ReasonCode,
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, blockResponse{
|
||||
Outcome: result.Outcome,
|
||||
UserID: result.UserID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetMyAccount(useCase GetMyAccountUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, selfservice.GetMyAccountInput{
|
||||
UserID: c.Param("user_id"),
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, getMyAccountResponse{
|
||||
Account: result.Account,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleUpdateMyProfile(useCase UpdateMyProfileUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request updateMyProfileRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, selfservice.UpdateMyProfileInput{
|
||||
UserID: c.Param("user_id"),
|
||||
RaceName: request.RaceName,
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, getMyAccountResponse{
|
||||
Account: result.Account,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleUpdateMySettings(useCase UpdateMySettingsUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request updateMySettingsRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, selfservice.UpdateMySettingsInput{
|
||||
UserID: c.Param("user_id"),
|
||||
PreferredLanguage: request.PreferredLanguage,
|
||||
TimeZone: request.TimeZone,
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, getMyAccountResponse{
|
||||
Account: result.Account,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetUserEligibility(useCase GetUserEligibilityUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, lobbyeligibility.GetUserEligibilityInput{
|
||||
UserID: c.Param("user_id"),
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
func handleSyncDeclaredCountry(useCase SyncDeclaredCountryUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request syncDeclaredCountryRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, geosync.SyncDeclaredCountryInput{
|
||||
UserID: c.Param("user_id"),
|
||||
DeclaredCountry: request.DeclaredCountry,
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, syncDeclaredCountryResponse{
|
||||
UserID: result.UserID,
|
||||
DeclaredCountry: result.DeclaredCountry,
|
||||
UpdatedAt: result.UpdatedAt.UTC(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleGrantEntitlement(useCase GrantEntitlementUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request grantEntitlementRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, entitlementsvc.GrantInput{
|
||||
UserID: c.Param("user_id"),
|
||||
PlanCode: request.PlanCode,
|
||||
Source: request.Source,
|
||||
ReasonCode: request.ReasonCode,
|
||||
Actor: entitlementsvc.ActorInput{
|
||||
Type: request.Actor.Type,
|
||||
ID: request.Actor.ID,
|
||||
},
|
||||
StartsAt: request.StartsAt,
|
||||
EndsAt: request.EndsAt,
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, entitlementCommandResponseFromResult(result))
|
||||
}
|
||||
}
|
||||
|
||||
func handleExtendEntitlement(useCase ExtendEntitlementUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request extendEntitlementRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, entitlementsvc.ExtendInput{
|
||||
UserID: c.Param("user_id"),
|
||||
Source: request.Source,
|
||||
ReasonCode: request.ReasonCode,
|
||||
Actor: entitlementsvc.ActorInput{
|
||||
Type: request.Actor.Type,
|
||||
ID: request.Actor.ID,
|
||||
},
|
||||
EndsAt: request.EndsAt,
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, entitlementCommandResponseFromResult(result))
|
||||
}
|
||||
}
|
||||
|
||||
func handleRevokeEntitlement(useCase RevokeEntitlementUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request revokeEntitlementRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, entitlementsvc.RevokeInput{
|
||||
UserID: c.Param("user_id"),
|
||||
Source: request.Source,
|
||||
ReasonCode: request.ReasonCode,
|
||||
Actor: entitlementsvc.ActorInput{
|
||||
Type: request.Actor.Type,
|
||||
ID: request.Actor.ID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, entitlementCommandResponseFromResult(result))
|
||||
}
|
||||
}
|
||||
|
||||
func handleApplySanction(useCase ApplySanctionUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request applySanctionRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, policysvc.ApplySanctionInput{
|
||||
UserID: c.Param("user_id"),
|
||||
SanctionCode: request.SanctionCode,
|
||||
Scope: request.Scope,
|
||||
ReasonCode: request.ReasonCode,
|
||||
Actor: policysvc.ActorInput{
|
||||
Type: request.Actor.Type,
|
||||
ID: request.Actor.ID,
|
||||
},
|
||||
AppliedAt: request.AppliedAt,
|
||||
ExpiresAt: request.ExpiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
func handleRemoveSanction(useCase RemoveSanctionUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request removeSanctionRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, policysvc.RemoveSanctionInput{
|
||||
UserID: c.Param("user_id"),
|
||||
SanctionCode: request.SanctionCode,
|
||||
ReasonCode: request.ReasonCode,
|
||||
Actor: policysvc.ActorInput{
|
||||
Type: request.Actor.Type,
|
||||
ID: request.Actor.ID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
func handleSetLimit(useCase SetLimitUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request setLimitRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, policysvc.SetLimitInput{
|
||||
UserID: c.Param("user_id"),
|
||||
LimitCode: request.LimitCode,
|
||||
Value: request.Value,
|
||||
ReasonCode: request.ReasonCode,
|
||||
Actor: policysvc.ActorInput{
|
||||
Type: request.Actor.Type,
|
||||
ID: request.Actor.ID,
|
||||
},
|
||||
AppliedAt: request.AppliedAt,
|
||||
ExpiresAt: request.ExpiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
func handleRemoveLimit(useCase RemoveLimitUseCase, timeout time.Duration) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request removeLimitRequest
|
||||
if err := decodeJSONRequest(c.Request, &request); err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := useCase.Execute(callCtx, policysvc.RemoveLimitInput{
|
||||
UserID: c.Param("user_id"),
|
||||
LimitCode: request.LimitCode,
|
||||
ReasonCode: request.ReasonCode,
|
||||
Actor: policysvc.ActorInput{
|
||||
Type: request.Actor.Type,
|
||||
ID: request.Actor.ID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
abortWithProjection(c, shared.ProjectInternalError(err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeDependencies(deps Dependencies) (Dependencies, error) {
|
||||
switch {
|
||||
case deps.ResolveByEmail == nil:
|
||||
return Dependencies{}, fmt.Errorf("resolve-by-email use case must not be nil")
|
||||
case deps.EnsureByEmail == nil:
|
||||
return Dependencies{}, fmt.Errorf("ensure-by-email use case must not be nil")
|
||||
case deps.ExistsByUserID == nil:
|
||||
return Dependencies{}, fmt.Errorf("exists-by-user-id use case must not be nil")
|
||||
case deps.BlockByUserID == nil:
|
||||
return Dependencies{}, fmt.Errorf("block-by-user-id use case must not be nil")
|
||||
case deps.BlockByEmail == nil:
|
||||
return Dependencies{}, fmt.Errorf("block-by-email use case must not be nil")
|
||||
case deps.GetMyAccount == nil:
|
||||
return Dependencies{}, fmt.Errorf("get-my-account use case must not be nil")
|
||||
case deps.UpdateMyProfile == nil:
|
||||
return Dependencies{}, fmt.Errorf("update-my-profile use case must not be nil")
|
||||
case deps.UpdateMySettings == nil:
|
||||
return Dependencies{}, fmt.Errorf("update-my-settings use case must not be nil")
|
||||
case deps.GetUserByID == nil:
|
||||
return Dependencies{}, fmt.Errorf("get-user-by-id use case must not be nil")
|
||||
case deps.GetUserByEmail == nil:
|
||||
return Dependencies{}, fmt.Errorf("get-user-by-email use case must not be nil")
|
||||
case deps.GetUserByRaceName == nil:
|
||||
return Dependencies{}, fmt.Errorf("get-user-by-race-name use case must not be nil")
|
||||
case deps.ListUsers == nil:
|
||||
return Dependencies{}, fmt.Errorf("list-users use case must not be nil")
|
||||
case deps.GetUserEligibility == nil:
|
||||
return Dependencies{}, fmt.Errorf("get-user-eligibility use case must not be nil")
|
||||
case deps.SyncDeclaredCountry == nil:
|
||||
return Dependencies{}, fmt.Errorf("sync-declared-country use case must not be nil")
|
||||
case deps.GrantEntitlement == nil:
|
||||
return Dependencies{}, fmt.Errorf("grant-entitlement use case must not be nil")
|
||||
case deps.ExtendEntitlement == nil:
|
||||
return Dependencies{}, fmt.Errorf("extend-entitlement use case must not be nil")
|
||||
case deps.RevokeEntitlement == nil:
|
||||
return Dependencies{}, fmt.Errorf("revoke-entitlement use case must not be nil")
|
||||
case deps.ApplySanction == nil:
|
||||
return Dependencies{}, fmt.Errorf("apply-sanction use case must not be nil")
|
||||
case deps.RemoveSanction == nil:
|
||||
return Dependencies{}, fmt.Errorf("remove-sanction use case must not be nil")
|
||||
case deps.SetLimit == nil:
|
||||
return Dependencies{}, fmt.Errorf("set-limit use case must not be nil")
|
||||
case deps.RemoveLimit == nil:
|
||||
return Dependencies{}, fmt.Errorf("remove-limit use case must not be nil")
|
||||
default:
|
||||
if deps.Logger == nil {
|
||||
deps.Logger = slog.Default()
|
||||
}
|
||||
return deps, nil
|
||||
}
|
||||
}
|
||||
|
||||
func entitlementCommandResponseFromResult(result entitlementsvc.CommandResult) entitlementCommandResponse {
|
||||
response := entitlementCommandResponse{
|
||||
UserID: result.UserID,
|
||||
Entitlement: entitlementSnapshotResponse{
|
||||
PlanCode: string(result.Entitlement.PlanCode),
|
||||
IsPaid: result.Entitlement.IsPaid,
|
||||
Source: result.Entitlement.Source.String(),
|
||||
Actor: actorDTO{Type: result.Entitlement.Actor.Type.String(), ID: result.Entitlement.Actor.ID.String()},
|
||||
ReasonCode: result.Entitlement.ReasonCode.String(),
|
||||
StartsAt: result.Entitlement.StartsAt.UTC(),
|
||||
UpdatedAt: result.Entitlement.UpdatedAt.UTC(),
|
||||
},
|
||||
}
|
||||
if result.Entitlement.EndsAt != nil {
|
||||
value := result.Entitlement.EndsAt.UTC()
|
||||
response.Entitlement.EndsAt = &value
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
func newOTelMiddleware(runtime *telemetry.Runtime) gin.HandlerFunc {
|
||||
options := []otelgin.Option{}
|
||||
if runtime != nil {
|
||||
options = append(
|
||||
options,
|
||||
otelgin.WithTracerProvider(runtime.TracerProvider()),
|
||||
otelgin.WithMeterProvider(runtime.MeterProvider()),
|
||||
)
|
||||
}
|
||||
|
||||
return otelgin.Middleware(internalHTTPServiceName, options...)
|
||||
}
|
||||
|
||||
func withObservability(logger *slog.Logger, metrics *telemetry.Runtime) gin.HandlerFunc {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
startedAt := time.Now()
|
||||
c.Next()
|
||||
|
||||
statusCode := c.Writer.Status()
|
||||
route := c.FullPath()
|
||||
if route == "" {
|
||||
route = "unmatched"
|
||||
}
|
||||
|
||||
errorCode, _ := c.Get(internalErrorCodeContextKey)
|
||||
errorCodeValue, _ := errorCode.(string)
|
||||
outcome := outcomeFromStatusCode(statusCode)
|
||||
duration := time.Since(startedAt)
|
||||
|
||||
attrs := []any{
|
||||
"transport", "http",
|
||||
"route", route,
|
||||
"method", c.Request.Method,
|
||||
"status_code", statusCode,
|
||||
"duration_ms", float64(duration.Microseconds()) / 1000,
|
||||
"edge_outcome", string(outcome),
|
||||
}
|
||||
if errorCodeValue != "" {
|
||||
attrs = append(attrs, "error_code", errorCodeValue)
|
||||
}
|
||||
attrs = append(attrs, logging.TraceAttrsFromContext(c.Request.Context())...)
|
||||
|
||||
metricAttrs := []attribute.KeyValue{
|
||||
attribute.String("route", route),
|
||||
attribute.String("method", c.Request.Method),
|
||||
attribute.String("edge_outcome", string(outcome)),
|
||||
}
|
||||
if errorCodeValue != "" {
|
||||
metricAttrs = append(metricAttrs, attribute.String("error_code", errorCodeValue))
|
||||
}
|
||||
metrics.RecordInternalHTTPRequest(c.Request.Context(), metricAttrs, duration)
|
||||
|
||||
switch outcome {
|
||||
case edgeOutcomeSuccess:
|
||||
logger.InfoContext(c.Request.Context(), "internal request completed", attrs...)
|
||||
case edgeOutcomeFailed:
|
||||
logger.ErrorContext(c.Request.Context(), "internal request failed", attrs...)
|
||||
default:
|
||||
logger.WarnContext(c.Request.Context(), "internal request rejected", attrs...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type edgeOutcome string
|
||||
|
||||
const (
|
||||
edgeOutcomeSuccess edgeOutcome = "success"
|
||||
edgeOutcomeRejected edgeOutcome = "rejected"
|
||||
edgeOutcomeFailed edgeOutcome = "failed"
|
||||
)
|
||||
|
||||
func outcomeFromStatusCode(statusCode int) edgeOutcome {
|
||||
switch {
|
||||
case statusCode >= 500:
|
||||
return edgeOutcomeFailed
|
||||
case statusCode >= 400:
|
||||
return edgeOutcomeRejected
|
||||
default:
|
||||
return edgeOutcomeSuccess
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"galaxy/user/internal/service/shared"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const internalErrorCodeContextKey = "internal_error_code"
|
||||
|
||||
type malformedJSONRequestError struct {
|
||||
message string
|
||||
}
|
||||
|
||||
func (err *malformedJSONRequestError) Error() string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return err.message
|
||||
}
|
||||
|
||||
func decodeJSONRequest(request *http.Request, target any) error {
|
||||
if request == nil || request.Body == nil {
|
||||
return &malformedJSONRequestError{message: "request body must not be empty"}
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(request.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return describeJSONDecodeError(err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &malformedJSONRequestError{message: "request body must contain a single JSON object"}
|
||||
}
|
||||
|
||||
return &malformedJSONRequestError{message: "request body must contain a single JSON object"}
|
||||
}
|
||||
|
||||
func describeJSONDecodeError(err error) error {
|
||||
var syntaxErr *json.SyntaxError
|
||||
var typeErr *json.UnmarshalTypeError
|
||||
|
||||
switch {
|
||||
case errors.Is(err, io.EOF):
|
||||
return &malformedJSONRequestError{message: "request body must not be empty"}
|
||||
case errors.As(err, &syntaxErr):
|
||||
return &malformedJSONRequestError{message: "request body contains malformed JSON"}
|
||||
case errors.Is(err, io.ErrUnexpectedEOF):
|
||||
return &malformedJSONRequestError{message: "request body contains malformed JSON"}
|
||||
case errors.As(err, &typeErr):
|
||||
if strings.TrimSpace(typeErr.Field) != "" {
|
||||
return &malformedJSONRequestError{
|
||||
message: fmt.Sprintf("request body contains an invalid value for %q", typeErr.Field),
|
||||
}
|
||||
}
|
||||
|
||||
return &malformedJSONRequestError{message: "request body contains an invalid JSON value"}
|
||||
case strings.HasPrefix(err.Error(), "json: unknown field "):
|
||||
return &malformedJSONRequestError{
|
||||
message: fmt.Sprintf("request body contains unknown field %s", strings.TrimPrefix(err.Error(), "json: unknown field ")),
|
||||
}
|
||||
default:
|
||||
return &malformedJSONRequestError{message: "request body contains invalid JSON"}
|
||||
}
|
||||
}
|
||||
|
||||
func abortWithProjection(c *gin.Context, projection shared.InternalErrorProjection) {
|
||||
c.Set(internalErrorCodeContextKey, projection.Code)
|
||||
c.AbortWithStatusJSON(projection.StatusCode, errorResponse{
|
||||
Error: errorBody{
|
||||
Code: projection.Code,
|
||||
Message: projection.Message,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"galaxy/user/internal/service/authdirectory"
|
||||
usertelemetry "galaxy/user/internal/telemetry"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||
)
|
||||
|
||||
func TestInternalHandlerEmitsTraceFieldsAndMetrics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger, buffer := newObservedLogger()
|
||||
telemetryRuntime, reader, recorder := newObservedInternalTelemetryRuntime(t)
|
||||
handler := mustNewHandler(t, Dependencies{
|
||||
Logger: logger,
|
||||
Telemetry: telemetryRuntime,
|
||||
ExistsByUserID: existsByUserIDFunc(func(context.Context, authdirectory.ExistsByUserIDInput) (authdirectory.ExistsByUserIDResult, error) {
|
||||
return authdirectory.ExistsByUserIDResult{Exists: true}, nil
|
||||
}),
|
||||
})
|
||||
|
||||
recorderHTTP := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/internal/users/user-123/exists", nil)
|
||||
request.Header.Set("traceparent", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")
|
||||
|
||||
handler.ServeHTTP(recorderHTTP, request)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorderHTTP.Code)
|
||||
require.NotEmpty(t, recorder.Ended())
|
||||
assert.Contains(t, buffer.String(), "otel_trace_id")
|
||||
assert.Contains(t, buffer.String(), "otel_span_id")
|
||||
|
||||
assertMetricCount(t, reader, "user.internal_http.requests", map[string]string{
|
||||
"route": "/api/v1/internal/users/:user_id/exists",
|
||||
"method": http.MethodGet,
|
||||
"edge_outcome": "success",
|
||||
}, 1)
|
||||
}
|
||||
|
||||
func newObservedInternalTelemetryRuntime(t *testing.T) (*usertelemetry.Runtime, *sdkmetric.ManualReader, *tracetest.SpanRecorder) {
|
||||
t.Helper()
|
||||
|
||||
reader := sdkmetric.NewManualReader()
|
||||
meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
|
||||
recorder := tracetest.NewSpanRecorder()
|
||||
tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
|
||||
|
||||
runtime, err := usertelemetry.NewWithProviders(meterProvider, tracerProvider)
|
||||
require.NoError(t, err)
|
||||
|
||||
return runtime, reader, recorder
|
||||
}
|
||||
|
||||
func newObservedLogger() (*slog.Logger, *bytes.Buffer) {
|
||||
buffer := &bytes.Buffer{}
|
||||
return slog.New(slog.NewJSONHandler(buffer, &slog.HandlerOptions{Level: slog.LevelDebug})), buffer
|
||||
}
|
||||
|
||||
func assertMetricCount(t *testing.T, reader *sdkmetric.ManualReader, metricName string, wantAttrs map[string]string, wantValue int64) {
|
||||
t.Helper()
|
||||
|
||||
var resourceMetrics metricdata.ResourceMetrics
|
||||
require.NoError(t, reader.Collect(context.Background(), &resourceMetrics))
|
||||
|
||||
for _, scopeMetrics := range resourceMetrics.ScopeMetrics {
|
||||
for _, metric := range scopeMetrics.Metrics {
|
||||
if metric.Name != metricName {
|
||||
continue
|
||||
}
|
||||
|
||||
sum, ok := metric.Data.(metricdata.Sum[int64])
|
||||
require.True(t, ok)
|
||||
|
||||
for _, point := range sum.DataPoints {
|
||||
if hasMetricAttributes(point.Attributes.ToSlice(), wantAttrs) {
|
||||
assert.Equal(t, wantValue, point.Value)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
require.Failf(t, "test failed", "metric %q with attrs %v not found", metricName, wantAttrs)
|
||||
}
|
||||
|
||||
func hasMetricAttributes(values []attribute.KeyValue, want map[string]string) bool {
|
||||
if len(values) != len(want) {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, value := range values {
|
||||
if want[string(value.Key)] != value.Value.AsString() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
// Package internalhttp exposes the trusted internal HTTP API used by auth,
|
||||
// gateway self-service, and internal administrative workflows.
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/service/adminusers"
|
||||
"galaxy/user/internal/service/authdirectory"
|
||||
"galaxy/user/internal/service/entitlementsvc"
|
||||
"galaxy/user/internal/service/geosync"
|
||||
"galaxy/user/internal/service/lobbyeligibility"
|
||||
"galaxy/user/internal/service/policysvc"
|
||||
"galaxy/user/internal/service/selfservice"
|
||||
"galaxy/user/internal/telemetry"
|
||||
)
|
||||
|
||||
const jsonContentType = "application/json; charset=utf-8"
|
||||
|
||||
var configureGinModeOnce sync.Once
|
||||
|
||||
// ResolveByEmailUseCase describes the auth-facing resolve-by-email service
|
||||
// consumed by the HTTP transport layer.
|
||||
type ResolveByEmailUseCase interface {
|
||||
// Execute resolves one e-mail subject without creating any account.
|
||||
Execute(ctx context.Context, input authdirectory.ResolveByEmailInput) (authdirectory.ResolveByEmailResult, error)
|
||||
}
|
||||
|
||||
// EnsureByEmailUseCase describes the auth-facing ensure-by-email service
|
||||
// consumed by the HTTP transport layer.
|
||||
type EnsureByEmailUseCase interface {
|
||||
// Execute returns an existing user, creates a new one, or reports a blocked
|
||||
// outcome for one e-mail subject.
|
||||
Execute(ctx context.Context, input authdirectory.EnsureByEmailInput) (authdirectory.EnsureByEmailResult, error)
|
||||
}
|
||||
|
||||
// ExistsByUserIDUseCase describes the auth-facing exists-by-user-id service
|
||||
// consumed by the HTTP transport layer.
|
||||
type ExistsByUserIDUseCase interface {
|
||||
// Execute reports whether one stable user identifier exists.
|
||||
Execute(ctx context.Context, input authdirectory.ExistsByUserIDInput) (authdirectory.ExistsByUserIDResult, error)
|
||||
}
|
||||
|
||||
// BlockByUserIDUseCase describes the auth-facing block-by-user-id service
|
||||
// consumed by the HTTP transport layer.
|
||||
type BlockByUserIDUseCase interface {
|
||||
// Execute blocks one account addressed by stable user identifier.
|
||||
Execute(ctx context.Context, input authdirectory.BlockByUserIDInput) (authdirectory.BlockResult, error)
|
||||
}
|
||||
|
||||
// BlockByEmailUseCase describes the auth-facing block-by-email service
|
||||
// consumed by the HTTP transport layer.
|
||||
type BlockByEmailUseCase interface {
|
||||
// Execute blocks one exact normalized e-mail subject.
|
||||
Execute(ctx context.Context, input authdirectory.BlockByEmailInput) (authdirectory.BlockResult, error)
|
||||
}
|
||||
|
||||
// GetMyAccountUseCase describes the self-service account-read use case
|
||||
// consumed by the HTTP transport layer.
|
||||
type GetMyAccountUseCase interface {
|
||||
// Execute returns the authenticated account aggregate for one user.
|
||||
Execute(ctx context.Context, input selfservice.GetMyAccountInput) (selfservice.GetMyAccountResult, error)
|
||||
}
|
||||
|
||||
// UpdateMyProfileUseCase describes the self-service profile-mutation use case
|
||||
// consumed by the HTTP transport layer.
|
||||
type UpdateMyProfileUseCase interface {
|
||||
// Execute updates the allowed self-service profile fields for one user.
|
||||
Execute(ctx context.Context, input selfservice.UpdateMyProfileInput) (selfservice.UpdateMyProfileResult, error)
|
||||
}
|
||||
|
||||
// UpdateMySettingsUseCase describes the self-service settings-mutation use
|
||||
// case consumed by the HTTP transport layer.
|
||||
type UpdateMySettingsUseCase interface {
|
||||
// Execute updates the allowed self-service settings fields for one user.
|
||||
Execute(ctx context.Context, input selfservice.UpdateMySettingsInput) (selfservice.UpdateMySettingsResult, error)
|
||||
}
|
||||
|
||||
// GetUserByIDUseCase describes the trusted admin exact-read by stable user id
|
||||
// consumed by the HTTP transport layer.
|
||||
type GetUserByIDUseCase interface {
|
||||
// Execute returns the full current account aggregate for one user id.
|
||||
Execute(ctx context.Context, input adminusers.GetUserByIDInput) (adminusers.LookupResult, error)
|
||||
}
|
||||
|
||||
// GetUserByEmailUseCase describes the trusted admin exact-read by normalized
|
||||
// e-mail consumed by the HTTP transport layer.
|
||||
type GetUserByEmailUseCase interface {
|
||||
// Execute returns the full current account aggregate for one normalized
|
||||
// e-mail address.
|
||||
Execute(ctx context.Context, input adminusers.GetUserByEmailInput) (adminusers.LookupResult, error)
|
||||
}
|
||||
|
||||
// GetUserByRaceNameUseCase describes the trusted admin exact-read by exact
|
||||
// stored race name consumed by the HTTP transport layer.
|
||||
type GetUserByRaceNameUseCase interface {
|
||||
// Execute returns the full current account aggregate for one exact race
|
||||
// name.
|
||||
Execute(ctx context.Context, input adminusers.GetUserByRaceNameInput) (adminusers.LookupResult, error)
|
||||
}
|
||||
|
||||
// ListUsersUseCase describes the trusted admin paginated listing use case
|
||||
// consumed by the HTTP transport layer.
|
||||
type ListUsersUseCase interface {
|
||||
// Execute returns one deterministic filtered page of full account
|
||||
// aggregates.
|
||||
Execute(ctx context.Context, input adminusers.ListUsersInput) (adminusers.ListUsersResult, error)
|
||||
}
|
||||
|
||||
// GetUserEligibilityUseCase describes the trusted lobby-facing eligibility
|
||||
// snapshot use case consumed by the HTTP transport layer.
|
||||
type GetUserEligibilityUseCase interface {
|
||||
// Execute returns one read-optimized lobby eligibility snapshot for one
|
||||
// user.
|
||||
Execute(ctx context.Context, input lobbyeligibility.GetUserEligibilityInput) (lobbyeligibility.GetUserEligibilityResult, error)
|
||||
}
|
||||
|
||||
// SyncDeclaredCountryUseCase describes the trusted geo-facing declared-country
|
||||
// sync use case consumed by the HTTP transport layer.
|
||||
type SyncDeclaredCountryUseCase interface {
|
||||
// Execute synchronizes the current effective declared country for one user.
|
||||
Execute(ctx context.Context, input geosync.SyncDeclaredCountryInput) (geosync.SyncDeclaredCountryResult, error)
|
||||
}
|
||||
|
||||
// GrantEntitlementUseCase describes the trusted entitlement-grant use case
|
||||
// consumed by the HTTP transport layer.
|
||||
type GrantEntitlementUseCase interface {
|
||||
// Execute grants a new current paid entitlement for one user.
|
||||
Execute(ctx context.Context, input entitlementsvc.GrantInput) (entitlementsvc.CommandResult, error)
|
||||
}
|
||||
|
||||
// ExtendEntitlementUseCase describes the trusted entitlement-extend use case
|
||||
// consumed by the HTTP transport layer.
|
||||
type ExtendEntitlementUseCase interface {
|
||||
// Execute extends the current finite paid entitlement for one user.
|
||||
Execute(ctx context.Context, input entitlementsvc.ExtendInput) (entitlementsvc.CommandResult, error)
|
||||
}
|
||||
|
||||
// RevokeEntitlementUseCase describes the trusted entitlement-revoke use case
|
||||
// consumed by the HTTP transport layer.
|
||||
type RevokeEntitlementUseCase interface {
|
||||
// Execute revokes the current paid entitlement for one user.
|
||||
Execute(ctx context.Context, input entitlementsvc.RevokeInput) (entitlementsvc.CommandResult, error)
|
||||
}
|
||||
|
||||
// ApplySanctionUseCase describes the trusted sanction-apply use case consumed
|
||||
// by the HTTP transport layer.
|
||||
type ApplySanctionUseCase interface {
|
||||
// Execute applies one new active sanction record.
|
||||
Execute(ctx context.Context, input policysvc.ApplySanctionInput) (policysvc.SanctionCommandResult, error)
|
||||
}
|
||||
|
||||
// RemoveSanctionUseCase describes the trusted sanction-remove use case
|
||||
// consumed by the HTTP transport layer.
|
||||
type RemoveSanctionUseCase interface {
|
||||
// Execute removes one current active sanction record by code.
|
||||
Execute(ctx context.Context, input policysvc.RemoveSanctionInput) (policysvc.SanctionCommandResult, error)
|
||||
}
|
||||
|
||||
// SetLimitUseCase describes the trusted limit-set use case consumed by the
|
||||
// HTTP transport layer.
|
||||
type SetLimitUseCase interface {
|
||||
// Execute creates or replaces one current active limit record.
|
||||
Execute(ctx context.Context, input policysvc.SetLimitInput) (policysvc.LimitCommandResult, error)
|
||||
}
|
||||
|
||||
// RemoveLimitUseCase describes the trusted limit-remove use case consumed by
|
||||
// the HTTP transport layer.
|
||||
type RemoveLimitUseCase interface {
|
||||
// Execute removes one current active limit record by code.
|
||||
Execute(ctx context.Context, input policysvc.RemoveLimitInput) (policysvc.LimitCommandResult, error)
|
||||
}
|
||||
|
||||
// Config describes the trusted internal HTTP listener owned by the user
|
||||
// service.
|
||||
type Config struct {
|
||||
// Addr stores the TCP listen address.
|
||||
Addr string
|
||||
|
||||
// ReadHeaderTimeout bounds how long the listener may spend reading request
|
||||
// headers before rejecting the connection.
|
||||
ReadHeaderTimeout time.Duration
|
||||
|
||||
// ReadTimeout bounds how long the listener may spend reading one request.
|
||||
ReadTimeout time.Duration
|
||||
|
||||
// IdleTimeout bounds how long keep-alive connections stay open.
|
||||
IdleTimeout time.Duration
|
||||
|
||||
// RequestTimeout bounds one application-layer request execution.
|
||||
RequestTimeout time.Duration
|
||||
}
|
||||
|
||||
// Validate reports whether cfg contains a usable internal HTTP listener
|
||||
// configuration.
|
||||
func (cfg Config) Validate() error {
|
||||
switch {
|
||||
case cfg.Addr == "":
|
||||
return errors.New("internal HTTP addr must not be empty")
|
||||
case cfg.ReadHeaderTimeout <= 0:
|
||||
return errors.New("internal HTTP read header timeout must be positive")
|
||||
case cfg.ReadTimeout <= 0:
|
||||
return errors.New("internal HTTP read timeout must be positive")
|
||||
case cfg.IdleTimeout <= 0:
|
||||
return errors.New("internal HTTP idle timeout must be positive")
|
||||
case cfg.RequestTimeout <= 0:
|
||||
return errors.New("internal HTTP request timeout must be positive")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Dependencies describes the collaborators used by the trusted internal HTTP
|
||||
// transport layer.
|
||||
type Dependencies struct {
|
||||
// ResolveByEmail executes the auth-facing resolve-by-email use case.
|
||||
ResolveByEmail ResolveByEmailUseCase
|
||||
|
||||
// EnsureByEmail executes the auth-facing ensure-by-email use case.
|
||||
EnsureByEmail EnsureByEmailUseCase
|
||||
|
||||
// ExistsByUserID executes the auth-facing exists-by-user-id use case.
|
||||
ExistsByUserID ExistsByUserIDUseCase
|
||||
|
||||
// BlockByUserID executes the auth-facing block-by-user-id use case.
|
||||
BlockByUserID BlockByUserIDUseCase
|
||||
|
||||
// BlockByEmail executes the auth-facing block-by-email use case.
|
||||
BlockByEmail BlockByEmailUseCase
|
||||
|
||||
// GetMyAccount executes the self-service authenticated account-read use
|
||||
// case.
|
||||
GetMyAccount GetMyAccountUseCase
|
||||
|
||||
// UpdateMyProfile executes the self-service profile-mutation use case.
|
||||
UpdateMyProfile UpdateMyProfileUseCase
|
||||
|
||||
// UpdateMySettings executes the self-service settings-mutation use case.
|
||||
UpdateMySettings UpdateMySettingsUseCase
|
||||
|
||||
// GetUserByID executes the trusted admin exact-read by stable user id.
|
||||
GetUserByID GetUserByIDUseCase
|
||||
|
||||
// GetUserByEmail executes the trusted admin exact-read by normalized
|
||||
// e-mail.
|
||||
GetUserByEmail GetUserByEmailUseCase
|
||||
|
||||
// GetUserByRaceName executes the trusted admin exact-read by exact stored
|
||||
// race name.
|
||||
GetUserByRaceName GetUserByRaceNameUseCase
|
||||
|
||||
// ListUsers executes the trusted admin paginated filtered listing use case.
|
||||
ListUsers ListUsersUseCase
|
||||
|
||||
// GetUserEligibility executes the trusted lobby-facing eligibility snapshot
|
||||
// read.
|
||||
GetUserEligibility GetUserEligibilityUseCase
|
||||
|
||||
// SyncDeclaredCountry executes the trusted geo-facing declared-country sync
|
||||
// command.
|
||||
SyncDeclaredCountry SyncDeclaredCountryUseCase
|
||||
|
||||
// GrantEntitlement executes the trusted entitlement-grant use case.
|
||||
GrantEntitlement GrantEntitlementUseCase
|
||||
|
||||
// ExtendEntitlement executes the trusted entitlement-extend use case.
|
||||
ExtendEntitlement ExtendEntitlementUseCase
|
||||
|
||||
// RevokeEntitlement executes the trusted entitlement-revoke use case.
|
||||
RevokeEntitlement RevokeEntitlementUseCase
|
||||
|
||||
// ApplySanction executes the trusted sanction-apply use case.
|
||||
ApplySanction ApplySanctionUseCase
|
||||
|
||||
// RemoveSanction executes the trusted sanction-remove use case.
|
||||
RemoveSanction RemoveSanctionUseCase
|
||||
|
||||
// SetLimit executes the trusted limit-set use case.
|
||||
SetLimit SetLimitUseCase
|
||||
|
||||
// RemoveLimit executes the trusted limit-remove use case.
|
||||
RemoveLimit RemoveLimitUseCase
|
||||
|
||||
// Logger writes structured transport logs. When nil, the default logger is
|
||||
// used.
|
||||
Logger *slog.Logger
|
||||
|
||||
// Telemetry records OpenTelemetry spans and low-cardinality HTTP metrics.
|
||||
Telemetry *telemetry.Runtime
|
||||
}
|
||||
|
||||
// Server owns the trusted internal HTTP listener exposed by the user service.
|
||||
type Server struct {
|
||||
cfg Config
|
||||
|
||||
handler http.Handler
|
||||
logger *slog.Logger
|
||||
|
||||
stateMu sync.RWMutex
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
}
|
||||
|
||||
// NewServer constructs one trusted internal HTTP server for cfg and deps.
|
||||
func NewServer(cfg Config, deps Dependencies) (*Server, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("new internal HTTP server: %w", err)
|
||||
}
|
||||
|
||||
handler, err := newHandlerWithConfig(cfg, deps)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new internal HTTP server: %w", err)
|
||||
}
|
||||
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
handler: handler,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Run binds the configured listener and serves the trusted internal HTTP
|
||||
// surface until ctx is cancelled or Shutdown closes the server.
|
||||
func (server *Server) Run(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("run internal HTTP server: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", server.cfg.Addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("run internal HTTP server: listen on %q: %w", server.cfg.Addr, err)
|
||||
}
|
||||
|
||||
httpServer := &http.Server{
|
||||
Handler: server.handler,
|
||||
ReadHeaderTimeout: server.cfg.ReadHeaderTimeout,
|
||||
ReadTimeout: server.cfg.ReadTimeout,
|
||||
IdleTimeout: server.cfg.IdleTimeout,
|
||||
}
|
||||
|
||||
server.stateMu.Lock()
|
||||
server.server = httpServer
|
||||
server.listener = listener
|
||||
server.stateMu.Unlock()
|
||||
|
||||
server.logger.Info("internal HTTP server started", "addr", listener.Addr().String())
|
||||
|
||||
shutdownDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(shutdownDone)
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), server.cfg.RequestTimeout)
|
||||
defer cancel()
|
||||
_ = server.Shutdown(shutdownCtx)
|
||||
}()
|
||||
|
||||
defer func() {
|
||||
server.stateMu.Lock()
|
||||
server.server = nil
|
||||
server.listener = nil
|
||||
server.stateMu.Unlock()
|
||||
<-shutdownDone
|
||||
}()
|
||||
|
||||
err = httpServer.Serve(listener)
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, http.ErrServerClosed):
|
||||
server.logger.Info("internal HTTP server stopped")
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("run internal HTTP server: serve on %q: %w", server.cfg.Addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown gracefully stops the internal HTTP server within ctx.
|
||||
func (server *Server) Shutdown(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("shutdown internal HTTP server: nil context")
|
||||
}
|
||||
|
||||
server.stateMu.RLock()
|
||||
httpServer := server.server
|
||||
server.stateMu.RUnlock()
|
||||
|
||||
if httpServer == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := httpServer.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return fmt.Errorf("shutdown internal HTTP server: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
// Package app wires the runnable user-service process.
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"galaxy/user/internal/adapters/local"
|
||||
"galaxy/user/internal/adapters/redis/domainevents"
|
||||
"galaxy/user/internal/adapters/redis/userstore"
|
||||
"galaxy/user/internal/adminapi"
|
||||
"galaxy/user/internal/api/internalhttp"
|
||||
"galaxy/user/internal/config"
|
||||
"galaxy/user/internal/service/adminusers"
|
||||
"galaxy/user/internal/service/authdirectory"
|
||||
"galaxy/user/internal/service/entitlementsvc"
|
||||
"galaxy/user/internal/service/geosync"
|
||||
"galaxy/user/internal/service/lobbyeligibility"
|
||||
"galaxy/user/internal/service/policysvc"
|
||||
"galaxy/user/internal/service/selfservice"
|
||||
"galaxy/user/internal/telemetry"
|
||||
)
|
||||
|
||||
type pinger interface {
|
||||
Ping(context.Context) error
|
||||
}
|
||||
|
||||
type closer interface {
|
||||
Close() error
|
||||
}
|
||||
|
||||
// Runtime owns the runnable user-service process plus the cleanup functions
|
||||
// that release runtime resources after shutdown.
|
||||
type Runtime struct {
|
||||
cfg config.Config
|
||||
logger *slog.Logger
|
||||
|
||||
// Server owns the internal HTTP listener exposed by the user service.
|
||||
Server *internalhttp.Server
|
||||
|
||||
// AdminServer owns the optional private admin HTTP listener.
|
||||
AdminServer *adminapi.Server
|
||||
|
||||
// Telemetry owns the process-wide OpenTelemetry providers and Prometheus
|
||||
// handler.
|
||||
Telemetry *telemetry.Runtime
|
||||
|
||||
cleanupFns []func() error
|
||||
}
|
||||
|
||||
// NewRuntime constructs the runnable user-service process from cfg.
|
||||
func NewRuntime(ctx context.Context, cfg config.Config, logger *slog.Logger) (*Runtime, error) {
|
||||
if ctx == nil {
|
||||
return nil, fmt.Errorf("new user-service runtime: nil context")
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("new user-service runtime: %w", err)
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
runtime := &Runtime{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
}
|
||||
cleanupOnError := func(err error) (*Runtime, error) {
|
||||
return nil, fmt.Errorf("%w; cleanup: %w", err, runtime.Close())
|
||||
}
|
||||
|
||||
telemetryRuntime, err := telemetry.NewProcess(ctx, telemetry.ProcessConfig{
|
||||
ServiceName: cfg.Telemetry.ServiceName,
|
||||
TracesExporter: cfg.Telemetry.TracesExporter,
|
||||
MetricsExporter: cfg.Telemetry.MetricsExporter,
|
||||
TracesProtocol: cfg.Telemetry.TracesProtocol,
|
||||
MetricsProtocol: cfg.Telemetry.MetricsProtocol,
|
||||
StdoutTracesEnabled: cfg.Telemetry.StdoutTracesEnabled,
|
||||
StdoutMetricsEnabled: cfg.Telemetry.StdoutMetricsEnabled,
|
||||
}, logger.With("component", "telemetry"))
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: telemetry runtime: %w", err))
|
||||
}
|
||||
runtime.Telemetry = telemetryRuntime
|
||||
runtime.cleanupFns = append(runtime.cleanupFns, func() error {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
|
||||
defer cancel()
|
||||
return telemetryRuntime.Shutdown(shutdownCtx)
|
||||
})
|
||||
|
||||
store, err := userstore.New(userstore.Config{
|
||||
Addr: cfg.Redis.Addr,
|
||||
Username: cfg.Redis.Username,
|
||||
Password: cfg.Redis.Password,
|
||||
DB: cfg.Redis.DB,
|
||||
TLSEnabled: cfg.Redis.TLSEnabled,
|
||||
KeyspacePrefix: cfg.Redis.KeyspacePrefix,
|
||||
OperationTimeout: cfg.Redis.OperationTimeout,
|
||||
})
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: redis user store: %w", err))
|
||||
}
|
||||
runtime.cleanupFns = append(runtime.cleanupFns, store.Close)
|
||||
|
||||
if err := pingDependency(ctx, "redis user store", store); err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: %w", err))
|
||||
}
|
||||
|
||||
domainEventPublisher, err := domainevents.New(domainevents.Config{
|
||||
Addr: cfg.Redis.Addr,
|
||||
Username: cfg.Redis.Username,
|
||||
Password: cfg.Redis.Password,
|
||||
DB: cfg.Redis.DB,
|
||||
TLSEnabled: cfg.Redis.TLSEnabled,
|
||||
Stream: cfg.Redis.DomainEventsStream,
|
||||
StreamMaxLen: cfg.Redis.DomainEventsStreamMaxLen,
|
||||
OperationTimeout: cfg.Redis.OperationTimeout,
|
||||
})
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: redis domain-event publisher: %w", err))
|
||||
}
|
||||
runtime.cleanupFns = append(runtime.cleanupFns, domainEventPublisher.Close)
|
||||
|
||||
if err := pingDependency(ctx, "redis domain-event publisher", domainEventPublisher); err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: %w", err))
|
||||
}
|
||||
|
||||
clock := local.Clock{}
|
||||
idGenerator := local.IDGenerator{}
|
||||
raceNamePolicy, err := local.NewRaceNamePolicy()
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: race-name policy: %w", err))
|
||||
}
|
||||
|
||||
componentLogger := func(component string) *slog.Logger {
|
||||
return logger.With("component", component)
|
||||
}
|
||||
|
||||
resolver, err := authdirectory.NewResolverWithObservability(store, componentLogger("authdirectory"), telemetryRuntime)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: resolver: %w", err))
|
||||
}
|
||||
ensurer, err := authdirectory.NewEnsurerWithObservability(
|
||||
store,
|
||||
clock,
|
||||
idGenerator,
|
||||
raceNamePolicy,
|
||||
componentLogger("authdirectory"),
|
||||
telemetryRuntime,
|
||||
domainEventPublisher,
|
||||
domainEventPublisher,
|
||||
domainEventPublisher,
|
||||
)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: ensurer: %w", err))
|
||||
}
|
||||
existenceChecker, err := authdirectory.NewExistenceChecker(store)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: existence checker: %w", err))
|
||||
}
|
||||
blockByUserID, err := authdirectory.NewBlockByUserIDService(store, clock)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: block-by-user-id service: %w", err))
|
||||
}
|
||||
blockByEmail, err := authdirectory.NewBlockByEmailService(store, clock)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: block-by-email service: %w", err))
|
||||
}
|
||||
entitlementReader, err := entitlementsvc.NewReaderWithObservability(
|
||||
store.EntitlementSnapshots(),
|
||||
store.EntitlementLifecycle(),
|
||||
clock,
|
||||
idGenerator,
|
||||
componentLogger("entitlementsvc"),
|
||||
telemetryRuntime,
|
||||
domainEventPublisher,
|
||||
)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: entitlement reader: %w", err))
|
||||
}
|
||||
grantEntitlement, err := entitlementsvc.NewGrantServiceWithObservability(
|
||||
store.Accounts(),
|
||||
store.EntitlementHistory(),
|
||||
entitlementReader,
|
||||
store.EntitlementLifecycle(),
|
||||
clock,
|
||||
idGenerator,
|
||||
componentLogger("entitlementsvc"),
|
||||
telemetryRuntime,
|
||||
domainEventPublisher,
|
||||
)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: grant entitlement service: %w", err))
|
||||
}
|
||||
extendEntitlement, err := entitlementsvc.NewExtendServiceWithObservability(
|
||||
store.Accounts(),
|
||||
store.EntitlementHistory(),
|
||||
entitlementReader,
|
||||
store.EntitlementLifecycle(),
|
||||
clock,
|
||||
idGenerator,
|
||||
componentLogger("entitlementsvc"),
|
||||
telemetryRuntime,
|
||||
domainEventPublisher,
|
||||
)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: extend entitlement service: %w", err))
|
||||
}
|
||||
revokeEntitlement, err := entitlementsvc.NewRevokeServiceWithObservability(
|
||||
store.Accounts(),
|
||||
store.EntitlementHistory(),
|
||||
entitlementReader,
|
||||
store.EntitlementLifecycle(),
|
||||
clock,
|
||||
idGenerator,
|
||||
componentLogger("entitlementsvc"),
|
||||
telemetryRuntime,
|
||||
domainEventPublisher,
|
||||
)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: revoke entitlement service: %w", err))
|
||||
}
|
||||
accountGetter, err := selfservice.NewAccountGetter(store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), clock)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: account getter: %w", err))
|
||||
}
|
||||
profileUpdater, err := selfservice.NewProfileUpdaterWithObservability(
|
||||
store.Accounts(),
|
||||
entitlementReader,
|
||||
store.Sanctions(),
|
||||
store.Limits(),
|
||||
clock,
|
||||
raceNamePolicy,
|
||||
componentLogger("selfservice"),
|
||||
telemetryRuntime,
|
||||
domainEventPublisher,
|
||||
)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: profile updater: %w", err))
|
||||
}
|
||||
settingsUpdater, err := selfservice.NewSettingsUpdaterWithObservability(
|
||||
store.Accounts(),
|
||||
entitlementReader,
|
||||
store.Sanctions(),
|
||||
store.Limits(),
|
||||
clock,
|
||||
componentLogger("selfservice"),
|
||||
telemetryRuntime,
|
||||
domainEventPublisher,
|
||||
)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: settings updater: %w", err))
|
||||
}
|
||||
getUserByID, err := adminusers.NewByIDGetter(store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), clock)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: admin get-user-by-id: %w", err))
|
||||
}
|
||||
getUserByEmail, err := adminusers.NewByEmailGetter(store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), clock)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: admin get-user-by-email: %w", err))
|
||||
}
|
||||
getUserByRaceName, err := adminusers.NewByRaceNameGetter(store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), clock)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: admin get-user-by-race-name: %w", err))
|
||||
}
|
||||
listUsers, err := adminusers.NewLister(store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), clock, store)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: admin list-users: %w", err))
|
||||
}
|
||||
userEligibility, err := lobbyeligibility.NewSnapshotReader(store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), clock)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: lobby eligibility snapshot reader: %w", err))
|
||||
}
|
||||
syncDeclaredCountry, err := geosync.NewSyncServiceWithObservability(
|
||||
store.Accounts(),
|
||||
clock,
|
||||
domainEventPublisher,
|
||||
componentLogger("geosync"),
|
||||
telemetryRuntime,
|
||||
)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: geo declared-country sync service: %w", err))
|
||||
}
|
||||
applySanction, err := policysvc.NewApplySanctionServiceWithObservability(
|
||||
store.Accounts(),
|
||||
store.Sanctions(),
|
||||
store.Limits(),
|
||||
store.PolicyLifecycle(),
|
||||
clock,
|
||||
idGenerator,
|
||||
componentLogger("policysvc"),
|
||||
telemetryRuntime,
|
||||
domainEventPublisher,
|
||||
)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: apply sanction service: %w", err))
|
||||
}
|
||||
removeSanction, err := policysvc.NewRemoveSanctionServiceWithObservability(
|
||||
store.Accounts(),
|
||||
store.Sanctions(),
|
||||
store.Limits(),
|
||||
store.PolicyLifecycle(),
|
||||
clock,
|
||||
idGenerator,
|
||||
componentLogger("policysvc"),
|
||||
telemetryRuntime,
|
||||
domainEventPublisher,
|
||||
)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: remove sanction service: %w", err))
|
||||
}
|
||||
setLimit, err := policysvc.NewSetLimitServiceWithObservability(
|
||||
store.Accounts(),
|
||||
store.Sanctions(),
|
||||
store.Limits(),
|
||||
store.PolicyLifecycle(),
|
||||
clock,
|
||||
idGenerator,
|
||||
componentLogger("policysvc"),
|
||||
telemetryRuntime,
|
||||
domainEventPublisher,
|
||||
)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: set limit service: %w", err))
|
||||
}
|
||||
removeLimit, err := policysvc.NewRemoveLimitServiceWithObservability(
|
||||
store.Accounts(),
|
||||
store.Sanctions(),
|
||||
store.Limits(),
|
||||
store.PolicyLifecycle(),
|
||||
clock,
|
||||
idGenerator,
|
||||
componentLogger("policysvc"),
|
||||
telemetryRuntime,
|
||||
domainEventPublisher,
|
||||
)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: remove limit service: %w", err))
|
||||
}
|
||||
|
||||
server, err := internalhttp.NewServer(internalhttp.Config{
|
||||
Addr: cfg.InternalHTTP.Addr,
|
||||
ReadHeaderTimeout: cfg.InternalHTTP.ReadHeaderTimeout,
|
||||
ReadTimeout: cfg.InternalHTTP.ReadTimeout,
|
||||
IdleTimeout: cfg.InternalHTTP.IdleTimeout,
|
||||
RequestTimeout: cfg.InternalHTTP.RequestTimeout,
|
||||
}, internalhttp.Dependencies{
|
||||
ResolveByEmail: resolver,
|
||||
EnsureByEmail: ensurer,
|
||||
ExistsByUserID: existenceChecker,
|
||||
BlockByUserID: blockByUserID,
|
||||
BlockByEmail: blockByEmail,
|
||||
GetMyAccount: accountGetter,
|
||||
UpdateMyProfile: profileUpdater,
|
||||
UpdateMySettings: settingsUpdater,
|
||||
GetUserByID: getUserByID,
|
||||
GetUserByEmail: getUserByEmail,
|
||||
GetUserByRaceName: getUserByRaceName,
|
||||
ListUsers: listUsers,
|
||||
GetUserEligibility: userEligibility,
|
||||
SyncDeclaredCountry: syncDeclaredCountry,
|
||||
GrantEntitlement: grantEntitlement,
|
||||
ExtendEntitlement: extendEntitlement,
|
||||
RevokeEntitlement: revokeEntitlement,
|
||||
ApplySanction: applySanction,
|
||||
RemoveSanction: removeSanction,
|
||||
SetLimit: setLimit,
|
||||
RemoveLimit: removeLimit,
|
||||
Logger: logger.With("component", "internal_http"),
|
||||
Telemetry: telemetryRuntime,
|
||||
})
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new user-service runtime: internal HTTP server: %w", err))
|
||||
}
|
||||
|
||||
adminServer := adminapi.NewServer(cfg.AdminHTTP, telemetryRuntime.Handler(), logger)
|
||||
|
||||
runtime.Server = server
|
||||
runtime.AdminServer = adminServer
|
||||
return runtime, nil
|
||||
}
|
||||
|
||||
// Run serves the internal and admin HTTP listeners until ctx is canceled or a
|
||||
// listener fails.
|
||||
func (runtime *Runtime) Run(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("run user-service runtime: nil context")
|
||||
}
|
||||
if runtime == nil {
|
||||
return errors.New("run user-service runtime: nil runtime")
|
||||
}
|
||||
if runtime.Server == nil {
|
||||
return errors.New("run user-service runtime: nil internal HTTP server")
|
||||
}
|
||||
if runtime.AdminServer == nil {
|
||||
return errors.New("run user-service runtime: nil admin HTTP server")
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
shutdownMu sync.Mutex
|
||||
shutdownDone bool
|
||||
shutdownErr error
|
||||
)
|
||||
shutdownServers := func() {
|
||||
shutdownMu.Lock()
|
||||
defer shutdownMu.Unlock()
|
||||
if shutdownDone {
|
||||
return
|
||||
}
|
||||
shutdownDone = true
|
||||
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), runtime.cfg.ShutdownTimeout)
|
||||
defer shutdownCancel()
|
||||
shutdownErr = errors.Join(
|
||||
runtime.Server.Shutdown(shutdownCtx),
|
||||
runtime.AdminServer.Shutdown(shutdownCtx),
|
||||
)
|
||||
}
|
||||
|
||||
errCh := make(chan error, 2)
|
||||
runServer := func(name string, serve func(context.Context) error) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := serve(runCtx); err != nil {
|
||||
select {
|
||||
case errCh <- fmt.Errorf("%s: %w", name, err):
|
||||
default:
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
runServer("internal HTTP server", runtime.Server.Run)
|
||||
runServer("admin HTTP server", runtime.AdminServer.Run)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
<-runCtx.Done()
|
||||
shutdownServers()
|
||||
wg.Wait()
|
||||
}()
|
||||
|
||||
var runErr error
|
||||
select {
|
||||
case runErr = <-errCh:
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
cancel()
|
||||
case <-done:
|
||||
}
|
||||
|
||||
<-done
|
||||
return errors.Join(runErr, shutdownErr)
|
||||
}
|
||||
|
||||
// Close releases every runtime dependency in reverse construction order.
|
||||
func (runtime *Runtime) Close() error {
|
||||
if runtime == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var messages []string
|
||||
for index := len(runtime.cleanupFns) - 1; index >= 0; index-- {
|
||||
if err := runtime.cleanupFns[index](); err != nil {
|
||||
messages = append(messages, err.Error())
|
||||
}
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.New(strings.Join(messages, "; "))
|
||||
}
|
||||
|
||||
func pingDependency(ctx context.Context, name string, dependency pinger) error {
|
||||
if err := dependency.Ping(ctx); err != nil {
|
||||
return fmt.Errorf("ping %s: %w", name, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ closer = (*userstore.Store)(nil)
|
||||
@@ -0,0 +1,551 @@
|
||||
// Package config loads the user-service process configuration from environment
|
||||
// variables.
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
shutdownTimeoutEnvVar = "USERSERVICE_SHUTDOWN_TIMEOUT"
|
||||
logLevelEnvVar = "USERSERVICE_LOG_LEVEL"
|
||||
|
||||
internalHTTPAddrEnvVar = "USERSERVICE_INTERNAL_HTTP_ADDR"
|
||||
internalHTTPReadHeaderTimeoutEnvVar = "USERSERVICE_INTERNAL_HTTP_READ_HEADER_TIMEOUT"
|
||||
internalHTTPReadTimeoutEnvVar = "USERSERVICE_INTERNAL_HTTP_READ_TIMEOUT"
|
||||
internalHTTPIdleTimeoutEnvVar = "USERSERVICE_INTERNAL_HTTP_IDLE_TIMEOUT"
|
||||
internalHTTPRequestTimeoutEnvVar = "USERSERVICE_INTERNAL_HTTP_REQUEST_TIMEOUT"
|
||||
|
||||
adminHTTPAddrEnvVar = "USERSERVICE_ADMIN_HTTP_ADDR"
|
||||
adminHTTPReadHeaderTimeoutEnvVar = "USERSERVICE_ADMIN_HTTP_READ_HEADER_TIMEOUT"
|
||||
adminHTTPReadTimeoutEnvVar = "USERSERVICE_ADMIN_HTTP_READ_TIMEOUT"
|
||||
adminHTTPIdleTimeoutEnvVar = "USERSERVICE_ADMIN_HTTP_IDLE_TIMEOUT"
|
||||
|
||||
redisAddrEnvVar = "USERSERVICE_REDIS_ADDR"
|
||||
redisUsernameEnvVar = "USERSERVICE_REDIS_USERNAME"
|
||||
redisPasswordEnvVar = "USERSERVICE_REDIS_PASSWORD"
|
||||
redisDBEnvVar = "USERSERVICE_REDIS_DB"
|
||||
redisTLSEnabledEnvVar = "USERSERVICE_REDIS_TLS_ENABLED"
|
||||
redisOperationTimeoutEnvVar = "USERSERVICE_REDIS_OPERATION_TIMEOUT"
|
||||
redisKeyspacePrefixEnvVar = "USERSERVICE_REDIS_KEYSPACE_PREFIX"
|
||||
redisDomainEventsStreamEnvVar = "USERSERVICE_REDIS_DOMAIN_EVENTS_STREAM"
|
||||
redisDomainEventsStreamMaxLenEnvVar = "USERSERVICE_REDIS_DOMAIN_EVENTS_STREAM_MAX_LEN"
|
||||
|
||||
otelServiceNameEnvVar = "OTEL_SERVICE_NAME"
|
||||
otelTracesExporterEnvVar = "OTEL_TRACES_EXPORTER"
|
||||
otelMetricsExporterEnvVar = "OTEL_METRICS_EXPORTER"
|
||||
otelExporterOTLPProtocolEnvVar = "OTEL_EXPORTER_OTLP_PROTOCOL"
|
||||
otelExporterOTLPTracesProtocolEnvVar = "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL"
|
||||
otelExporterOTLPMetricsProtocolEnvVar = "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL"
|
||||
otelStdoutTracesEnabledEnvVar = "USERSERVICE_OTEL_STDOUT_TRACES_ENABLED"
|
||||
otelStdoutMetricsEnabledEnvVar = "USERSERVICE_OTEL_STDOUT_METRICS_ENABLED"
|
||||
|
||||
defaultShutdownTimeout = 5 * time.Second
|
||||
defaultLogLevel = "info"
|
||||
defaultInternalHTTPAddr = ":8091"
|
||||
defaultAdminHTTPAddr = ""
|
||||
defaultReadHeaderTimeout = 2 * time.Second
|
||||
defaultReadTimeout = 10 * time.Second
|
||||
defaultIdleTimeout = time.Minute
|
||||
defaultRequestTimeout = 3 * time.Second
|
||||
defaultRedisDB = 0
|
||||
defaultRedisOperationTimeout = 250 * time.Millisecond
|
||||
defaultRedisKeyspacePrefix = "user:"
|
||||
defaultDomainEventsStream = "user:domain_events"
|
||||
defaultDomainEventsStreamMaxLen = 1024
|
||||
defaultOTelServiceName = "galaxy-user"
|
||||
otelExporterNone = "none"
|
||||
otelExporterOTLP = "otlp"
|
||||
otelProtocolHTTPProtobuf = "http/protobuf"
|
||||
otelProtocolGRPC = "grpc"
|
||||
)
|
||||
|
||||
// Config stores the full user-service process configuration.
|
||||
type Config struct {
|
||||
// ShutdownTimeout bounds graceful shutdown of the long-lived listeners and
|
||||
// runtime resources.
|
||||
ShutdownTimeout time.Duration
|
||||
|
||||
// Logging configures the process-wide logger.
|
||||
Logging LoggingConfig
|
||||
|
||||
// InternalHTTP configures the trusted internal HTTP listener.
|
||||
InternalHTTP InternalHTTPConfig
|
||||
|
||||
// AdminHTTP configures the optional private admin HTTP listener.
|
||||
AdminHTTP AdminHTTPConfig
|
||||
|
||||
// Redis configures the Redis-backed user store and domain-event publisher.
|
||||
Redis RedisConfig
|
||||
|
||||
// Telemetry configures the process-wide OpenTelemetry runtime.
|
||||
Telemetry TelemetryConfig
|
||||
}
|
||||
|
||||
// LoggingConfig configures the process-wide logger.
|
||||
type LoggingConfig struct {
|
||||
// Level stores the process log level.
|
||||
Level string
|
||||
}
|
||||
|
||||
// InternalHTTPConfig configures the internal HTTP listener.
|
||||
type InternalHTTPConfig struct {
|
||||
// Addr stores the TCP listen address.
|
||||
Addr string
|
||||
|
||||
// ReadHeaderTimeout bounds request-header reading.
|
||||
ReadHeaderTimeout time.Duration
|
||||
|
||||
// ReadTimeout bounds reading one request.
|
||||
ReadTimeout time.Duration
|
||||
|
||||
// IdleTimeout bounds how long keep-alive connections stay open.
|
||||
IdleTimeout time.Duration
|
||||
|
||||
// RequestTimeout bounds one application-layer request execution.
|
||||
RequestTimeout time.Duration
|
||||
}
|
||||
|
||||
// Validate reports whether cfg stores a usable internal HTTP listener
|
||||
// configuration.
|
||||
func (cfg InternalHTTPConfig) Validate() error {
|
||||
switch {
|
||||
case strings.TrimSpace(cfg.Addr) == "":
|
||||
return fmt.Errorf("internal HTTP addr must not be empty")
|
||||
case cfg.ReadHeaderTimeout <= 0:
|
||||
return fmt.Errorf("internal HTTP read header timeout must be positive")
|
||||
case cfg.ReadTimeout <= 0:
|
||||
return fmt.Errorf("internal HTTP read timeout must be positive")
|
||||
case cfg.IdleTimeout <= 0:
|
||||
return fmt.Errorf("internal HTTP idle timeout must be positive")
|
||||
case cfg.RequestTimeout <= 0:
|
||||
return fmt.Errorf("internal HTTP request timeout must be positive")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// AdminHTTPConfig describes the private operational HTTP listener used for
|
||||
// Prometheus metrics exposure. The listener remains disabled when Addr is
|
||||
// empty.
|
||||
type AdminHTTPConfig struct {
|
||||
// Addr stores the TCP listen address used by the admin HTTP server.
|
||||
Addr string
|
||||
|
||||
// ReadHeaderTimeout bounds request-header reading.
|
||||
ReadHeaderTimeout time.Duration
|
||||
|
||||
// ReadTimeout bounds reading one request.
|
||||
ReadTimeout time.Duration
|
||||
|
||||
// IdleTimeout bounds how long keep-alive connections stay open.
|
||||
IdleTimeout time.Duration
|
||||
}
|
||||
|
||||
// Validate reports whether cfg stores a usable optional admin HTTP listener
|
||||
// configuration.
|
||||
func (cfg AdminHTTPConfig) Validate() error {
|
||||
if strings.TrimSpace(cfg.Addr) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case cfg.ReadHeaderTimeout <= 0:
|
||||
return fmt.Errorf("admin HTTP read header timeout must be positive")
|
||||
case cfg.ReadTimeout <= 0:
|
||||
return fmt.Errorf("admin HTTP read timeout must be positive")
|
||||
case cfg.IdleTimeout <= 0:
|
||||
return fmt.Errorf("admin HTTP idle timeout must be positive")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// RedisConfig configures the Redis-backed store and domain-event publisher.
|
||||
type RedisConfig struct {
|
||||
// Addr stores the Redis network address.
|
||||
Addr string
|
||||
|
||||
// Username stores the optional Redis ACL username.
|
||||
Username string
|
||||
|
||||
// Password stores the optional Redis ACL password.
|
||||
Password string
|
||||
|
||||
// DB stores the Redis logical database index.
|
||||
DB int
|
||||
|
||||
// TLSEnabled reports whether TLS must be used for Redis connections.
|
||||
TLSEnabled bool
|
||||
|
||||
// OperationTimeout bounds one Redis round trip.
|
||||
OperationTimeout time.Duration
|
||||
|
||||
// KeyspacePrefix stores the root prefix of the service-owned Redis keyspace.
|
||||
KeyspacePrefix string
|
||||
|
||||
// DomainEventsStream stores the Redis Stream key used for auxiliary
|
||||
// post-commit domain events.
|
||||
DomainEventsStream string
|
||||
|
||||
// DomainEventsStreamMaxLen bounds the domain-events Redis Stream with
|
||||
// approximate trimming.
|
||||
DomainEventsStreamMaxLen int64
|
||||
}
|
||||
|
||||
// TLSConfig returns the conservative TLS configuration used by Redis adapters
|
||||
// when TLSEnabled is true.
|
||||
func (cfg RedisConfig) TLSConfig() *tls.Config {
|
||||
if !cfg.TLSEnabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
}
|
||||
|
||||
// Validate reports whether cfg stores a usable Redis configuration.
|
||||
func (cfg RedisConfig) Validate() error {
|
||||
switch {
|
||||
case strings.TrimSpace(cfg.Addr) == "":
|
||||
return fmt.Errorf("redis addr must not be empty")
|
||||
case cfg.DB < 0:
|
||||
return fmt.Errorf("redis db must not be negative")
|
||||
case cfg.OperationTimeout <= 0:
|
||||
return fmt.Errorf("redis operation timeout must be positive")
|
||||
case strings.TrimSpace(cfg.KeyspacePrefix) == "":
|
||||
return fmt.Errorf("redis keyspace prefix must not be empty")
|
||||
case strings.TrimSpace(cfg.DomainEventsStream) == "":
|
||||
return fmt.Errorf("redis domain events stream must not be empty")
|
||||
case cfg.DomainEventsStreamMaxLen <= 0:
|
||||
return fmt.Errorf("redis domain events stream max len must be positive")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// TelemetryConfig configures the user-service OpenTelemetry runtime.
|
||||
type TelemetryConfig struct {
|
||||
// ServiceName overrides the default OpenTelemetry service name.
|
||||
ServiceName string
|
||||
|
||||
// TracesExporter selects the external traces exporter. Supported values are
|
||||
// `none` and `otlp`.
|
||||
TracesExporter string
|
||||
|
||||
// MetricsExporter selects the external metrics exporter. Supported values
|
||||
// are `none` and `otlp`.
|
||||
MetricsExporter string
|
||||
|
||||
// TracesProtocol selects the OTLP traces protocol when TracesExporter is
|
||||
// `otlp`.
|
||||
TracesProtocol string
|
||||
|
||||
// MetricsProtocol selects the OTLP metrics protocol when MetricsExporter is
|
||||
// `otlp`.
|
||||
MetricsProtocol string
|
||||
|
||||
// StdoutTracesEnabled enables the additional stdout trace exporter used for
|
||||
// local development and debugging.
|
||||
StdoutTracesEnabled bool
|
||||
|
||||
// StdoutMetricsEnabled enables the additional stdout metric exporter used
|
||||
// for local development and debugging.
|
||||
StdoutMetricsEnabled bool
|
||||
}
|
||||
|
||||
// Validate reports whether cfg contains a supported OpenTelemetry exporter
|
||||
// configuration.
|
||||
func (cfg TelemetryConfig) Validate() error {
|
||||
switch cfg.TracesExporter {
|
||||
case otelExporterNone, otelExporterOTLP:
|
||||
default:
|
||||
return fmt.Errorf("%s %q is unsupported", otelTracesExporterEnvVar, cfg.TracesExporter)
|
||||
}
|
||||
|
||||
switch cfg.MetricsExporter {
|
||||
case otelExporterNone, otelExporterOTLP:
|
||||
default:
|
||||
return fmt.Errorf("%s %q is unsupported", otelMetricsExporterEnvVar, cfg.MetricsExporter)
|
||||
}
|
||||
|
||||
if cfg.TracesProtocol != "" && cfg.TracesProtocol != otelProtocolHTTPProtobuf && cfg.TracesProtocol != otelProtocolGRPC {
|
||||
return fmt.Errorf("%s %q is unsupported", otelExporterOTLPTracesProtocolEnvVar, cfg.TracesProtocol)
|
||||
}
|
||||
if cfg.MetricsProtocol != "" && cfg.MetricsProtocol != otelProtocolHTTPProtobuf && cfg.MetricsProtocol != otelProtocolGRPC {
|
||||
return fmt.Errorf("%s %q is unsupported", otelExporterOTLPMetricsProtocolEnvVar, cfg.MetricsProtocol)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultAdminHTTPConfig returns the default settings for the optional private
|
||||
// admin HTTP listener.
|
||||
func DefaultAdminHTTPConfig() AdminHTTPConfig {
|
||||
return AdminHTTPConfig{
|
||||
Addr: defaultAdminHTTPAddr,
|
||||
ReadHeaderTimeout: defaultReadHeaderTimeout,
|
||||
ReadTimeout: defaultReadTimeout,
|
||||
IdleTimeout: defaultIdleTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultConfig returns the default process configuration with all optional
|
||||
// values filled.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
ShutdownTimeout: defaultShutdownTimeout,
|
||||
Logging: LoggingConfig{
|
||||
Level: defaultLogLevel,
|
||||
},
|
||||
InternalHTTP: InternalHTTPConfig{
|
||||
Addr: defaultInternalHTTPAddr,
|
||||
ReadHeaderTimeout: defaultReadHeaderTimeout,
|
||||
ReadTimeout: defaultReadTimeout,
|
||||
IdleTimeout: defaultIdleTimeout,
|
||||
RequestTimeout: defaultRequestTimeout,
|
||||
},
|
||||
AdminHTTP: DefaultAdminHTTPConfig(),
|
||||
Redis: RedisConfig{
|
||||
DB: defaultRedisDB,
|
||||
OperationTimeout: defaultRedisOperationTimeout,
|
||||
KeyspacePrefix: defaultRedisKeyspacePrefix,
|
||||
DomainEventsStream: defaultDomainEventsStream,
|
||||
DomainEventsStreamMaxLen: defaultDomainEventsStreamMaxLen,
|
||||
},
|
||||
Telemetry: TelemetryConfig{
|
||||
ServiceName: defaultOTelServiceName,
|
||||
TracesExporter: otelExporterNone,
|
||||
MetricsExporter: otelExporterNone,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Validate reports whether cfg is process-ready.
|
||||
func (cfg Config) Validate() error {
|
||||
switch {
|
||||
case cfg.ShutdownTimeout <= 0:
|
||||
return fmt.Errorf("shutdown timeout must be positive")
|
||||
}
|
||||
if err := cfg.InternalHTTP.Validate(); err != nil {
|
||||
return fmt.Errorf("internal HTTP config: %w", err)
|
||||
}
|
||||
if err := cfg.AdminHTTP.Validate(); err != nil {
|
||||
return fmt.Errorf("admin HTTP config: %w", err)
|
||||
}
|
||||
if err := cfg.Redis.Validate(); err != nil {
|
||||
return fmt.Errorf("redis config: %w", err)
|
||||
}
|
||||
if _, err := parseLogLevel(cfg.Logging.Level); err != nil {
|
||||
return fmt.Errorf("logging config: %w", err)
|
||||
}
|
||||
if err := cfg.Telemetry.Validate(); err != nil {
|
||||
return fmt.Errorf("telemetry config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadFromEnv loads Config from the process environment.
|
||||
func LoadFromEnv() (Config, error) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
var err error
|
||||
cfg.ShutdownTimeout, err = loadDuration(shutdownTimeoutEnvVar, cfg.ShutdownTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Logging.Level = loadString(logLevelEnvVar, cfg.Logging.Level)
|
||||
|
||||
cfg.InternalHTTP.Addr = loadString(internalHTTPAddrEnvVar, cfg.InternalHTTP.Addr)
|
||||
cfg.InternalHTTP.ReadHeaderTimeout, err = loadDuration(internalHTTPReadHeaderTimeoutEnvVar, cfg.InternalHTTP.ReadHeaderTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.InternalHTTP.ReadTimeout, err = loadDuration(internalHTTPReadTimeoutEnvVar, cfg.InternalHTTP.ReadTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.InternalHTTP.IdleTimeout, err = loadDuration(internalHTTPIdleTimeoutEnvVar, cfg.InternalHTTP.IdleTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.InternalHTTP.RequestTimeout, err = loadDuration(internalHTTPRequestTimeoutEnvVar, cfg.InternalHTTP.RequestTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.AdminHTTP.Addr = loadString(adminHTTPAddrEnvVar, cfg.AdminHTTP.Addr)
|
||||
cfg.AdminHTTP.ReadHeaderTimeout, err = loadDuration(adminHTTPReadHeaderTimeoutEnvVar, cfg.AdminHTTP.ReadHeaderTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.AdminHTTP.ReadTimeout, err = loadDuration(adminHTTPReadTimeoutEnvVar, cfg.AdminHTTP.ReadTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.AdminHTTP.IdleTimeout, err = loadDuration(adminHTTPIdleTimeoutEnvVar, cfg.AdminHTTP.IdleTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.Redis.Addr = loadString(redisAddrEnvVar, cfg.Redis.Addr)
|
||||
cfg.Redis.Username = loadString(redisUsernameEnvVar, cfg.Redis.Username)
|
||||
cfg.Redis.Password = loadString(redisPasswordEnvVar, cfg.Redis.Password)
|
||||
cfg.Redis.DB, err = loadInt(redisDBEnvVar, cfg.Redis.DB)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Redis.TLSEnabled, err = loadBool(redisTLSEnabledEnvVar, cfg.Redis.TLSEnabled)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Redis.OperationTimeout, err = loadDuration(redisOperationTimeoutEnvVar, cfg.Redis.OperationTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Redis.KeyspacePrefix = loadString(redisKeyspacePrefixEnvVar, cfg.Redis.KeyspacePrefix)
|
||||
cfg.Redis.DomainEventsStream = loadString(redisDomainEventsStreamEnvVar, cfg.Redis.DomainEventsStream)
|
||||
cfg.Redis.DomainEventsStreamMaxLen, err = loadInt64(redisDomainEventsStreamMaxLenEnvVar, cfg.Redis.DomainEventsStreamMaxLen)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.Telemetry.ServiceName = loadString(otelServiceNameEnvVar, cfg.Telemetry.ServiceName)
|
||||
cfg.Telemetry.TracesExporter = normalizeExporterValue(loadString(otelTracesExporterEnvVar, cfg.Telemetry.TracesExporter))
|
||||
cfg.Telemetry.MetricsExporter = normalizeExporterValue(loadString(otelMetricsExporterEnvVar, cfg.Telemetry.MetricsExporter))
|
||||
cfg.Telemetry.TracesProtocol = loadOTLPProtocol(
|
||||
os.Getenv(otelExporterOTLPTracesProtocolEnvVar),
|
||||
os.Getenv(otelExporterOTLPProtocolEnvVar),
|
||||
cfg.Telemetry.TracesExporter,
|
||||
)
|
||||
cfg.Telemetry.MetricsProtocol = loadOTLPProtocol(
|
||||
os.Getenv(otelExporterOTLPMetricsProtocolEnvVar),
|
||||
os.Getenv(otelExporterOTLPProtocolEnvVar),
|
||||
cfg.Telemetry.MetricsExporter,
|
||||
)
|
||||
cfg.Telemetry.StdoutTracesEnabled, err = loadBool(otelStdoutTracesEnabledEnvVar, cfg.Telemetry.StdoutTracesEnabled)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Telemetry.StdoutMetricsEnabled, err = loadBool(otelStdoutMetricsEnabledEnvVar, cfg.Telemetry.StdoutMetricsEnabled)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func loadString(envName string, defaultValue string) string {
|
||||
value, ok := os.LookupEnv(envName)
|
||||
if !ok {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func loadDuration(envName string, defaultValue time.Duration) (time.Duration, error) {
|
||||
value, ok := os.LookupEnv(envName)
|
||||
if !ok {
|
||||
return defaultValue, nil
|
||||
}
|
||||
|
||||
duration, err := time.ParseDuration(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: parse duration: %w", envName, err)
|
||||
}
|
||||
|
||||
return duration, nil
|
||||
}
|
||||
|
||||
func loadInt(envName string, defaultValue int) (int, error) {
|
||||
value, ok := os.LookupEnv(envName)
|
||||
if !ok {
|
||||
return defaultValue, nil
|
||||
}
|
||||
|
||||
parsedValue, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: parse int: %w", envName, err)
|
||||
}
|
||||
|
||||
return parsedValue, nil
|
||||
}
|
||||
|
||||
func loadInt64(envName string, defaultValue int64) (int64, error) {
|
||||
value, ok := os.LookupEnv(envName)
|
||||
if !ok {
|
||||
return defaultValue, nil
|
||||
}
|
||||
|
||||
parsedValue, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: parse int64: %w", envName, err)
|
||||
}
|
||||
|
||||
return parsedValue, nil
|
||||
}
|
||||
|
||||
func loadBool(envName string, defaultValue bool) (bool, error) {
|
||||
value, ok := os.LookupEnv(envName)
|
||||
if !ok {
|
||||
return defaultValue, nil
|
||||
}
|
||||
|
||||
parsedValue, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%s: parse bool: %w", envName, err)
|
||||
}
|
||||
|
||||
return parsedValue, nil
|
||||
}
|
||||
|
||||
func parseLogLevel(value string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "debug", "info", "warn", "error":
|
||||
return value, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported log level %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeExporterValue(value string) string {
|
||||
switch strings.TrimSpace(value) {
|
||||
case "", otelExporterNone:
|
||||
return otelExporterNone
|
||||
default:
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
|
||||
func loadOTLPProtocol(primary string, fallback string, exporter string) string {
|
||||
protocol := strings.TrimSpace(primary)
|
||||
if protocol == "" {
|
||||
protocol = strings.TrimSpace(fallback)
|
||||
}
|
||||
if protocol == "" && exporter == otelExporterOTLP {
|
||||
return otelProtocolHTTPProtobuf
|
||||
}
|
||||
|
||||
return protocol
|
||||
}
|
||||
|
||||
// ListenAddress returns the resolved listen address used by tests and process
|
||||
// startup.
|
||||
func (cfg InternalHTTPConfig) ListenAddress() string {
|
||||
if strings.HasPrefix(cfg.Addr, ":") {
|
||||
return net.JoinHostPort("", strings.TrimPrefix(cfg.Addr, ":"))
|
||||
}
|
||||
|
||||
return cfg.Addr
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLoadFromEnvUsesDefaults(t *testing.T) {
|
||||
t.Setenv(redisAddrEnvVar, "127.0.0.1:6379")
|
||||
|
||||
cfg, err := LoadFromEnv()
|
||||
require.NoError(t, err)
|
||||
|
||||
defaults := DefaultConfig()
|
||||
require.Equal(t, defaults.ShutdownTimeout, cfg.ShutdownTimeout)
|
||||
require.Equal(t, defaults.Logging.Level, cfg.Logging.Level)
|
||||
require.Equal(t, defaults.InternalHTTP, cfg.InternalHTTP)
|
||||
require.Equal(t, defaults.AdminHTTP, cfg.AdminHTTP)
|
||||
require.Equal(t, "127.0.0.1:6379", cfg.Redis.Addr)
|
||||
require.Equal(t, defaults.Redis.DB, cfg.Redis.DB)
|
||||
require.Equal(t, defaults.Redis.DomainEventsStream, cfg.Redis.DomainEventsStream)
|
||||
require.Equal(t, defaults.Redis.DomainEventsStreamMaxLen, cfg.Redis.DomainEventsStreamMaxLen)
|
||||
require.Equal(t, defaults.Telemetry, cfg.Telemetry)
|
||||
}
|
||||
|
||||
func TestLoadFromEnvAppliesOverrides(t *testing.T) {
|
||||
t.Setenv(shutdownTimeoutEnvVar, "9s")
|
||||
t.Setenv(logLevelEnvVar, "debug")
|
||||
t.Setenv(internalHTTPAddrEnvVar, "127.0.0.1:18091")
|
||||
t.Setenv(internalHTTPReadHeaderTimeoutEnvVar, "3s")
|
||||
t.Setenv(internalHTTPRequestTimeoutEnvVar, "750ms")
|
||||
t.Setenv(adminHTTPAddrEnvVar, "127.0.0.1:19091")
|
||||
t.Setenv(adminHTTPIdleTimeoutEnvVar, "90s")
|
||||
t.Setenv(redisAddrEnvVar, "127.0.0.1:6380")
|
||||
t.Setenv(redisUsernameEnvVar, "alice")
|
||||
t.Setenv(redisPasswordEnvVar, "secret")
|
||||
t.Setenv(redisDBEnvVar, "3")
|
||||
t.Setenv(redisTLSEnabledEnvVar, "true")
|
||||
t.Setenv(redisOperationTimeoutEnvVar, "900ms")
|
||||
t.Setenv(redisKeyspacePrefixEnvVar, "user:custom:")
|
||||
t.Setenv(redisDomainEventsStreamEnvVar, "user:test_events")
|
||||
t.Setenv(redisDomainEventsStreamMaxLenEnvVar, "2048")
|
||||
t.Setenv(otelServiceNameEnvVar, "galaxy-user-stage12")
|
||||
t.Setenv(otelTracesExporterEnvVar, "otlp")
|
||||
t.Setenv(otelMetricsExporterEnvVar, "otlp")
|
||||
t.Setenv(otelExporterOTLPTracesProtocolEnvVar, "grpc")
|
||||
t.Setenv(otelExporterOTLPMetricsProtocolEnvVar, "http/protobuf")
|
||||
t.Setenv(otelStdoutTracesEnabledEnvVar, "true")
|
||||
t.Setenv(otelStdoutMetricsEnabledEnvVar, "true")
|
||||
|
||||
cfg, err := LoadFromEnv()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, 9*time.Second, cfg.ShutdownTimeout)
|
||||
require.Equal(t, "debug", cfg.Logging.Level)
|
||||
require.Equal(t, "127.0.0.1:18091", cfg.InternalHTTP.Addr)
|
||||
require.Equal(t, 3*time.Second, cfg.InternalHTTP.ReadHeaderTimeout)
|
||||
require.Equal(t, 750*time.Millisecond, cfg.InternalHTTP.RequestTimeout)
|
||||
require.Equal(t, "127.0.0.1:19091", cfg.AdminHTTP.Addr)
|
||||
require.Equal(t, 90*time.Second, cfg.AdminHTTP.IdleTimeout)
|
||||
require.Equal(t, "127.0.0.1:6380", cfg.Redis.Addr)
|
||||
require.Equal(t, "alice", cfg.Redis.Username)
|
||||
require.Equal(t, "secret", cfg.Redis.Password)
|
||||
require.Equal(t, 3, cfg.Redis.DB)
|
||||
require.True(t, cfg.Redis.TLSEnabled)
|
||||
require.Equal(t, 900*time.Millisecond, cfg.Redis.OperationTimeout)
|
||||
require.Equal(t, "user:custom:", cfg.Redis.KeyspacePrefix)
|
||||
require.Equal(t, "user:test_events", cfg.Redis.DomainEventsStream)
|
||||
require.Equal(t, int64(2048), cfg.Redis.DomainEventsStreamMaxLen)
|
||||
require.Equal(t, "galaxy-user-stage12", cfg.Telemetry.ServiceName)
|
||||
require.Equal(t, "otlp", cfg.Telemetry.TracesExporter)
|
||||
require.Equal(t, "otlp", cfg.Telemetry.MetricsExporter)
|
||||
require.Equal(t, "grpc", cfg.Telemetry.TracesProtocol)
|
||||
require.Equal(t, "http/protobuf", cfg.Telemetry.MetricsProtocol)
|
||||
require.True(t, cfg.Telemetry.StdoutTracesEnabled)
|
||||
require.True(t, cfg.Telemetry.StdoutMetricsEnabled)
|
||||
}
|
||||
|
||||
func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
envName string
|
||||
envVal string
|
||||
}{
|
||||
{name: "invalid duration", envName: shutdownTimeoutEnvVar, envVal: "later"},
|
||||
{name: "invalid bool", envName: redisTLSEnabledEnvVar, envVal: "sometimes"},
|
||||
{name: "invalid log level", envName: logLevelEnvVar, envVal: "verbose"},
|
||||
{name: "invalid int", envName: redisDBEnvVar, envVal: "db-three"},
|
||||
{name: "invalid stream max len", envName: redisDomainEventsStreamMaxLenEnvVar, envVal: "many"},
|
||||
{name: "invalid traces exporter", envName: otelTracesExporterEnvVar, envVal: "zipkin"},
|
||||
{name: "invalid metrics protocol", envName: otelExporterOTLPMetricsProtocolEnvVar, envVal: "udp"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv(redisAddrEnvVar, "127.0.0.1:6379")
|
||||
t.Setenv(tt.envName, tt.envVal)
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// Package account defines the logical user-account entities owned directly by
|
||||
// User Service.
|
||||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
)
|
||||
|
||||
// RaceNameCanonicalKey stores the policy-produced reservation key used to
|
||||
// enforce replaceable race-name uniqueness.
|
||||
type RaceNameCanonicalKey string
|
||||
|
||||
// String returns RaceNameCanonicalKey as its stored canonical string.
|
||||
func (key RaceNameCanonicalKey) String() string {
|
||||
return string(key)
|
||||
}
|
||||
|
||||
// IsZero reports whether RaceNameCanonicalKey does not contain a usable value.
|
||||
func (key RaceNameCanonicalKey) IsZero() bool {
|
||||
return strings.TrimSpace(string(key)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether RaceNameCanonicalKey is non-empty and trimmed.
|
||||
func (key RaceNameCanonicalKey) Validate() error {
|
||||
switch {
|
||||
case key.IsZero():
|
||||
return fmt.Errorf("race name canonical key must not be empty")
|
||||
case strings.TrimSpace(string(key)) != string(key):
|
||||
return fmt.Errorf("race name canonical key must not contain surrounding whitespace")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// UserAccount stores the current editable account state of one regular user.
|
||||
type UserAccount struct {
|
||||
// UserID identifies the durable regular-user account.
|
||||
UserID common.UserID
|
||||
|
||||
// Email stores the normalized login/contact address of the account.
|
||||
Email common.Email
|
||||
|
||||
// RaceName stores the original-casing user-facing race name.
|
||||
RaceName common.RaceName
|
||||
|
||||
// PreferredLanguage stores the current declared language tag.
|
||||
PreferredLanguage common.LanguageTag
|
||||
|
||||
// TimeZone stores the current declared time-zone name.
|
||||
TimeZone common.TimeZoneName
|
||||
|
||||
// DeclaredCountry stores the latest effective declared-country value. The
|
||||
// zero value means the geo workflow has not synchronized any country yet.
|
||||
DeclaredCountry common.CountryCode
|
||||
|
||||
// CreatedAt stores the account creation timestamp.
|
||||
CreatedAt time.Time
|
||||
|
||||
// UpdatedAt stores the last account mutation timestamp.
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether UserAccount satisfies the frozen Stage 02
|
||||
// structural invariants.
|
||||
func (record UserAccount) Validate() error {
|
||||
if err := record.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("user account user id: %w", err)
|
||||
}
|
||||
if err := record.Email.Validate(); err != nil {
|
||||
return fmt.Errorf("user account email: %w", err)
|
||||
}
|
||||
if err := record.RaceName.Validate(); err != nil {
|
||||
return fmt.Errorf("user account race name: %w", err)
|
||||
}
|
||||
if err := record.PreferredLanguage.Validate(); err != nil {
|
||||
return fmt.Errorf("user account preferred language: %w", err)
|
||||
}
|
||||
if err := record.TimeZone.Validate(); err != nil {
|
||||
return fmt.Errorf("user account time zone: %w", err)
|
||||
}
|
||||
if !record.DeclaredCountry.IsZero() {
|
||||
if err := record.DeclaredCountry.Validate(); err != nil {
|
||||
return fmt.Errorf("user account declared country: %w", err)
|
||||
}
|
||||
}
|
||||
if err := common.ValidateTimestamp("user account created at", record.CreatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := common.ValidateTimestamp("user account updated at", record.UpdatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.UpdatedAt.Before(record.CreatedAt) {
|
||||
return fmt.Errorf("user account updated at must not be before created at")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RaceNameReservation stores the current uniqueness reservation for one
|
||||
// canonicalized race-name key.
|
||||
type RaceNameReservation struct {
|
||||
// CanonicalKey stores the policy-produced uniqueness key.
|
||||
CanonicalKey RaceNameCanonicalKey
|
||||
|
||||
// UserID identifies the account that owns the reservation.
|
||||
UserID common.UserID
|
||||
|
||||
// RaceName stores the original-casing name linked to the reservation.
|
||||
RaceName common.RaceName
|
||||
|
||||
// ReservedAt stores when the reservation was acquired.
|
||||
ReservedAt time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether RaceNameReservation satisfies the frozen Stage 02
|
||||
// structural invariants.
|
||||
func (record RaceNameReservation) Validate() error {
|
||||
if err := record.CanonicalKey.Validate(); err != nil {
|
||||
return fmt.Errorf("race name reservation canonical key: %w", err)
|
||||
}
|
||||
if err := record.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("race name reservation user id: %w", err)
|
||||
}
|
||||
if err := record.RaceName.Validate(); err != nil {
|
||||
return fmt.Errorf("race name reservation race name: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("race name reservation reserved at", record.ReservedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUserAccountValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
createdAt := time.Unix(1_775_240_000, 0).UTC()
|
||||
updatedAt := createdAt.Add(2 * time.Hour)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
record UserAccount
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid without declared country",
|
||||
record: UserAccount{
|
||||
UserID: common.UserID("user-123"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
RaceName: common.RaceName("Pilot Nova"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Berlin"),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid with declared country",
|
||||
record: UserAccount{
|
||||
UserID: common.UserID("user-123"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
RaceName: common.RaceName("Pilot Nova"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Berlin"),
|
||||
DeclaredCountry: common.CountryCode("DE"),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "updated before created",
|
||||
record: UserAccount{
|
||||
UserID: common.UserID("user-123"),
|
||||
Email: common.Email("pilot@example.com"),
|
||||
RaceName: common.RaceName("Pilot Nova"),
|
||||
PreferredLanguage: common.LanguageTag("en"),
|
||||
TimeZone: common.TimeZoneName("Europe/Berlin"),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt.Add(-time.Second),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.record.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRaceNameReservationValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
record RaceNameReservation
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
record: RaceNameReservation{
|
||||
CanonicalKey: RaceNameCanonicalKey("pilot-nova"),
|
||||
UserID: common.UserID("user-123"),
|
||||
RaceName: common.RaceName("Pilot Nova"),
|
||||
ReservedAt: time.Unix(1_775_240_100, 0).UTC(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty canonical key",
|
||||
record: RaceNameReservation{
|
||||
UserID: common.UserID("user-123"),
|
||||
RaceName: common.RaceName("Pilot Nova"),
|
||||
ReservedAt: time.Unix(1_775_240_100, 0).UTC(),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.record.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Package authblock defines the dedicated pre-user auth-block entity stored by
|
||||
// User Service.
|
||||
package authblock
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
)
|
||||
|
||||
// BlockedEmailSubject stores a blocked e-mail subject that may exist before
|
||||
// any user account exists.
|
||||
type BlockedEmailSubject struct {
|
||||
// Email stores the normalized blocked e-mail subject.
|
||||
Email common.Email
|
||||
|
||||
// ReasonCode stores the machine-readable reason for the block.
|
||||
ReasonCode common.ReasonCode
|
||||
|
||||
// BlockedAt stores when the block became effective.
|
||||
BlockedAt time.Time
|
||||
|
||||
// Actor stores optional audit metadata for the block initiator.
|
||||
Actor common.ActorRef
|
||||
|
||||
// ResolvedUserID stores the linked user when the blocked e-mail already
|
||||
// belongs to an existing account.
|
||||
ResolvedUserID common.UserID
|
||||
}
|
||||
|
||||
// Validate reports whether BlockedEmailSubject satisfies the frozen Stage 02
|
||||
// structural invariants.
|
||||
func (record BlockedEmailSubject) Validate() error {
|
||||
if err := record.Email.Validate(); err != nil {
|
||||
return fmt.Errorf("blocked email subject email: %w", err)
|
||||
}
|
||||
if err := record.ReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("blocked email subject reason code: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("blocked email subject blocked at", record.BlockedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if !record.Actor.IsZero() {
|
||||
if err := record.Actor.Validate(); err != nil {
|
||||
return fmt.Errorf("blocked email subject actor: %w", err)
|
||||
}
|
||||
}
|
||||
if !record.ResolvedUserID.IsZero() {
|
||||
if err := record.ResolvedUserID.Validate(); err != nil {
|
||||
return fmt.Errorf("blocked email subject resolved user id: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package authblock
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBlockedEmailSubjectValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
record BlockedEmailSubject
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid without actor or user",
|
||||
record: BlockedEmailSubject{
|
||||
Email: common.Email("pilot@example.com"),
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
BlockedAt: time.Unix(1_775_240_000, 0).UTC(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid with actor and user",
|
||||
record: BlockedEmailSubject{
|
||||
Email: common.Email("pilot@example.com"),
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
BlockedAt: time.Unix(1_775_240_000, 0).UTC(),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ResolvedUserID: common.UserID("user-123"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing blocked at",
|
||||
record: BlockedEmailSubject{
|
||||
Email: common.Email("pilot@example.com"),
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.record.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
// Package common defines shared value objects used across the user-service
|
||||
// domain model.
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxRaceNameLength = 64
|
||||
maxLanguageTagLength = 32
|
||||
maxTimeZoneNameLength = 128
|
||||
)
|
||||
|
||||
// UserID identifies one regular-platform user owned by User Service.
|
||||
type UserID string
|
||||
|
||||
// String returns UserID as its stored identifier string.
|
||||
func (id UserID) String() string {
|
||||
return string(id)
|
||||
}
|
||||
|
||||
// IsZero reports whether UserID does not contain a usable identifier.
|
||||
func (id UserID) IsZero() bool {
|
||||
return strings.TrimSpace(string(id)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether UserID is non-empty, normalized, and uses the
|
||||
// frozen Stage 02 prefix.
|
||||
func (id UserID) Validate() error {
|
||||
return validatePrefixedToken("user id", string(id), "user-")
|
||||
}
|
||||
|
||||
// Email stores one normalized user-login e-mail address.
|
||||
type Email string
|
||||
|
||||
// String returns Email as its stored canonical string.
|
||||
func (email Email) String() string {
|
||||
return string(email)
|
||||
}
|
||||
|
||||
// IsZero reports whether Email does not contain a usable address.
|
||||
func (email Email) IsZero() bool {
|
||||
return strings.TrimSpace(string(email)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether Email is non-empty, trimmed, and matches the same
|
||||
// single-address syntax expected by internal REST contracts.
|
||||
func (email Email) Validate() error {
|
||||
raw := string(email)
|
||||
if err := validateToken("email", raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parsedAddress, err := mail.ParseAddress(raw)
|
||||
if err != nil || parsedAddress.Name != "" || parsedAddress.Address != raw {
|
||||
return fmt.Errorf("email %q must be a single valid email address", raw)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RaceName stores one original-casing race name selected for the user
|
||||
// account.
|
||||
type RaceName string
|
||||
|
||||
// String returns RaceName as its stored value.
|
||||
func (name RaceName) String() string {
|
||||
return string(name)
|
||||
}
|
||||
|
||||
// IsZero reports whether RaceName does not contain a usable value.
|
||||
func (name RaceName) IsZero() bool {
|
||||
return strings.TrimSpace(string(name)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether RaceName is non-empty, trimmed, and within the
|
||||
// frozen OpenAPI length bound.
|
||||
func (name RaceName) Validate() error {
|
||||
raw := string(name)
|
||||
if err := validateToken("race name", raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(raw) > maxRaceNameLength {
|
||||
return fmt.Errorf("race name must be at most %d bytes", maxRaceNameLength)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LanguageTag stores one declared BCP 47 language-tag string.
|
||||
type LanguageTag string
|
||||
|
||||
// String returns LanguageTag as its stored value.
|
||||
func (tag LanguageTag) String() string {
|
||||
return string(tag)
|
||||
}
|
||||
|
||||
// IsZero reports whether LanguageTag does not contain a usable value.
|
||||
func (tag LanguageTag) IsZero() bool {
|
||||
return strings.TrimSpace(string(tag)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether LanguageTag is non-empty, trimmed, and within the
|
||||
// frozen OpenAPI length bound. Stage 02 intentionally freezes the storage
|
||||
// shape and not the later boundary-level BCP 47 parser choice.
|
||||
func (tag LanguageTag) Validate() error {
|
||||
raw := string(tag)
|
||||
if err := validateToken("language tag", raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(raw) > maxLanguageTagLength {
|
||||
return fmt.Errorf("language tag must be at most %d bytes", maxLanguageTagLength)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TimeZoneName stores one declared IANA time-zone name.
|
||||
type TimeZoneName string
|
||||
|
||||
// String returns TimeZoneName as its stored value.
|
||||
func (name TimeZoneName) String() string {
|
||||
return string(name)
|
||||
}
|
||||
|
||||
// IsZero reports whether TimeZoneName does not contain a usable value.
|
||||
func (name TimeZoneName) IsZero() bool {
|
||||
return strings.TrimSpace(string(name)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether TimeZoneName is non-empty, trimmed, and within the
|
||||
// frozen OpenAPI length bound. Later application stages may tighten
|
||||
// boundary-level validation further.
|
||||
func (name TimeZoneName) Validate() error {
|
||||
raw := string(name)
|
||||
if err := validateToken("time zone name", raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(raw) > maxTimeZoneNameLength {
|
||||
return fmt.Errorf("time zone name must be at most %d bytes", maxTimeZoneNameLength)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountryCode stores one ISO 3166-1 alpha-2 code.
|
||||
type CountryCode string
|
||||
|
||||
// String returns CountryCode as its stored value.
|
||||
func (code CountryCode) String() string {
|
||||
return string(code)
|
||||
}
|
||||
|
||||
// IsZero reports whether CountryCode does not contain a usable value.
|
||||
func (code CountryCode) IsZero() bool {
|
||||
return strings.TrimSpace(string(code)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether CountryCode is an uppercase ISO 3166-1 alpha-2
|
||||
// code.
|
||||
func (code CountryCode) Validate() error {
|
||||
raw := string(code)
|
||||
if len(raw) != 2 {
|
||||
return fmt.Errorf("country code %q must contain exactly two letters", raw)
|
||||
}
|
||||
for idx := 0; idx < len(raw); idx++ {
|
||||
if raw[idx] < 'A' || raw[idx] > 'Z' {
|
||||
return fmt.Errorf("country code %q must contain only uppercase ASCII letters", raw)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ActorType stores one machine-readable actor type for audit metadata.
|
||||
type ActorType string
|
||||
|
||||
// String returns ActorType as its stored value.
|
||||
func (actorType ActorType) String() string {
|
||||
return string(actorType)
|
||||
}
|
||||
|
||||
// IsZero reports whether ActorType does not contain a usable value.
|
||||
func (actorType ActorType) IsZero() bool {
|
||||
return strings.TrimSpace(string(actorType)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether ActorType is non-empty and trimmed.
|
||||
func (actorType ActorType) Validate() error {
|
||||
return validateToken("actor type", string(actorType))
|
||||
}
|
||||
|
||||
// ActorID stores one optional stable actor identifier.
|
||||
type ActorID string
|
||||
|
||||
// String returns ActorID as its stored value.
|
||||
func (actorID ActorID) String() string {
|
||||
return string(actorID)
|
||||
}
|
||||
|
||||
// IsZero reports whether ActorID does not contain a usable value.
|
||||
func (actorID ActorID) IsZero() bool {
|
||||
return strings.TrimSpace(string(actorID)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether ActorID is trimmed when present.
|
||||
func (actorID ActorID) Validate() error {
|
||||
if actorID.IsZero() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return validateToken("actor id", string(actorID))
|
||||
}
|
||||
|
||||
// ActorRef stores actor metadata captured on trusted mutations.
|
||||
type ActorRef struct {
|
||||
// Type identifies the machine-readable actor class such as `admin`,
|
||||
// `service`, or `billing`.
|
||||
Type ActorType
|
||||
|
||||
// ID stores the optional stable actor identifier.
|
||||
ID ActorID
|
||||
}
|
||||
|
||||
// IsZero reports whether ActorRef does not contain any audit actor metadata.
|
||||
func (ref ActorRef) IsZero() bool {
|
||||
return ref.Type.IsZero() && ref.ID.IsZero()
|
||||
}
|
||||
|
||||
// Validate reports whether ActorRef contains a required type and an optional
|
||||
// trimmed identifier.
|
||||
func (ref ActorRef) Validate() error {
|
||||
if err := ref.Type.Validate(); err != nil {
|
||||
return fmt.Errorf("actor ref type: %w", err)
|
||||
}
|
||||
if err := ref.ID.Validate(); err != nil {
|
||||
return fmt.Errorf("actor ref id: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReasonCode stores one machine-readable reason code.
|
||||
type ReasonCode string
|
||||
|
||||
// String returns ReasonCode as its stored value.
|
||||
func (code ReasonCode) String() string {
|
||||
return string(code)
|
||||
}
|
||||
|
||||
// IsZero reports whether ReasonCode does not contain a usable value.
|
||||
func (code ReasonCode) IsZero() bool {
|
||||
return strings.TrimSpace(string(code)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether ReasonCode is non-empty and trimmed.
|
||||
func (code ReasonCode) Validate() error {
|
||||
return validateToken("reason code", string(code))
|
||||
}
|
||||
|
||||
// Source stores one machine-readable mutation source.
|
||||
type Source string
|
||||
|
||||
// String returns Source as its stored value.
|
||||
func (source Source) String() string {
|
||||
return string(source)
|
||||
}
|
||||
|
||||
// IsZero reports whether Source does not contain a usable value.
|
||||
func (source Source) IsZero() bool {
|
||||
return strings.TrimSpace(string(source)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether Source is non-empty and trimmed.
|
||||
func (source Source) Validate() error {
|
||||
return validateToken("source", string(source))
|
||||
}
|
||||
|
||||
// Scope stores one machine-readable sanction scope.
|
||||
type Scope string
|
||||
|
||||
// String returns Scope as its stored value.
|
||||
func (scope Scope) String() string {
|
||||
return string(scope)
|
||||
}
|
||||
|
||||
// IsZero reports whether Scope does not contain a usable value.
|
||||
func (scope Scope) IsZero() bool {
|
||||
return strings.TrimSpace(string(scope)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether Scope is non-empty and trimmed.
|
||||
func (scope Scope) Validate() error {
|
||||
return validateToken("scope", string(scope))
|
||||
}
|
||||
|
||||
// ValidateTimestamp reports whether value is set.
|
||||
func ValidateTimestamp(name string, value time.Time) error {
|
||||
if value.IsZero() {
|
||||
return fmt.Errorf("%s must not be zero", name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateToken(name string, value string) error {
|
||||
switch {
|
||||
case strings.TrimSpace(value) == "":
|
||||
return fmt.Errorf("%s must not be empty", name)
|
||||
case strings.TrimSpace(value) != value:
|
||||
return fmt.Errorf("%s must not contain surrounding whitespace", name)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func validatePrefixedToken(name string, value string, prefix string) error {
|
||||
if err := validateToken(name, value); err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.HasPrefix(value, prefix) {
|
||||
return fmt.Errorf("%s must start with %q", name, prefix)
|
||||
}
|
||||
if len(value) == len(prefix) {
|
||||
return fmt.Errorf("%s must contain opaque data after %q", name, prefix)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrInvertedTimeRange reports that the logical end of a range is not after
|
||||
// its start.
|
||||
var ErrInvertedTimeRange = errors.New("time range end must be after start")
|
||||
@@ -0,0 +1,207 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUserIDValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value UserID
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", value: UserID("user-abc123")},
|
||||
{name: "empty", value: UserID(""), wantErr: true},
|
||||
{name: "surrounding whitespace", value: UserID(" user-abc123 "), wantErr: true},
|
||||
{name: "wrong prefix", value: UserID("account-abc123"), wantErr: true},
|
||||
{name: "prefix only", value: UserID("user-"), wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.value.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value Email
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", value: Email("pilot@example.com")},
|
||||
{name: "empty", value: Email(""), wantErr: true},
|
||||
{name: "display name", value: Email("Pilot <pilot@example.com>"), wantErr: true},
|
||||
{name: "invalid", value: Email("not-an-email"), wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.value.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRaceNameValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value RaceName
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", value: RaceName("Admiral Nova")},
|
||||
{name: "empty", value: RaceName(""), wantErr: true},
|
||||
{name: "too long", value: RaceName("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmn"), wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.value.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLanguageTagValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value LanguageTag
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", value: LanguageTag("en-US")},
|
||||
{name: "empty", value: LanguageTag(""), wantErr: true},
|
||||
{name: "surrounding whitespace", value: LanguageTag(" en "), wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.value.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeZoneNameValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value TimeZoneName
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", value: TimeZoneName("Europe/Berlin")},
|
||||
{name: "empty", value: TimeZoneName(""), wantErr: true},
|
||||
{name: "surrounding whitespace", value: TimeZoneName(" UTC "), wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.value.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountryCodeValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value CountryCode
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", value: CountryCode("DE")},
|
||||
{name: "lowercase", value: CountryCode("de"), wantErr: true},
|
||||
{name: "wrong length", value: CountryCode("DEU"), wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.value.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestActorRefValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value ActorRef
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid without id", value: ActorRef{Type: ActorType("service")}},
|
||||
{name: "valid with id", value: ActorRef{Type: ActorType("admin"), ID: ActorID("admin-1")}},
|
||||
{name: "missing type", value: ActorRef{ID: ActorID("admin-1")}, wantErr: true},
|
||||
{name: "invalid id whitespace", value: ActorRef{Type: ActorType("admin"), ID: ActorID(" admin-1 ")}, wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.value.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
// Package entitlement defines the logical entitlement entities owned by User
|
||||
// Service.
|
||||
package entitlement
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
)
|
||||
|
||||
// PlanCode identifies one supported entitlement plan.
|
||||
type PlanCode string
|
||||
|
||||
const (
|
||||
// PlanCodeFree reports the free default entitlement.
|
||||
PlanCodeFree PlanCode = "free"
|
||||
|
||||
// PlanCodePaidMonthly reports a finite monthly paid entitlement.
|
||||
PlanCodePaidMonthly PlanCode = "paid_monthly"
|
||||
|
||||
// PlanCodePaidYearly reports a finite yearly paid entitlement.
|
||||
PlanCodePaidYearly PlanCode = "paid_yearly"
|
||||
|
||||
// PlanCodePaidLifetime reports a non-expiring paid entitlement.
|
||||
PlanCodePaidLifetime PlanCode = "paid_lifetime"
|
||||
)
|
||||
|
||||
// IsKnown reports whether PlanCode belongs to the frozen v1 catalog.
|
||||
func (code PlanCode) IsKnown() bool {
|
||||
switch code {
|
||||
case PlanCodeFree, PlanCodePaidMonthly, PlanCodePaidYearly, PlanCodePaidLifetime:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsPaid reports whether PlanCode represents a paid entitlement state.
|
||||
func (code PlanCode) IsPaid() bool {
|
||||
switch code {
|
||||
case PlanCodePaidMonthly, PlanCodePaidYearly, PlanCodePaidLifetime:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// HasFiniteExpiry reports whether PlanCode requires a bounded `ends_at`
|
||||
// value in the Stage 07 entitlement timeline model.
|
||||
func (code PlanCode) HasFiniteExpiry() bool {
|
||||
switch code {
|
||||
case PlanCodePaidMonthly, PlanCodePaidYearly:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// EntitlementRecordID identifies one immutable entitlement history record.
|
||||
type EntitlementRecordID string
|
||||
|
||||
// String returns EntitlementRecordID as its stored identifier string.
|
||||
func (id EntitlementRecordID) String() string {
|
||||
return string(id)
|
||||
}
|
||||
|
||||
// IsZero reports whether EntitlementRecordID does not contain a usable value.
|
||||
func (id EntitlementRecordID) IsZero() bool {
|
||||
return strings.TrimSpace(string(id)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether EntitlementRecordID is non-empty, normalized, and
|
||||
// uses the frozen Stage 02 prefix.
|
||||
func (id EntitlementRecordID) Validate() error {
|
||||
switch {
|
||||
case id.IsZero():
|
||||
return fmt.Errorf("entitlement record id must not be empty")
|
||||
case strings.TrimSpace(string(id)) != string(id):
|
||||
return fmt.Errorf("entitlement record id must not contain surrounding whitespace")
|
||||
case !strings.HasPrefix(string(id), "entitlement-"):
|
||||
return fmt.Errorf("entitlement record id must start with %q", "entitlement-")
|
||||
case len(string(id)) == len("entitlement-"):
|
||||
return fmt.Errorf("entitlement record id must contain opaque data after %q", "entitlement-")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// PeriodRecord stores one entitlement-period history record.
|
||||
type PeriodRecord struct {
|
||||
// RecordID identifies the immutable history record.
|
||||
RecordID EntitlementRecordID
|
||||
|
||||
// UserID identifies the account that owns the entitlement record.
|
||||
UserID common.UserID
|
||||
|
||||
// PlanCode stores the effective plan for the recorded period.
|
||||
PlanCode PlanCode
|
||||
|
||||
// Source stores the machine-readable mutation source.
|
||||
Source common.Source
|
||||
|
||||
// Actor stores the audit actor metadata captured for the mutation.
|
||||
Actor common.ActorRef
|
||||
|
||||
// ReasonCode stores the machine-readable reason for the mutation.
|
||||
ReasonCode common.ReasonCode
|
||||
|
||||
// StartsAt stores when the period becomes effective.
|
||||
StartsAt time.Time
|
||||
|
||||
// EndsAt stores the optional planned end of the period.
|
||||
EndsAt *time.Time
|
||||
|
||||
// CreatedAt stores when the history record was created.
|
||||
CreatedAt time.Time
|
||||
|
||||
// ClosedAt stores when the period was later closed early by another trusted
|
||||
// mutation.
|
||||
ClosedAt *time.Time
|
||||
|
||||
// ClosedBy stores optional audit actor metadata for the close mutation.
|
||||
ClosedBy common.ActorRef
|
||||
|
||||
// ClosedReasonCode stores the reason for closing the period early.
|
||||
ClosedReasonCode common.ReasonCode
|
||||
}
|
||||
|
||||
// Validate reports whether PeriodRecord satisfies the frozen Stage 02
|
||||
// structural invariants.
|
||||
func (record PeriodRecord) Validate() error {
|
||||
if err := record.RecordID.Validate(); err != nil {
|
||||
return fmt.Errorf("entitlement period record id: %w", err)
|
||||
}
|
||||
if err := record.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("entitlement period user id: %w", err)
|
||||
}
|
||||
if !record.PlanCode.IsKnown() {
|
||||
return fmt.Errorf("entitlement period plan code %q is unsupported", record.PlanCode)
|
||||
}
|
||||
if err := record.Source.Validate(); err != nil {
|
||||
return fmt.Errorf("entitlement period source: %w", err)
|
||||
}
|
||||
if err := record.Actor.Validate(); err != nil {
|
||||
return fmt.Errorf("entitlement period actor: %w", err)
|
||||
}
|
||||
if err := record.ReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("entitlement period reason code: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("entitlement period starts at", record.StartsAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePlanBounds("entitlement period", record.PlanCode, record.StartsAt, record.EndsAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := common.ValidateTimestamp("entitlement period created at", record.CreatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.ClosedAt == nil {
|
||||
if !record.ClosedBy.IsZero() {
|
||||
return fmt.Errorf("entitlement period closed by must be empty when closed at is absent")
|
||||
}
|
||||
if !record.ClosedReasonCode.IsZero() {
|
||||
return fmt.Errorf("entitlement period closed reason code must be empty when closed at is absent")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if record.ClosedAt.Before(record.StartsAt) {
|
||||
return fmt.Errorf("entitlement period closed at must not be before starts at")
|
||||
}
|
||||
if record.EndsAt != nil && record.ClosedAt.After(*record.EndsAt) {
|
||||
return fmt.Errorf("entitlement period closed at must not be after ends at")
|
||||
}
|
||||
if record.ClosedAt.Before(record.CreatedAt) {
|
||||
return fmt.Errorf("entitlement period closed at must not be before created at")
|
||||
}
|
||||
if err := record.ClosedBy.Validate(); err != nil {
|
||||
return fmt.Errorf("entitlement period closed by: %w", err)
|
||||
}
|
||||
if err := record.ClosedReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("entitlement period closed reason code: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsEffectiveAt reports whether PeriodRecord is the currently effective
|
||||
// segment at the supplied timestamp.
|
||||
func (record PeriodRecord) IsEffectiveAt(now time.Time) bool {
|
||||
if record.ClosedAt != nil {
|
||||
return false
|
||||
}
|
||||
if record.StartsAt.After(now) {
|
||||
return false
|
||||
}
|
||||
if record.EndsAt != nil && !record.EndsAt.After(now) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// CurrentSnapshot stores the read-optimized current entitlement state of one
|
||||
// user account.
|
||||
type CurrentSnapshot struct {
|
||||
// UserID identifies the account that owns the current entitlement.
|
||||
UserID common.UserID
|
||||
|
||||
// PlanCode stores the current effective plan code.
|
||||
PlanCode PlanCode
|
||||
|
||||
// IsPaid stores the materialized paid/free state used on hot read paths.
|
||||
IsPaid bool
|
||||
|
||||
// StartsAt stores when the current effective state started.
|
||||
StartsAt time.Time
|
||||
|
||||
// EndsAt stores the optional end of the current finite entitlement.
|
||||
EndsAt *time.Time
|
||||
|
||||
// Source stores the machine-readable source of the current state.
|
||||
Source common.Source
|
||||
|
||||
// Actor stores the actor metadata attached to the last successful mutation.
|
||||
Actor common.ActorRef
|
||||
|
||||
// ReasonCode stores the machine-readable reason attached to the last
|
||||
// successful mutation.
|
||||
ReasonCode common.ReasonCode
|
||||
|
||||
// UpdatedAt stores when the snapshot was last recomputed.
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether CurrentSnapshot satisfies the frozen Stage 02
|
||||
// structural invariants.
|
||||
func (record CurrentSnapshot) Validate() error {
|
||||
if err := record.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("entitlement snapshot user id: %w", err)
|
||||
}
|
||||
if !record.PlanCode.IsKnown() {
|
||||
return fmt.Errorf("entitlement snapshot plan code %q is unsupported", record.PlanCode)
|
||||
}
|
||||
if record.IsPaid != record.PlanCode.IsPaid() {
|
||||
return fmt.Errorf("entitlement snapshot paid flag must match plan code %q", record.PlanCode)
|
||||
}
|
||||
if err := common.ValidateTimestamp("entitlement snapshot starts at", record.StartsAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePlanBounds("entitlement snapshot", record.PlanCode, record.StartsAt, record.EndsAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := record.Source.Validate(); err != nil {
|
||||
return fmt.Errorf("entitlement snapshot source: %w", err)
|
||||
}
|
||||
if err := record.Actor.Validate(); err != nil {
|
||||
return fmt.Errorf("entitlement snapshot actor: %w", err)
|
||||
}
|
||||
if err := record.ReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("entitlement snapshot reason code: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("entitlement snapshot updated at", record.UpdatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasFiniteExpiry reports whether CurrentSnapshot participates in the finite
|
||||
// paid-expiry index.
|
||||
func (record CurrentSnapshot) HasFiniteExpiry() bool {
|
||||
return record.IsPaid && record.EndsAt != nil
|
||||
}
|
||||
|
||||
// IsExpiredAt reports whether CurrentSnapshot represents a finite paid state
|
||||
// that has already reached its stored expiry.
|
||||
func (record CurrentSnapshot) IsExpiredAt(now time.Time) bool {
|
||||
return record.HasFiniteExpiry() && !record.EndsAt.After(now)
|
||||
}
|
||||
|
||||
// PaidState identifies the coarse free-versus-paid filter used by admin
|
||||
// listing.
|
||||
type PaidState string
|
||||
|
||||
const (
|
||||
// PaidStateFree filters accounts whose current entitlement is free.
|
||||
PaidStateFree PaidState = "free"
|
||||
|
||||
// PaidStatePaid filters accounts whose current entitlement is paid.
|
||||
PaidStatePaid PaidState = "paid"
|
||||
)
|
||||
|
||||
// IsKnown reports whether PaidState belongs to the frozen Stage 02 filter
|
||||
// vocabulary.
|
||||
func (state PaidState) IsKnown() bool {
|
||||
switch state {
|
||||
case "", PaidStateFree, PaidStatePaid:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validatePlanBounds(
|
||||
name string,
|
||||
planCode PlanCode,
|
||||
startsAt time.Time,
|
||||
endsAt *time.Time,
|
||||
) error {
|
||||
switch {
|
||||
case planCode.HasFiniteExpiry():
|
||||
if endsAt == nil {
|
||||
return fmt.Errorf("%s ends at must be present for plan code %q", name, planCode)
|
||||
}
|
||||
if !endsAt.After(startsAt) {
|
||||
return common.ErrInvertedTimeRange
|
||||
}
|
||||
case endsAt != nil:
|
||||
return fmt.Errorf("%s ends at must be empty for plan code %q", name, planCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package entitlement
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPeriodRecordValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
startsAt := time.Unix(1_775_240_000, 0).UTC()
|
||||
endsAt := startsAt.Add(30 * 24 * time.Hour)
|
||||
createdAt := startsAt.Add(-time.Hour)
|
||||
closedAt := startsAt.Add(12 * time.Hour)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
record PeriodRecord
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid open record",
|
||||
record: PeriodRecord{
|
||||
RecordID: EntitlementRecordID("entitlement-123"),
|
||||
UserID: common.UserID("user-123"),
|
||||
PlanCode: PlanCodePaidMonthly,
|
||||
Source: common.Source("admin"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: common.ReasonCode("manual_grant"),
|
||||
StartsAt: startsAt,
|
||||
EndsAt: &endsAt,
|
||||
CreatedAt: createdAt,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid closed record",
|
||||
record: PeriodRecord{
|
||||
RecordID: EntitlementRecordID("entitlement-123"),
|
||||
UserID: common.UserID("user-123"),
|
||||
PlanCode: PlanCodePaidMonthly,
|
||||
Source: common.Source("admin"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: common.ReasonCode("manual_grant"),
|
||||
StartsAt: startsAt,
|
||||
EndsAt: &endsAt,
|
||||
CreatedAt: createdAt,
|
||||
ClosedAt: &closedAt,
|
||||
ClosedBy: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")},
|
||||
ClosedReasonCode: common.ReasonCode("manual_revoke"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "close metadata without closed at",
|
||||
record: PeriodRecord{
|
||||
RecordID: EntitlementRecordID("entitlement-123"),
|
||||
UserID: common.UserID("user-123"),
|
||||
PlanCode: PlanCodePaidMonthly,
|
||||
Source: common.Source("admin"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: common.ReasonCode("manual_grant"),
|
||||
StartsAt: startsAt,
|
||||
CreatedAt: createdAt,
|
||||
ClosedReasonCode: common.ReasonCode("manual_revoke"),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.record.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentSnapshotValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
startsAt := time.Unix(1_775_240_000, 0).UTC()
|
||||
endsAt := startsAt.Add(30 * 24 * time.Hour)
|
||||
updatedAt := startsAt.Add(2 * time.Hour)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
record CurrentSnapshot
|
||||
wantErr bool
|
||||
wantFinite bool
|
||||
}{
|
||||
{
|
||||
name: "valid finite paid snapshot",
|
||||
record: CurrentSnapshot{
|
||||
UserID: common.UserID("user-123"),
|
||||
PlanCode: PlanCodePaidMonthly,
|
||||
IsPaid: true,
|
||||
StartsAt: startsAt,
|
||||
EndsAt: &endsAt,
|
||||
Source: common.Source("admin"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
ReasonCode: common.ReasonCode("manual_grant"),
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
wantFinite: true,
|
||||
},
|
||||
{
|
||||
name: "valid free snapshot",
|
||||
record: CurrentSnapshot{
|
||||
UserID: common.UserID("user-123"),
|
||||
PlanCode: PlanCodeFree,
|
||||
IsPaid: false,
|
||||
StartsAt: startsAt,
|
||||
Source: common.Source("system"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("service")},
|
||||
ReasonCode: common.ReasonCode("default_free_plan"),
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "paid flag mismatch",
|
||||
record: CurrentSnapshot{
|
||||
UserID: common.UserID("user-123"),
|
||||
PlanCode: PlanCodeFree,
|
||||
IsPaid: true,
|
||||
StartsAt: startsAt,
|
||||
Source: common.Source("system"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("service")},
|
||||
ReasonCode: common.ReasonCode("default_free_plan"),
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.record.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantFinite, tt.record.HasFiniteExpiry())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
// Package policy defines sanction, limit, and eligibility-domain entities used
|
||||
// by User Service.
|
||||
package policy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
)
|
||||
|
||||
// SanctionCode identifies one supported sanction in the v1 policy catalog.
|
||||
type SanctionCode string
|
||||
|
||||
const (
|
||||
// SanctionCodeLoginBlock denies login.
|
||||
SanctionCodeLoginBlock SanctionCode = "login_block"
|
||||
|
||||
// SanctionCodePrivateGameCreateBlock denies private-game creation.
|
||||
SanctionCodePrivateGameCreateBlock SanctionCode = "private_game_create_block"
|
||||
|
||||
// SanctionCodePrivateGameManageBlock denies private-game management.
|
||||
SanctionCodePrivateGameManageBlock SanctionCode = "private_game_manage_block"
|
||||
|
||||
// SanctionCodeGameJoinBlock denies game joining.
|
||||
SanctionCodeGameJoinBlock SanctionCode = "game_join_block"
|
||||
|
||||
// SanctionCodeProfileUpdateBlock denies self-service profile/settings
|
||||
// mutations.
|
||||
SanctionCodeProfileUpdateBlock SanctionCode = "profile_update_block"
|
||||
)
|
||||
|
||||
// IsKnown reports whether SanctionCode belongs to the frozen v1 catalog.
|
||||
func (code SanctionCode) IsKnown() bool {
|
||||
switch code {
|
||||
case SanctionCodeLoginBlock,
|
||||
SanctionCodePrivateGameCreateBlock,
|
||||
SanctionCodePrivateGameManageBlock,
|
||||
SanctionCodeGameJoinBlock,
|
||||
SanctionCodeProfileUpdateBlock:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// LimitCode identifies one user-specific limit code recognized by User
|
||||
// Service.
|
||||
type LimitCode string
|
||||
|
||||
const (
|
||||
// LimitCodeMaxOwnedPrivateGames limits how many private games the user may
|
||||
// own while the current entitlement is paid.
|
||||
LimitCodeMaxOwnedPrivateGames LimitCode = "max_owned_private_games"
|
||||
|
||||
// LimitCodeMaxPendingPublicApplications stores the total public-games budget
|
||||
// consumed together with current active public memberships when Game Lobby
|
||||
// derives remaining pending application headroom.
|
||||
LimitCodeMaxPendingPublicApplications LimitCode = "max_pending_public_applications"
|
||||
|
||||
// LimitCodeMaxActiveGameMemberships limits how many active public-game
|
||||
// memberships the user may hold at once.
|
||||
LimitCodeMaxActiveGameMemberships LimitCode = "max_active_game_memberships"
|
||||
)
|
||||
|
||||
const (
|
||||
// LimitCodeMaxActivePrivateGames is a retired legacy code recognized only
|
||||
// so old stored records do not break current reads.
|
||||
LimitCodeMaxActivePrivateGames LimitCode = "max_active_private_games"
|
||||
|
||||
// LimitCodeMaxPendingPrivateJoinRequests is a retired legacy code
|
||||
// recognized only so old stored records do not break current reads.
|
||||
LimitCodeMaxPendingPrivateJoinRequests LimitCode = "max_pending_private_join_requests"
|
||||
|
||||
// LimitCodeMaxPendingPrivateInvitesSent is a retired legacy code
|
||||
// recognized only so old stored records do not break current reads.
|
||||
LimitCodeMaxPendingPrivateInvitesSent LimitCode = "max_pending_private_invites_sent"
|
||||
)
|
||||
|
||||
// IsKnown reports whether LimitCode belongs to the current supported write/API
|
||||
// catalog.
|
||||
func (code LimitCode) IsKnown() bool {
|
||||
return code.IsSupported()
|
||||
}
|
||||
|
||||
// IsSupported reports whether LimitCode belongs to the current supported
|
||||
// write/API catalog.
|
||||
func (code LimitCode) IsSupported() bool {
|
||||
switch code {
|
||||
case LimitCodeMaxOwnedPrivateGames,
|
||||
LimitCodeMaxPendingPublicApplications,
|
||||
LimitCodeMaxActiveGameMemberships:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsRetired reports whether LimitCode is a retired legacy code recognized
|
||||
// only for read compatibility with already stored history records.
|
||||
func (code LimitCode) IsRetired() bool {
|
||||
switch code {
|
||||
case LimitCodeMaxActivePrivateGames,
|
||||
LimitCodeMaxPendingPrivateJoinRequests,
|
||||
LimitCodeMaxPendingPrivateInvitesSent:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsRecognized reports whether LimitCode is either currently supported or
|
||||
// retired-but-recognized for read compatibility.
|
||||
func (code LimitCode) IsRecognized() bool {
|
||||
return code.IsSupported() || code.IsRetired()
|
||||
}
|
||||
|
||||
// EligibilityMarker identifies one derived eligibility boolean that may be
|
||||
// indexed for admin listing.
|
||||
type EligibilityMarker string
|
||||
|
||||
const (
|
||||
// EligibilityMarkerCanLogin tracks whether the user may currently log in.
|
||||
EligibilityMarkerCanLogin EligibilityMarker = "can_login"
|
||||
|
||||
// EligibilityMarkerCanCreatePrivateGame tracks whether the user may create
|
||||
// a private game.
|
||||
EligibilityMarkerCanCreatePrivateGame EligibilityMarker = "can_create_private_game"
|
||||
|
||||
// EligibilityMarkerCanManagePrivateGame tracks whether the user may manage
|
||||
// a private game.
|
||||
EligibilityMarkerCanManagePrivateGame EligibilityMarker = "can_manage_private_game"
|
||||
|
||||
// EligibilityMarkerCanJoinGame tracks whether the user may join a game.
|
||||
EligibilityMarkerCanJoinGame EligibilityMarker = "can_join_game"
|
||||
|
||||
// EligibilityMarkerCanUpdateProfile tracks whether the user may update
|
||||
// self-service profile/settings fields.
|
||||
EligibilityMarkerCanUpdateProfile EligibilityMarker = "can_update_profile"
|
||||
)
|
||||
|
||||
// IsKnown reports whether EligibilityMarker belongs to the frozen v1 set.
|
||||
func (marker EligibilityMarker) IsKnown() bool {
|
||||
switch marker {
|
||||
case EligibilityMarkerCanLogin,
|
||||
EligibilityMarkerCanCreatePrivateGame,
|
||||
EligibilityMarkerCanManagePrivateGame,
|
||||
EligibilityMarkerCanJoinGame,
|
||||
EligibilityMarkerCanUpdateProfile:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// SanctionRecordID identifies one sanction history record.
|
||||
type SanctionRecordID string
|
||||
|
||||
// String returns SanctionRecordID as its stored identifier string.
|
||||
func (id SanctionRecordID) String() string {
|
||||
return string(id)
|
||||
}
|
||||
|
||||
// IsZero reports whether SanctionRecordID does not contain a usable value.
|
||||
func (id SanctionRecordID) IsZero() bool {
|
||||
return strings.TrimSpace(string(id)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether SanctionRecordID is non-empty, normalized, and
|
||||
// uses the frozen Stage 02 prefix.
|
||||
func (id SanctionRecordID) Validate() error {
|
||||
return validatePrefixedRecordID("sanction record id", string(id), "sanction-")
|
||||
}
|
||||
|
||||
// LimitRecordID identifies one limit history record.
|
||||
type LimitRecordID string
|
||||
|
||||
// String returns LimitRecordID as its stored identifier string.
|
||||
func (id LimitRecordID) String() string {
|
||||
return string(id)
|
||||
}
|
||||
|
||||
// IsZero reports whether LimitRecordID does not contain a usable value.
|
||||
func (id LimitRecordID) IsZero() bool {
|
||||
return strings.TrimSpace(string(id)) == ""
|
||||
}
|
||||
|
||||
// Validate reports whether LimitRecordID is non-empty, normalized, and uses
|
||||
// the frozen Stage 02 prefix.
|
||||
func (id LimitRecordID) Validate() error {
|
||||
return validatePrefixedRecordID("limit record id", string(id), "limit-")
|
||||
}
|
||||
|
||||
// SanctionRecord stores one sanction history record.
|
||||
type SanctionRecord struct {
|
||||
// RecordID identifies the sanction history record.
|
||||
RecordID SanctionRecordID
|
||||
|
||||
// UserID identifies the account that owns the sanction.
|
||||
UserID common.UserID
|
||||
|
||||
// SanctionCode stores the sanction applied to the account.
|
||||
SanctionCode SanctionCode
|
||||
|
||||
// Scope stores the machine-readable scope attached to the sanction.
|
||||
Scope common.Scope
|
||||
|
||||
// ReasonCode stores the reason for the sanction mutation.
|
||||
ReasonCode common.ReasonCode
|
||||
|
||||
// Actor stores the audit actor metadata for the apply mutation.
|
||||
Actor common.ActorRef
|
||||
|
||||
// AppliedAt stores when the sanction becomes effective.
|
||||
AppliedAt time.Time
|
||||
|
||||
// ExpiresAt stores the optional planned expiry of the sanction.
|
||||
ExpiresAt *time.Time
|
||||
|
||||
// RemovedAt stores when the sanction was later removed explicitly.
|
||||
RemovedAt *time.Time
|
||||
|
||||
// RemovedBy stores the audit actor metadata for the remove mutation.
|
||||
RemovedBy common.ActorRef
|
||||
|
||||
// RemovedReasonCode stores the reason for the remove mutation.
|
||||
RemovedReasonCode common.ReasonCode
|
||||
}
|
||||
|
||||
// Validate reports whether SanctionRecord satisfies the frozen structural
|
||||
// invariants that do not depend on a caller-supplied clock.
|
||||
func (record SanctionRecord) Validate() error {
|
||||
if err := record.RecordID.Validate(); err != nil {
|
||||
return fmt.Errorf("sanction record id: %w", err)
|
||||
}
|
||||
if err := record.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("sanction user id: %w", err)
|
||||
}
|
||||
if !record.SanctionCode.IsKnown() {
|
||||
return fmt.Errorf("sanction code %q is unsupported", record.SanctionCode)
|
||||
}
|
||||
if err := record.Scope.Validate(); err != nil {
|
||||
return fmt.Errorf("sanction scope: %w", err)
|
||||
}
|
||||
if err := record.ReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("sanction reason code: %w", err)
|
||||
}
|
||||
if err := record.Actor.Validate(); err != nil {
|
||||
return fmt.Errorf("sanction actor: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("sanction applied at", record.AppliedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.ExpiresAt != nil && !record.ExpiresAt.After(record.AppliedAt) {
|
||||
return common.ErrInvertedTimeRange
|
||||
}
|
||||
if record.RemovedAt == nil {
|
||||
if !record.RemovedBy.IsZero() {
|
||||
return fmt.Errorf("sanction removed by must be empty when removed at is absent")
|
||||
}
|
||||
if !record.RemovedReasonCode.IsZero() {
|
||||
return fmt.Errorf("sanction removed reason code must be empty when removed at is absent")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if record.RemovedAt.Before(record.AppliedAt) {
|
||||
return fmt.Errorf("sanction removed at must not be before applied at")
|
||||
}
|
||||
if err := record.RemovedBy.Validate(); err != nil {
|
||||
return fmt.Errorf("sanction removed by: %w", err)
|
||||
}
|
||||
if err := record.RemovedReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("sanction removed reason code: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateAt reports whether SanctionRecord also satisfies the current-time
|
||||
// Stage 02 invariant that `applied_at` must not be in the future.
|
||||
func (record SanctionRecord) ValidateAt(now time.Time) error {
|
||||
if err := record.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if now.IsZero() {
|
||||
return fmt.Errorf("sanction validation time must not be zero")
|
||||
}
|
||||
if record.AppliedAt.After(now.UTC()) {
|
||||
return fmt.Errorf("sanction applied at must not be in the future")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsActiveAt reports whether SanctionRecord is active at now according to the
|
||||
// frozen Stage 02 rules.
|
||||
func (record SanctionRecord) IsActiveAt(now time.Time) bool {
|
||||
now = now.UTC()
|
||||
switch {
|
||||
case now.IsZero():
|
||||
return false
|
||||
case record.AppliedAt.After(now):
|
||||
return false
|
||||
case record.RemovedAt != nil:
|
||||
return false
|
||||
case record.ExpiresAt != nil && !record.ExpiresAt.After(now):
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// LimitRecord stores one user-specific limit history record.
|
||||
type LimitRecord struct {
|
||||
// RecordID identifies the limit history record.
|
||||
RecordID LimitRecordID
|
||||
|
||||
// UserID identifies the account that owns the limit.
|
||||
UserID common.UserID
|
||||
|
||||
// LimitCode stores which count-based limit is overridden.
|
||||
LimitCode LimitCode
|
||||
|
||||
// Value stores the override value.
|
||||
Value int
|
||||
|
||||
// ReasonCode stores the reason for the limit mutation.
|
||||
ReasonCode common.ReasonCode
|
||||
|
||||
// Actor stores the audit actor metadata for the set mutation.
|
||||
Actor common.ActorRef
|
||||
|
||||
// AppliedAt stores when the limit becomes effective.
|
||||
AppliedAt time.Time
|
||||
|
||||
// ExpiresAt stores the optional planned expiry of the limit.
|
||||
ExpiresAt *time.Time
|
||||
|
||||
// RemovedAt stores when the limit was later removed explicitly.
|
||||
RemovedAt *time.Time
|
||||
|
||||
// RemovedBy stores the audit actor metadata for the remove mutation.
|
||||
RemovedBy common.ActorRef
|
||||
|
||||
// RemovedReasonCode stores the reason for the remove mutation.
|
||||
RemovedReasonCode common.ReasonCode
|
||||
}
|
||||
|
||||
// Validate reports whether LimitRecord satisfies the structural invariants
|
||||
// that do not depend on a caller-supplied clock. Retired legacy limit codes
|
||||
// remain recognized here so already stored records still decode safely.
|
||||
func (record LimitRecord) Validate() error {
|
||||
if err := record.RecordID.Validate(); err != nil {
|
||||
return fmt.Errorf("limit record id: %w", err)
|
||||
}
|
||||
if err := record.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("limit user id: %w", err)
|
||||
}
|
||||
if !record.LimitCode.IsRecognized() {
|
||||
return fmt.Errorf("limit code %q is unsupported", record.LimitCode)
|
||||
}
|
||||
if record.Value < 0 {
|
||||
return fmt.Errorf("limit value must not be negative")
|
||||
}
|
||||
if err := record.ReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("limit reason code: %w", err)
|
||||
}
|
||||
if err := record.Actor.Validate(); err != nil {
|
||||
return fmt.Errorf("limit actor: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("limit applied at", record.AppliedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.ExpiresAt != nil && !record.ExpiresAt.After(record.AppliedAt) {
|
||||
return common.ErrInvertedTimeRange
|
||||
}
|
||||
if record.RemovedAt == nil {
|
||||
if !record.RemovedBy.IsZero() {
|
||||
return fmt.Errorf("limit removed by must be empty when removed at is absent")
|
||||
}
|
||||
if !record.RemovedReasonCode.IsZero() {
|
||||
return fmt.Errorf("limit removed reason code must be empty when removed at is absent")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if record.RemovedAt.Before(record.AppliedAt) {
|
||||
return fmt.Errorf("limit removed at must not be before applied at")
|
||||
}
|
||||
if err := record.RemovedBy.Validate(); err != nil {
|
||||
return fmt.Errorf("limit removed by: %w", err)
|
||||
}
|
||||
if err := record.RemovedReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("limit removed reason code: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateAt reports whether LimitRecord also satisfies the current-time Stage
|
||||
// 02 invariant that `applied_at` must not be in the future.
|
||||
func (record LimitRecord) ValidateAt(now time.Time) error {
|
||||
if err := record.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if now.IsZero() {
|
||||
return fmt.Errorf("limit validation time must not be zero")
|
||||
}
|
||||
if record.AppliedAt.After(now.UTC()) {
|
||||
return fmt.Errorf("limit applied at must not be in the future")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsActiveAt reports whether LimitRecord is active at now according to the
|
||||
// frozen Stage 02 rules.
|
||||
func (record LimitRecord) IsActiveAt(now time.Time) bool {
|
||||
now = now.UTC()
|
||||
switch {
|
||||
case now.IsZero():
|
||||
return false
|
||||
case record.AppliedAt.After(now):
|
||||
return false
|
||||
case record.RemovedAt != nil:
|
||||
return false
|
||||
case record.ExpiresAt != nil && !record.ExpiresAt.After(now):
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// ActiveSanctionsAt returns the active sanctions at now, sorted
|
||||
// deterministically by `sanction_code`. The function returns an error when the
|
||||
// input contains structurally invalid records or more than one active sanction
|
||||
// for the same `user_id + sanction_code`.
|
||||
func ActiveSanctionsAt(records []SanctionRecord, now time.Time) ([]SanctionRecord, error) {
|
||||
active := make([]SanctionRecord, 0, len(records))
|
||||
seen := make(map[SanctionCode]struct{}, len(records))
|
||||
|
||||
for _, record := range records {
|
||||
if err := record.ValidateAt(now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !record.IsActiveAt(now) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[record.SanctionCode]; ok {
|
||||
return nil, fmt.Errorf("multiple active sanctions for code %q", record.SanctionCode)
|
||||
}
|
||||
seen[record.SanctionCode] = struct{}{}
|
||||
active = append(active, record)
|
||||
}
|
||||
|
||||
slices.SortFunc(active, func(left SanctionRecord, right SanctionRecord) int {
|
||||
return strings.Compare(string(left.SanctionCode), string(right.SanctionCode))
|
||||
})
|
||||
|
||||
return active, nil
|
||||
}
|
||||
|
||||
// ActiveLimitsAt returns the active limits at now, sorted deterministically by
|
||||
// `limit_code`. Retired legacy limit codes are ignored so historical records
|
||||
// stored under the old catalog do not affect current effective reads. The
|
||||
// function returns an error when the input contains structurally invalid
|
||||
// records or more than one active current limit for the same
|
||||
// `user_id + limit_code`.
|
||||
func ActiveLimitsAt(records []LimitRecord, now time.Time) ([]LimitRecord, error) {
|
||||
active := make([]LimitRecord, 0, len(records))
|
||||
seen := make(map[LimitCode]struct{}, len(records))
|
||||
|
||||
for _, record := range records {
|
||||
if err := record.ValidateAt(now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !record.IsActiveAt(now) {
|
||||
continue
|
||||
}
|
||||
if !record.LimitCode.IsSupported() {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[record.LimitCode]; ok {
|
||||
return nil, fmt.Errorf("multiple active limits for code %q", record.LimitCode)
|
||||
}
|
||||
seen[record.LimitCode] = struct{}{}
|
||||
active = append(active, record)
|
||||
}
|
||||
|
||||
slices.SortFunc(active, func(left LimitRecord, right LimitRecord) int {
|
||||
return strings.Compare(string(left.LimitCode), string(right.LimitCode))
|
||||
})
|
||||
|
||||
return active, nil
|
||||
}
|
||||
|
||||
func validatePrefixedRecordID(name string, value string, prefix string) error {
|
||||
switch {
|
||||
case strings.TrimSpace(value) == "":
|
||||
return fmt.Errorf("%s must not be empty", name)
|
||||
case strings.TrimSpace(value) != value:
|
||||
return fmt.Errorf("%s must not contain surrounding whitespace", name)
|
||||
case !strings.HasPrefix(value, prefix):
|
||||
return fmt.Errorf("%s must start with %q", name, prefix)
|
||||
case len(value) == len(prefix):
|
||||
return fmt.Errorf("%s must contain opaque data after %q", name, prefix)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSanctionRecordValidateAt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
expiresAt := now.Add(time.Hour)
|
||||
removedAt := now.Add(30 * time.Minute)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
record SanctionRecord
|
||||
wantErr bool
|
||||
wantActive bool
|
||||
}{
|
||||
{
|
||||
name: "active",
|
||||
record: SanctionRecord{
|
||||
RecordID: SanctionRecordID("sanction-1"),
|
||||
UserID: common.UserID("user-123"),
|
||||
SanctionCode: SanctionCodeLoginBlock,
|
||||
Scope: common.Scope("auth"),
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
AppliedAt: now.Add(-time.Minute),
|
||||
ExpiresAt: &expiresAt,
|
||||
},
|
||||
wantActive: true,
|
||||
},
|
||||
{
|
||||
name: "expired",
|
||||
record: SanctionRecord{
|
||||
RecordID: SanctionRecordID("sanction-1"),
|
||||
UserID: common.UserID("user-123"),
|
||||
SanctionCode: SanctionCodeLoginBlock,
|
||||
Scope: common.Scope("auth"),
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
AppliedAt: now.Add(-2 * time.Hour),
|
||||
ExpiresAt: ptrTime(now.Add(-time.Minute)),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "removed",
|
||||
record: SanctionRecord{
|
||||
RecordID: SanctionRecordID("sanction-1"),
|
||||
UserID: common.UserID("user-123"),
|
||||
SanctionCode: SanctionCodeLoginBlock,
|
||||
Scope: common.Scope("auth"),
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
AppliedAt: now.Add(-time.Hour),
|
||||
RemovedAt: &removedAt,
|
||||
RemovedBy: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")},
|
||||
RemovedReasonCode: common.ReasonCode("manual_remove"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "future applied at",
|
||||
record: SanctionRecord{
|
||||
RecordID: SanctionRecordID("sanction-1"),
|
||||
UserID: common.UserID("user-123"),
|
||||
SanctionCode: SanctionCodeLoginBlock,
|
||||
Scope: common.Scope("auth"),
|
||||
ReasonCode: common.ReasonCode("policy_blocked"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
AppliedAt: now.Add(time.Minute),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := tt.record.ValidateAt(now)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantActive, tt.record.IsActiveAt(now))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveSanctionsAt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
records := []SanctionRecord{
|
||||
{
|
||||
RecordID: SanctionRecordID("sanction-1"),
|
||||
UserID: common.UserID("user-123"),
|
||||
SanctionCode: SanctionCodeProfileUpdateBlock,
|
||||
Scope: common.Scope("profile"),
|
||||
ReasonCode: common.ReasonCode("moderation"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
AppliedAt: now.Add(-time.Hour),
|
||||
},
|
||||
{
|
||||
RecordID: SanctionRecordID("sanction-2"),
|
||||
UserID: common.UserID("user-123"),
|
||||
SanctionCode: SanctionCodeLoginBlock,
|
||||
Scope: common.Scope("auth"),
|
||||
ReasonCode: common.ReasonCode("policy"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")},
|
||||
AppliedAt: now.Add(-2 * time.Hour),
|
||||
ExpiresAt: ptrTime(now.Add(-time.Minute)),
|
||||
},
|
||||
}
|
||||
|
||||
active, err := ActiveSanctionsAt(records, now)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, active, 1)
|
||||
require.Equal(t, SanctionCodeProfileUpdateBlock, active[0].SanctionCode)
|
||||
}
|
||||
|
||||
func TestActiveSanctionsAtDuplicateActiveCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
_, err := ActiveSanctionsAt([]SanctionRecord{
|
||||
{
|
||||
RecordID: SanctionRecordID("sanction-1"),
|
||||
UserID: common.UserID("user-123"),
|
||||
SanctionCode: SanctionCodeLoginBlock,
|
||||
Scope: common.Scope("auth"),
|
||||
ReasonCode: common.ReasonCode("policy"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin")},
|
||||
AppliedAt: now.Add(-time.Hour),
|
||||
},
|
||||
{
|
||||
RecordID: SanctionRecordID("sanction-2"),
|
||||
UserID: common.UserID("user-123"),
|
||||
SanctionCode: SanctionCodeLoginBlock,
|
||||
Scope: common.Scope("auth"),
|
||||
ReasonCode: common.ReasonCode("policy"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin")},
|
||||
AppliedAt: now.Add(-2 * time.Hour),
|
||||
},
|
||||
}, now)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestLimitRecordValidateAtAndActiveLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
|
||||
record := LimitRecord{
|
||||
RecordID: LimitRecordID("limit-1"),
|
||||
UserID: common.UserID("user-123"),
|
||||
LimitCode: LimitCodeMaxOwnedPrivateGames,
|
||||
Value: 3,
|
||||
ReasonCode: common.ReasonCode("manual_override"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
|
||||
AppliedAt: now.Add(-time.Minute),
|
||||
}
|
||||
require.NoError(t, record.ValidateAt(now))
|
||||
require.True(t, record.IsActiveAt(now))
|
||||
|
||||
active, err := ActiveLimitsAt([]LimitRecord{
|
||||
record,
|
||||
{
|
||||
RecordID: LimitRecordID("limit-2"),
|
||||
UserID: common.UserID("user-123"),
|
||||
LimitCode: LimitCodeMaxActivePrivateGames,
|
||||
Value: 7,
|
||||
ReasonCode: common.ReasonCode("manual_override"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin")},
|
||||
AppliedAt: now.Add(-time.Hour),
|
||||
},
|
||||
}, now)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, active, 1)
|
||||
require.Equal(t, LimitCodeMaxOwnedPrivateGames, active[0].LimitCode)
|
||||
}
|
||||
|
||||
func TestLimitCodeSupportAndRetiredRecognition(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.True(t, LimitCodeMaxOwnedPrivateGames.IsSupported())
|
||||
require.True(t, LimitCodeMaxPendingPublicApplications.IsSupported())
|
||||
require.True(t, LimitCodeMaxActiveGameMemberships.IsSupported())
|
||||
|
||||
require.True(t, LimitCodeMaxActivePrivateGames.IsRetired())
|
||||
require.True(t, LimitCodeMaxPendingPrivateJoinRequests.IsRetired())
|
||||
require.True(t, LimitCodeMaxPendingPrivateInvitesSent.IsRetired())
|
||||
|
||||
require.True(t, LimitCodeMaxActivePrivateGames.IsRecognized())
|
||||
require.False(t, LimitCode("unknown_limit").IsRecognized())
|
||||
require.False(t, LimitCodeMaxActivePrivateGames.IsKnown())
|
||||
}
|
||||
|
||||
func TestActiveLimitsAtDuplicateActiveCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(1_775_240_000, 0).UTC()
|
||||
_, err := ActiveLimitsAt([]LimitRecord{
|
||||
{
|
||||
RecordID: LimitRecordID("limit-1"),
|
||||
UserID: common.UserID("user-123"),
|
||||
LimitCode: LimitCodeMaxOwnedPrivateGames,
|
||||
Value: 2,
|
||||
ReasonCode: common.ReasonCode("manual_override"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin")},
|
||||
AppliedAt: now.Add(-time.Hour),
|
||||
},
|
||||
{
|
||||
RecordID: LimitRecordID("limit-2"),
|
||||
UserID: common.UserID("user-123"),
|
||||
LimitCode: LimitCodeMaxOwnedPrivateGames,
|
||||
Value: 5,
|
||||
ReasonCode: common.ReasonCode("manual_override"),
|
||||
Actor: common.ActorRef{Type: common.ActorType("admin")},
|
||||
AppliedAt: now.Add(-2 * time.Hour),
|
||||
},
|
||||
}, now)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func ptrTime(value time.Time) *time.Time {
|
||||
return &value
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Package logging configures the user-service process logger and provides
|
||||
// context-aware helpers for attaching OpenTelemetry trace identifiers.
|
||||
package logging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// New constructs the process-wide JSON logger from level.
|
||||
func New(level string) (*slog.Logger, error) {
|
||||
var slogLevel slog.Level
|
||||
if err := slogLevel.UnmarshalText([]byte(strings.TrimSpace(level))); err != nil {
|
||||
return nil, fmt.Errorf("build logger: %w", err)
|
||||
}
|
||||
|
||||
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slogLevel,
|
||||
})), nil
|
||||
}
|
||||
|
||||
// TraceAttrsFromContext returns slog key-value pairs for the active
|
||||
// OpenTelemetry span when ctx carries a valid span context.
|
||||
func TraceAttrsFromContext(ctx context.Context) []any {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
spanContext := trace.SpanContextFromContext(ctx)
|
||||
if !spanContext.IsValid() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []any{
|
||||
"otel_trace_id", spanContext.TraceID().String(),
|
||||
"otel_span_id", spanContext.SpanID().String(),
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user