diff --git a/docs/modules/gaming.md b/docs/modules/gaming.md index cd3d346b..e9413bf2 100644 --- a/docs/modules/gaming.md +++ b/docs/modules/gaming.md @@ -225,6 +225,14 @@ Every writer of `game_category_game` takes locks in the order **game rows, then `POST`/`PATCH` on a category also accept `membershipMode` and `membershipRule`. The preview needs `game-config:view`. The preview, a create or update that sends a rule or switches to rule mode, and an on-demand evaluation also need `report:view` when a clause's definition sets `exposesReporting` - none of the built-ins does. The check runs on the rule the write stores and the evaluation resolves, re-checked on every retry, so a rule another admin saves meanwhile is never applied on the strength of a check against the one before it. +## Admin game list geo filters + +`GET /backoffice/gaming/games` takes three geo filters. Any of them needs `compliance:view` on top of `game-config:view`, and answers 400 when the compliance module is not loaded. A game counts as blocked in a country when it or its provider has a rule for that country, as in `ComplianceService.checkGame`. + +- **`geoBlocked`** - `true`: blocked in at least one country. `false`: no game or provider rule at all. +- **`geoBlockedCountries`** (up to 50) - blocked in every listed country. Not combinable with `geoBlocked=false`. +- **`geoAvailableCountries`** (up to 50) - no game or provider rule for any listed country. Not combinable with `geoBlocked=true`, and must not share a code with `geoBlockedCountries`. If a listed country is blocked platform-wide, the page is empty; `GET /compliance/blocked-countries` tells the caller why. + ## Audited events Every admin change to sort config, order, or pins emits an event carrying the actor's id, the before/after state, and optional request-origin metadata. diff --git a/packages/core/src/audit/__tests__/map-event.test.ts b/packages/core/src/audit/__tests__/map-event.test.ts index 7a76cec9..6c59cf97 100644 --- a/packages/core/src/audit/__tests__/map-event.test.ts +++ b/packages/core/src/audit/__tests__/map-event.test.ts @@ -390,6 +390,55 @@ describe('mapEventToRecord: gaming.games.bulk_updated', () => { }); }); +describe('mapEventToRecord: compliance.game-geo-rules.bulk_updated', () => { + const rule = { + id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + gameId: '99999999-9999-4999-8999-999999999999', + countryCode: 'DK', + reason: 'regulator letter 12', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + const payload = { + countryCode: 'DK', + reason: 'licence change', + rules: [rule], + target: { gameIds: [], providerIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'] }, + notFound: { gameIds: [], providerIds: [] }, + actorId: adminId, + ip: '198.51.100.7', + userAgent: null, + }; + + it('audits a bulk restrict call once, with the added rules as the after-state', async () => { + const restrict = { ...payload, operation: 'restrict' }; + const row = await mapEventToRecord('compliance.game-geo-rules.bulk_updated', restrict); + + expect(row).toMatchObject({ + actorType: 'admin', + actorId: adminId, + action: 'compliance.game-geo-rules.bulk_updated', + resourceType: 'game-geo-rule', + resourceId: null, + before: { rules: [] }, + after: restrict, + ip: '198.51.100.7', + }); + }); + + it('audits a bulk unrestrict call with the removed rules as the before-state', async () => { + const unrestrict = { ...payload, operation: 'unrestrict' }; + const row = await mapEventToRecord('compliance.game-geo-rules.bulk_updated', unrestrict); + + expect(row).toMatchObject({ + resourceType: 'game-geo-rule', + resourceId: null, + before: { rules: [rule] }, + after: { ...unrestrict, rules: [] }, + }); + }); +}); + describe('mapEventToRecord: gaming.provider.updated', () => { const providerId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; const snapshot = { diff --git a/packages/core/src/audit/plugin.ts b/packages/core/src/audit/plugin.ts index 6553cbc5..ced6f854 100644 --- a/packages/core/src/audit/plugin.ts +++ b/packages/core/src/audit/plugin.ts @@ -121,6 +121,19 @@ export async function mapEventToRecord( }; } + if (topic === 'compliance.game-geo-rules.bulk_updated') { + const rules = Array.isArray(p['rules']) ? p['rules'] : []; + const restricted = p['operation'] === 'restrict'; + return { + ...base, + actorType: 'admin', + actorId: str(p['actorId']), + resourceType: 'game-geo-rule', + before: { rules: restricted ? [] : rules }, + after: restricted ? p : { ...p, rules: [] }, + }; + } + if ( topic === 'compliance.provider-geo-rule.upserted' || topic === 'compliance.provider-geo-rule.deleted' @@ -1290,6 +1303,7 @@ const SUBSCRIBED_TOPICS: DomainEventName[] = [ 'compliance.geo-rule.added', 'compliance.game-geo-rule.upserted', 'compliance.game-geo-rule.deleted', + 'compliance.game-geo-rules.bulk_updated', 'compliance.provider-geo-rule.upserted', 'compliance.provider-geo-rule.deleted', 'cms.page.published', diff --git a/packages/core/src/casino/gaming/__tests__/gaming-catalog-admin.router.int.test.ts b/packages/core/src/casino/gaming/__tests__/gaming-catalog-admin.router.int.test.ts index 3604d10d..16f06e32 100644 --- a/packages/core/src/casino/gaming/__tests__/gaming-catalog-admin.router.int.test.ts +++ b/packages/core/src/casino/gaming/__tests__/gaming-catalog-admin.router.int.test.ts @@ -561,6 +561,9 @@ describe('gaming catalog router authz', () => { await expect( call(router.listAdminGames, { geoBlockedCountries: ['DE'] }, { context: CTX }), ).rejects.toMatchObject({ code: 'BAD_REQUEST', status: 400 }); + await expect( + call(router.listAdminGames, { geoAvailableCountries: ['DE'] }, { context: CTX }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST', status: 400 }); await expect(call(router.listAdminGames, {}, { context: CTX })).resolves.toMatchObject({ total: 0, }); @@ -575,6 +578,9 @@ describe('gaming catalog router authz', () => { await expect( call(router.listAdminGames, { geoBlockedCountries: ['DE'] }, { context: CTX }), ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + await expect( + call(router.listAdminGames, { geoAvailableCountries: ['DE'] }, { context: CTX }), + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); await expect( call(router.listAdminGames, { tagIds: [randomUUID()] }, { context: CTX }), ).resolves.toMatchObject({ total: 0 }); diff --git a/packages/core/src/casino/gaming/__tests__/gaming.service.int.test.ts b/packages/core/src/casino/gaming/__tests__/gaming.service.int.test.ts index d5693d75..32f0a12e 100644 --- a/packages/core/src/casino/gaming/__tests__/gaming.service.int.test.ts +++ b/packages/core/src/casino/gaming/__tests__/gaming.service.int.test.ts @@ -727,9 +727,97 @@ describe('GamingService admin list filters (real PG)', () => { await expect( svc.listGamesAdmin({ page: 1, limit: 10, geoBlockedCountries: ['DE'] }), ).rejects.toBeInstanceOf(GameGeoFiltersUnavailableError); + await expect( + svc.listGamesAdmin({ page: 1, limit: 10, geoAvailableCountries: ['DE'] }), + ).rejects.toBeInstanceOf(GameGeoFiltersUnavailableError); await expect(svc.listGamesAdmin({ page: 1, limit: 10 })).resolves.toMatchObject({ total: 0 }); }); + function geoCheckMock(globallyBlocked: string[] = []) { + return mock({ + listGloballyBlockedCountries: vi.fn().mockResolvedValue(globallyBlocked), + }); + } + + it('geoAvailableCountries excludes a game blocked by its own or its provider rule', async () => { + const blockedStudio = await seedProvider(); + const gameBlockedDk = await seedGame(); + await seedGame({ providerId: blockedStudio.id }); + const onlySe = await seedGame(); + const open = await seedGame(); + await blockGame(gameBlockedDk.id, ['DK']); + await db.drizzle.db + .insert(providerGeoRule) + .values({ providerId: blockedStudio.id, countryCode: 'DK', reason: 'licence' }); + await blockGame(onlySe.id, ['SE']); + const svc = makeService({ gameGeoCheck: geoCheckMock() }); + + expect( + ids(await svc.listGamesAdmin({ page: 1, limit: 10, geoAvailableCountries: ['DK'] })), + ).toEqual([onlySe.id, open.id].sort()); + }); + + it('geoAvailableCountries requires availability in every listed country', async () => { + const dkOnly = await seedGame(); + const frOnly = await seedGame(); + const openBoth = await seedGame(); + await blockGame(dkOnly.id, ['DK']); + await blockGame(frOnly.id, ['FR']); + const svc = makeService({ gameGeoCheck: geoCheckMock() }); + + expect( + ids(await svc.listGamesAdmin({ page: 1, limit: 10, geoAvailableCountries: ['DK', 'FR'] })), + ).toEqual([openBoth.id]); + }); + + it('geoAvailableCountries combines with categoryIds, isActive, isUnavailable, tagIds, gameTypes, and q', async () => { + const hot = await seedTag(); + const category = await seedCategory(); + const match = await seedGame({ name: 'Aurora Slots', gameType: 'original', isActive: true }, [ + category.id, + ]); + await tagGame(match.id, [hot.id]); + const blockedMatch = await seedGame( + { name: 'Aurora Blocked', gameType: 'original', isActive: true }, + [category.id], + ); + await tagGame(blockedMatch.id, [hot.id]); + await blockGame(blockedMatch.id, ['DK']); + const inactiveMatch = await seedGame( + { name: 'Aurora Inactive', gameType: 'original', isActive: false }, + [category.id], + ); + await tagGame(inactiveMatch.id, [hot.id]); + const otherType = await seedGame({ name: 'Aurora Other', gameType: 'casino', isActive: true }, [ + category.id, + ]); + await tagGame(otherType.id, [hot.id]); + const svc = makeService({ gameGeoCheck: geoCheckMock() }); + + const result = await svc.listGamesAdmin({ + page: 1, + limit: 10, + q: 'Aurora', + categoryIds: [category.id], + isActive: true, + isUnavailable: false, + tagIds: [hot.id], + gameTypes: ['original'], + geoAvailableCountries: ['DK'], + }); + + expect(ids(result)).toEqual([match.id]); + }); + + it('geoAvailableCountries returns an empty page when a requested country is globally blocked', async () => { + await seedGame(); + const svc = makeService({ gameGeoCheck: geoCheckMock(['DK']) }); + + await expect( + svc.listGamesAdmin({ page: 1, limit: 10, geoAvailableCountries: ['DK'] }), + ).resolves.toMatchObject({ items: [], total: 0 }); + }); + it('combines filters with AND', async () => { const hot = await seedTag(); const match = await seedGame({ gameType: 'original', isActive: true }); @@ -804,6 +892,32 @@ describe('ListAdminGamesInputSchema', () => { false, ); }); + + it('rejects geoBlocked=true combined with geoAvailableCountries', () => { + expect( + ListAdminGamesInputSchema.safeParse({ geoBlocked: 'true', geoAvailableCountries: ['DE'] }) + .success, + ).toBe(false); + expect( + ListAdminGamesInputSchema.safeParse({ geoBlocked: 'false', geoAvailableCountries: ['DE'] }) + .success, + ).toBe(true); + }); + + it('rejects a country shared between geoAvailableCountries and geoBlockedCountries', () => { + expect( + ListAdminGamesInputSchema.safeParse({ + geoAvailableCountries: ['DE', 'FR'], + geoBlockedCountries: ['FR'], + }).success, + ).toBe(false); + expect( + ListAdminGamesInputSchema.safeParse({ + geoAvailableCountries: ['DE'], + geoBlockedCountries: ['FR'], + }).success, + ).toBe(true); + }); }); describe('GamingService unavailable games (real PG)', () => { diff --git a/packages/core/src/casino/gaming/contract/index.ts b/packages/core/src/casino/gaming/contract/index.ts index 8ff0de52..25de7ab5 100644 --- a/packages/core/src/casino/gaming/contract/index.ts +++ b/packages/core/src/casino/gaming/contract/index.ts @@ -217,6 +217,7 @@ export const ListAdminGamesInputSchema = ListGamesInputSchema.extend({ gameTypes: queryArraySchema(GameTypeSchema, GAME_TYPES.length).optional(), geoBlocked: QueryBooleanSchema.optional(), geoBlockedCountries: queryArraySchema(CountryCodeSchema, 50).optional(), + geoAvailableCountries: queryArraySchema(CountryCodeSchema, 50).optional(), }) .refine( (input) => !(input.uncategorized === true && (input.categoryId || input.categoryIds?.length)), @@ -225,7 +226,21 @@ export const ListAdminGamesInputSchema = ListGamesInputSchema.extend({ .refine((input) => !(input.geoBlocked === false && input.geoBlockedCountries?.length), { message: 'geoBlocked=false cannot be combined with geoBlockedCountries', path: ['geoBlocked'], - }); + }) + .refine((input) => !(input.geoBlocked === true && input.geoAvailableCountries?.length), { + message: 'geoBlocked=true cannot be combined with geoAvailableCountries', + path: ['geoAvailableCountries'], + }) + .refine( + (input) => + !input.geoBlockedCountries?.length || + !input.geoAvailableCountries?.length || + !input.geoBlockedCountries.some((code) => input.geoAvailableCountries?.includes(code)), + { + message: 'geoAvailableCountries cannot share a country with geoBlockedCountries', + path: ['geoAvailableCountries'], + }, + ); export type ListAdminGamesInput = z.infer; // `active` and `inactive` count each row's own `isActive` flag, matching the admin list filters. diff --git a/packages/core/src/casino/gaming/router/index.ts b/packages/core/src/casino/gaming/router/index.ts index 8c77713a..9b159176 100644 --- a/packages/core/src/casino/gaming/router/index.ts +++ b/packages/core/src/casino/gaming/router/index.ts @@ -364,7 +364,11 @@ export function createGamingRouter({ listAdminGames: os.listAdminGames.handler(async ({ input, context }) => { await adminGuard.assert(context, 'game-config', 'view'); // Geo rules are compliance data; require the same grant compliance's own geo-rule routes do. - if (input.geoBlocked !== undefined || input.geoBlockedCountries) { + if ( + input.geoBlocked !== undefined || + input.geoBlockedCountries || + input.geoAvailableCountries + ) { await adminGuard.assert(context, 'compliance', 'view'); } return mapErrors({ BAD_REQUEST: GameGeoFiltersUnavailableError }, () => diff --git a/packages/core/src/casino/gaming/service/gaming.service.ts b/packages/core/src/casino/gaming/service/gaming.service.ts index 6336d745..60ae7217 100644 --- a/packages/core/src/casino/gaming/service/gaming.service.ts +++ b/packages/core/src/casino/gaming/service/gaming.service.ts @@ -237,12 +237,21 @@ export class GamingService { gameTypes, geoBlocked, geoBlockedCountries, + geoAvailableCountries, ...input }: ListAdminGamesInput) { - if (!this.gameGeoCheck && (geoBlocked !== undefined || geoBlockedCountries)) { + const gameGeoCheck = this.gameGeoCheck; + if ( + !gameGeoCheck && + (geoBlocked !== undefined || geoBlockedCountries || geoAvailableCountries) + ) { throw new GameGeoFiltersUnavailableError(); } const db = this.drizzle.db; + const geoAvailableFilter = + geoAvailableCountries && gameGeoCheck + ? await this.buildGeoAvailableFilter(geoAvailableCountries, gameGeoCheck) + : undefined; const anyCategory = this.rowsWhere({ table: gameCategoryGame, column: gameCategoryGame.gameId, @@ -322,10 +331,41 @@ export class GamingService { ), }) : undefined, + geoAvailableFilter, ], }); } + private async buildGeoAvailableFilter( + countries: string[], + gameGeoCheck: GameGeoCheckPort, + ): Promise { + const globallyBlocked = new Set(await gameGeoCheck.listGloballyBlockedCountries()); + if (countries.some((countryCode) => globallyBlocked.has(countryCode))) { + return sql`false`; + } + const db = this.drizzle.db; + return and( + notExists( + db + .select({ one: sql`1` }) + .from(gameGeoRule) + .where(and(eq(gameGeoRule.gameId, game.id), inArray(gameGeoRule.countryCode, countries))), + ), + notExists( + db + .select({ one: sql`1` }) + .from(providerGeoRule) + .where( + and( + eq(providerGeoRule.providerId, game.providerId), + inArray(providerGeoRule.countryCode, countries), + ), + ), + ), + ); + } + private rowsWhere({ table, column, diff --git a/packages/core/src/compliance/__tests__/compliance.service.int.test.ts b/packages/core/src/compliance/__tests__/compliance.service.int.test.ts index 5370c4a7..f8a80e53 100644 --- a/packages/core/src/compliance/__tests__/compliance.service.int.test.ts +++ b/packages/core/src/compliance/__tests__/compliance.service.int.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; import { randomUUID } from 'node:crypto'; -import { sql } from 'drizzle-orm'; +import { count, eq, inArray, sql } from 'drizzle-orm'; import { defineIgamingConfig, type GeoIpAdapter, @@ -23,6 +23,7 @@ import { ComplianceService, CountryRuleConfirmationRequiredError, CountryRuleVersionConflictError, + GeoRuleBulkTooManyGamesError, GeoRuleProviderNotFoundError, LicensedJurisdictionBlacklistError, } from '../service/compliance.service.js'; @@ -59,6 +60,24 @@ async function seedGame(id: string, name: string, providerId?: string) { }); } +async function seedManyGames(providerId: string, gameCount: number) { + const rows = await db.drizzle.db + .insert(game) + .values( + Array.from({ length: gameCount }, () => ({ + name: 'Game', + slug: `game-${randomUUID()}`, + providerId, + aggregator: 'mock', + isActive: true, + })), + ) + .returning({ id: game.id }); + return rows.map((row) => row.id); +} + +const NO_META = { ip: null, userAgent: null }; + beforeAll(async () => { db = await createTestDb([migrate, migrateProfile, migrateGaming]); }); @@ -917,3 +936,276 @@ describe('ComplianceService per-provider geo rules (real PG)', () => { expect((await svc.listProviderGeoRules({ page: 1, limit: 100 })).total).toBe(4); }); }); + +describe('ComplianceService.listGloballyBlockedCountries (real PG)', () => { + it('unions the runtime config with block rules, sorted and deduped, ignoring allow rules', async () => { + const igaming = defineIgamingConfig({ + branding: { name: 'Test' }, + currencies: ['EUR'], + jurisdictions: ['MT'], + blockedCountries: ['US', 'DE'], + }); + const { svc } = makeService(undefined, igaming); + await db.drizzle.db.insert(countryRule).values([ + { countryCode: 'DE', action: 'block' }, + { countryCode: 'FR', action: 'block' }, + { countryCode: 'GB', action: 'allow' }, + ]); + + expect(await svc.listGloballyBlockedCountries()).toEqual(['DE', 'FR', 'US']); + }); + + it('is empty when nothing blocks globally', async () => { + const { svc } = makeService(); + + expect(await svc.listGloballyBlockedCountries()).toEqual([]); + }); +}); + +describe('ComplianceService bulk game geo rules (real PG)', () => { + it('restricts many games at once, leaving an already-restricted game (and its reason) untouched', async () => { + const providerId = await seedProvider(); + const [first, second, third] = await seedManyGames(providerId, 3); + const actorId = randomUUID(); + const { svc, events } = makeService(); + await svc.upsertGameGeoRules( + { gameId: first!, countryCodes: ['DK'], reason: 'original reason' }, + actorId, + NO_META, + ); + events.emit.mockClear(); + + const result = await svc.bulkRestrictGameGeoRules( + { gameIds: [first!, second!, third!], countryCode: 'DK', reason: 'bulk restriction' }, + actorId, + NO_META, + ); + + expect(result).toEqual({ + changed: 2, + unchanged: 1, + notFound: { gameIds: [], providerIds: [] }, + }); + expect(events.emit).toHaveBeenCalledTimes(1); + expect(events.emit).toHaveBeenCalledWith('compliance.game-geo-rules.bulk_updated', { + operation: 'restrict', + countryCode: 'DK', + reason: 'bulk restriction', + rules: [second, third] + .sort() + .map((gameId) => + expect.objectContaining({ gameId, countryCode: 'DK', reason: 'bulk restriction' }), + ), + target: { gameIds: [first, second, third].sort(), providerIds: [] }, + notFound: { gameIds: [], providerIds: [] }, + actorId, + ip: NO_META.ip, + userAgent: NO_META.userAgent, + }); + + const firstRule = await db.drizzle.db + .select() + .from(gameGeoRule) + .where(eq(gameGeoRule.gameId, first!)); + expect(firstRule).toHaveLength(1); + expect(firstRule[0]?.reason).toBe('original reason'); + }); + + it('restrict is idempotent: re-running the same call changes nothing and emits nothing', async () => { + const providerId = await seedProvider(); + const [first, second] = await seedManyGames(providerId, 2); + const actorId = randomUUID(); + const { svc, events } = makeService(); + await svc.bulkRestrictGameGeoRules( + { gameIds: [first!, second!], countryCode: 'DK', reason: 'bulk restriction' }, + actorId, + NO_META, + ); + events.emit.mockClear(); + + const result = await svc.bulkRestrictGameGeoRules( + { gameIds: [first!, second!], countryCode: 'DK', reason: 'repeat' }, + actorId, + NO_META, + ); + + expect(result).toEqual({ + changed: 0, + unchanged: 2, + notFound: { gameIds: [], providerIds: [] }, + }); + expect(events.emit).not.toHaveBeenCalled(); + }); + + it('unrestricts every game of a provider, reporting the ones still blocked by the provider rule', async () => { + const providerId = await seedProvider(); + const gameIds = await seedManyGames(providerId, 3); + const actorId = randomUUID(); + const { svc, events } = makeService(); + await svc.bulkRestrictGameGeoRules( + { gameIds, countryCode: 'DK', reason: 'restricted' }, + actorId, + NO_META, + ); + await db.drizzle.db + .insert(providerGeoRule) + .values({ providerId, countryCode: 'DK', reason: 'provider licence restriction' }); + events.emit.mockClear(); + + const result = await svc.bulkUnrestrictGameGeoRules( + { providerIds: [providerId], countryCode: 'DK', reason: 'licence restored' }, + actorId, + NO_META, + ); + + expect(result).toEqual({ + changed: 3, + unchanged: 0, + stillBlockedByProvider: 3, + globallyBlocked: false, + notFound: { gameIds: [], providerIds: [] }, + }); + expect(events.emit).toHaveBeenCalledTimes(1); + expect(events.emit).toHaveBeenCalledWith( + 'compliance.game-geo-rules.bulk_updated', + expect.objectContaining({ + operation: 'unrestrict', + rules: [...gameIds] + .sort() + .map((gameId) => expect.objectContaining({ gameId, reason: 'restricted' })), + target: { gameIds: [], providerIds: [providerId] }, + }), + ); + expect( + await db.drizzle.db.select().from(gameGeoRule).where(inArray(gameGeoRule.gameId, gameIds)), + ).toHaveLength(0); + expect( + await db.drizzle.db + .select() + .from(providerGeoRule) + .where(eq(providerGeoRule.providerId, providerId)), + ).toHaveLength(1); + }); + + it('unrestrict is idempotent: a game with no matching rule is unchanged, not an error', async () => { + const providerId = await seedProvider(); + const [first, second] = await seedManyGames(providerId, 2); + const actorId = randomUUID(); + const { svc, events } = makeService(); + await svc.bulkRestrictGameGeoRules( + { gameIds: [first!], countryCode: 'DK', reason: 'restricted' }, + actorId, + NO_META, + ); + events.emit.mockClear(); + + const result = await svc.bulkUnrestrictGameGeoRules( + { gameIds: [first!, second!], countryCode: 'DK', reason: 'licence restored' }, + actorId, + NO_META, + ); + + expect(result).toEqual({ + changed: 1, + unchanged: 1, + stillBlockedByProvider: 0, + globallyBlocked: false, + notFound: { gameIds: [], providerIds: [] }, + }); + expect(events.emit).toHaveBeenCalledTimes(1); + }); + + it('reports globallyBlocked when the unrestricted country is also blocked platform-wide', async () => { + const providerId = await seedProvider(); + const [gameId] = await seedManyGames(providerId, 1); + const actorId = randomUUID(); + const { svc } = makeService(); + await svc.bulkRestrictGameGeoRules( + { gameIds: [gameId!], countryCode: 'DK', reason: 'restricted' }, + actorId, + NO_META, + ); + await db.drizzle.db.insert(countryRule).values({ countryCode: 'DK', action: 'block' }); + + const result = await svc.bulkUnrestrictGameGeoRules( + { gameIds: [gameId!], countryCode: 'DK', reason: 'licence restored' }, + actorId, + NO_META, + ); + + expect(result).toMatchObject({ changed: 1, globallyBlocked: true }); + }); + + it('reports unknown game and provider ids in notFound while the rest applies', async () => { + const providerId = await seedProvider(); + const [first] = await seedManyGames(providerId, 1); + const ghostGameId = randomUUID(); + const ghostProviderId = randomUUID(); + const actorId = randomUUID(); + const { svc } = makeService(); + + const result = await svc.bulkRestrictGameGeoRules( + { + gameIds: [first!, ghostGameId], + providerIds: [ghostProviderId], + countryCode: 'DK', + reason: 'bulk restriction', + }, + actorId, + NO_META, + ); + + expect(result).toEqual({ + changed: 1, + unchanged: 0, + notFound: { gameIds: [ghostGameId], providerIds: [ghostProviderId] }, + }); + }); + + it('rejects a whole-provider scope over 5,000 games and writes nothing', async () => { + const providerId = await seedProvider(); + await seedManyGames(providerId, 5001); + const actorId = randomUUID(); + const { svc, events } = makeService(); + + await expect( + svc.bulkRestrictGameGeoRules( + { providerIds: [providerId], countryCode: 'DK', reason: 'bulk restriction' }, + actorId, + NO_META, + ), + ).rejects.toBeInstanceOf(GeoRuleBulkTooManyGamesError); + expect(events.emit).not.toHaveBeenCalled(); + const [row] = await db.drizzle.db.select({ n: count() }).from(gameGeoRule); + expect(Number(row?.n)).toBe(0); + }, 30_000); + + it('a bulk restrict and a concurrent single-target upsert for the same country both finish without deadlocking', async () => { + const providerId = await seedProvider(); + const [bulkA, bulkB, single] = await seedManyGames(providerId, 3); + const actorId = randomUUID(); + const { svc } = makeService(); + + await expect( + Promise.all([ + svc.bulkRestrictGameGeoRules( + { gameIds: [bulkA!, bulkB!], countryCode: 'DK', reason: 'bulk restriction' }, + actorId, + NO_META, + ), + svc.upsertGameGeoRules( + { gameId: single!, countryCodes: ['DK'], reason: 'single restriction' }, + actorId, + NO_META, + ), + ]), + ).resolves.toBeDefined(); + + const rules = await db.drizzle.db + .select({ gameId: gameGeoRule.gameId, countryCode: gameGeoRule.countryCode }) + .from(gameGeoRule) + .where(inArray(gameGeoRule.gameId, [bulkA!, bulkB!, single!])); + expect(rules.map((r) => r.gameId).sort()).toEqual([bulkA, bulkB, single].sort()); + expect(rules.every((r) => r.countryCode === 'DK')).toBe(true); + }); +}); diff --git a/packages/core/src/compliance/contract/index.ts b/packages/core/src/compliance/contract/index.ts index 3753750c..1117be0e 100644 --- a/packages/core/src/compliance/contract/index.ts +++ b/packages/core/src/compliance/contract/index.ts @@ -7,6 +7,7 @@ import { KycCheckResultSchema, TimestampSchema, CountryCodeSchema, + GameBulkIdsSchema, GeoRuleActionSchema, NonEmptyReasonSchema, PageQuerySchema, @@ -273,6 +274,39 @@ const GeoCheckOutputSchema = z.object({ reason: z.string().nullable(), }); +export const GetBlockedCountriesOutputSchema = z.object({ + countryCodes: z.array(CountryCodeSchema), +}); +export type GetBlockedCountriesOutput = z.infer; + +export const BulkGameGeoRuleInputSchema = z + .object({ + providerIds: z.array(UuidSchema).max(50).optional(), + gameIds: z.array(UuidSchema).max(500).optional(), + countryCode: CountryCodeSchema, + reason: NonEmptyReasonSchema.max(500), + }) + .refine((target) => (target.providerIds?.length ?? 0) > 0 || (target.gameIds?.length ?? 0) > 0, { + message: 'Provide at least one non-empty providerIds or gameIds', + path: ['gameIds'], + }); +export type BulkGameGeoRuleInput = z.infer; + +export const BulkRestrictGameGeoRulesOutputSchema = z.object({ + changed: z.number().int().nonnegative(), + unchanged: z.number().int().nonnegative(), + notFound: GameBulkIdsSchema, +}); +export type BulkRestrictGameGeoRulesOutput = z.infer; + +export const BulkUnrestrictGameGeoRulesOutputSchema = BulkRestrictGameGeoRulesOutputSchema.extend({ + stillBlockedByProvider: z.number().int().nonnegative(), + globallyBlocked: z.boolean(), +}); +export type BulkUnrestrictGameGeoRulesOutput = z.infer< + typeof BulkUnrestrictGameGeoRulesOutputSchema +>; + export const complianceContract = { getLimits: oc .route({ method: 'GET', path: '/compliance/limits' }) @@ -314,6 +348,20 @@ export const complianceContract = { .input(ListGameGeoRulesInputSchema) .output(paginated(GameGeoRuleSchema)), + bulkRestrictGameGeoRules: oc + .route({ method: 'POST', path: '/compliance/game-geo-rules/bulk/restrict' }) + .input(BulkGameGeoRuleInputSchema) + .output(BulkRestrictGameGeoRulesOutputSchema), + + bulkUnrestrictGameGeoRules: oc + .route({ method: 'POST', path: '/compliance/game-geo-rules/bulk/unrestrict' }) + .input(BulkGameGeoRuleInputSchema) + .output(BulkUnrestrictGameGeoRulesOutputSchema), + + getBlockedCountries: oc + .route({ method: 'GET', path: '/compliance/blocked-countries' }) + .output(GetBlockedCountriesOutputSchema), + upsertProviderGeoRules: oc .route({ method: 'PUT', path: '/compliance/provider-geo-rules/{providerId}' }) .input(UpsertProviderGeoRulesInputSchema) diff --git a/packages/core/src/compliance/router/index.ts b/packages/core/src/compliance/router/index.ts index fd11e3e6..c3780d4b 100644 --- a/packages/core/src/compliance/router/index.ts +++ b/packages/core/src/compliance/router/index.ts @@ -20,6 +20,7 @@ import { complianceContract, type KycStatusUpdate } from '../contract/index.js'; import { ComplianceService, GameGeoRuleNotFoundError, + GeoRuleBulkTooManyGamesError, GeoRuleGameNotFoundError, GeoRuleProviderNotFoundError, ProviderGeoRuleNotFoundError, @@ -163,6 +164,35 @@ export function createComplianceRouter({ return compliance.listGameGeoRules(input); }), + bulkRestrictGameGeoRules: os.bulkRestrictGameGeoRules.handler(async ({ input, context }) => { + const { userId, ip, userAgent } = await adminGuard.assert( + context, + 'compliance', + 'manage-geo', + ); + return mapErrors({ BAD_REQUEST: GeoRuleBulkTooManyGamesError }, () => + compliance.bulkRestrictGameGeoRules(input, userId, { ip, userAgent }), + ); + }), + + bulkUnrestrictGameGeoRules: os.bulkUnrestrictGameGeoRules.handler( + async ({ input, context }) => { + const { userId, ip, userAgent } = await adminGuard.assert( + context, + 'compliance', + 'manage-geo', + ); + return mapErrors({ BAD_REQUEST: GeoRuleBulkTooManyGamesError }, () => + compliance.bulkUnrestrictGameGeoRules(input, userId, { ip, userAgent }), + ); + }, + ), + + getBlockedCountries: os.getBlockedCountries.handler(async ({ context }) => { + await adminGuard.assert(context, 'compliance', 'view'); + return { countryCodes: await compliance.listGloballyBlockedCountries() }; + }), + upsertProviderGeoRules: os.upsertProviderGeoRules.handler(async ({ input, context }) => { const { userId, ip, userAgent } = await adminGuard.assert( context, diff --git a/packages/core/src/compliance/service/compliance.service.ts b/packages/core/src/compliance/service/compliance.service.ts index b5a947cf..c677226d 100644 --- a/packages/core/src/compliance/service/compliance.service.ts +++ b/packages/core/src/compliance/service/compliance.service.ts @@ -1,15 +1,18 @@ import { DrizzleService, + createDomainError, findOneOrThrow, pageToOffset, makeConflictError, makeNotFoundError, makeOwnershipError, serializeRow, + withAdvisoryXactLock, withAdvisoryXactLocks, + type DrizzleTx, type EventBus, } from '@openora/core/server'; -import { and, asc, count, eq, exists, inArray, sql } from 'drizzle-orm'; +import { and, asc, count, eq, exists, inArray, or, sql, type SQL } from 'drizzle-orm'; import { countryRule, gameGeoRule, @@ -19,6 +22,9 @@ import { } from '../schema/index.js'; import type { AddGeoRuleInput, + BulkGameGeoRuleInput, + BulkRestrictGameGeoRulesOutput, + BulkUnrestrictGameGeoRulesOutput, DeleteGameGeoRulesInput, DeleteProviderGeoRulesInput, UpsertGameGeoRulesInput, @@ -144,6 +150,95 @@ function gameGeoRuleLockKey( return `game-geo-rule:${gameId}:${countryCode}`; } +// Bulk writers take this exclusive; single-target writers take it shared, before their +// per-(game, country) keys. +function gameGeoRuleCountryLockKey(countryCode: string): string { + return `game-geo-rule-country:${countryCode}`; +} + +function withGameGeoRuleLocks( + tx: DrizzleTx, + gameId: UpsertGameGeoRulesInput['gameId'], + countryCodes: UpsertGameGeoRulesInput['countryCodes'], + fn: () => Promise, +): Promise { + return withAdvisoryXactLocks( + tx, + countryCodes.map(gameGeoRuleCountryLockKey), + () => + withAdvisoryXactLocks( + tx, + countryCodes.map((countryCode) => gameGeoRuleLockKey(gameId, countryCode)), + fn, + ), + 'shared', + ); +} + +const GEO_RULE_BULK_GAME_CAP = 5000; + +export const GeoRuleBulkTooManyGamesError = createDomainError<[matchedCount: number, cap: number]>( + 'GeoRuleBulkTooManyGamesError', + (matchedCount, cap) => `bulk action matched ${matchedCount} games, exceeding the ${cap}-game cap`, +); + +function bulkGameGeoTargetCondition(gameIds: string[], providerIds: string[]): SQL | undefined { + return or( + gameIds.length > 0 ? inArray(game.id, gameIds) : undefined, + providerIds.length > 0 ? inArray(game.providerId, providerIds) : undefined, + ); +} + +async function resolveBulkGeoScope( + tx: DrizzleTx, + gameIds: string[], + providerIds: string[], + limit: number, +): Promise<{ + games: { id: string; providerId: string }[]; + notFoundGameIds: string[]; + notFoundProviderIds: string[]; +}> { + const games = await tx + .select({ id: game.id, providerId: game.providerId }) + .from(game) + .where(bulkGameGeoTargetCondition(gameIds, providerIds)) + .limit(limit); + const foundGameIds = new Set(games.map((row) => row.id)); + const notFoundGameIds = gameIds.filter((id) => !foundGameIds.has(id)).sort(); + + const foundProviderIds = + providerIds.length > 0 + ? new Set( + ( + await tx + .select({ id: gameProvider.id }) + .from(gameProvider) + .where(inArray(gameProvider.id, providerIds)) + ).map((row) => row.id), + ) + : new Set(); + const notFoundProviderIds = providerIds.filter((id) => !foundProviderIds.has(id)).sort(); + + return { games, notFoundGameIds, notFoundProviderIds }; +} + +async function assertWithinGeoCap( + tx: DrizzleTx, + gameIds: string[], + providerIds: string[], + scopeLength: number, +): Promise { + if (scopeLength <= GEO_RULE_BULK_GAME_CAP) { + return; + } + const [{ n }] = await tx + .select({ n: count() }) + .from(game) + .where(bulkGameGeoTargetCondition(gameIds, providerIds)); + throw new GeoRuleBulkTooManyGamesError(Number(n), GEO_RULE_BULK_GAME_CAP); +} + export const ProviderGeoRuleNotFoundError = makeNotFoundError('ProviderGeoRule'); export const GeoRuleProviderNotFoundError = makeNotFoundError('GameProvider'); @@ -164,6 +259,10 @@ function serializeGeoRule(rul return serializeRow(rule, { dateFields: ['createdAt', 'updatedAt'] }); } +function serializeBulkGeoRules(rows: (typeof gameGeoRule.$inferSelect)[]) { + return rows.map(serializeGeoRule).sort((a, b) => a.gameId.localeCompare(b.gameId)); +} + function pairGeoRuleChanges( before: Rule[], after: Rule[], @@ -284,6 +383,18 @@ export class ComplianceService { return { allowed: result.allowed, countryCode: result.countryCode }; } + async listGloballyBlockedCountries(): Promise { + const rows = await this.drizzle.db + .select({ countryCode: countryRule.countryCode }) + .from(countryRule) + .where(eq(countryRule.action, 'block')); + const blocked = new Set([ + ...(this.igaming?.blockedCountries ?? []), + ...rows.map((row) => row.countryCode), + ]); + return [...blocked].sort(); + } + async upsertCountryRule(input: UpsertCountryRuleInput, actorId: User['id'], meta?: ClientMeta) { return this.drizzle.db.transaction(async (tx) => { if (input.blacklisted && this.igaming?.jurisdictions.includes(input.countryCode)) { @@ -524,36 +635,32 @@ export class ComplianceService { new GeoRuleGameNotFoundError(input.gameId), ); - return withAdvisoryXactLocks( - tx, - countryCodes.map((countryCode) => gameGeoRuleLockKey(input.gameId, countryCode)), - async () => { - const before = await tx - .select() - .from(gameGeoRule) - .where( - and( - eq(gameGeoRule.gameId, input.gameId), - inArray(gameGeoRule.countryCode, countryCodes), - ), - ); - const rows = await tx - .insert(gameGeoRule) - .values( - countryCodes.map((countryCode) => ({ - gameId: input.gameId, - countryCode, - reason: input.reason, - })), - ) - .onConflictDoUpdate({ - target: [gameGeoRule.gameId, gameGeoRule.countryCode], - set: { reason: input.reason, updatedAt: new Date() }, - }) - .returning(); - return pairGeoRuleChanges(before, rows); - }, - ); + return withGameGeoRuleLocks(tx, input.gameId, countryCodes, async () => { + const before = await tx + .select() + .from(gameGeoRule) + .where( + and( + eq(gameGeoRule.gameId, input.gameId), + inArray(gameGeoRule.countryCode, countryCodes), + ), + ); + const rows = await tx + .insert(gameGeoRule) + .values( + countryCodes.map((countryCode) => ({ + gameId: input.gameId, + countryCode, + reason: input.reason, + })), + ) + .onConflictDoUpdate({ + target: [gameGeoRule.gameId, gameGeoRule.countryCode], + set: { reason: input.reason, updatedAt: new Date() }, + }) + .returning(); + return pairGeoRuleChanges(before, rows); + }); }); for (const { before, after } of changes) { @@ -575,28 +682,24 @@ export class ComplianceService { async deleteGameGeoRules(input: DeleteGameGeoRulesInput, actorId: User['id'], meta: ClientMeta) { const countryCodes = [...new Set(input.countryCodes)].sort(); const deleted = await this.drizzle.db.transaction((tx) => - withAdvisoryXactLocks( - tx, - countryCodes.map((countryCode) => gameGeoRuleLockKey(input.gameId, countryCode)), - async () => { - const rows = await tx - .delete(gameGeoRule) - .where( - and( - eq(gameGeoRule.gameId, input.gameId), - inArray(gameGeoRule.countryCode, countryCodes), - ), - ) - .returning(); - const missing = missingCountryCodes(countryCodes, rows); - if (missing.length > 0) { - throw new GameGeoRuleNotFoundError(`${input.gameId}:${missing.join(',')}`); - } - return rows - .map(serializeGeoRule) - .sort((a, b) => a.countryCode.localeCompare(b.countryCode)); - }, - ), + withGameGeoRuleLocks(tx, input.gameId, countryCodes, async () => { + const rows = await tx + .delete(gameGeoRule) + .where( + and( + eq(gameGeoRule.gameId, input.gameId), + inArray(gameGeoRule.countryCode, countryCodes), + ), + ) + .returning(); + const missing = missingCountryCodes(countryCodes, rows); + if (missing.length > 0) { + throw new GameGeoRuleNotFoundError(`${input.gameId}:${missing.join(',')}`); + } + return rows + .map(serializeGeoRule) + .sort((a, b) => a.countryCode.localeCompare(b.countryCode)); + }), ); for (const before of deleted) { @@ -615,6 +718,152 @@ export class ComplianceService { return deleted; } + /** + * Idempotent: a game that already has the rule keeps it, reason included, and counts as + * `unchanged`. Never writes `provider_geo_rule`. + */ + async bulkRestrictGameGeoRules( + input: BulkGameGeoRuleInput, + actorId: User['id'], + meta: ClientMeta, + ): Promise { + const outcome = await this.bulkWriteGameGeoRules( + 'restrict', + input, + actorId, + meta, + async (tx, games) => { + if (games.length === 0) { + return { rules: [] }; + } + const rows = await tx + .insert(gameGeoRule) + .values( + games.map((row) => ({ + gameId: row.id, + countryCode: input.countryCode, + reason: input.reason, + })), + ) + .onConflictDoNothing({ target: [gameGeoRule.gameId, gameGeoRule.countryCode] }) + .returning(); + return { rules: serializeBulkGeoRules(rows) }; + }, + ); + + return { + changed: outcome.rules.length, + unchanged: outcome.matchedCount - outcome.rules.length, + notFound: outcome.notFound, + }; + } + + /** + * Idempotent: a game without the rule counts as `unchanged`, not NOT_FOUND. Never deletes + * `provider_geo_rule`; games it keeps blocked are counted in `stillBlockedByProvider`. + */ + async bulkUnrestrictGameGeoRules( + input: BulkGameGeoRuleInput, + actorId: User['id'], + meta: ClientMeta, + ): Promise { + const outcome = await this.bulkWriteGameGeoRules( + 'unrestrict', + input, + actorId, + meta, + async (tx, games) => { + if (games.length === 0) { + return { rules: [], stillBlockedByProvider: 0 }; + } + const rows = await tx + .delete(gameGeoRule) + .where( + and( + inArray( + gameGeoRule.gameId, + games.map((row) => row.id), + ), + eq(gameGeoRule.countryCode, input.countryCode), + ), + ) + .returning(); + + const blockingProviders = await tx + .select({ providerId: providerGeoRule.providerId }) + .from(providerGeoRule) + .where( + and( + inArray(providerGeoRule.providerId, [...new Set(games.map((row) => row.providerId))]), + eq(providerGeoRule.countryCode, input.countryCode), + ), + ); + const blockingProviderIds = new Set(blockingProviders.map((row) => row.providerId)); + + return { + rules: serializeBulkGeoRules(rows), + stillBlockedByProvider: games.filter((row) => blockingProviderIds.has(row.providerId)) + .length, + }; + }, + ); + + const globallyBlocked = (await this.listGloballyBlockedCountries()).includes(input.countryCode); + + return { + changed: outcome.rules.length, + unchanged: outcome.matchedCount - outcome.rules.length, + stillBlockedByProvider: outcome.stillBlockedByProvider, + globallyBlocked, + notFound: outcome.notFound, + }; + } + + private async bulkWriteGameGeoRules< + T extends { rules: ReturnType }, + >( + operation: 'restrict' | 'unrestrict', + input: BulkGameGeoRuleInput, + actorId: User['id'], + meta: ClientMeta, + write: (tx: DrizzleTx, games: { id: string; providerId: string }[]) => Promise, + ) { + const gameIds = [...new Set(input.gameIds ?? [])].sort(); + const providerIds = [...new Set(input.providerIds ?? [])].sort(); + + const outcome = await this.drizzle.db.transaction((tx) => + withAdvisoryXactLock(tx, gameGeoRuleCountryLockKey(input.countryCode), async () => { + const { games, notFoundGameIds, notFoundProviderIds } = await resolveBulkGeoScope( + tx, + gameIds, + providerIds, + GEO_RULE_BULK_GAME_CAP + 1, + ); + await assertWithinGeoCap(tx, gameIds, providerIds, games.length); + return { + ...(await write(tx, games)), + matchedCount: games.length, + notFound: { gameIds: notFoundGameIds, providerIds: notFoundProviderIds }, + }; + }), + ); + + if (outcome.rules.length > 0) { + this.events.emit('compliance.game-geo-rules.bulk_updated', { + operation, + countryCode: input.countryCode, + reason: input.reason, + rules: outcome.rules, + target: { gameIds, providerIds }, + notFound: outcome.notFound, + actorId, + ip: meta.ip, + userAgent: meta.userAgent, + }); + } + return outcome; + } + async listGameGeoRules({ gameIds, page, limit }: ListGameGeoRulesInput) { const where = gameIds ? inArray(gameGeoRule.gameId, gameIds) : undefined; const db = this.drizzle.db; diff --git a/packages/core/src/contracts/adapters/game-geo-check.ts b/packages/core/src/contracts/adapters/game-geo-check.ts index 120c20a9..4594ceeb 100644 --- a/packages/core/src/contracts/adapters/game-geo-check.ts +++ b/packages/core/src/contracts/adapters/game-geo-check.ts @@ -35,6 +35,8 @@ export type GameGeoDecision = z.infer; export type GameGeoCheckPort = { checkGame(input: GameGeoCheckInput): Promise; + /** Sorted, deduped country codes blocked platform-wide (config + a global block rule). */ + listGloballyBlockedCountries(): Promise; }; export const GAME_GEO_CHECK: Token = diff --git a/packages/core/src/contracts/schemas/events.ts b/packages/core/src/contracts/schemas/events.ts index 906a00b4..c2f1f004 100644 --- a/packages/core/src/contracts/schemas/events.ts +++ b/packages/core/src/contracts/schemas/events.ts @@ -898,6 +898,14 @@ export const domainEventSchemas = { actorId: UuidSchema, }), + 'compliance.game-geo-rules.bulk_updated': gameBulkEventBase.extend({ + operation: z.enum(['restrict', 'unrestrict']), + countryCode: CountryCodeSchema, + reason: NonEmptyReasonSchema, + // The rules the call added (restrict) or removed (unrestrict). + rules: z.array(gameGeoRuleEventState), + }), + 'compliance.provider-geo-rule.upserted': authContextBase.extend({ ruleId: UuidSchema, providerId: UuidSchema, diff --git a/packages/core/src/server/db/query-helpers.ts b/packages/core/src/server/db/query-helpers.ts index 702612b0..072c9e12 100644 --- a/packages/core/src/server/db/query-helpers.ts +++ b/packages/core/src/server/db/query-helpers.ts @@ -218,14 +218,18 @@ export async function withAdvisoryXactLocks( txn: DrizzleTx, keys: readonly string[], fn: () => Promise, + mode: 'exclusive' | 'shared' = 'exclusive', ): Promise { if (keys.length > 0) { const keyList = sql.join( keys.map((key) => sql`${key}`), sql`, `, ); + const lockFn = sql.raw( + mode === 'shared' ? 'pg_advisory_xact_lock_shared' : 'pg_advisory_xact_lock', + ); await txn.execute( - sql`select pg_advisory_xact_lock(lock_id) from (select distinct hashtext(key) as lock_id from unnest(array[${keyList}]::text[]) as key order by lock_id) as locks`, + sql`select ${lockFn}(lock_id) from (select distinct hashtext(key) as lock_id from unnest(array[${keyList}]::text[]) as key order by lock_id) as locks`, ); } return fn(); diff --git a/packages/testing/src/__tests__/game-geo-blocking.e2e.test.ts b/packages/testing/src/__tests__/game-geo-blocking.e2e.test.ts index 531f441b..3f1b0c9d 100644 --- a/packages/testing/src/__tests__/game-geo-blocking.e2e.test.ts +++ b/packages/testing/src/__tests__/game-geo-blocking.e2e.test.ts @@ -65,6 +65,20 @@ async function auditEntries(resourceId: string, action: string) { return (await readJson(response)).items as Array>; } +async function bulkAuditEntries(gameId: string, operation: 'restrict' | 'unrestrict') { + const response = await admin.get('/audit/logs?action=compliance.game-geo-rules.bulk_updated'); + expect(response.status).toBe(200); + const entries = (await readJson(response)).items as Array>; + return entries.filter((entry) => { + const before = entry['before'] as { rules: { gameId: string }[] }; + const after = entry['after'] as { operation: string; rules: { gameId: string }[] }; + return ( + after.operation === operation && + [...before.rules, ...after.rules].some((rule) => rule.gameId === gameId) + ); + }); +} + async function seedGame(label: string): Promise { const drizzle = app.container.get(DRIZZLE).db; const [provider] = await drizzle @@ -543,3 +557,223 @@ describe('multi-country geo-blocking', () => { expect((await player.post(`/gaming/rounds/${roundId}/end`, { roundId })).status).toBe(200); }); }); + +describe('bulk geo restrict / unrestrict', () => { + it('restricts and unrestricts many games for one country in a single call, audited once per call', async () => { + const first = await seedGame('Bulk geo first'); + const second = await seedGame('Bulk geo second'); + const bothGameIds = [first.gameId, second.gameId]; + const gamesQuery = bothGameIds.map((id) => `gameIds[]=${id}`).join('&'); + + const forbiddenRestrict = await player.post('/compliance/game-geo-rules/bulk/restrict', { + gameIds: bothGameIds, + countryCode: 'US', + reason: 'player must not administer geo policy', + }); + expect(forbiddenRestrict.status).toBe(403); + + const restrict = await admin.post('/compliance/game-geo-rules/bulk/restrict', { + gameIds: bothGameIds, + countryCode: 'US', + reason: 'bulk restriction', + }); + expect(restrict.status).toBe(200); + expect(await readJson(restrict)).toEqual({ + changed: 2, + unchanged: 0, + notFound: { gameIds: [], providerIds: [] }, + }); + + const rulesAfterRestrict = await admin.get(`/compliance/game-geo-rules?${gamesQuery}`); + expect(rulesAfterRestrict.status).toBe(200); + const rules = (await readJson(rulesAfterRestrict)).items as Array<{ + id: string; + gameId: string; + countryCode: string; + }>; + expect(rules).toHaveLength(2); + + await vi.waitFor(async () => { + expect(await bulkAuditEntries(first.gameId, 'restrict')).toEqual([ + expect.objectContaining({ + actorType: 'admin', + resourceType: 'game-geo-rule', + resourceId: null, + after: expect.objectContaining({ + operation: 'restrict', + countryCode: 'US', + reason: 'bulk restriction', + rules: [...bothGameIds] + .sort() + .map((gameId) => + expect.objectContaining({ gameId, countryCode: 'US', reason: 'bulk restriction' }), + ), + target: { gameIds: [...bothGameIds].sort(), providerIds: [] }, + }), + }), + ]); + }); + + const restrictAgain = await admin.post('/compliance/game-geo-rules/bulk/restrict', { + gameIds: bothGameIds, + countryCode: 'US', + reason: 'repeat', + }); + expect(await readJson(restrictAgain)).toEqual({ + changed: 0, + unchanged: 2, + notFound: { gameIds: [], providerIds: [] }, + }); + + const forbiddenUnrestrict = await player.post('/compliance/game-geo-rules/bulk/unrestrict', { + gameIds: bothGameIds, + countryCode: 'US', + reason: 'player must not administer geo policy', + }); + expect(forbiddenUnrestrict.status).toBe(403); + + const unrestrict = await admin.post('/compliance/game-geo-rules/bulk/unrestrict', { + gameIds: bothGameIds, + countryCode: 'US', + reason: 'bulk restore', + }); + expect(unrestrict.status).toBe(200); + expect(await readJson(unrestrict)).toEqual({ + changed: 2, + unchanged: 0, + stillBlockedByProvider: 0, + globallyBlocked: false, + notFound: { gameIds: [], providerIds: [] }, + }); + + await vi.waitFor(async () => { + expect(await bulkAuditEntries(first.gameId, 'unrestrict')).toEqual([ + expect.objectContaining({ + resourceType: 'game-geo-rule', + before: { + rules: rules.map((rule) => + expect.objectContaining({ + id: rule.id, + gameId: rule.gameId, + reason: 'bulk restriction', + }), + ), + }, + after: expect.objectContaining({ + operation: 'unrestrict', + reason: 'bulk restore', + rules: [], + }), + }), + ]); + }); + expect(await bulkAuditEntries(first.gameId, 'restrict')).toHaveLength(1); + + const rulesAfterUnrestrict = await admin.get(`/compliance/game-geo-rules?${gamesQuery}`); + expect(await readJson(rulesAfterUnrestrict)).toMatchObject({ items: [], total: 0 }); + }); + + it('records one audit row listing only the games the call changed', async () => { + const alreadyRestricted = await seedGame('Bulk audit already-restricted'); + const changedFirst = await seedGame('Bulk audit changed first'); + const changedSecond = await seedGame('Bulk audit changed second'); + + const seedUpsert = await admin.put(`/compliance/game-geo-rules/${alreadyRestricted.gameId}`, { + countryCodes: ['DK'], + reason: 'pre-existing restriction', + }); + expect(seedUpsert.status).toBe(200); + const [preExistingRule] = (await readJson(seedUpsert)) as [{ id: string }]; + + const restrict = await admin.post('/compliance/game-geo-rules/bulk/restrict', { + gameIds: [alreadyRestricted.gameId, changedFirst.gameId, changedSecond.gameId], + countryCode: 'DK', + reason: 'bulk audit check', + }); + expect(restrict.status).toBe(200); + expect(await readJson(restrict)).toEqual({ + changed: 2, + unchanged: 1, + notFound: { gameIds: [], providerIds: [] }, + }); + + await vi.waitFor(async () => { + expect(await bulkAuditEntries(changedFirst.gameId, 'restrict')).toEqual([ + expect.objectContaining({ + after: expect.objectContaining({ + rules: [changedFirst.gameId, changedSecond.gameId] + .sort() + .map((gameId) => expect.objectContaining({ gameId })), + }), + }), + ]); + }); + expect(await bulkAuditEntries(alreadyRestricted.gameId, 'restrict')).toEqual([]); + expect( + await auditEntries(preExistingRule.id, 'compliance.game-geo-rule.upserted'), + ).toHaveLength(1); + }); + + it('rejects a bulk call with no gameIds/providerIds, more than 500 gameIds, a malformed country code, or a reason over 500 characters', async () => { + const seeded = await seedGame('Bulk validation target'); + const tooManyGameIds = Array.from({ length: 501 }, () => randomUUID()); + const tooLongReason = 'x'.repeat(501); + + for (const path of [ + '/compliance/game-geo-rules/bulk/restrict', + '/compliance/game-geo-rules/bulk/unrestrict', + ]) { + const noTargets = await admin.post(path, { + countryCode: 'US', + reason: 'no targets given', + }); + expect(noTargets.status).toBe(400); + + const overCap = await admin.post(path, { + gameIds: tooManyGameIds, + countryCode: 'US', + reason: 'too many game ids', + }); + expect(overCap.status).toBe(400); + + const badCountry = await admin.post(path, { + gameIds: [seeded.gameId], + countryCode: 'USA', + reason: 'bad country code', + }); + expect(badCountry.status).toBe(400); + + const reasonTooLong = await admin.post(path, { + gameIds: [seeded.gameId], + countryCode: 'US', + reason: tooLongReason, + }); + expect(reasonTooLong.status).toBe(400); + } + }); +}); + +describe('platform-wide blocked-countries read', () => { + it('lists the blocked-countries union, guarded by compliance:view', async () => { + const forbidden = await player.get('/compliance/blocked-countries'); + expect(forbidden.status).toBe(403); + + const before = await admin.get('/compliance/blocked-countries'); + expect(before.status).toBe(200); + expect((await readJson(before)).countryCodes as string[]).not.toContain('US'); + + const rule = await admin.put('/compliance/country-rules', { + countryCode: 'US', + blacklisted: true, + redirectIp: false, + kycRequired: true, + expectedUpdatedAt: null, + confirm: true, + }); + expect(rule.status).toBe(200); + + const after = await admin.get('/compliance/blocked-countries'); + expect(after.status).toBe(200); + expect(await readJson(after)).toMatchObject({ countryCodes: expect.arrayContaining(['US']) }); + }); +}); diff --git a/packages/testing/src/__tests__/gaming-admin-game-filters.e2e.test.ts b/packages/testing/src/__tests__/gaming-admin-game-filters.e2e.test.ts index 07a60e99..81087ba6 100644 --- a/packages/testing/src/__tests__/gaming-admin-game-filters.e2e.test.ts +++ b/packages/testing/src/__tests__/gaming-admin-game-filters.e2e.test.ts @@ -140,6 +140,9 @@ describe('admin game list filters e2e', () => { [full.id, partial.id, bare.id].sort(), ); expect(await listIds('geoBlocked=false')).toEqual([]); + + expect(await listIds('geoAvailableCountries[]=DE')).toEqual([bare.id]); + expect(await listIds('geoAvailableCountries[]=IT')).toEqual([]); }); it('rejects geoBlocked=false combined with geoBlockedCountries', async () => { @@ -149,6 +152,20 @@ describe('admin game list filters e2e', () => { expect(res.status).toBe(400); }); + it('rejects geoBlocked=true combined with geoAvailableCountries', async () => { + const res = await admin.get( + '/backoffice/gaming/games?geoBlocked=true&geoAvailableCountries[]=DE', + ); + expect(res.status).toBe(400); + }); + + it('rejects a country shared between geoAvailableCountries and geoBlockedCountries', async () => { + const res = await admin.get( + '/backoffice/gaming/games?geoAvailableCountries[]=DE&geoBlockedCountries[]=DE', + ); + expect(res.status).toBe(400); + }); + it('rejects uncategorized combined with a category filter', async () => { const res = await admin.get( `/backoffice/gaming/games?uncategorized=true&categoryIds[]=${randomUUID()}`,