package internalhttp import ( "bytes" "context" "net/http" "net/http/httptest" "testing" "time" "galaxy/user/internal/domain/common" "galaxy/user/internal/domain/entitlement" "galaxy/user/internal/domain/policy" "galaxy/user/internal/ports" "galaxy/user/internal/service/accountdeletion" "galaxy/user/internal/service/adminusers" "galaxy/user/internal/service/authdirectory" "galaxy/user/internal/service/entitlementsvc" "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, "NovaPrime", input.DisplayName) accountView := sampleAccountView() accountView.DisplayName = input.DisplayName 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 }), DeleteUser: deleteUserFunc(func(_ context.Context, input accountdeletion.Input) (accountdeletion.Result, error) { require.Equal(t, "user-123", input.UserID) require.Equal(t, "user_right_to_be_forgotten", input.ReasonCode) require.Equal(t, "admin", input.Actor.Type) require.Equal(t, "admin-1", input.Actor.ID) return accountdeletion.Result{ UserID: "user-123", DeletedAt: time.Date(2026, time.April, 24, 12, 0, 0, 0, time.UTC), }, nil }), }) tests := []struct { 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","user_name":"player-abcdefgh","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}}`, }, { name: "update my profile", method: http.MethodPost, path: "/api/v1/internal/users/user-123/profile", body: `{"display_name":"NovaPrime"}`, wantStatus: http.StatusOK, wantBody: `{"account":{"user_id":"user-123","email":"pilot@example.com","user_name":"player-abcdefgh","display_name":"NovaPrime","preferred_language":"en","time_zone":"Europe/Kaliningrad","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}}`, }, { name: "update my settings", 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","user_name":"player-abcdefgh","preferred_language":"en-US","time_zone":"UTC","declared_country":"DE","entitlement":{"plan_code":"free","is_paid":false,"source":"auth_registration","actor":{"type":"service","id":"user-service"},"reason_code":"initial_free_entitlement","starts_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[],"active_limits":[],"created_at":"2026-04-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"}}`, }, { name: "get user eligibility", method: http.MethodGet, path: "/api/v1/internal/users/user-123/eligibility", wantStatus: http.StatusOK, wantBody: `{"exists":true,"user_id":"user-123","entitlement":{"plan_code":"paid_monthly","is_paid":true,"source":"billing","actor":{"type":"billing","id":"invoice-1"},"reason_code":"renewal","starts_at":"2026-04-09T10:00:00Z","ends_at":"2026-05-09T10:00:00Z","updated_at":"2026-04-09T10:00:00Z"},"active_sanctions":[{"sanction_code":"private_game_create_block","scope":"lobby","reason_code":"manual_block","actor":{"type":"admin","id":"admin-1"},"applied_at":"2026-04-09T10:00:00Z","expires_at":"2026-05-09T10:00:00Z"}],"effective_limits":[{"limit_code":"max_owned_private_games","value":3},{"limit_code":"max_pending_public_applications","value":10},{"limit_code":"max_active_game_memberships","value":10},{"limit_code":"max_registered_race_names","value":2}],"markers":{"can_login":true,"can_create_private_game":false,"can_manage_private_game":true,"can_join_game":true,"can_update_profile":true}}`, }, { name: "get user eligibility not found snapshot", 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":[]}`, }, { name: "delete user", method: http.MethodPost, path: "/api/v1/internal/users/user-123/delete", body: `{"reason_code":"user_right_to_be_forgotten","actor":{"type":"admin","id":"admin-1"}}`, wantStatus: http.StatusOK, wantBody: `{"user_id":"user-123","deleted_at":"2026-04-24T12:00:00Z"}`, }, } for _, tt := range tests { 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: `{"display_name":"TakenName"}`, 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: `{"display_name":"NovaPrime","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"), userName: common.UserName("player-test123"), entitlementRecordID: entitlement.EntitlementRecordID("entitlement-created"), }) 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.GetUserByUserName == nil { deps.GetUserByUserName = getUserByUserNameFunc(func(context.Context, adminusers.GetUserByUserNameInput) (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 }) } if deps.DeleteUser == nil { deps.DeleteUser = deleteUserFunc(func(context.Context, accountdeletion.Input) (accountdeletion.Result, error) { return accountdeletion.Result{}, nil }) } handler, err := newHandlerWithConfig(Config{ Addr: "127.0.0.1:0", 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 getUserByUserNameFunc func(context.Context, adminusers.GetUserByUserNameInput) (adminusers.LookupResult, error) func (fn getUserByUserNameFunc) Execute(ctx context.Context, input adminusers.GetUserByUserNameInput) (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 deleteUserFunc func(context.Context, accountdeletion.Input) (accountdeletion.Result, error) func (fn deleteUserFunc) Execute(ctx context.Context, input accountdeletion.Input) (accountdeletion.Result, error) { return fn(ctx, input) } type handlerTestStore struct{} func (handlerTestStore) ResolveByEmail(context.Context, common.Email) (ports.ResolveByEmailResult, error) { 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 userName common.UserName entitlementRecordID entitlement.EntitlementRecordID sanctionRecordID policy.SanctionRecordID limitRecordID policy.LimitRecordID } func (generator handlerTestIDGenerator) NewUserID() (common.UserID, error) { return generator.userID, nil } func (generator handlerTestIDGenerator) NewUserName() (common.UserName, error) { return generator.userName, 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 } 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", UserName: "player-abcdefgh", 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}, {LimitCode: "max_registered_race_names", Value: 2}, }, 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{} )