Files
core2026/app/game-api/test/nationHtmlRouter.test.ts
T
Hide_D 630bc29100 fix: 특수 유저 커맨드의 Ref 호환 경계를 보강한다
수뇌 국가 설정과 NPC 정책을 actor-bound ENGINE mutation으로 옮기고, 추방·등용·점령·멸망·아이템 폐기의 특수 분기를 Ref와 맞춘다.

요청 ID를 사용자·프로필별로 격리하고 토너먼트 손상 projection을 fail-closed하며 실제 DB 및 Ref 차등 회귀를 보강한다.
2026-08-24 12:10:57 +00:00

202 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it, vi } from 'vitest';
import { ChangeJournal } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { RedisConnector } from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js';
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
import { appRouter } from '../src/router.js';
import { resolveNationNotice, resolveNationScoutMessage } from '../src/router/nation/shared.js';
const general: GeneralRow = {
id: 1,
userId: 'user-1',
name: '정책담당',
nationId: 1,
cityId: 1,
troopId: 0,
npcState: 0,
affinity: null,
bornYear: 180,
deadYear: 300,
picture: null,
imageServer: 0,
leadership: 50,
strength: 50,
intel: 50,
injury: 0,
experience: 0,
dedication: 0,
officerLevel: 5,
gold: 1_000,
rice: 1_000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
weaponCode: 'None',
bookCode: 'None',
horseCode: 'None',
itemCode: 'None',
turnTime: new Date('2026-01-01T00:00:00.000Z'),
recentWarTime: null,
age: 20,
startAge: 20,
personalCode: 'None',
specialCode: 'None',
special2Code: 'None',
lastTurn: {},
meta: {},
penalty: {},
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
};
const auth: GameSessionTokenPayload = {
version: 1,
profile: 'che:default',
issuedAt: '2026-01-01T00:00:00.000Z',
expiresAt: '2026-01-02T00:00:00.000Z',
sessionId: 'session-1',
user: {
id: 'user-1',
username: 'tester',
displayName: 'Tester',
roles: [],
},
sanctions: {},
};
const buildContext = () => {
const changeJournal = new ChangeJournal();
const requestCommand = vi.fn(async (command: unknown) => ({
type: 'setNationSetting',
ok: true,
nationId: 1,
updatedAt: '2026-01-01T00:00:01.000Z',
command,
}));
const db = {
general: {
findFirst: vi.fn(async () => general),
},
nation: {
findUnique: vi.fn(async () => ({ meta: {} })),
},
};
const redisClient = {
get: async () => null,
set: async () => null,
};
const context: GameApiContext = {
db: db as unknown as DatabaseClient,
redis: {} as RedisConnector['client'],
turnDaemon: { requestCommand } as unknown as TurnDaemonTransport,
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth,
requestId: 'http-nation-html',
changeJournal,
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:default'),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
};
return { caller: appRouter.createCaller(context), requestCommand, changeJournal };
};
describe('nation HTML API boundary', () => {
it.each([
{
procedure: 'setNotice',
metaKey: 'notice',
limit: 16_384,
},
{
procedure: 'setScoutMsg',
metaKey: 'infoText',
limit: 1_000,
},
] as const)('purifies $procedure before daemon persistence', async ({ procedure, metaKey, limit }) => {
const fixture = buildContext();
const dirty = `<p data-flip="x">안전</p><img src=x onerror="alert(1)"><script>alert(2)</script>`;
const msg = '<p data-flip="x">안전</p><img src="x" alt="x" />';
await expect(fixture.caller.nation[procedure]({ msg: dirty.slice(0, limit) })).resolves.toEqual({
ok: true,
msg,
});
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'setNationSetting',
requestId: `http-nation-html:nation.${procedure}:engine:0:setNationSetting`,
userId: 'user-1',
generalId: 1,
nationId: 1,
mutation: metaKey === 'notice' ? { kind: 'notice', message: msg } : { kind: 'scoutMessage', message: msg },
});
expect(fixture.changeJournal.snapshot()).toEqual([]);
});
it.each(['setNotice', 'setScoutMsg'] as const)(
'rejects empty and whitespace-only $procedure values like Ref required validation',
async (procedure) => {
for (const msg of ['', ' \t\n\v\0']) {
const fixture = buildContext();
await expect(fixture.caller.nation[procedure]({ msg })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
}
}
);
it.each(['setNotice', 'setScoutMsg'] as const)(
'keeps PHP trim semantics for a Unicode-only $procedure value',
async (procedure) => {
const fixture = buildContext();
await expect(fixture.caller.nation[procedure]({ msg: ' ' })).resolves.toMatchObject({
ok: true,
msg: ' ',
});
expect(fixture.requestCommand).toHaveBeenCalledOnce();
}
);
it.each([
['setNotice', 'notice'],
['setScoutMsg', 'scoutMessage'],
] as const)('dispatches unsafe-only $procedure HTML as the empty string Ref persists', async (procedure, kind) => {
const fixture = buildContext();
await expect(fixture.caller.nation[procedure]({ msg: '<script></script>' })).resolves.toEqual({
ok: true,
msg: '',
});
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({
type: 'setNationSetting',
mutation: { kind, message: '' },
})
);
});
it('purifies legacy stored values on every read resolver', () => {
expect(
resolveNationNotice({
notice: '<strong>방침</strong><svg onload="alert(1)"></svg>',
})
).toBe('<strong>방침</strong>');
expect(
resolveNationScoutMessage({
infoText: '<a href="javascript:alert(1)">임관</a>',
})
).toBe('<a>임관</a>');
});
});