import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import { createGamePostgresConnector, createRedisConnector, resolveRedisConfigFromEnv, type GamePrismaClient, type RedisConnector, } from '@sammo-ts/infra'; import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; import { createGameApiServer } from '../src/server.js'; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; const integration = describe.skipIf(!databaseUrl || !process.env.REDIS_URL); const profileId = process.env.POSTGRES_SCHEMA ?? 'public'; const profileName = `che:diplomacy-html-${process.pid}`; const userId = `diplomacy-html-user-${process.pid}`; const fixtureId = 920_000 + (process.pid % 50_000); const foreignNationId = fixtureId + 1; const secret = 'diplomacy-html-http-secret'; const redisPrefix = `sammo:diplomacy-html:${process.pid}`; const envKeys = [ 'PROFILE', 'SCENARIO', 'GAME_PROFILE_NAME', 'GAME_API_HOST', 'GAME_API_PORT', 'GAME_TOKEN_SECRET', 'GATEWAY_REDIS_PREFIX', 'GAME_UPLOAD_DIR', 'DATABASE_URL', ] 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 accessToken = ''; let legacyLetterId = 0; const restoreEnv = (): void => { for (const [key, value] of originalEnv) { if (value === undefined) { delete process.env[key]; } else { process.env[key] = value; } } }; const deleteProfileRedisKeys = async (): Promise => { if (!redis) return; for await (const keys of redis.client.scanIterator({ MATCH: `sammo:game:*:${profileName}:*`, COUNT: 100 })) { if (keys.length > 0) await redis.client.del(keys); } }; const cleanup = async (): Promise => { await db.diplomacyLetter.deleteMany({ where: { OR: [ { srcNationId: fixtureId }, { destNationId: fixtureId }, { srcNationId: foreignNationId }, { destNationId: foreignNationId }, ], }, }); await db.generalAccessLog.deleteMany({ where: { generalId: fixtureId } }); await db.general.deleteMany({ where: { id: fixtureId } }); await db.nation.deleteMany({ where: { id: { in: [fixtureId, foreignNationId] } } }); await db.worldState.deleteMany({ where: { scenarioCode: 'diplomacy-html' } }); }; integration('diplomacy HTML purification over PostgreSQL and HTTP transport', () => { beforeAll(async () => { uploadDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-diplomacy-html-http-')); process.env.PROFILE = profileId; process.env.SCENARIO = 'diplomacy-html'; 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.GAME_UPLOAD_DIR = uploadDir; process.env.DATABASE_URL = databaseUrl; const connector = createGamePostgresConnector({ url: databaseUrl! }); await connector.connect(); db = connector.prisma; disconnectDb = () => connector.disconnect(); await cleanup(); await db.worldState.create({ data: { scenarioCode: 'diplomacy-html', currentYear: 200, currentMonth: 1, tickSeconds: 600, meta: {}, }, }); await db.nation.createMany({ data: [ { id: fixtureId, name: '정화국', color: '#00ffff' }, { id: foreignNationId, name: '상대국', color: '#ff0000' }, ], }); await db.general.create({ data: { id: fixtureId, userId, name: '정화외교관', nationId: fixtureId, officerLevel: 12, turnTime: new Date('2026-07-31T00:00:00.000Z'), }, }); const legacyLetter = await db.diplomacyLetter.create({ data: { srcNationId: fixtureId, destNationId: foreignNationId, state: 'PROPOSED', textBrief: '

과거 공개

', textDetail: '과거 기밀', srcSignerId: fixtureId, aux: { src: { nationName: '정화국', nationColor: '#00ffff', generalId: fixtureId }, dest: { nationName: '상대국', nationColor: '#ff0000' }, }, }, }); legacyLetterId = legacyLetter.id; redis = createRedisConnector(resolveRedisConfigFromEnv()); await redis.connect(); const store = new RedisAccessTokenStore(redis.client, profileName); const payload: GameSessionTokenPayload = { version: 1, profile: profileName, issuedAt: new Date(Date.now() - 1_000).toISOString(), expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), sessionId: `diplomacy-html-session-${process.pid}`, user: { id: userId, username: 'diplomacy-html-user', displayName: 'Diplomacy HTML User', roles: ['user'], createdAt: '2026-07-31T00:00:00.000Z', }, sanctions: {}, }; const created = await store.create(payload); if (!created) throw new Error('failed to seed diplomacy HTML access token'); accessToken = created.accessToken; server = await createGameApiServer(); baseUrl = await server.app.listen({ host: server.config.host, port: server.config.port }); }, 30_000); afterAll(async () => { await server?.app.close(); if (db) await cleanup(); await disconnectDb?.(); await deleteProfileRedisKeys(); await redis?.disconnect(); if (uploadDir) await fs.rm(uploadDir, { recursive: true, force: true }); restoreEnv(); }, 30_000); it('purifies contaminated stored rows before HTTP serialization', async () => { const response = await fetch(`${baseUrl}/trpc/diplomacy.getLetters`, { headers: { authorization: `Bearer ${accessToken}` }, }); const body = (await response.json()) as { result?: { data?: { letters?: Array<{ id: number; brief: string; detail: string }> } }; }; expect(response.status, JSON.stringify(body)).toBe(200); expect(body.result?.data?.letters?.find(({ id }) => id === legacyLetterId)).toMatchObject({ brief: '

과거 공개

', detail: '과거 기밀', }); expect(JSON.stringify(body)).not.toMatch(/onerror| { const response = await fetch(`${baseUrl}/trpc/diplomacy.sendLetter`, { method: 'POST', headers: { authorization: `Bearer ${accessToken}`, 'content-type': 'application/json', }, body: JSON.stringify({ destNationId: foreignNationId, brief: '

신규 공개

', detail: '
  1. 조건
자료', }), }); const body = (await response.json()) as { result?: { data?: { id?: number } } }; expect(response.status, JSON.stringify(body)).toBe(200); const createdId = body.result?.data?.id; expect(createdId).toBeTypeOf('number'); const stored = await db.diplomacyLetter.findUniqueOrThrow({ where: { id: createdId } }); expect(stored.textBrief).toBe('

신규 공개

'); expect(stored.textDetail).toBe( '
  1. 조건
자료' ); }); });