Files
galaxy-game/user/internal/api/internalhttp/handler_test.go
T
2026-04-10 19:05:02 +02:00

1265 lines
58 KiB
Go

package internalhttp
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/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/service/shared"
"github.com/stretchr/testify/require"
)
func TestAuthFacingHandlersSuccessCases(t *testing.T) {
t.Parallel()
handler := mustNewHandler(t, Dependencies{
ResolveByEmail: resolveByEmailFunc(func(_ context.Context, input authdirectory.ResolveByEmailInput) (authdirectory.ResolveByEmailResult, error) {
require.Equal(t, "pilot@example.com", input.Email)
return authdirectory.ResolveByEmailResult{Kind: "existing", UserID: "user-123"}, nil
}),
EnsureByEmail: ensureByEmailFunc(func(_ context.Context, input authdirectory.EnsureByEmailInput) (authdirectory.EnsureByEmailResult, error) {
require.Equal(t, "created@example.com", input.Email)
require.NotNil(t, input.RegistrationContext)
return authdirectory.EnsureByEmailResult{Outcome: "created", UserID: "user-234"}, nil
}),
ExistsByUserID: existsByUserIDFunc(func(_ context.Context, input authdirectory.ExistsByUserIDInput) (authdirectory.ExistsByUserIDResult, error) {
require.Equal(t, "user-123", input.UserID)
return authdirectory.ExistsByUserIDResult{Exists: true}, nil
}),
BlockByUserID: blockByUserIDFunc(func(_ context.Context, input authdirectory.BlockByUserIDInput) (authdirectory.BlockResult, error) {
require.Equal(t, "user-123", input.UserID)
return authdirectory.BlockResult{Outcome: "blocked", UserID: "user-123"}, nil
}),
BlockByEmail: blockByEmailFunc(func(_ context.Context, input authdirectory.BlockByEmailInput) (authdirectory.BlockResult, error) {
require.Equal(t, "blocked@example.com", input.Email)
return authdirectory.BlockResult{Outcome: "already_blocked", UserID: "user-345"}, nil
}),
GetMyAccount: getMyAccountFunc(func(_ context.Context, input selfservice.GetMyAccountInput) (selfservice.GetMyAccountResult, error) {
require.Equal(t, "user-123", input.UserID)
return selfservice.GetMyAccountResult{Account: sampleAccountView()}, nil
}),
UpdateMyProfile: updateMyProfileFunc(func(_ context.Context, input selfservice.UpdateMyProfileInput) (selfservice.UpdateMyProfileResult, error) {
require.Equal(t, "user-123", input.UserID)
require.Equal(t, "Nova Prime", input.RaceName)
accountView := sampleAccountView()
accountView.RaceName = input.RaceName
return selfservice.UpdateMyProfileResult{Account: accountView}, nil
}),
UpdateMySettings: updateMySettingsFunc(func(_ context.Context, input selfservice.UpdateMySettingsInput) (selfservice.UpdateMySettingsResult, error) {
require.Equal(t, "user-123", input.UserID)
require.Equal(t, "en-US", input.PreferredLanguage)
require.Equal(t, "UTC", input.TimeZone)
accountView := sampleAccountView()
accountView.PreferredLanguage = input.PreferredLanguage
accountView.TimeZone = input.TimeZone
return selfservice.UpdateMySettingsResult{Account: accountView}, nil
}),
GetUserEligibility: getUserEligibilityFunc(func(_ context.Context, input lobbyeligibility.GetUserEligibilityInput) (lobbyeligibility.GetUserEligibilityResult, error) {
switch input.UserID {
case "user-123":
return sampleEligibilityView(true), nil
case "user-missing":
return sampleEligibilityView(false), nil
default:
return lobbyeligibility.GetUserEligibilityResult{}, shared.InvalidRequest("unexpected user id")
}
}),
SyncDeclaredCountry: syncDeclaredCountryFunc(func(_ context.Context, input geosync.SyncDeclaredCountryInput) (geosync.SyncDeclaredCountryResult, error) {
require.Equal(t, "user-123", input.UserID)
switch input.DeclaredCountry {
case "FR":
return geosync.SyncDeclaredCountryResult{
UserID: "user-123",
DeclaredCountry: "FR",
UpdatedAt: time.Date(2026, time.April, 9, 11, 0, 0, 0, time.UTC),
}, nil
case "DE":
return geosync.SyncDeclaredCountryResult{
UserID: "user-123",
DeclaredCountry: "DE",
UpdatedAt: time.Date(2026, time.April, 9, 10, 0, 0, 0, time.UTC),
}, nil
default:
return geosync.SyncDeclaredCountryResult{}, shared.InvalidRequest("unexpected declared country")
}
}),
GrantEntitlement: grantEntitlementFunc(func(_ context.Context, input entitlementsvc.GrantInput) (entitlementsvc.CommandResult, error) {
require.Equal(t, "user-123", input.UserID)
require.Equal(t, "paid_monthly", input.PlanCode)
require.Equal(t, "admin", input.Source)
require.Equal(t, "manual_grant", input.ReasonCode)
require.Equal(t, "admin", input.Actor.Type)
require.Equal(t, "admin-1", input.Actor.ID)
return entitlementsvc.CommandResult{
UserID: "user-123",
Entitlement: entitlement.CurrentSnapshot{
UserID: common.UserID("user-123"),
PlanCode: entitlement.PlanCodePaidMonthly,
IsPaid: true,
StartsAt: time.Date(2026, time.April, 9, 10, 0, 0, 0, time.UTC),
EndsAt: timePointer(time.Date(2026, time.May, 9, 10, 0, 0, 0, time.UTC)),
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_grant"),
UpdatedAt: time.Date(2026, time.April, 9, 10, 0, 0, 0, time.UTC),
},
}, nil
}),
ExtendEntitlement: extendEntitlementFunc(func(_ context.Context, input entitlementsvc.ExtendInput) (entitlementsvc.CommandResult, error) {
require.Equal(t, "user-123", input.UserID)
require.Equal(t, "admin", input.Source)
require.Equal(t, "manual_extend", input.ReasonCode)
require.Equal(t, "2026-06-09T10:00:00Z", input.EndsAt)
return entitlementsvc.CommandResult{
UserID: "user-123",
Entitlement: entitlement.CurrentSnapshot{
UserID: common.UserID("user-123"),
PlanCode: entitlement.PlanCodePaidMonthly,
IsPaid: true,
StartsAt: time.Date(2026, time.April, 9, 10, 0, 0, 0, time.UTC),
EndsAt: timePointer(time.Date(2026, time.June, 9, 10, 0, 0, 0, time.UTC)),
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_extend"),
UpdatedAt: time.Date(2026, time.April, 9, 10, 0, 0, 0, time.UTC),
},
}, nil
}),
RevokeEntitlement: revokeEntitlementFunc(func(_ context.Context, input entitlementsvc.RevokeInput) (entitlementsvc.CommandResult, error) {
require.Equal(t, "user-123", input.UserID)
require.Equal(t, "admin", input.Source)
require.Equal(t, "manual_revoke", input.ReasonCode)
return entitlementsvc.CommandResult{
UserID: "user-123",
Entitlement: entitlement.CurrentSnapshot{
UserID: common.UserID("user-123"),
PlanCode: entitlement.PlanCodeFree,
IsPaid: false,
StartsAt: time.Date(2026, time.April, 9, 10, 0, 0, 0, time.UTC),
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_revoke"),
UpdatedAt: time.Date(2026, time.April, 9, 10, 0, 0, 0, time.UTC),
},
}, nil
}),
ApplySanction: applySanctionFunc(func(_ context.Context, input policysvc.ApplySanctionInput) (policysvc.SanctionCommandResult, error) {
require.Equal(t, "user-123", input.UserID)
require.Equal(t, "login_block", input.SanctionCode)
require.Equal(t, "auth", input.Scope)
require.Equal(t, "manual_block", input.ReasonCode)
require.Equal(t, "admin", input.Actor.Type)
require.Equal(t, "admin-1", input.Actor.ID)
return policysvc.SanctionCommandResult{
UserID: "user-123",
ActiveSanctions: []policysvc.ActiveSanctionView{
{
SanctionCode: "login_block",
Scope: "auth",
ReasonCode: "manual_block",
Actor: policysvc.ActorRefView{Type: "admin", ID: "admin-1"},
AppliedAt: time.Date(2026, time.April, 9, 10, 0, 0, 0, time.UTC),
ExpiresAt: timePointer(time.Date(2026, time.May, 9, 10, 0, 0, 0, time.UTC)),
},
},
}, nil
}),
RemoveSanction: removeSanctionFunc(func(_ context.Context, input policysvc.RemoveSanctionInput) (policysvc.SanctionCommandResult, error) {
require.Equal(t, "user-123", input.UserID)
require.Equal(t, "login_block", input.SanctionCode)
require.Equal(t, "manual_remove", input.ReasonCode)
return policysvc.SanctionCommandResult{UserID: "user-123", ActiveSanctions: []policysvc.ActiveSanctionView{}}, nil
}),
SetLimit: setLimitFunc(func(_ context.Context, input policysvc.SetLimitInput) (policysvc.LimitCommandResult, error) {
require.Equal(t, "user-123", input.UserID)
require.Equal(t, "max_owned_private_games", input.LimitCode)
require.Equal(t, 5, input.Value)
require.Equal(t, "manual_override", input.ReasonCode)
return policysvc.LimitCommandResult{
UserID: "user-123",
ActiveLimits: []policysvc.ActiveLimitView{
{
LimitCode: "max_owned_private_games",
Value: 5,
ReasonCode: "manual_override",
Actor: policysvc.ActorRefView{Type: "admin", ID: "admin-1"},
AppliedAt: time.Date(2026, time.April, 9, 10, 0, 0, 0, time.UTC),
ExpiresAt: timePointer(time.Date(2026, time.June, 9, 10, 0, 0, 0, time.UTC)),
},
},
}, nil
}),
RemoveLimit: removeLimitFunc(func(_ context.Context, input policysvc.RemoveLimitInput) (policysvc.LimitCommandResult, error) {
require.Equal(t, "user-123", input.UserID)
require.Equal(t, "max_owned_private_games", input.LimitCode)
require.Equal(t, "manual_remove", input.ReasonCode)
return policysvc.LimitCommandResult{UserID: "user-123", ActiveLimits: []policysvc.ActiveLimitView{}}, nil
}),
})
tests := []struct {
name string
method string
path string
body string
wantStatus int
wantBody string
}{
{
name: "resolve by email",
method: http.MethodPost,
path: "/api/v1/internal/user-resolutions/by-email",
body: `{"email":"pilot@example.com"}`,
wantStatus: http.StatusOK,
wantBody: `{"kind":"existing","user_id":"user-123"}`,
},
{
name: "exists by user id",
method: http.MethodGet,
path: "/api/v1/internal/users/user-123/exists",
wantStatus: http.StatusOK,
wantBody: `{"exists":true}`,
},
{
name: "ensure by email",
method: http.MethodPost,
path: "/api/v1/internal/users/ensure-by-email",
body: `{"email":"created@example.com","registration_context":{"preferred_language":"en","time_zone":"Europe/Kaliningrad"}}`,
wantStatus: http.StatusOK,
wantBody: `{"outcome":"created","user_id":"user-234"}`,
},
{
name: "block by user id",
method: http.MethodPost,
path: "/api/v1/internal/users/user-123/block",
body: `{"reason_code":"policy_blocked"}`,
wantStatus: http.StatusOK,
wantBody: `{"outcome":"blocked","user_id":"user-123"}`,
},
{
name: "block by email",
method: http.MethodPost,
path: "/api/v1/internal/user-blocks/by-email",
body: `{"email":"blocked@example.com","reason_code":"policy_blocked"}`,
wantStatus: http.StatusOK,
wantBody: `{"outcome":"already_blocked","user_id":"user-345"}`,
},
{
name: "get my account",
method: http.MethodGet,
path: "/api/v1/internal/users/user-123/account",
wantStatus: http.StatusOK,
wantBody: `{"account":{"user_id":"user-123","email":"pilot@example.com","race_name":"Pilot Nova","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}}`,
},
{
name: "update my profile",
method: http.MethodPost,
path: "/api/v1/internal/users/user-123/profile",
body: `{"race_name":"Nova Prime"}`,
wantStatus: http.StatusOK,
wantBody: `{"account":{"user_id":"user-123","email":"pilot@example.com","race_name":"Nova Prime","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}}`,
},
{
name: "update my settings",
method: http.MethodPost,
path: "/api/v1/internal/users/user-123/settings",
body: `{"preferred_language":"en-US","time_zone":"UTC"}`,
wantStatus: http.StatusOK,
wantBody: `{"account":{"user_id":"user-123","email":"pilot@example.com","race_name":"Pilot Nova","preferred_language":"en-US","time_zone":"UTC","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}}`,
},
{
name: "get user eligibility",
method: http.MethodGet,
path: "/api/v1/internal/users/user-123/eligibility",
wantStatus: http.StatusOK,
wantBody: `{"exists":true,"user_id":"user-123","entitlement":{"plan_code":"paid_monthly","is_paid":true,"source":"billing","actor":{"type":"billing","id":"invoice-1"},"reason_code":"renewal","starts_at":"2026-04-09T10:00:00Z","ends_at":"2026-05-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[{"sanction_code":"private_game_create_block","scope":"lobby","reason_code":"manual_block","actor":{"type":"admin","id":"admin-1"},"applied_at":"2026-04-09T10:00:00Z","expires_at":"2026-05-09T10:00:00Z"}],"effective_limits":[{"limit_code":"max_owned_private_games","value":3},{"limit_code":"max_pending_public_applications","value":10},{"limit_code":"max_active_game_memberships","value":10}],"markers":{"can_login":true,"can_create_private_game":false,"can_manage_private_game":true,"can_join_game":true,"can_update_profile":true}}`,
},
{
name: "get user eligibility not found snapshot",
method: http.MethodGet,
path: "/api/v1/internal/users/user-missing/eligibility",
wantStatus: http.StatusOK,
wantBody: `{"exists":false,"user_id":"user-missing","active_sanctions":[],"effective_limits":[],"markers":{"can_login":false,"can_create_private_game":false,"can_manage_private_game":false,"can_join_game":false,"can_update_profile":false}}`,
},
{
name: "sync declared country change",
method: http.MethodPost,
path: "/api/v1/internal/users/user-123/declared-country/sync",
body: `{"declared_country":"FR"}`,
wantStatus: http.StatusOK,
wantBody: `{"user_id":"user-123","declared_country":"FR","updated_at":"2026-04-09T11:00:00Z"}`,
},
{
name: "sync declared country same value no-op",
method: http.MethodPost,
path: "/api/v1/internal/users/user-123/declared-country/sync",
body: `{"declared_country":"DE"}`,
wantStatus: http.StatusOK,
wantBody: `{"user_id":"user-123","declared_country":"DE","updated_at":"2026-04-09T10:00:00Z"}`,
},
{
name: "grant entitlement",
method: http.MethodPost,
path: "/api/v1/internal/users/user-123/entitlements/grant",
body: `{"plan_code":"paid_monthly","source":"admin","reason_code":"manual_grant","actor":{"type":"admin","id":"admin-1"},"starts_at":"2026-04-09T10:00:00Z","ends_at":"2026-05-09T10:00:00Z"}`,
wantStatus: http.StatusOK,
wantBody: `{"user_id":"user-123","entitlement":{"plan_code":"paid_monthly","is_paid":true,"source":"admin","actor":{"type":"admin","id":"admin-1"},"reason_code":"manual_grant","starts_at":"2026-04-09T10:00:00Z","ends_at":"2026-05-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}}`,
},
{
name: "extend entitlement",
method: http.MethodPost,
path: "/api/v1/internal/users/user-123/entitlements/extend",
body: `{"source":"admin","reason_code":"manual_extend","actor":{"type":"admin","id":"admin-1"},"ends_at":"2026-06-09T10:00:00Z"}`,
wantStatus: http.StatusOK,
wantBody: `{"user_id":"user-123","entitlement":{"plan_code":"paid_monthly","is_paid":true,"source":"admin","actor":{"type":"admin","id":"admin-1"},"reason_code":"manual_extend","starts_at":"2026-04-09T10:00:00Z","ends_at":"2026-06-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}}`,
},
{
name: "revoke entitlement",
method: http.MethodPost,
path: "/api/v1/internal/users/user-123/entitlements/revoke",
body: `{"source":"admin","reason_code":"manual_revoke","actor":{"type":"admin","id":"admin-1"}}`,
wantStatus: http.StatusOK,
wantBody: `{"user_id":"user-123","entitlement":{"plan_code":"free","is_paid":false,"source":"admin","actor":{"type":"admin","id":"admin-1"},"reason_code":"manual_revoke","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}}`,
},
{
name: "apply sanction",
method: http.MethodPost,
path: "/api/v1/internal/users/user-123/sanctions/apply",
body: `{"sanction_code":"login_block","scope":"auth","reason_code":"manual_block","actor":{"type":"admin","id":"admin-1"},"applied_at":"2026-04-09T10:00:00Z","expires_at":"2026-05-09T10:00:00Z"}`,
wantStatus: http.StatusOK,
wantBody: `{"user_id":"user-123","active_sanctions":[{"sanction_code":"login_block","scope":"auth","reason_code":"manual_block","actor":{"type":"admin","id":"admin-1"},"applied_at":"2026-04-09T10:00:00Z","expires_at":"2026-05-09T10:00:00Z"}]}`,
},
{
name: "remove sanction",
method: http.MethodPost,
path: "/api/v1/internal/users/user-123/sanctions/remove",
body: `{"sanction_code":"login_block","reason_code":"manual_remove","actor":{"type":"admin","id":"admin-1"}}`,
wantStatus: http.StatusOK,
wantBody: `{"user_id":"user-123","active_sanctions":[]}`,
},
{
name: "set limit",
method: http.MethodPost,
path: "/api/v1/internal/users/user-123/limits/set",
body: `{"limit_code":"max_owned_private_games","value":5,"reason_code":"manual_override","actor":{"type":"admin","id":"admin-1"},"applied_at":"2026-04-09T10:00:00Z","expires_at":"2026-06-09T10:00:00Z"}`,
wantStatus: http.StatusOK,
wantBody: `{"user_id":"user-123","active_limits":[{"limit_code":"max_owned_private_games","value":5,"reason_code":"manual_override","actor":{"type":"admin","id":"admin-1"},"applied_at":"2026-04-09T10:00:00Z","expires_at":"2026-06-09T10:00:00Z"}]}`,
},
{
name: "remove limit",
method: http.MethodPost,
path: "/api/v1/internal/users/user-123/limits/remove",
body: `{"limit_code":"max_owned_private_games","reason_code":"manual_remove","actor":{"type":"admin","id":"admin-1"}}`,
wantStatus: http.StatusOK,
wantBody: `{"user_id":"user-123","active_limits":[]}`,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
recorder := httptest.NewRecorder()
request := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
if tt.body != "" {
request.Header.Set("Content-Type", "application/json")
}
handler.ServeHTTP(recorder, request)
require.Equal(t, tt.wantStatus, recorder.Code)
require.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
assertJSONEq(t, recorder.Body.String(), tt.wantBody)
})
}
}
func TestHandlersRejectInvalidJSONAndMissingRegistrationContext(t *testing.T) {
t.Parallel()
handler := mustNewHandler(t, Dependencies{
ResolveByEmail: resolveByEmailFunc(func(context.Context, authdirectory.ResolveByEmailInput) (authdirectory.ResolveByEmailResult, error) {
return authdirectory.ResolveByEmailResult{}, nil
}),
EnsureByEmail: ensureByEmailFunc(func(context.Context, authdirectory.EnsureByEmailInput) (authdirectory.EnsureByEmailResult, error) {
return authdirectory.EnsureByEmailResult{}, nil
}),
ExistsByUserID: existsByUserIDFunc(func(context.Context, authdirectory.ExistsByUserIDInput) (authdirectory.ExistsByUserIDResult, error) {
return authdirectory.ExistsByUserIDResult{}, nil
}),
BlockByUserID: blockByUserIDFunc(func(context.Context, authdirectory.BlockByUserIDInput) (authdirectory.BlockResult, error) {
return authdirectory.BlockResult{}, nil
}),
BlockByEmail: blockByEmailFunc(func(context.Context, authdirectory.BlockByEmailInput) (authdirectory.BlockResult, error) {
return authdirectory.BlockResult{}, nil
}),
GetMyAccount: getMyAccountFunc(func(context.Context, selfservice.GetMyAccountInput) (selfservice.GetMyAccountResult, error) {
return selfservice.GetMyAccountResult{}, nil
}),
UpdateMyProfile: updateMyProfileFunc(func(context.Context, selfservice.UpdateMyProfileInput) (selfservice.UpdateMyProfileResult, error) {
return selfservice.UpdateMyProfileResult{}, nil
}),
UpdateMySettings: updateMySettingsFunc(func(context.Context, selfservice.UpdateMySettingsInput) (selfservice.UpdateMySettingsResult, error) {
return selfservice.UpdateMySettingsResult{}, nil
}),
GetUserEligibility: getUserEligibilityFunc(func(context.Context, lobbyeligibility.GetUserEligibilityInput) (lobbyeligibility.GetUserEligibilityResult, error) {
return lobbyeligibility.GetUserEligibilityResult{}, nil
}),
SyncDeclaredCountry: syncDeclaredCountryFunc(func(context.Context, geosync.SyncDeclaredCountryInput) (geosync.SyncDeclaredCountryResult, error) {
return geosync.SyncDeclaredCountryResult{}, nil
}),
ApplySanction: applySanctionFunc(func(context.Context, policysvc.ApplySanctionInput) (policysvc.SanctionCommandResult, error) {
return policysvc.SanctionCommandResult{}, nil
}),
RemoveSanction: removeSanctionFunc(func(context.Context, policysvc.RemoveSanctionInput) (policysvc.SanctionCommandResult, error) {
return policysvc.SanctionCommandResult{}, nil
}),
SetLimit: setLimitFunc(func(_ context.Context, input policysvc.SetLimitInput) (policysvc.LimitCommandResult, error) {
if input.LimitCode == string(policy.LimitCodeMaxPendingPrivateJoinRequests) {
return policysvc.LimitCommandResult{}, shared.InvalidRequest("limit_code is unsupported")
}
return policysvc.LimitCommandResult{}, nil
}),
RemoveLimit: removeLimitFunc(func(_ context.Context, input policysvc.RemoveLimitInput) (policysvc.LimitCommandResult, error) {
if input.LimitCode == string(policy.LimitCodeMaxPendingPrivateInvitesSent) {
return policysvc.LimitCommandResult{}, shared.InvalidRequest("limit_code is unsupported")
}
return policysvc.LimitCommandResult{}, nil
}),
})
tests := []struct {
name string
method string
path string
body string
wantBody string
}{
{
name: "resolve empty body",
method: http.MethodPost,
path: "/api/v1/internal/user-resolutions/by-email",
body: ``,
wantBody: `{"error":{"code":"invalid_request","message":"request body must not be empty"}}`,
},
{
name: "resolve malformed json",
method: http.MethodPost,
path: "/api/v1/internal/user-resolutions/by-email",
body: `{"email":`,
wantBody: `{"error":{"code":"invalid_request","message":"request body contains malformed JSON"}}`,
},
{
name: "ensure trailing json",
method: http.MethodPost,
path: "/api/v1/internal/users/ensure-by-email",
body: `{"email":"pilot@example.com","registration_context":{"preferred_language":"en","time_zone":"UTC"}}{}`,
wantBody: `{"error":{"code":"invalid_request","message":"request body must contain a single JSON object"}}`,
},
{
name: "block by email unknown field",
method: http.MethodPost,
path: "/api/v1/internal/user-blocks/by-email",
body: `{"email":"pilot@example.com","reason_code":"policy_blocked","extra":true}`,
wantBody: `{"error":{"code":"invalid_request","message":"request body contains unknown field \"extra\""}}`,
},
{
name: "ensure missing registration context",
method: http.MethodPost,
path: "/api/v1/internal/users/ensure-by-email",
body: `{"email":"pilot@example.com"}`,
wantBody: `{"error":{"code":"invalid_request","message":"registration_context must be present"}}`,
},
{
name: "sync declared country unknown field",
method: http.MethodPost,
path: "/api/v1/internal/users/user-123/declared-country/sync",
body: `{"declared_country":"DE","extra":true}`,
wantBody: `{"error":{"code":"invalid_request","message":"request body contains unknown field \"extra\""}}`,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
recorder := httptest.NewRecorder()
request := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
if tt.body != "" {
request.Header.Set("Content-Type", "application/json")
}
handler.ServeHTTP(recorder, request)
require.Equal(t, http.StatusBadRequest, recorder.Code)
assertJSONEq(t, recorder.Body.String(), tt.wantBody)
})
}
}
func TestBlockByUserIDNotFound(t *testing.T) {
t.Parallel()
handler := mustNewHandler(t, Dependencies{
ResolveByEmail: resolveByEmailFunc(func(context.Context, authdirectory.ResolveByEmailInput) (authdirectory.ResolveByEmailResult, error) {
return authdirectory.ResolveByEmailResult{}, nil
}),
EnsureByEmail: ensureByEmailFunc(func(context.Context, authdirectory.EnsureByEmailInput) (authdirectory.EnsureByEmailResult, error) {
return authdirectory.EnsureByEmailResult{}, nil
}),
ExistsByUserID: existsByUserIDFunc(func(context.Context, authdirectory.ExistsByUserIDInput) (authdirectory.ExistsByUserIDResult, error) {
return authdirectory.ExistsByUserIDResult{}, nil
}),
BlockByUserID: blockByUserIDFunc(func(context.Context, authdirectory.BlockByUserIDInput) (authdirectory.BlockResult, error) {
return authdirectory.BlockResult{}, shared.SubjectNotFound()
}),
BlockByEmail: blockByEmailFunc(func(context.Context, authdirectory.BlockByEmailInput) (authdirectory.BlockResult, error) {
return authdirectory.BlockResult{}, nil
}),
GetMyAccount: getMyAccountFunc(func(context.Context, selfservice.GetMyAccountInput) (selfservice.GetMyAccountResult, error) {
return selfservice.GetMyAccountResult{}, nil
}),
UpdateMyProfile: updateMyProfileFunc(func(context.Context, selfservice.UpdateMyProfileInput) (selfservice.UpdateMyProfileResult, error) {
return selfservice.UpdateMyProfileResult{}, nil
}),
UpdateMySettings: updateMySettingsFunc(func(context.Context, selfservice.UpdateMySettingsInput) (selfservice.UpdateMySettingsResult, error) {
return selfservice.UpdateMySettingsResult{}, nil
}),
GetUserEligibility: getUserEligibilityFunc(func(context.Context, lobbyeligibility.GetUserEligibilityInput) (lobbyeligibility.GetUserEligibilityResult, error) {
return lobbyeligibility.GetUserEligibilityResult{}, nil
}),
ApplySanction: applySanctionFunc(func(context.Context, policysvc.ApplySanctionInput) (policysvc.SanctionCommandResult, error) {
return policysvc.SanctionCommandResult{}, nil
}),
RemoveSanction: removeSanctionFunc(func(context.Context, policysvc.RemoveSanctionInput) (policysvc.SanctionCommandResult, error) {
return policysvc.SanctionCommandResult{}, nil
}),
SetLimit: setLimitFunc(func(context.Context, policysvc.SetLimitInput) (policysvc.LimitCommandResult, error) {
return policysvc.LimitCommandResult{}, shared.InvalidRequest("limit_code is unsupported")
}),
RemoveLimit: removeLimitFunc(func(context.Context, policysvc.RemoveLimitInput) (policysvc.LimitCommandResult, error) {
return policysvc.LimitCommandResult{}, shared.InvalidRequest("limit_code is unsupported")
}),
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodPost,
"/api/v1/internal/users/user-missing/block",
bytes.NewBufferString(`{"reason_code":"policy_blocked"}`),
)
request.Header.Set("Content-Type", "application/json")
handler.ServeHTTP(recorder, request)
require.Equal(t, http.StatusNotFound, recorder.Code)
assertJSONEq(t, recorder.Body.String(), `{"error":{"code":"subject_not_found","message":"subject not found"}}`)
}
func TestSelfServiceHandlersRejectUnknownFieldsAndProjectErrors(t *testing.T) {
t.Parallel()
handler := mustNewHandler(t, Dependencies{
ResolveByEmail: resolveByEmailFunc(func(context.Context, authdirectory.ResolveByEmailInput) (authdirectory.ResolveByEmailResult, error) {
return authdirectory.ResolveByEmailResult{}, nil
}),
EnsureByEmail: ensureByEmailFunc(func(context.Context, authdirectory.EnsureByEmailInput) (authdirectory.EnsureByEmailResult, error) {
return authdirectory.EnsureByEmailResult{}, nil
}),
ExistsByUserID: existsByUserIDFunc(func(context.Context, authdirectory.ExistsByUserIDInput) (authdirectory.ExistsByUserIDResult, error) {
return authdirectory.ExistsByUserIDResult{}, nil
}),
BlockByUserID: blockByUserIDFunc(func(context.Context, authdirectory.BlockByUserIDInput) (authdirectory.BlockResult, error) {
return authdirectory.BlockResult{}, nil
}),
BlockByEmail: blockByEmailFunc(func(context.Context, authdirectory.BlockByEmailInput) (authdirectory.BlockResult, error) {
return authdirectory.BlockResult{}, nil
}),
GetMyAccount: getMyAccountFunc(func(context.Context, selfservice.GetMyAccountInput) (selfservice.GetMyAccountResult, error) {
return selfservice.GetMyAccountResult{}, shared.SubjectNotFound()
}),
UpdateMyProfile: updateMyProfileFunc(func(context.Context, selfservice.UpdateMyProfileInput) (selfservice.UpdateMyProfileResult, error) {
return selfservice.UpdateMyProfileResult{}, shared.Conflict()
}),
UpdateMySettings: updateMySettingsFunc(func(context.Context, selfservice.UpdateMySettingsInput) (selfservice.UpdateMySettingsResult, error) {
return selfservice.UpdateMySettingsResult{}, nil
}),
GetUserEligibility: getUserEligibilityFunc(func(_ context.Context, input lobbyeligibility.GetUserEligibilityInput) (lobbyeligibility.GetUserEligibilityResult, error) {
if input.UserID == "bad-id" {
return lobbyeligibility.GetUserEligibilityResult{}, shared.InvalidRequest("user id must start with \"user-\"")
}
return lobbyeligibility.GetUserEligibilityResult{}, nil
}),
SyncDeclaredCountry: syncDeclaredCountryFunc(func(_ context.Context, input geosync.SyncDeclaredCountryInput) (geosync.SyncDeclaredCountryResult, error) {
if input.UserID == "user-missing" {
return geosync.SyncDeclaredCountryResult{}, shared.SubjectNotFound()
}
if input.DeclaredCountry == "ZZ" {
return geosync.SyncDeclaredCountryResult{}, shared.InvalidRequest("declared_country must be a valid ISO 3166-1 alpha-2 country code")
}
return geosync.SyncDeclaredCountryResult{}, nil
}),
GrantEntitlement: grantEntitlementFunc(func(context.Context, entitlementsvc.GrantInput) (entitlementsvc.CommandResult, error) {
return entitlementsvc.CommandResult{}, shared.Conflict()
}),
ExtendEntitlement: extendEntitlementFunc(func(context.Context, entitlementsvc.ExtendInput) (entitlementsvc.CommandResult, error) {
return entitlementsvc.CommandResult{}, nil
}),
RevokeEntitlement: revokeEntitlementFunc(func(context.Context, entitlementsvc.RevokeInput) (entitlementsvc.CommandResult, error) {
return entitlementsvc.CommandResult{}, nil
}),
ApplySanction: applySanctionFunc(func(context.Context, policysvc.ApplySanctionInput) (policysvc.SanctionCommandResult, error) {
return policysvc.SanctionCommandResult{}, shared.Conflict()
}),
RemoveSanction: removeSanctionFunc(func(context.Context, policysvc.RemoveSanctionInput) (policysvc.SanctionCommandResult, error) {
return policysvc.SanctionCommandResult{}, shared.SubjectNotFound()
}),
SetLimit: setLimitFunc(func(context.Context, policysvc.SetLimitInput) (policysvc.LimitCommandResult, error) {
return policysvc.LimitCommandResult{}, shared.InvalidRequest("limit_code is unsupported")
}),
RemoveLimit: removeLimitFunc(func(context.Context, policysvc.RemoveLimitInput) (policysvc.LimitCommandResult, error) {
return policysvc.LimitCommandResult{}, shared.InvalidRequest("limit_code is unsupported")
}),
})
tests := []struct {
name string
method string
path string
body string
wantStatus int
wantBody string
}{
{
name: "get my account not found",
method: http.MethodGet,
path: "/api/v1/internal/users/user-missing/account",
wantStatus: http.StatusNotFound,
wantBody: `{"error":{"code":"subject_not_found","message":"subject not found"}}`,
},
{
name: "update my profile conflict",
method: http.MethodPost,
path: "/api/v1/internal/users/user-123/profile",
body: `{"race_name":"Taken Name"}`,
wantStatus: http.StatusConflict,
wantBody: `{"error":{"code":"conflict","message":"request conflicts with current state"}}`,
},
{
name: "update my profile rejects email field",
method: http.MethodPost,
path: "/api/v1/internal/users/user-123/profile",
body: `{"race_name":"Nova Prime","email":"pilot@example.com"}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"request body contains unknown field \"email\""}}`,
},
{
name: "update my settings rejects declared country field",
method: http.MethodPost,
path: "/api/v1/internal/users/user-123/settings",
body: `{"preferred_language":"en","time_zone":"UTC","declared_country":"DE"}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"request body contains unknown field \"declared_country\""}}`,
},
{
name: "grant entitlement conflict",
method: http.MethodPost,
path: "/api/v1/internal/users/user-123/entitlements/grant",
body: `{"plan_code":"paid_monthly","source":"admin","reason_code":"manual_grant","actor":{"type":"admin","id":"admin-1"},"starts_at":"2026-04-09T10:00:00Z","ends_at":"2026-05-09T10:00:00Z"}`,
wantStatus: http.StatusConflict,
wantBody: `{"error":{"code":"conflict","message":"request conflicts with current state"}}`,
},
{
name: "apply sanction conflict",
method: http.MethodPost,
path: "/api/v1/internal/users/user-123/sanctions/apply",
body: `{"sanction_code":"login_block","scope":"auth","reason_code":"manual_block","actor":{"type":"admin","id":"admin-1"},"applied_at":"2026-04-09T10:00:00Z"}`,
wantStatus: http.StatusConflict,
wantBody: `{"error":{"code":"conflict","message":"request conflicts with current state"}}`,
},
{
name: "eligibility invalid user id",
method: http.MethodGet,
path: "/api/v1/internal/users/bad-id/eligibility",
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"user id must start with \"user-\""}}`,
},
{
name: "sync declared country invalid",
method: http.MethodPost,
path: "/api/v1/internal/users/user-123/declared-country/sync",
body: `{"declared_country":"ZZ"}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"declared_country must be a valid ISO 3166-1 alpha-2 country code"}}`,
},
{
name: "sync declared country not found",
method: http.MethodPost,
path: "/api/v1/internal/users/user-missing/declared-country/sync",
body: `{"declared_country":"DE"}`,
wantStatus: http.StatusNotFound,
wantBody: `{"error":{"code":"subject_not_found","message":"subject not found"}}`,
},
{
name: "set limit retired code rejected",
method: http.MethodPost,
path: "/api/v1/internal/users/user-123/limits/set",
body: `{"limit_code":"max_pending_private_join_requests","value":1,"reason_code":"manual_override","actor":{"type":"admin","id":"admin-1"},"applied_at":"2026-04-09T10:00:00Z"}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"limit_code is unsupported"}}`,
},
{
name: "remove limit retired code rejected",
method: http.MethodPost,
path: "/api/v1/internal/users/user-123/limits/remove",
body: `{"limit_code":"max_pending_private_invites_sent","reason_code":"manual_remove","actor":{"type":"admin","id":"admin-1"}}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"limit_code is unsupported"}}`,
},
{
name: "apply sanction rejects unknown field",
method: http.MethodPost,
path: "/api/v1/internal/users/user-123/sanctions/apply",
body: `{"sanction_code":"login_block","scope":"auth","reason_code":"manual_block","actor":{"type":"admin","id":"admin-1"},"applied_at":"2026-04-09T10:00:00Z","extra":true}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"request body contains unknown field \"extra\""}}`,
},
{
name: "remove sanction not found",
method: http.MethodPost,
path: "/api/v1/internal/users/user-123/sanctions/remove",
body: `{"sanction_code":"login_block","reason_code":"manual_remove","actor":{"type":"admin","id":"admin-1"}}`,
wantStatus: http.StatusNotFound,
wantBody: `{"error":{"code":"subject_not_found","message":"subject not found"}}`,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
recorder := httptest.NewRecorder()
request := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
if tt.body != "" {
request.Header.Set("Content-Type", "application/json")
}
handler.ServeHTTP(recorder, request)
require.Equal(t, tt.wantStatus, recorder.Code)
assertJSONEq(t, recorder.Body.String(), tt.wantBody)
})
}
}
func TestEnsureByEmailHandlerRejectsSemanticRegistrationContext(t *testing.T) {
t.Parallel()
ensurer, err := authdirectory.NewEnsurer(handlerTestStore{}, handlerTestClock{now: time.Unix(1_775_240_000, 0).UTC()}, handlerTestIDGenerator{
userID: common.UserID("user-created"),
raceName: common.RaceName("player-test123"),
entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"),
}, handlerTestRaceNamePolicy{})
require.NoError(t, err)
handler := mustNewHandler(t, Dependencies{
ResolveByEmail: resolveByEmailFunc(func(context.Context, authdirectory.ResolveByEmailInput) (authdirectory.ResolveByEmailResult, error) {
return authdirectory.ResolveByEmailResult{}, nil
}),
EnsureByEmail: ensurer,
ExistsByUserID: existsByUserIDFunc(func(context.Context, authdirectory.ExistsByUserIDInput) (authdirectory.ExistsByUserIDResult, error) {
return authdirectory.ExistsByUserIDResult{}, nil
}),
BlockByUserID: blockByUserIDFunc(func(context.Context, authdirectory.BlockByUserIDInput) (authdirectory.BlockResult, error) {
return authdirectory.BlockResult{}, nil
}),
BlockByEmail: blockByEmailFunc(func(context.Context, authdirectory.BlockByEmailInput) (authdirectory.BlockResult, error) {
return authdirectory.BlockResult{}, nil
}),
GetMyAccount: getMyAccountFunc(func(context.Context, selfservice.GetMyAccountInput) (selfservice.GetMyAccountResult, error) {
return selfservice.GetMyAccountResult{}, nil
}),
UpdateMyProfile: updateMyProfileFunc(func(context.Context, selfservice.UpdateMyProfileInput) (selfservice.UpdateMyProfileResult, error) {
return selfservice.UpdateMyProfileResult{}, nil
}),
UpdateMySettings: updateMySettingsFunc(func(context.Context, selfservice.UpdateMySettingsInput) (selfservice.UpdateMySettingsResult, error) {
return selfservice.UpdateMySettingsResult{}, nil
}),
GetUserEligibility: getUserEligibilityFunc(func(context.Context, lobbyeligibility.GetUserEligibilityInput) (lobbyeligibility.GetUserEligibilityResult, error) {
return lobbyeligibility.GetUserEligibilityResult{}, nil
}),
ApplySanction: applySanctionFunc(func(context.Context, policysvc.ApplySanctionInput) (policysvc.SanctionCommandResult, error) {
return policysvc.SanctionCommandResult{}, nil
}),
RemoveSanction: removeSanctionFunc(func(context.Context, policysvc.RemoveSanctionInput) (policysvc.SanctionCommandResult, error) {
return policysvc.SanctionCommandResult{}, nil
}),
SetLimit: setLimitFunc(func(context.Context, policysvc.SetLimitInput) (policysvc.LimitCommandResult, error) {
return policysvc.LimitCommandResult{}, nil
}),
RemoveLimit: removeLimitFunc(func(context.Context, policysvc.RemoveLimitInput) (policysvc.LimitCommandResult, error) {
return policysvc.LimitCommandResult{}, nil
}),
})
tests := []struct {
name string
body string
wantBody string
}{
{
name: "invalid preferred language",
body: `{"email":"pilot@example.com","registration_context":{"preferred_language":"bad@@tag","time_zone":"Europe/Kaliningrad"}}`,
wantBody: `{"error":{"code":"invalid_request","message":"registration_context.preferred_language must be a valid BCP 47 language tag"}}`,
},
{
name: "invalid time zone",
body: `{"email":"pilot@example.com","registration_context":{"preferred_language":"en","time_zone":"Mars/Olympus"}}`,
wantBody: `{"error":{"code":"invalid_request","message":"registration_context.time_zone must be a valid IANA time zone name"}}`,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
recorder := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodPost,
"/api/v1/internal/users/ensure-by-email",
bytes.NewBufferString(tt.body),
)
request.Header.Set("Content-Type", "application/json")
handler.ServeHTTP(recorder, request)
require.Equal(t, http.StatusBadRequest, recorder.Code)
assertJSONEq(t, recorder.Body.String(), tt.wantBody)
})
}
}
func mustNewHandler(t *testing.T, deps Dependencies) http.Handler {
t.Helper()
if deps.ResolveByEmail == nil {
deps.ResolveByEmail = resolveByEmailFunc(func(context.Context, authdirectory.ResolveByEmailInput) (authdirectory.ResolveByEmailResult, error) {
return authdirectory.ResolveByEmailResult{}, nil
})
}
if deps.EnsureByEmail == nil {
deps.EnsureByEmail = ensureByEmailFunc(func(context.Context, authdirectory.EnsureByEmailInput) (authdirectory.EnsureByEmailResult, error) {
return authdirectory.EnsureByEmailResult{}, nil
})
}
if deps.ExistsByUserID == nil {
deps.ExistsByUserID = existsByUserIDFunc(func(context.Context, authdirectory.ExistsByUserIDInput) (authdirectory.ExistsByUserIDResult, error) {
return authdirectory.ExistsByUserIDResult{}, nil
})
}
if deps.BlockByUserID == nil {
deps.BlockByUserID = blockByUserIDFunc(func(context.Context, authdirectory.BlockByUserIDInput) (authdirectory.BlockResult, error) {
return authdirectory.BlockResult{}, nil
})
}
if deps.BlockByEmail == nil {
deps.BlockByEmail = blockByEmailFunc(func(context.Context, authdirectory.BlockByEmailInput) (authdirectory.BlockResult, error) {
return authdirectory.BlockResult{}, nil
})
}
if deps.GetMyAccount == nil {
deps.GetMyAccount = getMyAccountFunc(func(context.Context, selfservice.GetMyAccountInput) (selfservice.GetMyAccountResult, error) {
return selfservice.GetMyAccountResult{}, nil
})
}
if deps.UpdateMyProfile == nil {
deps.UpdateMyProfile = updateMyProfileFunc(func(context.Context, selfservice.UpdateMyProfileInput) (selfservice.UpdateMyProfileResult, error) {
return selfservice.UpdateMyProfileResult{}, nil
})
}
if deps.UpdateMySettings == nil {
deps.UpdateMySettings = updateMySettingsFunc(func(context.Context, selfservice.UpdateMySettingsInput) (selfservice.UpdateMySettingsResult, error) {
return selfservice.UpdateMySettingsResult{}, nil
})
}
if deps.GrantEntitlement == nil {
deps.GrantEntitlement = grantEntitlementFunc(func(context.Context, entitlementsvc.GrantInput) (entitlementsvc.CommandResult, error) {
return entitlementsvc.CommandResult{}, nil
})
}
if deps.ExtendEntitlement == nil {
deps.ExtendEntitlement = extendEntitlementFunc(func(context.Context, entitlementsvc.ExtendInput) (entitlementsvc.CommandResult, error) {
return entitlementsvc.CommandResult{}, nil
})
}
if deps.RevokeEntitlement == nil {
deps.RevokeEntitlement = revokeEntitlementFunc(func(context.Context, entitlementsvc.RevokeInput) (entitlementsvc.CommandResult, error) {
return entitlementsvc.CommandResult{}, nil
})
}
if deps.ApplySanction == nil {
deps.ApplySanction = applySanctionFunc(func(context.Context, policysvc.ApplySanctionInput) (policysvc.SanctionCommandResult, error) {
return policysvc.SanctionCommandResult{}, nil
})
}
if deps.GetUserEligibility == nil {
deps.GetUserEligibility = getUserEligibilityFunc(func(context.Context, lobbyeligibility.GetUserEligibilityInput) (lobbyeligibility.GetUserEligibilityResult, error) {
return lobbyeligibility.GetUserEligibilityResult{}, nil
})
}
if deps.GetUserByID == nil {
deps.GetUserByID = getUserByIDFunc(func(context.Context, adminusers.GetUserByIDInput) (adminusers.LookupResult, error) {
return adminusers.LookupResult{}, nil
})
}
if deps.GetUserByEmail == nil {
deps.GetUserByEmail = getUserByEmailFunc(func(context.Context, adminusers.GetUserByEmailInput) (adminusers.LookupResult, error) {
return adminusers.LookupResult{}, nil
})
}
if deps.GetUserByRaceName == nil {
deps.GetUserByRaceName = getUserByRaceNameFunc(func(context.Context, adminusers.GetUserByRaceNameInput) (adminusers.LookupResult, error) {
return adminusers.LookupResult{}, nil
})
}
if deps.ListUsers == nil {
deps.ListUsers = listUsersFunc(func(context.Context, adminusers.ListUsersInput) (adminusers.ListUsersResult, error) {
return adminusers.ListUsersResult{}, nil
})
}
if deps.SyncDeclaredCountry == nil {
deps.SyncDeclaredCountry = syncDeclaredCountryFunc(func(context.Context, geosync.SyncDeclaredCountryInput) (geosync.SyncDeclaredCountryResult, error) {
return geosync.SyncDeclaredCountryResult{}, nil
})
}
if deps.RemoveSanction == nil {
deps.RemoveSanction = removeSanctionFunc(func(context.Context, policysvc.RemoveSanctionInput) (policysvc.SanctionCommandResult, error) {
return policysvc.SanctionCommandResult{}, nil
})
}
if deps.SetLimit == nil {
deps.SetLimit = setLimitFunc(func(context.Context, policysvc.SetLimitInput) (policysvc.LimitCommandResult, error) {
return policysvc.LimitCommandResult{}, nil
})
}
if deps.RemoveLimit == nil {
deps.RemoveLimit = removeLimitFunc(func(context.Context, policysvc.RemoveLimitInput) (policysvc.LimitCommandResult, error) {
return policysvc.LimitCommandResult{}, nil
})
}
handler, err := newHandlerWithConfig(Config{
Addr: "127.0.0.1:0",
ReadHeaderTimeout: time.Second,
ReadTimeout: 2 * time.Second,
IdleTimeout: time.Minute,
RequestTimeout: time.Second,
}, deps)
require.NoError(t, err)
return handler
}
func assertJSONEq(t *testing.T, got string, want string) {
t.Helper()
require.JSONEq(t, want, got)
}
type resolveByEmailFunc func(context.Context, authdirectory.ResolveByEmailInput) (authdirectory.ResolveByEmailResult, error)
func (fn resolveByEmailFunc) Execute(ctx context.Context, input authdirectory.ResolveByEmailInput) (authdirectory.ResolveByEmailResult, error) {
return fn(ctx, input)
}
type ensureByEmailFunc func(context.Context, authdirectory.EnsureByEmailInput) (authdirectory.EnsureByEmailResult, error)
func (fn ensureByEmailFunc) Execute(ctx context.Context, input authdirectory.EnsureByEmailInput) (authdirectory.EnsureByEmailResult, error) {
return fn(ctx, input)
}
type existsByUserIDFunc func(context.Context, authdirectory.ExistsByUserIDInput) (authdirectory.ExistsByUserIDResult, error)
func (fn existsByUserIDFunc) Execute(ctx context.Context, input authdirectory.ExistsByUserIDInput) (authdirectory.ExistsByUserIDResult, error) {
return fn(ctx, input)
}
type blockByUserIDFunc func(context.Context, authdirectory.BlockByUserIDInput) (authdirectory.BlockResult, error)
func (fn blockByUserIDFunc) Execute(ctx context.Context, input authdirectory.BlockByUserIDInput) (authdirectory.BlockResult, error) {
return fn(ctx, input)
}
type blockByEmailFunc func(context.Context, authdirectory.BlockByEmailInput) (authdirectory.BlockResult, error)
func (fn blockByEmailFunc) Execute(ctx context.Context, input authdirectory.BlockByEmailInput) (authdirectory.BlockResult, error) {
return fn(ctx, input)
}
type getMyAccountFunc func(context.Context, selfservice.GetMyAccountInput) (selfservice.GetMyAccountResult, error)
func (fn getMyAccountFunc) Execute(ctx context.Context, input selfservice.GetMyAccountInput) (selfservice.GetMyAccountResult, error) {
return fn(ctx, input)
}
type updateMyProfileFunc func(context.Context, selfservice.UpdateMyProfileInput) (selfservice.UpdateMyProfileResult, error)
func (fn updateMyProfileFunc) Execute(ctx context.Context, input selfservice.UpdateMyProfileInput) (selfservice.UpdateMyProfileResult, error) {
return fn(ctx, input)
}
type updateMySettingsFunc func(context.Context, selfservice.UpdateMySettingsInput) (selfservice.UpdateMySettingsResult, error)
func (fn updateMySettingsFunc) Execute(ctx context.Context, input selfservice.UpdateMySettingsInput) (selfservice.UpdateMySettingsResult, error) {
return fn(ctx, input)
}
type getUserByIDFunc func(context.Context, adminusers.GetUserByIDInput) (adminusers.LookupResult, error)
func (fn getUserByIDFunc) Execute(ctx context.Context, input adminusers.GetUserByIDInput) (adminusers.LookupResult, error) {
return fn(ctx, input)
}
type getUserByEmailFunc func(context.Context, adminusers.GetUserByEmailInput) (adminusers.LookupResult, error)
func (fn getUserByEmailFunc) Execute(ctx context.Context, input adminusers.GetUserByEmailInput) (adminusers.LookupResult, error) {
return fn(ctx, input)
}
type getUserByRaceNameFunc func(context.Context, adminusers.GetUserByRaceNameInput) (adminusers.LookupResult, error)
func (fn getUserByRaceNameFunc) Execute(ctx context.Context, input adminusers.GetUserByRaceNameInput) (adminusers.LookupResult, error) {
return fn(ctx, input)
}
type listUsersFunc func(context.Context, adminusers.ListUsersInput) (adminusers.ListUsersResult, error)
func (fn listUsersFunc) Execute(ctx context.Context, input adminusers.ListUsersInput) (adminusers.ListUsersResult, error) {
return fn(ctx, input)
}
type getUserEligibilityFunc func(context.Context, lobbyeligibility.GetUserEligibilityInput) (lobbyeligibility.GetUserEligibilityResult, error)
func (fn getUserEligibilityFunc) Execute(ctx context.Context, input lobbyeligibility.GetUserEligibilityInput) (lobbyeligibility.GetUserEligibilityResult, error) {
return fn(ctx, input)
}
type syncDeclaredCountryFunc func(context.Context, geosync.SyncDeclaredCountryInput) (geosync.SyncDeclaredCountryResult, error)
func (fn syncDeclaredCountryFunc) Execute(ctx context.Context, input geosync.SyncDeclaredCountryInput) (geosync.SyncDeclaredCountryResult, error) {
return fn(ctx, input)
}
type grantEntitlementFunc func(context.Context, entitlementsvc.GrantInput) (entitlementsvc.CommandResult, error)
func (fn grantEntitlementFunc) Execute(ctx context.Context, input entitlementsvc.GrantInput) (entitlementsvc.CommandResult, error) {
return fn(ctx, input)
}
type extendEntitlementFunc func(context.Context, entitlementsvc.ExtendInput) (entitlementsvc.CommandResult, error)
func (fn extendEntitlementFunc) Execute(ctx context.Context, input entitlementsvc.ExtendInput) (entitlementsvc.CommandResult, error) {
return fn(ctx, input)
}
type revokeEntitlementFunc func(context.Context, entitlementsvc.RevokeInput) (entitlementsvc.CommandResult, error)
func (fn revokeEntitlementFunc) Execute(ctx context.Context, input entitlementsvc.RevokeInput) (entitlementsvc.CommandResult, error) {
return fn(ctx, input)
}
type applySanctionFunc func(context.Context, policysvc.ApplySanctionInput) (policysvc.SanctionCommandResult, error)
func (fn applySanctionFunc) Execute(ctx context.Context, input policysvc.ApplySanctionInput) (policysvc.SanctionCommandResult, error) {
return fn(ctx, input)
}
type removeSanctionFunc func(context.Context, policysvc.RemoveSanctionInput) (policysvc.SanctionCommandResult, error)
func (fn removeSanctionFunc) Execute(ctx context.Context, input policysvc.RemoveSanctionInput) (policysvc.SanctionCommandResult, error) {
return fn(ctx, input)
}
type setLimitFunc func(context.Context, policysvc.SetLimitInput) (policysvc.LimitCommandResult, error)
func (fn setLimitFunc) Execute(ctx context.Context, input policysvc.SetLimitInput) (policysvc.LimitCommandResult, error) {
return fn(ctx, input)
}
type removeLimitFunc func(context.Context, policysvc.RemoveLimitInput) (policysvc.LimitCommandResult, error)
func (fn removeLimitFunc) Execute(ctx context.Context, input policysvc.RemoveLimitInput) (policysvc.LimitCommandResult, error) {
return fn(ctx, input)
}
type handlerTestStore struct{}
func (handlerTestStore) ResolveByEmail(context.Context, common.Email) (ports.ResolveByEmailResult, error) {
return ports.ResolveByEmailResult{}, nil
}
func (handlerTestStore) ExistsByUserID(context.Context, common.UserID) (bool, error) {
return false, nil
}
func (handlerTestStore) EnsureByEmail(context.Context, ports.EnsureByEmailInput) (ports.EnsureByEmailResult, error) {
return ports.EnsureByEmailResult{}, nil
}
func (handlerTestStore) BlockByUserID(context.Context, ports.BlockByUserIDInput) (ports.BlockResult, error) {
return ports.BlockResult{}, nil
}
func (handlerTestStore) BlockByEmail(context.Context, ports.BlockByEmailInput) (ports.BlockResult, error) {
return ports.BlockResult{}, nil
}
type handlerTestClock struct {
now time.Time
}
func (clock handlerTestClock) Now() time.Time {
return clock.now
}
type handlerTestIDGenerator struct {
userID common.UserID
raceName common.RaceName
entitlementRecordID entitlement.EntitlementRecordID
sanctionRecordID policy.SanctionRecordID
limitRecordID policy.LimitRecordID
}
func (generator handlerTestIDGenerator) NewUserID() (common.UserID, error) {
return generator.userID, nil
}
func (generator handlerTestIDGenerator) NewInitialRaceName() (common.RaceName, error) {
return generator.raceName, nil
}
func (generator handlerTestIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
return generator.entitlementRecordID, nil
}
func (generator handlerTestIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
return generator.sanctionRecordID, nil
}
func (generator handlerTestIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
return generator.limitRecordID, nil
}
type handlerTestRaceNamePolicy struct{}
func (handlerTestRaceNamePolicy) CanonicalKey(raceName common.RaceName) (account.RaceNameCanonicalKey, error) {
return account.RaceNameCanonicalKey("key:" + raceName.String()), nil
}
func sampleAccountView() selfservice.AccountView {
timestamp := time.Date(2026, time.April, 9, 10, 0, 0, 0, time.UTC)
return selfservice.AccountView{
UserID: "user-123",
Email: "pilot@example.com",
RaceName: "Pilot Nova",
PreferredLanguage: "en",
TimeZone: "Europe/Kaliningrad",
DeclaredCountry: "DE",
Entitlement: selfservice.EntitlementSnapshotView{
PlanCode: "free",
IsPaid: false,
Source: "auth_registration",
Actor: selfservice.ActorRefView{Type: "service", ID: "user-service"},
ReasonCode: "initial_free_entitlement",
StartsAt: timestamp,
UpdatedAt: timestamp,
},
ActiveSanctions: []selfservice.ActiveSanctionView{},
ActiveLimits: []selfservice.ActiveLimitView{},
CreatedAt: timestamp,
UpdatedAt: timestamp,
}
}
func sampleEligibilityView(exists bool) lobbyeligibility.GetUserEligibilityResult {
timestamp := time.Date(2026, time.April, 9, 10, 0, 0, 0, time.UTC)
if !exists {
return lobbyeligibility.GetUserEligibilityResult{
Exists: false,
UserID: "user-missing",
ActiveSanctions: []lobbyeligibility.ActiveSanctionView{},
EffectiveLimits: []lobbyeligibility.EffectiveLimitView{},
Markers: lobbyeligibility.EligibilityMarkersView{},
}
}
return lobbyeligibility.GetUserEligibilityResult{
Exists: true,
UserID: "user-123",
Entitlement: &lobbyeligibility.EntitlementSnapshotView{
PlanCode: "paid_monthly",
IsPaid: true,
Source: "billing",
Actor: lobbyeligibility.ActorRefView{Type: "billing", ID: "invoice-1"},
ReasonCode: "renewal",
StartsAt: timestamp,
EndsAt: timePointer(timestamp.Add(30 * 24 * time.Hour)),
UpdatedAt: timestamp,
},
ActiveSanctions: []lobbyeligibility.ActiveSanctionView{
{
SanctionCode: "private_game_create_block",
Scope: "lobby",
ReasonCode: "manual_block",
Actor: lobbyeligibility.ActorRefView{Type: "admin", ID: "admin-1"},
AppliedAt: timestamp,
ExpiresAt: timePointer(timestamp.Add(30 * 24 * time.Hour)),
},
},
EffectiveLimits: []lobbyeligibility.EffectiveLimitView{
{LimitCode: "max_owned_private_games", Value: 3},
{LimitCode: "max_pending_public_applications", Value: 10},
{LimitCode: "max_active_game_memberships", Value: 10},
},
Markers: lobbyeligibility.EligibilityMarkersView{
CanLogin: true,
CanCreatePrivateGame: false,
CanManagePrivateGame: true,
CanJoinGame: true,
CanUpdateProfile: true,
},
}
}
func timePointer(value time.Time) *time.Time {
utcValue := value.UTC()
return &utcValue
}
var (
_ ports.AuthDirectoryStore = handlerTestStore{}
_ ports.Clock = handlerTestClock{}
_ ports.IDGenerator = handlerTestIDGenerator{}
_ ports.RaceNamePolicy = handlerTestRaceNamePolicy{}
)