import fs from 'node:fs/promises'; import { createServer, type Server as HttpServer } from 'node:http'; import os from 'node:os'; import path from 'node:path'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { encryptGameSessionToken, type GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import { createGamePostgresConnector, createRedisConnector, enqueueWebPushOutboxEvents, resolveRedisConfigFromEnv, type GamePrismaClient, type RedisConnector, } from '@sammo-ts/infra'; import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; import { createApiInputPayloadIdentity } from '../src/inputEventBoundary.js'; import { scopeHttpIdempotencyKey } from '../src/requestId.js'; import { createGameApiServer } from '../src/server.js'; import { WebPushOutboxWorker } from '../src/services/webPushOutboxWorker.js'; const databaseUrl = process.env.SECURITY_TRANSPORT_DATABASE_URL; const integration = describe.skipIf(!databaseUrl || !process.env.REDIS_URL); const dedicatedSuffix = 'security_transport'; let profileId = process.env.POSTGRES_SCHEMA ?? 'conditional_integration'; const runId = process.env.CONDITIONAL_INTEGRATION_RUN_ID ?? String(process.pid); const profileName = `che:security-http-${runId}`; const userId = `security-http-user-${process.pid}`; const noGeneralUserId = `security-http-no-general-${process.pid}`; const sameNationUserId = `security-http-same-nation-${process.pid}`; const foreignUserId = `security-http-foreign-${process.pid}`; const ordinaryUserId = `security-http-ordinary-${process.pid}`; const generalId = 990_001; const sameNationGeneralId = 990_002; const foreignGeneralId = 990_003; const npcGeneralId = 990_004; const ordinaryGeneralId = 990_005; const fixtureGeneralIds = [generalId, sameNationGeneralId, foreignGeneralId, npcGeneralId, ordinaryGeneralId]; const ownerNationId = 99_001; const foreignNationId = 99_002; const fixtureNationIds = [ownerNationId, foreignNationId]; const fixtureWorldId = 990_001; const mutationRequestPrefix = `security-http-matrix-${process.pid}-`; const matrixApiEventTypes = [ 'messages.send', 'turns.reserved.setGeneral', 'turns.reserved.setNation', 'turns.reserved.setNationBulk', ] as const; const fixtureActorUserIds = [userId, noGeneralUserId, sameNationUserId, foreignUserId, ordinaryUserId]; const secret = 'security-http-e2e-secret'; const redisPrefix = `sammo:security-http:${process.pid}`; const envKeys = [ 'DATABASE_URL', 'PROFILE', 'SCENARIO', 'GAME_PROFILE_NAME', 'GAME_API_HOST', 'GAME_API_PORT', 'GAME_TOKEN_SECRET', 'GATEWAY_REDIS_PREFIX', 'GATEWAY_INTERNAL_API_URL', 'GAME_UPLOAD_DIR', ] as const; const originalEnv = new Map(envKeys.map((key) => [key, process.env[key]])); type RunningServer = Awaited>; let server: RunningServer | null = null; let baseUrl = ''; let uploadDir = ''; let db: GamePrismaClient; let disconnectDb: (() => Promise) | null = null; let redis: RedisConnector | null = null; let accessTokenStore: RedisAccessTokenStore; let createdFixtureWorld = false; let reservationWorldId = fixtureWorldId; let gatewayStatusServer: HttpServer | null = null; let receivedGatewayWebPushEvents: Array<{ internalToken: string | null; body: unknown }> = []; export const assertDedicatedSecurityTransportDatabase = (rawUrl: string): void => { resolveDedicatedSecurityTransportTarget(rawUrl); }; const resolveDedicatedSecurityTransportTarget = (rawUrl: string): { databaseUrl: string; schema: string } => { const url = new URL(rawUrl); const schema = url.searchParams.get('schema'); const databaseName = decodeURIComponent(url.pathname.replace(/^\/+/, '')); if (!schema?.endsWith(dedicatedSuffix) && !databaseName.endsWith(dedicatedSuffix)) { throw new Error( `Refusing to mutate non-dedicated security transport database: schema=${schema ?? '(missing)'}, database=${databaseName || '(missing)'}` ); } const effectiveSchema = schema?.trim() || 'public'; url.searchParams.set('schema', effectiveSchema); return { databaseUrl: url.href, schema: effectiveSchema }; }; const restoreEnv = (): void => { for (const [key, value] of originalEnv) { if (value === undefined) { delete process.env[key]; } else { process.env[key] = value; } } }; const listenGatewayStatusStub = async (): Promise => { gatewayStatusServer = createServer((request, response) => { if (request.method === 'GET' && request.url === `/internal/profile-status/${encodeURIComponent(profileName)}`) { response.writeHead(200, { 'content-type': 'application/json' }); response.end(JSON.stringify({ profileName, status: 'RUNNING' })); return; } if (request.method === 'POST' && request.url === '/internal/account-icon-resets') { response.writeHead(200, { 'content-type': 'application/json' }); response.end(JSON.stringify({ resets: [] })); return; } if (request.method === 'POST' && request.url === '/internal/web-push-events') { const chunks: Buffer[] = []; request.on('data', (chunk: Buffer | string) => chunks.push(Buffer.from(chunk))); request.on('end', () => { try { receivedGatewayWebPushEvents.push({ internalToken: typeof request.headers['x-sammo-internal-token'] === 'string' ? request.headers['x-sammo-internal-token'] : null, body: JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown, }); response.writeHead(200, { 'content-type': 'application/json' }); response.end('{}'); } catch { response.writeHead(400); response.end(); } }); return; } response.writeHead(404); response.end(); }); await new Promise((resolve, reject) => { gatewayStatusServer!.once('error', reject); gatewayStatusServer!.listen(0, '127.0.0.1', () => { gatewayStatusServer!.off('error', reject); resolve(); }); }); const address = gatewayStatusServer.address(); if (!address || typeof address === 'string') throw new Error('gateway status stub did not bind a TCP port'); return `http://127.0.0.1:${address.port}`; }; const closeGatewayStatusStub = async (): Promise => { if (!gatewayStatusServer) return; const current = gatewayStatusServer; gatewayStatusServer = null; await new Promise((resolve, reject) => current.close((error) => (error ? reject(error) : resolve()))); }; const deleteProfileRedisKeys = async (): Promise => { if (!redis) { return; } for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`]) { for await (const keys of redis.client.scanIterator({ MATCH: pattern, COUNT: 100, })) { if (keys.length > 0) { await redis.client.del(keys); } } } }; const buildPayload = ( suffix: string, sanctions: GameSessionTokenPayload['sanctions'], actorUserId = userId, actorProfile = profileName ): GameSessionTokenPayload => ({ version: 1, profile: actorProfile, issuedAt: new Date(Date.now() - 1_000).toISOString(), expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), sessionId: `security-http-session-${process.pid}-${suffix}`, user: { id: actorUserId, username: 'security-http-user', displayName: 'Security HTTP User', roles: ['user'], createdAt: '2026-07-26T00:00:00.000Z', }, sanctions, }); const createAccessToken = async ( suffix: string, sanctions: GameSessionTokenPayload['sanctions'], actorUserId = userId ): Promise => { const created = await accessTokenStore.create(buildPayload(suffix, sanctions, actorUserId)); if (!created) { throw new Error('failed to seed the game access token'); } return created.accessToken; }; const requestTrpc = async ( procedure: string, options: { method?: 'GET' | 'POST'; input?: unknown; accessToken?: string; idempotencyKey?: string; } = {} ): Promise<{ response: Response; body: unknown }> => { const method = options.method ?? 'GET'; const response = await fetch(`${baseUrl}/trpc/${procedure}`, { method, headers: { ...(method === 'POST' ? { 'content-type': 'application/json' } : {}), ...(options.accessToken ? { authorization: `Bearer ${options.accessToken}` } : {}), ...(options.idempotencyKey ? { 'idempotency-key': options.idempotencyKey } : {}), }, ...(method === 'POST' ? { body: JSON.stringify(options.input) } : {}), }); return { response, body: (await response.json()) as unknown, }; }; const readReservedMutationState = async () => ({ generals: await db.general.findMany({ where: { id: { in: fixtureGeneralIds } }, select: { id: true, userId: true, nationId: true, officerLevel: true, lastTurn: true, meta: true, }, orderBy: { id: 'asc' }, }), generalTurns: await db.generalTurn.findMany({ where: { generalId: { in: fixtureGeneralIds } }, select: { generalId: true, turnIdx: true, actionCode: true, arg: true }, orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }], }), generalTurnRevisions: await db.generalTurnRevision.findMany({ where: { generalId: { in: fixtureGeneralIds } }, select: { generalId: true, revision: true, leaseOwner: true, leaseExpiresAt: true }, orderBy: { generalId: 'asc' }, }), generalAccessLogs: await db.generalAccessLog.findMany({ where: { generalId: { in: fixtureGeneralIds } }, select: { generalId: true, userId: true, lastRefresh: true, refresh: true, refreshTotal: true, refreshScore: true, refreshScoreTotal: true, lastActionAt: true, }, orderBy: { generalId: 'asc' }, }), nationTurns: await db.nationTurn.findMany({ where: { nationId: { in: fixtureNationIds } }, select: { nationId: true, officerLevel: true, turnIdx: true, actionCode: true, arg: true }, orderBy: [{ nationId: 'asc' }, { officerLevel: 'asc' }, { turnIdx: 'asc' }], }), nationTurnRevisions: await db.nationTurnRevision.findMany({ where: { nationId: { in: fixtureNationIds } }, select: { nationId: true, officerLevel: true, revision: true, leaseOwner: true, leaseExpiresAt: true }, orderBy: [{ nationId: 'asc' }, { officerLevel: 'asc' }], }), readModelRevisions: await db.readModelRevision.findMany({ where: { OR: [ { domain: 'reserved.general', entityId: { in: fixtureGeneralIds } }, { domain: 'general.content', entityId: { in: fixtureGeneralIds } }, { domain: 'dashboard.global', entityId: 0 }, ], }, select: { domain: true, entityId: true, revision: true }, orderBy: [{ domain: 'asc' }, { entityId: 'asc' }], }), readModelOutbox: await db.readModelOutbox.findMany({ select: { id: true, payload: true }, orderBy: { id: 'asc' }, }), messages: await db.message.findMany({ where: { OR: [{ src: { in: fixtureGeneralIds } }, { dest: { in: [...fixtureGeneralIds, ...fixtureNationIds] } }], }, select: { id: true, mailbox: true, type: true, src: true, dest: true, message: true }, orderBy: { id: 'asc' }, }), logs: await db.logEntry.findMany({ where: { OR: [{ generalId: { in: fixtureGeneralIds } }, { nationId: { in: fixtureNationIds } }], }, select: { id: true, scope: true, category: true, generalId: true, nationId: true, text: true }, orderBy: { id: 'asc' }, }), engineInputEvents: await db.inputEvent.findMany({ where: { target: 'ENGINE', actorUserId: { in: fixtureActorUserIds } }, select: { requestId: true, eventType: true, status: true, actorUserId: true }, orderBy: { sequence: 'asc' }, }), webPushOutboxCount: await db.webPushOutbox.count(), eventCount: await db.event.count(), auctionCount: await db.auction.count(), auctionBidCount: await db.auctionBid.count(), }); const quotePostgresIdentifier = (value: string): string => `"${value.replaceAll('"', '""')}"`; const isMatrixApiInputEvent = (rowJson: string): boolean => { const row = JSON.parse(rowJson) as { target?: unknown; event_type?: unknown }; return row.target === 'API' && matrixApiEventTypes.includes(row.event_type as (typeof matrixApiEventTypes)[number]); }; const deleteMatrixInputEvents = async (): Promise => { await db.inputEvent.deleteMany({ where: { OR: [ { requestId: { startsWith: mutationRequestPrefix } }, { target: 'API', eventType: { in: [...matrixApiEventTypes] } }, { target: 'ENGINE', actorUserId: { in: fixtureActorUserIds } }, ], }, }); }; const resolveScopedApiRequestId = (idempotencyKey: string, procedure: string, actorUserId: string): string => { const scopedRequestId = scopeHttpIdempotencyKey({ rawKey: idempotencyKey, profileId, userId: actorUserId }); if (!scopedRequestId) { throw new Error('matrix idempotency key unexpectedly resolved to an empty request ID'); } return `${scopedRequestId}:${procedure}`; }; const readDurableSchemaStateExcludingMatrixApiJournal = async () => { const tables = await db.$queryRawUnsafe>( `SELECT table_name AS "tableName" FROM information_schema.tables WHERE table_schema = $1 AND table_type = 'BASE TABLE' ORDER BY table_name`, profileId ); return Promise.all( tables.map(async ({ tableName }) => { const qualifiedTable = `${quotePostgresIdentifier(profileId)}.${quotePostgresIdentifier(tableName)}`; const rows = await db.$queryRawUnsafe>( `SELECT to_jsonb(snapshot_row)::text AS "rowJson" FROM ${qualifiedTable} AS snapshot_row ORDER BY to_jsonb(snapshot_row)::text` ); return { tableName, rows: rows .map(({ rowJson }) => rowJson) .filter((rowJson) => tableName !== 'input_event' || !isMatrixApiInputEvent(rowJson)), }; }) ); }; type DurableSchemaState = Awaited>; const withoutDurableTables = (state: DurableSchemaState, allowedTables: readonly string[]): DurableSchemaState => { const allowed = new Set(allowedTables); return state.filter(({ tableName }) => !allowed.has(tableName)); }; const readSuccessAllowedTableState = async () => ({ generalTurns: await db.generalTurn.findMany({ orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }] }), generalTurnRevisions: await db.generalTurnRevision.findMany({ orderBy: { generalId: 'asc' } }), nationTurns: await db.nationTurn.findMany({ orderBy: [{ nationId: 'asc' }, { officerLevel: 'asc' }, { turnIdx: 'asc' }], }), nationTurnRevisions: await db.nationTurnRevision.findMany({ orderBy: [{ nationId: 'asc' }, { officerLevel: 'asc' }], }), generalAccessLogs: await db.generalAccessLog.findMany({ orderBy: { generalId: 'asc' } }), readModelRevisions: await db.readModelRevision.findMany({ orderBy: [{ domain: 'asc' }, { entityId: 'asc' }], }), readModelOutbox: await db.readModelOutbox.findMany({ orderBy: { id: 'asc' } }), }); const readAccessTelemetryState = async () => ({ periods: await db.trafficPeriod.findMany({ orderBy: { id: 'asc' } }), generals: await db.trafficPeriodGeneral.findMany({ orderBy: [{ periodId: 'asc' }, { generalId: 'asc' }], }), accessLogs: await db.generalAccessLog.findMany({ orderBy: { generalId: 'asc' } }), }); const readRealtimeRedisState = async (): Promise> => { if (!redis) return []; const keys = new Set(); for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`]) { for await (const batch of redis.client.scanIterator({ MATCH: pattern, COUNT: 100 })) { for (const key of batch) keys.add(key); } } return Promise.all( [...keys].sort().map(async (key) => [key, await redis!.client.get(key)] as [string, string | null]) ); }; const expectApiInputEvent = async ( idempotencyKey: string, procedure: string, expected: { actorUserId: string; status: 'FAILED' | 'SUCCEEDED'; payload?: unknown; result?: unknown; } | null ): Promise => { const events = await db.inputEvent.findMany({ // The HTTP boundary hashes the raw client key together with profile and // actor. Query the whole procedure matrix so an unexpected extra row // cannot hide behind the full-schema snapshot's explicit exclusion. where: { target: 'API', eventType: { in: [...matrixApiEventTypes] } }, select: { requestId: true, target: true, eventType: true, payload: true, actorUserId: true, status: true, result: true, error: true, attempts: true, lockedBy: true, leaseUntil: true, processingAt: true, completedAt: true, createdAt: true, }, orderBy: { sequence: 'asc' }, }); if (!expected) { expect(events).toEqual([]); return; } const requestId = resolveScopedApiRequestId(idempotencyKey, procedure, expected.actorUserId); expect(events).toEqual([ { requestId, target: 'API', eventType: procedure, payload: expected.payload === undefined ? { version: 1, digest: expect.stringMatching(/^sha256:[0-9a-f]{64}$/u), } : createApiInputPayloadIdentity(expected.payload), actorUserId: expected.actorUserId, status: expected.status, result: expected.status === 'SUCCEEDED' ? (expected.result ?? expect.objectContaining({ ok: true })) : null, error: expected.status === 'SUCCEEDED' ? null : expect.any(String), attempts: 1, lockedBy: null, leaseUntil: null, processingAt: expect.any(Date), completedAt: expect.any(Date), createdAt: expect.any(Date), }, ]); const event = events[0]; if (!event?.processingAt || !event.completedAt) { throw new Error('API input event must have processing/completion timestamps'); } expect(event.completedAt.getTime()).toBeGreaterThanOrEqual(event.processingAt.getTime()); if (expected.status === 'FAILED') { expect(event.error?.length).toBeGreaterThan(0); } }; const expectSingleActorActivity = ( rows: Awaited>['generalAccessLogs'] ) => { expect(rows).toEqual([ { generalId, userId, lastRefresh: null, refresh: 0, refreshTotal: 0, refreshScore: 0, refreshScoreTotal: 0, lastActionAt: expect.any(Date), }, ]); }; const requestReservedGeneral = (accessToken: string | undefined, idempotencyKey: string, targetGeneralId = generalId) => requestTrpc('turns.reserved.setGeneral', { method: 'POST', input: { generalId: targetGeneralId, turnIndex: 0, action: '휴식', args: {}, expectedRevision: 0, }, accessToken, idempotencyKey, }); const requestReservedNation = ( accessToken: string, idempotencyKey: string, targetGeneralId: number, command: { action: string; args: unknown } = { action: '휴식', args: {} } ) => requestTrpc('turns.reserved.setNation', { method: 'POST', input: { generalId: targetGeneralId, turnIndex: 0, action: command.action, args: command.args, expectedRevision: 0, }, accessToken, idempotencyKey, }); const requestReservedNationBulk = ( accessToken: string, idempotencyKey: string, targetGeneralId: number, command: { action: string; args: unknown } = { action: '휴식', args: {} } ) => requestTrpc('turns.reserved.setNationBulk', { method: 'POST', input: { generalId: targetGeneralId, entries: [{ turnList: [0, 1], action: command.action, args: command.args }], expectedRevision: 0, }, accessToken, idempotencyKey, }); type NationReservationKind = 'single' | 'bulk'; const nationReservationProcedure = (kind: NationReservationKind) => kind === 'single' ? 'turns.reserved.setNation' : 'turns.reserved.setNationBulk'; const requestNationReservation = ( kind: NationReservationKind, accessToken: string, idempotencyKey: string, targetGeneralId = generalId, command: { action: string; args: unknown } = { action: '휴식', args: {} } ) => kind === 'single' ? requestReservedNation(accessToken, idempotencyKey, targetGeneralId, command) : requestReservedNationBulk(accessToken, idempotencyKey, targetGeneralId, command); const ownershipDenialCases = [ { label: 'authenticated user without a general', actorUserId: noGeneralUserId, targetGeneralId: generalId, }, { label: 'same-nation foreign-owned general', actorUserId: userId, targetGeneralId: sameNationGeneralId, }, { label: 'other-nation foreign-owned general', actorUserId: userId, targetGeneralId: foreignGeneralId, }, { label: 'NPC general', actorUserId: userId, targetGeneralId: npcGeneralId, }, ] as const; describe('security transport database guard', () => { it('rejects a shared database and schema before connecting', () => { expect(() => assertDedicatedSecurityTransportDatabase('postgresql://fixture:fixture@127.0.0.1:5432/sammo?schema=public') ).toThrow('Refusing to mutate non-dedicated security transport database'); }); it('accepts only an explicitly dedicated schema or database name', () => { expect(() => assertDedicatedSecurityTransportDatabase( 'postgresql://fixture:fixture@127.0.0.1:5432/sammo?schema=ci_security_transport' ) ).not.toThrow(); expect(() => assertDedicatedSecurityTransportDatabase( 'postgresql://fixture:fixture@127.0.0.1:5432/ci_security_transport' ) ).not.toThrow(); }); }); integration('game API security over HTTP transport', () => { beforeAll(async () => { const dedicatedTarget = resolveDedicatedSecurityTransportTarget(databaseUrl!); profileId = dedicatedTarget.schema; uploadDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-game-security-http-')); process.env.DATABASE_URL = dedicatedTarget.databaseUrl; process.env.PROFILE = dedicatedTarget.schema; process.env.SCENARIO = 'security-http'; process.env.GAME_PROFILE_NAME = profileName; process.env.GAME_API_HOST = '127.0.0.1'; process.env.GAME_API_PORT = '0'; process.env.GAME_TOKEN_SECRET = secret; process.env.GATEWAY_REDIS_PREFIX = redisPrefix; process.env.GATEWAY_INTERNAL_API_URL = await listenGatewayStatusStub(); process.env.GAME_UPLOAD_DIR = uploadDir; const connector = createGamePostgresConnector({ url: dedicatedTarget.databaseUrl }); await connector.connect(); db = connector.prisma; disconnectDb = () => connector.disconnect(); await db.generalAccessLog.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } }); await db.generalTurn.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } }); await db.generalTurnRevision.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } }); await db.nationTurn.deleteMany({ where: { nationId: { in: fixtureNationIds } } }); await db.nationTurnRevision.deleteMany({ where: { nationId: { in: fixtureNationIds } } }); await db.general.deleteMany({ where: { id: { in: fixtureGeneralIds } } }); await db.general.createMany({ data: [ { id: generalId, userId, name: '보안HTTP', nationId: ownerNationId, officerLevel: 12, turnTime: new Date('2026-07-26T00:00:00.000Z'), }, { id: sameNationGeneralId, userId: sameNationUserId, name: '동일국타인', nationId: ownerNationId, officerLevel: 5, turnTime: new Date('2026-07-26T00:00:00.000Z'), }, { id: foreignGeneralId, userId: foreignUserId, name: '타국타인', nationId: foreignNationId, officerLevel: 5, turnTime: new Date('2026-07-26T00:00:00.000Z'), }, { id: npcGeneralId, userId: null, name: 'NPC장수', nationId: ownerNationId, npcState: 2, officerLevel: 5, turnTime: new Date('2026-07-26T00:00:00.000Z'), }, { id: ordinaryGeneralId, userId: ordinaryUserId, name: '비수뇌', nationId: ownerNationId, officerLevel: 4, turnTime: new Date('2026-07-26T00:00:00.000Z'), }, ], }); const existingWorlds = await db.worldState.findMany({ select: { id: true, scenarioCode: true }, orderBy: { id: 'asc' }, }); if (existingWorlds.length === 0) { await db.worldState.create({ data: { id: fixtureWorldId, scenarioCode: 'security-http', currentYear: 190, currentMonth: 1, tickSeconds: 600, config: {}, meta: {}, }, }); createdFixtureWorld = true; } else if ( existingWorlds.length === 1 && existingWorlds[0]?.id === fixtureWorldId && existingWorlds[0].scenarioCode === 'security-http' ) { // A previously interrupted run may leave our own fixture row. It // remains owned by this suite and is removed during teardown. createdFixtureWorld = true; } else { throw new Error( `security transport fixture requires an empty schema or its owned world row, got ${JSON.stringify(existingWorlds)}` ); } const reservationWorlds = await db.worldState.findMany({ select: { id: true } }); if (reservationWorlds.length !== 1 || reservationWorlds[0]?.id !== fixtureWorldId) { throw new Error( `security transport fixture requires world ${fixtureWorldId}, got ${JSON.stringify(reservationWorlds)}` ); } reservationWorldId = reservationWorlds[0].id; await db.trafficPeriodGeneral.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } }); await db.trafficPeriod.deleteMany({ where: { worldStateId: fixtureWorldId } }); await db.readModelOutbox.deleteMany(); await db.webPushOutbox.deleteMany(); redis = createRedisConnector(resolveRedisConfigFromEnv()); await redis.connect(); accessTokenStore = new RedisAccessTokenStore(redis.client, profileName); server = await createGameApiServer(); baseUrl = await server.app.listen({ host: server.config.host, port: server.config.port, }); }, 30_000); afterAll(async () => { await server?.app.close(); await closeGatewayStatusStub(); if (db) await deleteMatrixInputEvents(); await db?.trafficPeriodGeneral.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } }); await db?.trafficPeriod.deleteMany({ where: { worldStateId: fixtureWorldId } }); await db?.generalAccessLog.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } }); await db?.generalTurn.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } }); await db?.generalTurnRevision.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } }); await db?.nationTurn.deleteMany({ where: { nationId: { in: fixtureNationIds } } }); await db?.nationTurnRevision.deleteMany({ where: { nationId: { in: fixtureNationIds } } }); await db?.readModelRevision.deleteMany({ where: { OR: [ { domain: 'reserved.general', entityId: { in: fixtureGeneralIds } }, { domain: 'general.content', entityId: { in: fixtureGeneralIds } }, { domain: 'dashboard.global', entityId: 0 }, ], }, }); await db?.readModelOutbox.deleteMany(); await db?.webPushOutbox.deleteMany(); await db?.general.deleteMany({ where: { id: { in: fixtureGeneralIds } } }); if (createdFixtureWorld) { await db?.worldState.deleteMany({ where: { id: fixtureWorldId } }); } await disconnectDb?.(); await deleteProfileRedisKeys(); await redis?.disconnect(); if (uploadDir) { await fs.rm(uploadDir, { recursive: true, force: true }); } restoreEnv(); }, 30_000); beforeEach(async () => { await deleteMatrixInputEvents(); await db.general.update({ where: { id: generalId }, data: { npcState: 0, meta: {}, penalty: {} }, }); await db.worldState.update({ where: { id: reservationWorldId }, data: { meta: {} }, }); await db.trafficPeriodGeneral.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } }); await db.trafficPeriod.deleteMany({ where: { worldStateId: fixtureWorldId } }); await db.generalAccessLog.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } }); await db.generalTurn.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } }); await db.generalTurnRevision.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } }); await db.nationTurn.deleteMany({ where: { nationId: { in: fixtureNationIds } } }); await db.nationTurnRevision.deleteMany({ where: { nationId: { in: fixtureNationIds } } }); await db.readModelRevision.deleteMany({ where: { OR: [ { domain: 'reserved.general', entityId: { in: fixtureGeneralIds } }, { domain: 'general.content', entityId: { in: fixtureGeneralIds } }, { domain: 'dashboard.global', entityId: 0 }, ], }, }); await db.readModelOutbox.deleteMany(); await db.webPushOutbox.deleteMany(); receivedGatewayWebPushEvents = []; if (redis) { await redis.client.del(`sammo:${profileName}:read-model:revision`); } }); it('accepts an authenticated query from a POST JSON body', async () => { const accessToken = await createAccessToken('json-query-body', {}); const general = await requestTrpc('general.me', { method: 'POST', input: null, accessToken, }); expect(general.response.status).toBe(200); expect(general.body).toMatchObject({ result: { data: { general: { id: generalId, }, }, }, }); }); it.each([ { label: 'global suspension', sanctions: () => ({ suspendedUntil: '2099-01-01T00:00:00.000Z' }), }, { label: 'instance game restriction', sanctions: () => ({ serverRestrictions: { [profileName]: { blockedFeatures: ['game'], }, }, }), }, { label: 'profile-id wildcard restriction', sanctions: () => ({ serverRestrictions: { [profileId]: { blockedFeatures: ['*'], }, }, }), }, ])('blocks an authenticated game API request for $label', async ({ label, sanctions }) => { const accessToken = await createAccessToken(label.replaceAll(' ', '-'), sanctions()); const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal(); const redisBefore = await readRealtimeRedisState(); const blocked = await requestTrpc('general.me', { accessToken }); expect(blocked.response.status).toBe(403); expect(blocked.body).toMatchObject({ error: { data: { code: 'FORBIDDEN', }, }, }); expect(await readDurableSchemaStateExcludingMatrixApiJournal()).toEqual(durableBefore); expect(await readRealtimeRedisState()).toEqual(redisBefore); }); it.each([ { label: 'global mute', sanctions: { mutedUntil: '2099-01-01T00:00:00.000Z' }, }, { label: 'instance message restriction', sanctions: { serverRestrictions: { [profileName]: { blockedFeatures: ['messages'], }, }, }, }, ])('allows non-message APIs but blocks message send for $label', async ({ label, sanctions }) => { const accessToken = await createAccessToken(label.replaceAll(' ', '-'), sanctions); const idempotencyKey = `${mutationRequestPrefix}message-${label.replaceAll(' ', '-')}`; const general = await requestTrpc('general.me', { accessToken }); expect(general.response.status).toBe(200); expect(general.body).toMatchObject({ result: { data: { general: { id: generalId, }, }, }, }); const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal(); const telemetryBefore = await readAccessTelemetryState(); const redisBefore = await readRealtimeRedisState(); const message = await requestTrpc('messages.send', { method: 'POST', input: { generalId, mailbox: 0, text: '차단되어야 하는 메시지', }, accessToken, idempotencyKey, }); expect(message.response.status).toBe(403); expect(message.body).toMatchObject({ error: { data: { code: 'FORBIDDEN', }, }, }); await expectApiInputEvent(idempotencyKey, 'messages.send', { actorUserId: userId, status: 'FAILED', }); expect(telemetryBefore).toEqual({ periods: [], generals: [], accessLogs: [] }); const telemetryAfter = await readAccessTelemetryState(); expect(telemetryAfter.periods).toEqual([ { id: expect.any(Number), worldStateId: fixtureWorldId, year: 190, month: 1, startedAt: expect.any(Date), lastRefresh: expect.any(Date), refresh: 1, online: 1, }, ]); const trafficPeriod = telemetryAfter.periods[0]; if (!trafficPeriod) throw new Error('message access did not create its traffic period'); expect(telemetryAfter.generals).toEqual([ { periodId: trafficPeriod.id, generalId, userId, refresh: 1, lastRefresh: trafficPeriod.lastRefresh, }, ]); expect(telemetryAfter.accessLogs).toEqual([ { id: expect.any(Number), generalId, userId, lastRefresh: trafficPeriod.lastRefresh, lastActionAt: null, refresh: 1, refreshTotal: 1, refreshScore: 1, refreshScoreTotal: 1, }, ]); expect(trafficPeriod.lastRefresh.getTime()).toBeGreaterThanOrEqual(trafficPeriod.startedAt.getTime()); expect( withoutDurableTables(await readDurableSchemaStateExcludingMatrixApiJournal(), [ 'traffic_period', 'traffic_period_general', 'general_access_log', ]) ).toEqual( withoutDurableTables(durableBefore, ['traffic_period', 'traffic_period_general', 'general_access_log']) ); expect(await readRealtimeRedisState()).toEqual(redisBefore); }); it('rejects a restricted signed gateway token before issuing a game access token', async () => { const gatewayToken = encryptGameSessionToken( buildPayload('gateway-restricted', { serverRestrictions: { [profileName]: { blockedFeatures: ['gameplay'], }, }, }), secret ); const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal(); const redisBefore = await readRealtimeRedisState(); const blocked = await requestTrpc('auth.exchangeGatewayToken', { method: 'POST', input: { gatewayToken }, }); expect(blocked.response.status).toBe(403); expect(blocked.body).toMatchObject({ error: { data: { code: 'FORBIDDEN', }, }, }); expect(await readDurableSchemaStateExcludingMatrixApiJournal()).toEqual(durableBefore); expect(await readRealtimeRedisState()).toEqual(redisBefore); }); it.each([ { label: 'missing bearer token', accessToken: async () => undefined, expectedStatus: 401, expectedCode: 'UNAUTHORIZED', }, { label: 'unknown bearer token', accessToken: async () => 'unknown-security-http-token', expectedStatus: 401, expectedCode: 'UNAUTHORIZED', }, { label: 'access token stored for another profile', accessToken: async () => { const otherProfileStore = new RedisAccessTokenStore(redis!.client, `${profileName}:other`); const created = await otherProfileStore.create( buildPayload('cross-profile', {}, userId, `${profileName}:other`) ); if (!created) throw new Error('failed to seed the cross-profile access token'); return created.accessToken; }, expectedStatus: 401, expectedCode: 'UNAUTHORIZED', }, { label: 'gameplay sanction', accessToken: () => createAccessToken('matrix-sanction', { serverRestrictions: { [profileName]: { blockedFeatures: ['gameplay'] } }, }), expectedStatus: 403, expectedCode: 'FORBIDDEN', }, ])( 'rejects $label before creating an API input event or any durable/Redis gameplay side effect', async ({ label, accessToken, expectedStatus, expectedCode }) => { const idempotencyKey = `${mutationRequestPrefix}auth-${label.replaceAll(' ', '-')}`; const token = await accessToken(); const databaseBefore = await readReservedMutationState(); const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal(); const redisBefore = await readRealtimeRedisState(); const result = await requestReservedGeneral(token, idempotencyKey); expect(result.response.status).toBe(expectedStatus); expect(result.body).toMatchObject({ error: { data: { code: expectedCode } } }); expect(await readReservedMutationState()).toEqual(databaseBefore); expect(await readDurableSchemaStateExcludingMatrixApiJournal()).toEqual(durableBefore); expect(await readRealtimeRedisState()).toEqual(redisBefore); await expectApiInputEvent(idempotencyKey, 'turns.reserved.setGeneral', null); } ); it.each(ownershipDenialCases)( 'keeps reserved queues, journal/outbox, ENGINE events, and profile Redis unchanged for $label general ownership denial', async ({ label, actorUserId, targetGeneralId }) => { const idempotencyKey = `${mutationRequestPrefix}owner-${label.replaceAll(' ', '-')}`; const accessToken = await createAccessToken(`matrix-owner-${label.replaceAll(' ', '-')}`, {}, actorUserId); const databaseBefore = await readReservedMutationState(); const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal(); const redisBefore = await readRealtimeRedisState(); const result = await requestReservedGeneral(accessToken, idempotencyKey, targetGeneralId); expect(result.response.status).toBe(403); expect(result.body).toMatchObject({ error: { data: { code: 'FORBIDDEN' } } }); expect(await readReservedMutationState()).toEqual(databaseBefore); expect(await readDurableSchemaStateExcludingMatrixApiJournal()).toEqual(durableBefore); expect(await readRealtimeRedisState()).toEqual(redisBefore); await expectApiInputEvent(idempotencyKey, 'turns.reserved.setGeneral', { actorUserId, status: 'FAILED', }); } ); it.each(ownershipDenialCases)( 'keeps reserved queues, journal/outbox, ENGINE events, and profile Redis unchanged for $label nation ownership denial', async ({ label, actorUserId, targetGeneralId }) => { const idempotencyKey = `${mutationRequestPrefix}nation-owner-${label.replaceAll(' ', '-')}`; const accessToken = await createAccessToken( `matrix-nation-owner-${label.replaceAll(' ', '-')}`, {}, actorUserId ); const databaseBefore = await readReservedMutationState(); const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal(); const redisBefore = await readRealtimeRedisState(); const result = await requestReservedNation(accessToken, idempotencyKey, targetGeneralId); expect(result.response.status).toBe(403); expect(result.body).toMatchObject({ error: { data: { code: 'FORBIDDEN' } } }); expect(await readReservedMutationState()).toEqual(databaseBefore); expect(await readDurableSchemaStateExcludingMatrixApiJournal()).toEqual(durableBefore); expect(await readRealtimeRedisState()).toEqual(redisBefore); await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', { actorUserId, status: 'FAILED', }); } ); it('keeps the nation queue unchanged when an owned general is below the officer threshold', async () => { const idempotencyKey = `${mutationRequestPrefix}nation-non-officer`; const accessToken = await createAccessToken('matrix-nation-non-officer', {}, ordinaryUserId); const databaseBefore = await readReservedMutationState(); const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal(); const redisBefore = await readRealtimeRedisState(); const result = await requestReservedNation(accessToken, idempotencyKey, ordinaryGeneralId); expect(result.response.status).toBe(403); expect(result.body).toMatchObject({ error: { data: { code: 'FORBIDDEN' } } }); expect(await readReservedMutationState()).toEqual(databaseBefore); expect(await readDurableSchemaStateExcludingMatrixApiJournal()).toEqual(durableBefore); expect(await readRealtimeRedisState()).toEqual(redisBefore); await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', { actorUserId: ordinaryUserId, status: 'FAILED', }); }); it.each( (['single', 'bulk'] as const).flatMap((kind) => [ { label: 'zero', value: 0 }, { label: 'false', value: false }, { label: 'null', value: null }, ].map((penalty) => ({ kind, ...penalty })) ) )( 'rejects $kind nation reservation when noChiefTurnInput is $label without committing queue or journal', async ({ kind, label, value }) => { const procedure = nationReservationProcedure(kind); const idempotencyKey = `${mutationRequestPrefix}nation-penalty-${kind}-${label}`; const accessToken = await createAccessToken(`matrix-nation-penalty-${kind}-${label}`, {}); await db.general.update({ where: { id: generalId }, data: { meta: { killturn: 3, marker: 'penalty-preserved' }, penalty: { noChiefTurnInput: value }, }, }); await db.worldState.update({ where: { id: reservationWorldId }, data: { meta: { killturn: 12 } }, }); const result = await requestNationReservation(kind, accessToken, idempotencyKey, generalId, { action: 'che_포상', args: {}, }); expect(result.response.status).toBe(412); expect(result.body).toMatchObject({ error: { message: '수뇌 턴 입력 불가능', data: { code: 'PRECONDITION_FAILED' }, }, }); expect(await db.nationTurn.count({ where: { nationId: ownerNationId, officerLevel: 12 } })).toBe(0); expect( await db.nationTurnRevision.findUnique({ where: { nationId_officerLevel: { nationId: ownerNationId, officerLevel: 12 } }, }) ).toBeNull(); expect(await db.general.findUniqueOrThrow({ where: { id: generalId } })).toMatchObject({ meta: { killturn: 3, marker: 'penalty-preserved' }, penalty: { noChiefTurnInput: value }, }); expect(await db.readModelRevision.count()).toBe(0); expect(await db.readModelOutbox.count()).toBe(0); await expectApiInputEvent(idempotencyKey, procedure, { actorUserId: userId, status: 'FAILED', }); } ); it.each([ { kind: 'single' as const, label: 'single' }, { kind: 'bulk' as const, label: 'bulk' }, ])('commits $label nation queue, JSONB killturn refill, and read-model journal together', async ({ kind }) => { const procedure = nationReservationProcedure(kind); const idempotencyKey = `${mutationRequestPrefix}nation-refill-${kind}`; const accessToken = await createAccessToken(`matrix-nation-refill-${kind}`, {}); await db.general.update({ where: { id: generalId }, data: { npcState: 0, meta: { killturn: 3, marker: 'preserved-by-jsonb-set' }, penalty: {}, }, }); await db.worldState.update({ where: { id: reservationWorldId }, data: { meta: { killturn: 12, marker: 'world-preserved' } }, }); const result = await requestNationReservation(kind, accessToken, idempotencyKey); expect(result.response.status).toBe(200); expect(result.body).toMatchObject({ result: { data: { ok: true, revision: 1 } } }); expect( await db.nationTurn.findMany({ where: { nationId: ownerNationId, officerLevel: 12 }, select: { turnIdx: true, actionCode: true, arg: true }, orderBy: { turnIdx: 'asc' }, }) ).toEqual( Array.from({ length: 12 }, (_, turnIdx) => ({ turnIdx, actionCode: '휴식', arg: {}, })) ); expect( await db.nationTurnRevision.findUniqueOrThrow({ where: { nationId_officerLevel: { nationId: ownerNationId, officerLevel: 12 } }, }) ).toMatchObject({ revision: 1, leaseOwner: null, leaseExpiresAt: null }); expect(await db.general.findUniqueOrThrow({ where: { id: generalId } })).toMatchObject({ npcState: 0, meta: { killturn: 12, marker: 'preserved-by-jsonb-set' }, }); expect(await db.worldState.findUniqueOrThrow({ where: { id: reservationWorldId } })).toMatchObject({ meta: { killturn: 12, marker: 'world-preserved' }, }); expect( await db.readModelRevision.findMany({ select: { domain: true, entityId: true, revision: true }, orderBy: [{ domain: 'asc' }, { entityId: 'asc' }], }) ).toEqual([ { domain: 'dashboard.global', entityId: 0, revision: 1n }, { domain: 'general.content', entityId: generalId, revision: 1n }, ]); expect(await db.readModelOutbox.findMany({ select: { payload: true } })).toEqual([ { payload: { version: 1, changes: [ ['dashboard.global', 0, '1'], ['general.content', generalId, '1'], ], }, }, ]); await expectApiInputEvent(idempotencyKey, procedure, { actorUserId: userId, status: 'SUCCEEDED', }); }); it.each([ { kind: 'single' as const, label: 'already-higher user killturn', npcState: 0, currentKillturn: 20, }, { kind: 'bulk' as const, label: 'NPC actor', npcState: 2, currentKillturn: 3, }, ])('keeps $label unchanged while committing its nation queue', async ({ kind, npcState, currentKillturn }) => { const procedure = nationReservationProcedure(kind); const idempotencyKey = `${mutationRequestPrefix}nation-refill-noop-${kind}`; const accessToken = await createAccessToken(`matrix-nation-refill-noop-${kind}`, {}); await db.general.update({ where: { id: generalId }, data: { npcState, meta: { killturn: currentKillturn, marker: 'no-op-preserved' }, penalty: {}, }, }); await db.worldState.update({ where: { id: reservationWorldId }, data: { meta: { killturn: 12 } }, }); const result = await requestNationReservation(kind, accessToken, idempotencyKey); expect(result.response.status).toBe(200); expect(result.body).toMatchObject({ result: { data: { ok: true, revision: 1 } } }); expect(await db.nationTurn.count({ where: { nationId: ownerNationId, officerLevel: 12 } })).toBe(12); expect(await db.general.findUniqueOrThrow({ where: { id: generalId } })).toMatchObject({ npcState, meta: { killturn: currentKillturn, marker: 'no-op-preserved' }, }); expect(await db.readModelRevision.count()).toBe(0); expect(await db.readModelOutbox.count()).toBe(0); await expectApiInputEvent(idempotencyKey, procedure, { actorUserId: userId, status: 'SUCCEEDED', }); }); it.each([ { kind: 'single' as const, label: 'single' }, { kind: 'bulk' as const, label: 'bulk' }, ])( 'rolls back $label queue, killturn, and read-model revisions when journal persistence fails', async ({ kind }) => { const procedure = nationReservationProcedure(kind); const idempotencyKey = `${mutationRequestPrefix}nation-refill-rollback-${kind}`; const accessToken = await createAccessToken(`matrix-nation-refill-rollback-${kind}`, {}); await db.general.update({ where: { id: generalId }, data: { npcState: 0, meta: { killturn: 3, marker: 'rollback-preserved' }, penalty: {}, }, }); await db.worldState.update({ where: { id: reservationWorldId }, data: { meta: { killturn: 12 } }, }); await db.$executeRawUnsafe(` CREATE FUNCTION security_transport_fail_read_model_outbox() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'forced security transport read-model journal failure'; END; $$ `); await db.$executeRawUnsafe(` CREATE TRIGGER security_transport_fail_read_model_outbox BEFORE INSERT ON read_model_outbox FOR EACH ROW EXECUTE FUNCTION security_transport_fail_read_model_outbox() `); const result = await (async () => { try { return await requestNationReservation(kind, accessToken, idempotencyKey); } finally { await db.$executeRawUnsafe( 'DROP TRIGGER IF EXISTS security_transport_fail_read_model_outbox ON read_model_outbox' ); await db.$executeRawUnsafe('DROP FUNCTION IF EXISTS security_transport_fail_read_model_outbox()'); } })(); expect(result.response.status).toBe(500); expect(result.body).toMatchObject({ error: { data: { code: 'INTERNAL_SERVER_ERROR' } } }); expect(await db.nationTurn.count({ where: { nationId: ownerNationId, officerLevel: 12 } })).toBe(0); expect( await db.nationTurnRevision.findUnique({ where: { nationId_officerLevel: { nationId: ownerNationId, officerLevel: 12 } }, }) ).toBeNull(); expect(await db.general.findUniqueOrThrow({ where: { id: generalId } })).toMatchObject({ npcState: 0, meta: { killturn: 3, marker: 'rollback-preserved' }, }); expect(await db.readModelRevision.count()).toBe(0); expect(await db.readModelOutbox.count()).toBe(0); await expectApiInputEvent(idempotencyKey, procedure, { actorUserId: userId, status: 'FAILED', }); expect( await db.inputEvent.findUniqueOrThrow({ where: { requestId: resolveScopedApiRequestId(idempotencyKey, procedure, userId) }, select: { error: true }, }) ).toEqual({ error: expect.stringContaining('forced security transport read-model journal failure') }); } ); it('commits an owned general reservation once with an authenticated actor and durable journal', async () => { const idempotencyKey = `${mutationRequestPrefix}general-success`; const accessToken = await createAccessToken('matrix-general-success', {}); const databaseBefore = await readReservedMutationState(); const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal(); const allowedTablesBefore = await readSuccessAllowedTableState(); const redisBefore = await readRealtimeRedisState(); const result = await requestReservedGeneral(accessToken, idempotencyKey); expect(result.response.status).toBe(200); expect(result.body).toMatchObject({ result: { data: { ok: true, revision: 1 } } }); expect( await db.generalTurn.findMany({ where: { generalId }, select: { generalId: true, turnIdx: true, actionCode: true, arg: true }, orderBy: { turnIdx: 'asc' }, }) ).toEqual( Array.from({ length: 30 }, (_, turnIdx) => ({ generalId, turnIdx, actionCode: '휴식', arg: {}, })) ); expect(await db.generalTurnRevision.findUnique({ where: { generalId } })).toMatchObject({ revision: 1, leaseOwner: null, leaseExpiresAt: null, }); await expectApiInputEvent(idempotencyKey, 'turns.reserved.setGeneral', { actorUserId: userId, status: 'SUCCEEDED', }); expect( await db.inputEvent.count({ where: { target: 'ENGINE', actorUserId: userId }, }) ).toBe(0); await expect.poll(() => db.readModelOutbox.count()).toBe(1); await expect .poll( async () => { const row = await db.readModelOutbox.findFirst(); return { delivered: row?.deliveredAt instanceof Date, attempts: row?.attempts ?? null, locked: row?.lockedAt instanceof Date, lastError: row?.lastError ?? null, }; }, { timeout: 5_000, interval: 50 } ) .toEqual({ delivered: true, attempts: 1, locked: false, lastError: null }); const readModelRedisRevisionKey = `sammo:${profileName}:read-model:revision`; await expect .poll(() => redis!.client.get(readModelRedisRevisionKey), { timeout: 5_000, interval: 50 }) .toBe('1'); expect( await db.readModelRevision.findMany({ where: { OR: [ { domain: 'reserved.general', entityId: generalId }, { domain: 'dashboard.global', entityId: 0 }, ], }, select: { domain: true, entityId: true, revision: true }, orderBy: [{ domain: 'asc' }, { entityId: 'asc' }], }) ).toEqual([ { domain: 'dashboard.global', entityId: 0, revision: 1n }, { domain: 'reserved.general', entityId: generalId, revision: 1n }, ]); expect(await db.readModelOutbox.findMany({ select: { payload: true } })).toEqual([ { payload: { version: 1, changes: [ ['dashboard.global', 0, '1'], ['reserved.general', generalId, '1'], ], }, }, ]); const databaseAfter = await readReservedMutationState(); expect(databaseAfter.generals).toEqual(databaseBefore.generals); expect(databaseAfter.generalTurns).toEqual( Array.from({ length: 30 }, (_, turnIdx) => ({ generalId, turnIdx, actionCode: '휴식', arg: {}, })) ); expect(databaseAfter.generalTurnRevisions).toEqual([ { generalId, revision: 1, leaseOwner: null, leaseExpiresAt: null }, ]); expect(databaseAfter.nationTurns).toEqual(databaseBefore.nationTurns); expect(databaseAfter.nationTurnRevisions).toEqual(databaseBefore.nationTurnRevisions); expect(databaseAfter.messages).toEqual(databaseBefore.messages); expect(databaseAfter.logs).toEqual(databaseBefore.logs); expect(databaseAfter.engineInputEvents).toEqual(databaseBefore.engineInputEvents); expect(databaseAfter.webPushOutboxCount).toBe(databaseBefore.webPushOutboxCount); expect(databaseAfter.eventCount).toBe(databaseBefore.eventCount); expect(databaseAfter.auctionCount).toBe(databaseBefore.auctionCount); expect(databaseAfter.auctionBidCount).toBe(databaseBefore.auctionBidCount); expectSingleActorActivity(databaseAfter.generalAccessLogs); const allowedTablesAfter = await readSuccessAllowedTableState(); expect(allowedTablesBefore.readModelOutbox).toEqual([]); expect(allowedTablesAfter.generalTurns.filter((row) => row.generalId !== generalId)).toEqual( allowedTablesBefore.generalTurns.filter((row) => row.generalId !== generalId) ); const committedGeneralTurns = allowedTablesAfter.generalTurns.filter((row) => row.generalId === generalId); expect( committedGeneralTurns.map(({ generalId: rowGeneralId, turnIdx, actionCode, arg }) => ({ generalId: rowGeneralId, turnIdx, actionCode, arg, })) ).toEqual(databaseAfter.generalTurns); expect(new Set(committedGeneralTurns.map(({ id }) => id)).size).toBe(30); expect(committedGeneralTurns.every(({ id, createdAt }) => id > 0 && createdAt instanceof Date)).toBe(true); expect(allowedTablesAfter.generalTurnRevisions.filter((row) => row.generalId !== generalId)).toEqual( allowedTablesBefore.generalTurnRevisions.filter((row) => row.generalId !== generalId) ); expect(allowedTablesAfter.generalTurnRevisions.filter((row) => row.generalId === generalId)).toEqual([ { generalId, revision: 1, leaseOwner: null, leaseExpiresAt: null, updatedAt: expect.any(Date), }, ]); expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId !== generalId)).toEqual( allowedTablesBefore.generalAccessLogs.filter((row) => row.generalId !== generalId) ); expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId === generalId)).toEqual([ { id: expect.any(Number), generalId, userId, lastRefresh: null, lastActionAt: expect.any(Date), refresh: 0, refreshTotal: 0, refreshScore: 0, refreshScoreTotal: 0, }, ]); const expectedReadModelKeys = new Set([`dashboard.global:0`, `reserved.general:${generalId}`]); expect( allowedTablesAfter.readModelRevisions.filter( ({ domain, entityId }) => !expectedReadModelKeys.has(`${domain}:${entityId}`) ) ).toEqual( allowedTablesBefore.readModelRevisions.filter( ({ domain, entityId }) => !expectedReadModelKeys.has(`${domain}:${entityId}`) ) ); expect( allowedTablesAfter.readModelRevisions.filter(({ domain, entityId }) => expectedReadModelKeys.has(`${domain}:${entityId}`) ) ).toEqual([ { domain: 'dashboard.global', entityId: 0, revision: 1n, updatedAt: expect.any(Date) }, { domain: 'reserved.general', entityId: generalId, revision: 1n, updatedAt: expect.any(Date) }, ]); expect(allowedTablesAfter.readModelOutbox).toEqual([ { id: expect.anything(), payload: { version: 1, changes: [ ['dashboard.global', 0, '1'], ['reserved.general', generalId, '1'], ], }, attempts: 1, availableAt: expect.any(Date), lockedAt: null, lockOwner: null, deliveredAt: expect.any(Date), lastError: null, createdAt: expect.any(Date), }, ]); const deliveredOutbox = allowedTablesAfter.readModelOutbox[0]; if (!deliveredOutbox?.deliveredAt) throw new Error('read-model outbox was not delivered'); expect(typeof deliveredOutbox.id).toBe('bigint'); expect(deliveredOutbox.id).toBeGreaterThan(0n); expect(deliveredOutbox.deliveredAt.getTime()).toBeGreaterThanOrEqual(deliveredOutbox.createdAt.getTime()); expect( withoutDurableTables(await readDurableSchemaStateExcludingMatrixApiJournal(), [ 'general_turn', 'general_turn_revision', 'general_access_log', 'read_model_revision', 'read_model_outbox', ]) ).toEqual( withoutDurableTables(durableBefore, [ 'general_turn', 'general_turn_revision', 'general_access_log', 'read_model_revision', 'read_model_outbox', ]) ); expect(redisBefore.some(([key]) => key === readModelRedisRevisionKey)).toBe(false); const expectedRedisAfter: Array<[string, string | null]> = [...redisBefore, [readModelRedisRevisionKey, '1']]; expectedRedisAfter.sort(([left], [right]) => left.localeCompare(right)); expect(await readRealtimeRedisState()).toEqual(expectedRedisAfter); }, 15_000); it('accepts the minimum officer level into its own nation queue partition over HTTP', async () => { const idempotencyKey = `${mutationRequestPrefix}nation-minimum-officer-success`; const accessToken = await createAccessToken('matrix-nation-minimum-officer-success', {}, sameNationUserId); const databaseBefore = await readReservedMutationState(); const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal(); const allowedTablesBefore = await readSuccessAllowedTableState(); const redisBefore = await readRealtimeRedisState(); const result = await requestReservedNation(accessToken, idempotencyKey, sameNationGeneralId); expect(result.response.status).toBe(200); expect(result.body).toMatchObject({ result: { data: { ok: true, revision: 1 } } }); const expectedTurns = Array.from({ length: 12 }, (_, turnIdx) => ({ nationId: ownerNationId, officerLevel: 5, turnIdx, actionCode: '휴식', arg: {}, })); expect( await db.nationTurn.findMany({ where: { nationId: ownerNationId, officerLevel: 5 }, select: { nationId: true, officerLevel: true, turnIdx: true, actionCode: true, arg: true }, orderBy: { turnIdx: 'asc' }, }) ).toEqual(expectedTurns); expect( await db.nationTurnRevision.findUnique({ where: { nationId_officerLevel: { nationId: ownerNationId, officerLevel: 5 } }, }) ).toMatchObject({ revision: 1, leaseOwner: null, leaseExpiresAt: null }); await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', { actorUserId: sameNationUserId, status: 'SUCCEEDED', }); const committed = await readReservedMutationState(); expect(committed.generals).toEqual(databaseBefore.generals); expect(committed.generalTurns).toEqual(databaseBefore.generalTurns); expect(committed.generalTurnRevisions).toEqual(databaseBefore.generalTurnRevisions); expect(committed.nationTurns).toEqual(expectedTurns); expect(committed.nationTurnRevisions).toEqual([ { nationId: ownerNationId, officerLevel: 5, revision: 1, leaseOwner: null, leaseExpiresAt: null, }, ]); expect(committed.generalAccessLogs).toEqual([ { generalId: sameNationGeneralId, userId: sameNationUserId, lastRefresh: null, refresh: 0, refreshTotal: 0, refreshScore: 0, refreshScoreTotal: 0, lastActionAt: expect.any(Date), }, ]); expect(committed.readModelRevisions).toEqual(databaseBefore.readModelRevisions); expect(committed.readModelOutbox).toEqual(databaseBefore.readModelOutbox); expect(committed.messages).toEqual(databaseBefore.messages); expect(committed.logs).toEqual(databaseBefore.logs); expect(committed.engineInputEvents).toEqual(databaseBefore.engineInputEvents); expect(committed.webPushOutboxCount).toBe(databaseBefore.webPushOutboxCount); expect(committed.eventCount).toBe(databaseBefore.eventCount); expect(committed.auctionCount).toBe(databaseBefore.auctionCount); expect(committed.auctionBidCount).toBe(databaseBefore.auctionBidCount); const allowedTablesAfter = await readSuccessAllowedTableState(); expect( allowedTablesAfter.nationTurns.filter((row) => row.nationId !== ownerNationId || row.officerLevel !== 5) ).toEqual( allowedTablesBefore.nationTurns.filter((row) => row.nationId !== ownerNationId || row.officerLevel !== 5) ); const committedNationTurns = allowedTablesAfter.nationTurns.filter( (row) => row.nationId === ownerNationId && row.officerLevel === 5 ); expect( committedNationTurns.map(({ nationId, officerLevel, turnIdx, actionCode, arg }) => ({ nationId, officerLevel, turnIdx, actionCode, arg, })) ).toEqual(expectedTurns); expect(new Set(committedNationTurns.map(({ id }) => id)).size).toBe(12); expect(committedNationTurns.every(({ id, createdAt }) => id > 0 && createdAt instanceof Date)).toBe(true); expect( allowedTablesAfter.nationTurnRevisions.filter( (row) => row.nationId !== ownerNationId || row.officerLevel !== 5 ) ).toEqual( allowedTablesBefore.nationTurnRevisions.filter( (row) => row.nationId !== ownerNationId || row.officerLevel !== 5 ) ); expect( allowedTablesAfter.nationTurnRevisions.filter( (row) => row.nationId === ownerNationId && row.officerLevel === 5 ) ).toEqual([ { nationId: ownerNationId, officerLevel: 5, revision: 1, leaseOwner: null, leaseExpiresAt: null, updatedAt: expect.any(Date), }, ]); expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId !== sameNationGeneralId)).toEqual( allowedTablesBefore.generalAccessLogs.filter((row) => row.generalId !== sameNationGeneralId) ); expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId === sameNationGeneralId)).toEqual([ { id: expect.any(Number), generalId: sameNationGeneralId, userId: sameNationUserId, lastRefresh: null, lastActionAt: expect.any(Date), refresh: 0, refreshTotal: 0, refreshScore: 0, refreshScoreTotal: 0, }, ]); expect( withoutDurableTables(await readDurableSchemaStateExcludingMatrixApiJournal(), [ 'nation_turn', 'nation_turn_revision', 'general_access_log', ]) ).toEqual(withoutDurableTables(durableBefore, ['nation_turn', 'nation_turn_revision', 'general_access_log'])); expect(await readRealtimeRedisState()).toEqual(redisBefore); }, 15_000); it('commits an owned officer nation reservation and replays the durable response without a second queue mutation', async () => { const idempotencyKey = `${mutationRequestPrefix}nation-success`; const inputPayload = { generalId, turnIndex: 0, action: '휴식', args: {}, expectedRevision: 0, }; const accessToken = await createAccessToken('matrix-nation-success', {}); const databaseBefore = await readReservedMutationState(); const durableBefore = await readDurableSchemaStateExcludingMatrixApiJournal(); const allowedTablesBefore = await readSuccessAllowedTableState(); const redisBefore = await readRealtimeRedisState(); const first = await requestReservedNation(accessToken, idempotencyKey, generalId); expect(first.response.status).toBe(200); expect(first.body).toMatchObject({ result: { data: { ok: true, revision: 1 } } }); const firstResult = (first.body as { result: { data: unknown } }).result.data; expect( await db.nationTurn.findMany({ where: { nationId: ownerNationId, officerLevel: 12 }, select: { nationId: true, officerLevel: true, turnIdx: true, actionCode: true, arg: true }, orderBy: { turnIdx: 'asc' }, }) ).toEqual( Array.from({ length: 12 }, (_, turnIdx) => ({ nationId: ownerNationId, officerLevel: 12, turnIdx, actionCode: '휴식', arg: {}, })) ); expect( await db.nationTurnRevision.findUnique({ where: { nationId_officerLevel: { nationId: ownerNationId, officerLevel: 12 } }, }) ).toMatchObject({ revision: 1, leaseOwner: null, leaseExpiresAt: null }); await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', { actorUserId: userId, status: 'SUCCEEDED', payload: inputPayload, result: firstResult, }); const committed = await readReservedMutationState(); expect(committed.generals).toEqual(databaseBefore.generals); expect(committed.generalTurns).toEqual(databaseBefore.generalTurns); expect(committed.generalTurnRevisions).toEqual(databaseBefore.generalTurnRevisions); expect(committed.nationTurns).toEqual( Array.from({ length: 12 }, (_, turnIdx) => ({ nationId: ownerNationId, officerLevel: 12, turnIdx, actionCode: '휴식', arg: {}, })) ); expect(committed.nationTurnRevisions).toEqual([ { nationId: ownerNationId, officerLevel: 12, revision: 1, leaseOwner: null, leaseExpiresAt: null, }, ]); expect(committed.readModelRevisions).toEqual(databaseBefore.readModelRevisions); expect(committed.readModelOutbox).toEqual(databaseBefore.readModelOutbox); expect(committed.messages).toEqual(databaseBefore.messages); expect(committed.logs).toEqual(databaseBefore.logs); expect(committed.engineInputEvents).toEqual(databaseBefore.engineInputEvents); expect(committed.webPushOutboxCount).toBe(databaseBefore.webPushOutboxCount); expect(committed.eventCount).toBe(databaseBefore.eventCount); expect(committed.auctionCount).toBe(databaseBefore.auctionCount); expect(committed.auctionBidCount).toBe(databaseBefore.auctionBidCount); expectSingleActorActivity(committed.generalAccessLogs); const allowedTablesAfter = await readSuccessAllowedTableState(); expect( allowedTablesAfter.nationTurns.filter((row) => row.nationId !== ownerNationId || row.officerLevel !== 12) ).toEqual( allowedTablesBefore.nationTurns.filter((row) => row.nationId !== ownerNationId || row.officerLevel !== 12) ); const committedNationTurns = allowedTablesAfter.nationTurns.filter( (row) => row.nationId === ownerNationId && row.officerLevel === 12 ); expect( committedNationTurns.map(({ nationId, officerLevel, turnIdx, actionCode, arg }) => ({ nationId, officerLevel, turnIdx, actionCode, arg, })) ).toEqual(committed.nationTurns); expect(new Set(committedNationTurns.map(({ id }) => id)).size).toBe(12); expect(committedNationTurns.every(({ id, createdAt }) => id > 0 && createdAt instanceof Date)).toBe(true); expect( allowedTablesAfter.nationTurnRevisions.filter( (row) => row.nationId !== ownerNationId || row.officerLevel !== 12 ) ).toEqual( allowedTablesBefore.nationTurnRevisions.filter( (row) => row.nationId !== ownerNationId || row.officerLevel !== 12 ) ); expect( allowedTablesAfter.nationTurnRevisions.filter( (row) => row.nationId === ownerNationId && row.officerLevel === 12 ) ).toEqual([ { nationId: ownerNationId, officerLevel: 12, revision: 1, leaseOwner: null, leaseExpiresAt: null, updatedAt: expect.any(Date), }, ]); expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId !== generalId)).toEqual( allowedTablesBefore.generalAccessLogs.filter((row) => row.generalId !== generalId) ); expect(allowedTablesAfter.generalAccessLogs.filter((row) => row.generalId === generalId)).toEqual([ { id: expect.any(Number), generalId, userId, lastRefresh: null, lastActionAt: expect.any(Date), refresh: 0, refreshTotal: 0, refreshScore: 0, refreshScoreTotal: 0, }, ]); expect( withoutDurableTables(await readDurableSchemaStateExcludingMatrixApiJournal(), [ 'nation_turn', 'nation_turn_revision', 'general_access_log', ]) ).toEqual(withoutDurableTables(durableBefore, ['nation_turn', 'nation_turn_revision', 'general_access_log'])); expect(await readRealtimeRedisState()).toEqual(redisBefore); const replayDurableBefore = await readDurableSchemaStateExcludingMatrixApiJournal(); const replayRedisBefore = await readRealtimeRedisState(); const replayRequestId = resolveScopedApiRequestId(idempotencyKey, 'turns.reserved.setNation', userId); const replayJournalBefore = await db.inputEvent.findUniqueOrThrow({ where: { requestId: replayRequestId }, }); const replay = await requestReservedNation(accessToken, idempotencyKey, generalId); expect(replay.response.status).toBe(200); expect(replay.body).toEqual(first.body); const replayState = await readReservedMutationState(); expect({ ...replayState, generalAccessLogs: committed.generalAccessLogs }).toEqual(committed); expectSingleActorActivity(replayState.generalAccessLogs); expect( withoutDurableTables(await readDurableSchemaStateExcludingMatrixApiJournal(), ['general_access_log']) ).toEqual(withoutDurableTables(replayDurableBefore, ['general_access_log'])); expect(await readRealtimeRedisState()).toEqual(replayRedisBefore); expect( await db.inputEvent.findUniqueOrThrow({ where: { requestId: replayRequestId }, }) ).toEqual(replayJournalBefore); expect(await db.inputEvent.count({ where: { requestId: replayRequestId } })).toBe(1); await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', { actorUserId: userId, status: 'SUCCEEDED', payload: inputPayload, result: firstResult, }); }); it('delivers a non-UTC-session Web Push outbox row once with its original instant', async () => { const eventId = `security-http-web-push-${process.pid}`; const beforeInsert = Date.now(); await db.$transaction(async (transaction) => { await transaction.$executeRaw`SET LOCAL TIME ZONE 'Asia/Seoul'`; await expect( enqueueWebPushOutboxEvents(transaction, [ { eventId, eventType: 'PRIVATE_MESSAGE_RECEIVED', userIds: [userId], }, ]) ).resolves.toBe(1); }); const afterInsert = Date.now(); await expect.poll(() => receivedGatewayWebPushEvents.length, { timeout: 6_000, interval: 50 }).toBe(1); await expect .poll( async () => { const row = await db.webPushOutbox.findUniqueOrThrow({ where: { eventId } }); return { attempts: row.attempts, locked: row.lockedAt instanceof Date, lockOwner: row.lockOwner, delivered: row.deliveredAt instanceof Date, lastError: row.lastError, }; }, { timeout: 6_000, interval: 50 } ) .toEqual({ attempts: 1, locked: false, lockOwner: null, delivered: true, lastError: null }); const delivered = await db.webPushOutbox.findUniqueOrThrow({ where: { eventId } }); expect(delivered).toMatchObject({ attempts: 1, lockedAt: null, lockOwner: null, deliveredAt: expect.any(Date), lastError: null, }); const [storedInstant] = await db.$queryRaw>` SELECT (EXTRACT(EPOCH FROM "created_at") * 1000)::double precision AS "createdMs" FROM "web_push_outbox" WHERE "event_id" = ${eventId} `; if (!storedInstant) throw new Error('web push outbox instant was not persisted'); const [received] = receivedGatewayWebPushEvents; expect(received?.internalToken).toMatch(/^[a-f0-9]{64}$/u); expect(received?.body).toEqual({ version: 1, eventId: `game:${profileName}:${eventId}`, eventType: 'PRIVATE_MESSAGE_RECEIVED', profileName, userIds: [userId], occurredAt: expect.any(String), }); const occurredAt = Date.parse((received?.body as { occurredAt: string }).occurredAt); expect(occurredAt).toBeGreaterThanOrEqual(beforeInsert - 1_000); expect(occurredAt).toBeLessThanOrEqual(afterInsert + 1_000); expect(Math.abs(occurredAt - storedInstant.createdMs)).toBeLessThanOrEqual(1); }, 10_000); it('keeps Web Push due, lease, and prune boundaries in UTC wall time under a KST database session', async () => { const eventIds = { future: `security-http-web-push-future-${process.pid}`, recentLock: `security-http-web-push-recent-lock-${process.pid}`, staleLock: `security-http-web-push-stale-lock-${process.pid}`, retainedDelivery: `security-http-web-push-retained-delivery-${process.pid}`, prunedDelivery: `security-http-web-push-pruned-delivery-${process.pid}`, } as const; const [databaseSession] = await db.$queryRaw>` SELECT current_setting('TIMEZONE') AS "timeZone" `; expect(databaseSession?.timeZone).toBe('Asia/Seoul'); await db.$transaction(async (transaction) => { await transaction.$executeRaw`SET LOCAL TIME ZONE 'Asia/Seoul'`; const [transactionSession] = await transaction.$queryRaw>` SELECT current_setting('TIMEZONE') AS "timeZone" `; expect(transactionSession?.timeZone).toBe('Asia/Seoul'); await transaction.$executeRaw` INSERT INTO "web_push_outbox" ( "event_id", "event_type", "user_ids", "attempts", "available_at", "locked_at", "lock_owner", "delivered_at", "created_at" ) VALUES ( ${eventIds.future}, 'PRIVATE_MESSAGE_RECEIVED', ARRAY[${userId}]::text[], 0, (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + INTERVAL '2 hours', NULL, NULL, NULL, CURRENT_TIMESTAMP AT TIME ZONE 'UTC' ), ( ${eventIds.recentLock}, 'PRIVATE_MESSAGE_RECEIVED', ARRAY[${userId}]::text[], 4, (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '1 minute', (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '1 second', 'previous-owner', NULL, CURRENT_TIMESTAMP AT TIME ZONE 'UTC' ), ( ${eventIds.staleLock}, 'PRIVATE_MESSAGE_RECEIVED', ARRAY[${userId}]::text[], 2, (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '1 minute', (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '31 seconds', 'previous-owner', NULL, CURRENT_TIMESTAMP AT TIME ZONE 'UTC' ), ( ${eventIds.retainedDelivery}, 'PRIVATE_MESSAGE_RECEIVED', ARRAY[${userId}]::text[], 1, (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '20 hours', NULL, NULL, (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '20 hours', (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '21 hours' ), ( ${eventIds.prunedDelivery}, 'PRIVATE_MESSAGE_RECEIVED', ARRAY[${userId}]::text[], 1, (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '25 hours', NULL, NULL, (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '25 hours', (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '26 hours' ) `; }); const boundaryWorker = new WebPushOutboxWorker(db, process.env.GATEWAY_INTERNAL_API_URL!, secret, profileName, { intervalMs: 60_000, }); boundaryWorker.start(); await boundaryWorker.stop(); await expect .poll( async () => { const staleLock = await db.webPushOutbox.findUnique({ where: { eventId: eventIds.staleLock }, }); return staleLock ? { attempts: staleLock.attempts, lockedAt: staleLock.lockedAt, lockOwner: staleLock.lockOwner, delivered: staleLock.deliveredAt instanceof Date, lastError: staleLock.lastError, } : null; }, { timeout: 6_000, interval: 50 } ) .toEqual({ attempts: 3, lockedAt: null, lockOwner: null, delivered: true, lastError: null }); await expect .poll( async () => db.webPushOutbox.count({ where: { eventId: eventIds.prunedDelivery }, }), { timeout: 6_000, interval: 50 } ) .toBe(0); const remaining = await db.webPushOutbox.findMany({ where: { eventId: { in: Object.values(eventIds) } }, orderBy: { eventId: 'asc' }, }); const byEventId = new Map(remaining.map((row) => [row.eventId, row])); expect(byEventId.get(eventIds.future)).toMatchObject({ attempts: 0, lockedAt: null, lockOwner: null, deliveredAt: null, lastError: null, }); expect(byEventId.get(eventIds.recentLock)).toMatchObject({ attempts: 4, lockedAt: expect.any(Date), lockOwner: 'previous-owner', deliveredAt: null, lastError: null, }); expect(byEventId.get(eventIds.retainedDelivery)).toMatchObject({ attempts: 1, lockedAt: null, lockOwner: null, deliveredAt: expect.any(Date), lastError: null, }); expect(byEventId.has(eventIds.prunedDelivery)).toBe(false); expect(receivedGatewayWebPushEvents).toHaveLength(1); expect(receivedGatewayWebPushEvents[0]?.body).toMatchObject({ eventId: `game:${profileName}:${eventIds.staleLock}`, }); }, 10_000); // Flush invalidates every token issued before the user watermark. Keep it // last so this lifecycle assertion cannot invalidate the actor tokens used // by the transport authorization matrix above. it('invalidates an existing access token after a gateway flush event', async () => { const accessToken = await createAccessToken('flush', {}); expect((await requestTrpc('general.me', { accessToken })).response.status).toBe(200); await redis!.client.publish( `${redisPrefix}:flush`, JSON.stringify({ userId, flushedAt: new Date().toISOString(), reason: 'security-http-e2e', }) ); const deadline = Date.now() + 5_000; let response = await requestTrpc('general.me', { accessToken }); while (response.response.status !== 401 && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 50)); response = await requestTrpc('general.me', { accessToken }); } expect(response.response.status).toBe(401); expect(response.body).toMatchObject({ error: { data: { code: 'UNAUTHORIZED', }, }, }); }); });