fix: 특수 유저 커맨드의 Ref 호환 경계를 보강한다

수뇌 국가 설정과 NPC 정책을 actor-bound ENGINE mutation으로 옮기고, 추방·등용·점령·멸망·아이템 폐기의 특수 분기를 Ref와 맞춘다.

요청 ID를 사용자·프로필별로 격리하고 토너먼트 손상 projection을 fail-closed하며 실제 DB 및 Ref 차등 회귀를 보강한다.
This commit is contained in:
2026-08-24 12:10:57 +00:00
parent 5389f8ed94
commit 630bc29100
74 changed files with 3893 additions and 1141 deletions
+3
View File
@@ -241,6 +241,7 @@ describe('auction router actor and permission boundaries', () => {
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'auctionOpen',
auctionType: 'BUY_RICE',
userId: 'user-1',
generalId: 7,
amount: 1000,
closeTurnCnt: 3,
@@ -269,6 +270,7 @@ describe('auction router actor and permission boundaries', () => {
type: 'auctionOpen',
requestId: 'http-auction-open:auction.openBuyRice:engine:0:auctionOpen',
auctionType: 'BUY_RICE',
userId: 'user-1',
generalId: 7,
amount: 1000,
closeTurnCnt: 3,
@@ -381,6 +383,7 @@ describe('auction router actor and permission boundaries', () => {
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'auctionBid',
userId: 'user-1',
auctionId: 31,
generalId: 7,
amount: 110,
+10
View File
@@ -108,6 +108,15 @@ const buildContext = (officerLevel = 12, letter: Record<string, unknown> = store
findFirst: vi.fn(async () => null),
create,
},
worldState: {
findFirst: vi.fn(async () => ({
clockBaseTime: new Date('0185-01-01T00:00:00.000Z'),
clockTick: 6n,
clockMode: 'manual',
clockWallAnchor: new Date('2026-07-31T00:00:00.000Z'),
tickSeconds: 600,
})),
},
};
const redis = {
get: async () => null,
@@ -146,6 +155,7 @@ describe('diplomacy HTML API boundary', () => {
textBrief: '<p><strong>공개</strong></p>',
textDetail:
'<ul><li>조건</li></ul><a href="https://example.com" target="_blank" rel="noopener noreferrer nofollow">자료</a>',
date: new Date('0185-01-01T00:00:00.000Z'),
}),
});
});
@@ -10,16 +10,6 @@ const classifications = {
'messages.delete',
'messages.respond',
'messages.send',
'nation.setBill',
'nation.setBlockScout',
'nation.setBlockWar',
'nation.setNotice',
'nation.setRate',
'nation.setScoutMsg',
'nation.setSecretLimit',
'npc.setGeneralPriority',
'npc.setNationPolicy',
'npc.setNationPriority',
'turns.repeatGeneral',
'turns.setGeneral',
'turns.setGeneralBulk',
@@ -69,6 +59,16 @@ const classifications = {
'nation.appoint',
'nation.changePermission',
'nation.kick',
'nation.setBill',
'nation.setBlockScout',
'nation.setBlockWar',
'nation.setNotice',
'nation.setRate',
'nation.setScoutMsg',
'nation.setSecretLimit',
'npc.setGeneralPriority',
'npc.setNationPolicy',
'npc.setNationPriority',
'troop.create',
'troop.exit',
'troop.join',
@@ -14,9 +14,9 @@ describe('IdempotentTurnDaemonTransport', () => {
const firstAttempt = new IdempotentTurnDaemonTransport(inner, 'api-event');
const retry = new IdempotentTurnDaemonTransport(inner, 'api-event');
await firstAttempt.sendCommand({ type: 'vacation', generalId: 7 });
await firstAttempt.sendCommand({ type: 'dropItem', generalId: 7, itemType: 'weapon' });
await retry.sendCommand({ type: 'vacation', generalId: 7 });
await firstAttempt.sendCommand({ type: 'vacation', userId: 'user-7', generalId: 7 });
await firstAttempt.sendCommand({ type: 'dropItem', userId: 'user-7', generalId: 7, itemType: 'weapon' });
await retry.sendCommand({ type: 'vacation', userId: 'user-7', generalId: 7 });
expect(inner.commands.map((entry) => entry.requestId)).toEqual([
'api-event:engine:0:vacation',
@@ -29,6 +29,7 @@ describe('IdempotentTurnDaemonTransport', () => {
const auctionBid = {
type: 'auctionBid',
requestId: 'auction-bid',
userId: 'user-7',
auctionId: 31,
generalId: 7,
amount: 500,
@@ -40,6 +41,7 @@ describe('IdempotentTurnDaemonTransport', () => {
const voteReward = {
type: 'voteReward',
requestId: 'vote-reward',
userId: 'user-7',
voteId: 1,
generalId: 7,
selection: [0],
@@ -93,6 +95,7 @@ describe('IdempotentTurnDaemonTransport', () => {
const persistedPayload = {
type: 'voteReward' as const,
requestId: 'vote-reward',
userId: 'user-7',
voteId: 1,
generalId: 7,
selection: [0],
@@ -595,6 +595,7 @@ describe('in-game my information ownership', () => {
await caller.general.setMySetting({ tnmt: 1, defence_train: 999 });
expect(requestCommand).toHaveBeenCalledWith({
type: 'setMySetting',
userId: 'user-7',
generalId: 7,
settings: { tnmt: 1, defence_train: 999 },
});
@@ -622,18 +623,51 @@ describe('in-game my information ownership', () => {
requestCommand,
});
await expect(
appRouter.createCaller(fixture.context).general.setMySetting({ tnmt: 1 })
).resolves.toEqual({ ok: true });
await expect(appRouter.createCaller(fixture.context).general.setMySetting({ tnmt: 1 })).resolves.toEqual({
ok: true,
});
expect(transaction).not.toHaveBeenCalled();
expect(requestCommand).toHaveBeenCalledWith({
type: 'setMySetting',
requestId: 'http-general-setting:general.setMySetting:engine:0:setMySetting',
userId: 'user-7',
generalId: 7,
settings: { tnmt: 1 },
});
});
it.each(['horse', 'weapon', 'book', 'item'] as const)(
'dispatches the authenticated dropItem command for the %s slot',
async (itemType) => {
const requestCommand = vi.fn(async () => ({
type: 'dropItem' as const,
ok: true as const,
generalId: 7,
}));
const fixture = createContext({ requestCommand });
await expect(appRouter.createCaller(fixture.context).general.dropItem({ itemType })).resolves.toEqual({
ok: true,
});
expect(requestCommand).toHaveBeenCalledWith({
type: 'dropItem',
userId: 'user-7',
generalId: 7,
itemType,
});
}
);
it('rejects an unknown dropItem slot before dispatching it to ENGINE', async () => {
const requestCommand = vi.fn(async () => ({ type: 'dropItem' as const, ok: true as const, generalId: 7 }));
const fixture = createContext({ requestCommand });
await expect(
appRouter.createCaller(fixture.context).general.dropItem({ itemType: 'armor' as never })
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
expect(requestCommand).not.toHaveBeenCalled();
});
it('uses the authenticated user for both the page and its logs without accepting a target general id', async () => {
const otherUser = buildGeneral({ id: 8, userId: 'user-8', name: '타유저' });
const fixture = createContext({ targets: [buildGeneral(), otherUser] });
@@ -32,10 +32,7 @@ const payloadHasGeneral = (payload: unknown, generalId: number): boolean => {
const changes = (payload as { changes?: unknown }).changes;
return (
Array.isArray(changes) &&
changes.some(
(change) =>
Array.isArray(change) && change[0] === 'front.general' && change[1] === generalId
)
changes.some((change) => Array.isArray(change) && change[0] === 'front.general' && change[1] === generalId)
);
};
@@ -134,10 +131,7 @@ integration('API input event boundary', () => {
).resolves.toEqual({ ok: true });
expect(wakeSnapshot).toBeDefined();
const [event, revision] = (await wakeSnapshot) as [
{ status: string },
{ revision: bigint },
];
const [event, revision] = (await wakeSnapshot) as [{ status: string }, { revision: bigint }];
expect(event.status).toBe('SUCCEEDED');
expect(revision.revision).toBe(1n);
expect(redisPublish).not.toHaveBeenCalled();
@@ -158,9 +152,7 @@ integration('API input event boundary', () => {
} as unknown as GameApiContext;
await expect(
journalBoundaryRouter
.createCaller(context)
.mutate({ generalId: journalGeneralIds[1], fail: true })
journalBoundaryRouter.createCaller(context).mutate({ generalId: journalGeneralIds[1], fail: true })
).rejects.toThrow('injected journal rollback');
await expect(
@@ -257,21 +249,24 @@ integration('API input event boundary', () => {
const transport = new DatabaseTurnDaemonTransport(db, 100);
const requestId = 'integration:api:engine-child';
const acceptedWindowStart = Date.now();
await transport.sendCommand({ type: 'vacation', requestId, generalId: 7 });
await transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 7 });
const acceptedWindowEnd = Date.now();
const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
expect(event.actorUserId).toBe('user-7');
expect(event.createdAt.getTime()).toBeGreaterThanOrEqual(acceptedWindowStart);
expect(event.createdAt.getTime()).toBeLessThanOrEqual(acceptedWindowEnd);
await expect(transport.sendCommand({ type: 'vacation', requestId, generalId: 7 })).resolves.toBe(requestId);
await expect(transport.sendCommand({ type: 'vacation', requestId, generalId: 8 })).rejects.toBeInstanceOf(
ConflictingTurnDaemonCommandError
);
await expect(
transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 7 })
).resolves.toBe(requestId);
await expect(
transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 8 })
).rejects.toBeInstanceOf(ConflictingTurnDaemonCommandError);
});
it('distinguishes a stored terminal engine failure from a result timeout', async () => {
const transport = new DatabaseTurnDaemonTransport(db, 100);
const requestId = 'integration:api:engine-failed';
const command = { type: 'vacation' as const, requestId, generalId: 7 };
const command = { type: 'vacation' as const, requestId, userId: 'user-7', generalId: 7 };
await transport.sendCommand(command);
await db.inputEvent.update({
where: { requestId },
+47 -15
View File
@@ -73,8 +73,9 @@ const auth: GameSessionTokenPayload = {
const buildContext = () => {
const changeJournal = new ChangeJournal();
const requestCommand = vi.fn(async (command: unknown) => ({
type: 'setNationMeta',
type: 'setNationSetting',
ok: true,
nationId: 1,
updatedAt: '2026-01-01T00:00:01.000Z',
command,
}));
@@ -97,6 +98,7 @@ const buildContext = () => {
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth,
requestId: 'http-nation-html',
changeJournal,
uploadDir: 'uploads',
uploadPath: '/uploads',
@@ -131,29 +133,59 @@ describe('nation HTML API boundary', () => {
});
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'setNationMeta',
type: 'setNationSetting',
requestId: `http-nation-html:nation.${procedure}:engine:0:setNationSetting`,
userId: 'user-1',
generalId: 1,
nationId: 1,
updates: {
[metaKey]: msg,
},
expectedUpdatedAt: undefined,
mutation: metaKey === 'notice' ? { kind: 'notice', message: msg } : { kind: 'scoutMessage', message: msg },
});
expect(fixture.changeJournal.snapshot()).toEqual([
{ domain: 'dashboard.global', entityId: 0 },
...(procedure === 'setNotice' ? [{ domain: 'front.nation' as const, entityId: 1 }] : []),
{ domain: 'nation.content', entityId: 1 },
]);
expect(fixture.changeJournal.snapshot()).toEqual([]);
});
it.each(['setNotice', 'setScoutMsg'] as const)(
'rejects an empty $procedure value like Ref required validation',
'rejects empty and whitespace-only $procedure values like Ref required validation',
async (procedure) => {
await expect(buildContext().caller.nation[procedure]({ msg: '' })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
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({
+34 -11
View File
@@ -140,9 +140,26 @@ describe('nation personnel router', () => {
await caller.nation.changePermission({ isAmbassador: true, targetGeneralIds: [9] });
expect(requestCommand.mock.calls).toEqual([
[{ type: 'appoint', generalId: 22, destGeneralId: 7, destCityId: 1, officerLevel: 4 }],
[{ type: 'kick', generalId: 22, destGeneralId: 8 }],
[{ type: 'changePermission', generalId: 22, isAmbassador: true, targetGeneralIds: [9] }],
[
{
type: 'appoint',
userId: 'user-22',
generalId: 22,
destGeneralId: 7,
destCityId: 1,
officerLevel: 4,
},
],
[{ type: 'kick', userId: 'user-22', generalId: 22, destGeneralId: 8 }],
[
{
type: 'changePermission',
userId: 'user-22',
generalId: 22,
isAmbassador: true,
targetGeneralIds: [9],
},
],
]);
});
@@ -160,6 +177,7 @@ describe('nation personnel router', () => {
expect(requestCommand).toHaveBeenCalledWith({
type: 'kick',
requestId: 'http-nation-kick:nation.kick:engine:0:kick',
userId: 'user-22',
generalId: 22,
destGeneralId: 8,
});
@@ -277,7 +295,7 @@ describe('nation personnel router', () => {
};
const makeCommand = () =>
vi.fn(async () => ({
type: 'setNationMeta',
type: 'setNationSetting',
ok: true,
nationId: 1,
updatedAt: '2026-01-01T00:01:00.000Z',
@@ -291,15 +309,13 @@ describe('nation personnel router', () => {
.nation.setRate({ amount: 20 })
).resolves.toEqual({ ok: true });
expect(headCommand).toHaveBeenCalledWith({
type: 'setNationMeta',
type: 'setNationSetting',
userId: 'user-22',
generalId: 22,
nationId: 1,
updates: { rate: 20 },
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
mutation: { kind: 'rate', amount: 20 },
});
expect(changeJournal.snapshot()).toEqual([
{ domain: 'dashboard.global', entityId: 0 },
{ domain: 'nation.content', entityId: 1 },
]);
expect(changeJournal.snapshot()).toEqual([]);
const ambassadorCommand = makeCommand();
const ambassador = {
@@ -312,6 +328,13 @@ describe('nation personnel router', () => {
.createCaller(createContext({ me: ambassador, db: nationDb, requestCommand: ambassadorCommand }))
.nation.setRate({ amount: 25 })
).resolves.toEqual({ ok: true });
expect(ambassadorCommand).toHaveBeenCalledWith({
type: 'setNationSetting',
userId: 'user-22',
generalId: 22,
nationId: 1,
mutation: { kind: 'rate', amount: 25 },
});
const memberCommand = makeCommand();
const member = { ...baseGeneral, officerLevel: 1 };
@@ -0,0 +1,206 @@
import { describe, expect, it, vi } from 'vitest';
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';
const general: GeneralRow = {
id: 71,
userId: 'authenticated-user',
name: '국가설정담당',
nationId: 3,
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-setting',
user: {
id: 'authenticated-user',
username: 'tester',
displayName: 'Tester',
roles: [],
},
sanctions: {},
};
const buildContext = (authenticated = true) => {
const requestCommand = vi.fn(async (command: unknown) => {
const kind = (command as { mutation?: { kind?: string } }).mutation?.kind;
return {
type: 'setNationSetting' as const,
ok: true as const,
nationId: 3,
updatedAt: '2026-01-01T00:00:01.000Z',
...(kind === 'blockWar' ? { availableCnt: 7 } : {}),
};
});
const transaction = vi.fn(async () => {
throw new Error('nation settings must not use an API input-event transaction');
});
const db = {
$transaction: transaction,
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: authenticated ? auth : null,
requestId: 'http-setting-boundary',
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:default'),
flushStore: new InMemoryFlushStore(),
gameTokenSecret: 'test-secret',
};
return { context, requestCommand, transaction };
};
describe('nation setting router engine boundary', () => {
it('binds all semantic setting commands to the authenticated actor and a stable request id', async () => {
const fixture = buildContext();
const caller = appRouter.createCaller(fixture.context);
await expect(caller.nation.setNotice({ msg: '<strong>방침</strong>' })).resolves.toEqual({
ok: true,
msg: '<strong>방침</strong>',
});
await expect(caller.nation.setScoutMsg({ msg: '<em>등용문</em>' })).resolves.toEqual({
ok: true,
msg: '<em>등용문</em>',
});
await expect(caller.nation.setRate({ amount: 30 })).resolves.toEqual({ ok: true });
await expect(caller.nation.setBill({ amount: 200 })).resolves.toEqual({ ok: true });
await expect(caller.nation.setSecretLimit({ amount: 99 })).resolves.toEqual({ ok: true });
await expect(caller.nation.setBlockWar({ value: false })).resolves.toEqual({ availableCnt: 7 });
await expect(caller.nation.setBlockScout({ value: true })).resolves.toEqual({ ok: true });
expect(fixture.requestCommand.mock.calls.map(([command]) => command)).toEqual([
{
type: 'setNationSetting',
requestId: 'http-setting-boundary:nation.setNotice:engine:0:setNationSetting',
userId: 'authenticated-user',
generalId: 71,
nationId: 3,
mutation: { kind: 'notice', message: '<strong>방침</strong>' },
},
{
type: 'setNationSetting',
requestId: 'http-setting-boundary:nation.setScoutMsg:engine:0:setNationSetting',
userId: 'authenticated-user',
generalId: 71,
nationId: 3,
mutation: { kind: 'scoutMessage', message: '<em>등용문</em>' },
},
{
type: 'setNationSetting',
requestId: 'http-setting-boundary:nation.setRate:engine:0:setNationSetting',
userId: 'authenticated-user',
generalId: 71,
nationId: 3,
mutation: { kind: 'rate', amount: 30 },
},
{
type: 'setNationSetting',
requestId: 'http-setting-boundary:nation.setBill:engine:0:setNationSetting',
userId: 'authenticated-user',
generalId: 71,
nationId: 3,
mutation: { kind: 'bill', amount: 200 },
},
{
type: 'setNationSetting',
requestId: 'http-setting-boundary:nation.setSecretLimit:engine:0:setNationSetting',
userId: 'authenticated-user',
generalId: 71,
nationId: 3,
mutation: { kind: 'secretLimit', amount: 99 },
},
{
type: 'setNationSetting',
requestId: 'http-setting-boundary:nation.setBlockWar:engine:0:setNationSetting',
userId: 'authenticated-user',
generalId: 71,
nationId: 3,
mutation: { kind: 'blockWar', value: false },
},
{
type: 'setNationSetting',
requestId: 'http-setting-boundary:nation.setBlockScout:engine:0:setNationSetting',
userId: 'authenticated-user',
generalId: 71,
nationId: 3,
mutation: { kind: 'blockScout', value: true },
},
]);
expect(fixture.transaction).not.toHaveBeenCalled();
});
it('rejects unauthenticated calls before querying or dispatching a setting command', async () => {
const fixture = buildContext(false);
await expect(appRouter.createCaller(fixture.context).nation.setBill({ amount: 20 })).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
expect(fixture.context.db.general.findFirst).not.toHaveBeenCalled();
expect(fixture.requestCommand).not.toHaveBeenCalled();
expect(fixture.transaction).not.toHaveBeenCalled();
});
});
+30 -46
View File
@@ -105,7 +105,7 @@ const createContext = (
const requestCommand =
options.requestCommand ??
vi.fn(async () => ({
type: 'setNationMeta',
type: 'setNpcPolicy',
ok: true,
nationId: 1,
updatedAt: '2026-01-01T00:01:00.000Z',
@@ -161,46 +161,43 @@ describe('NPC policy router', () => {
});
});
it('lets a secret-level reader load the page but rejects every mutation before daemon dispatch', async () => {
it('lets a secret-level reader load the page while mapping authoritative ENGINE rejection', async () => {
const reader = { ...baseGeneral, officerLevel: 2 };
const fixture = createContext({ me: reader });
const requestCommand = vi.fn(async () => ({
type: 'setNpcPolicy' as const,
ok: false as const,
code: 'FORBIDDEN' as const,
reason: '권한이 부족합니다.',
nationId: 1,
}));
const fixture = createContext({ me: reader, requestCommand });
const caller = appRouter.createCaller(fixture.context);
await expect(caller.npc.getPolicy()).resolves.toMatchObject({ permissionLevel: 1 });
await expect(caller.npc.setNationPriority(['천도'])).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(caller.npc.setGeneralPriority(['출병', '일반내정'])).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await expect(caller.npc.setNationPolicy({ reqNationGold: 100 })).rejects.toMatchObject({
code: 'FORBIDDEN',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
expect(fixture.requestCommand).toHaveBeenCalledOnce();
});
it.each([
['군주', { ...baseGeneral, officerLevel: 12 }],
['감찰권자', { ...baseGeneral, officerLevel: 1, meta: { belong: 0, permission: 'auditor' } }],
['외교권자', { ...baseGeneral, officerLevel: 1, meta: { belong: 0, permission: 'ambassador' } }],
])('%s can persist policy through the daemon-owned metadata command', async (_label, me) => {
])('%s dispatches actor-bound policy intent to the daemon', async (_label, me) => {
const fixture = createContext({ me });
await expect(appRouter.createCaller(fixture.context).npc.setNationPriority(['천도', '천도'])).resolves.toEqual({
ok: true,
});
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'setNationMeta',
type: 'setNpcPolicy',
userId: 'user-22',
generalId: 22,
nationId: 1,
updates: {
npc_nation_policy: expect.objectContaining({
priority: ['천도', '천도'],
prioritySetter: '정책담당',
prioritySetTime: expect.any(String),
}),
},
expectedUpdatedAt: '2026-01-01T00:00:00.000Z',
mutation: { kind: 'nationPriority', priority: ['천도', '천도'] },
});
});
it('clamps legacy integer values, preserves float values, and validates troop ownership before dispatch', async () => {
it('forwards raw policy intent so ENGINE can validate current troop and city state atomically', async () => {
const fixture = createContext();
const caller = appRouter.createCaller(fixture.context);
@@ -211,43 +208,29 @@ describe('NPC policy router', () => {
});
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({
updates: {
npc_nation_policy: expect.objectContaining({
values: expect.objectContaining({
reqNationGold: 0,
safeRecruitCityPopulationRatio: -0.5,
CombatForce: { 101: [1, 2] },
}),
}),
type: 'setNpcPolicy',
mutation: {
kind: 'nationPolicy',
values: {
reqNationGold: -100,
safeRecruitCityPopulationRatio: -0.5,
CombatForce: { 101: [1, 2] },
},
},
})
);
fixture.requestCommand.mockClear();
await expect(caller.npc.setNationPolicy({ SupportForce: [999] })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
expect(fixture.requestCommand).not.toHaveBeenCalled();
});
it('preserves duplicate legacy priority entries and enforces required general actions and ordering', async () => {
it('preserves duplicate priority entries in the dispatched intent', async () => {
const fixture = createContext();
const caller = appRouter.createCaller(fixture.context);
await caller.npc.setGeneralPriority(['출병', '출병', '일반내정']);
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({
updates: {
npc_general_policy: expect.objectContaining({
priority: ['출병', '출병', '일반내정'],
}),
},
mutation: { kind: 'generalPriority', priority: ['출병', '출병', '일반내정'] },
})
);
await expect(caller.npc.setGeneralPriority(['일반내정', '출병'])).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
await expect(caller.npc.setGeneralPriority(['출병'])).rejects.toMatchObject({ code: 'BAD_REQUEST' });
});
it('blocks nationless, penalized, and stale writers without changing lifecycle state directly', async () => {
@@ -262,10 +245,11 @@ describe('NPC policy router', () => {
});
const staleCommand = vi.fn(async () => ({
type: 'setNationMeta',
type: 'setNpcPolicy',
ok: false,
code: 'CONFLICT',
nationId: 1,
reason: 'CONFLICT',
reason: '다른 사용자가 정책을 변경했습니다.',
}));
const stale = createContext({ requestCommand: staleCommand });
await expect(appRouter.createCaller(stale.context).npc.setNationPriority(['천도'])).rejects.toMatchObject({
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { scopeHttpIdempotencyKey } from '../src/requestId.js';
describe('HTTP idempotency request IDs', () => {
it('is stable for one principal and isolated across users and profiles', () => {
const first = scopeHttpIdempotencyKey({ rawKey: 'same-client-key', profileId: 'hwe', userId: 'user-a' });
expect(first).toBe(scopeHttpIdempotencyKey({ rawKey: 'same-client-key', profileId: 'hwe', userId: 'user-a' }));
expect(first).not.toBe(
scopeHttpIdempotencyKey({ rawKey: 'same-client-key', profileId: 'hwe', userId: 'user-b' })
);
expect(first).not.toBe(
scopeHttpIdempotencyKey({ rawKey: 'same-client-key', profileId: 'che', userId: 'user-a' })
);
});
it('bounds and neutralizes untrusted header content while omitting blank keys', () => {
expect(scopeHttpIdempotencyKey({ rawKey: ' \n\t ', profileId: 'hwe', userId: 'user-a' })).toBeUndefined();
const scoped = scopeHttpIdempotencyKey({
rawKey: `${'x'.repeat(10_000)}:../../unexpected`,
profileId: 'hwe',
userId: null,
});
expect(scoped).toMatch(/^http:[0-9a-f]{64}$/u);
expect(scoped).toHaveLength(69);
});
});
+25 -12
View File
@@ -67,8 +67,14 @@ const auth: GameSessionTokenPayload = {
sanctions: {},
};
const buildContext = (blockChangeScout: boolean) => {
const requestCommand = vi.fn();
const buildContext = () => {
const requestCommand = vi.fn(async () => ({
type: 'setNationSetting' as const,
ok: false as const,
code: 'FORBIDDEN' as const,
nationId: 1,
reason: '임관 설정을 바꿀 수 없도록 설정되어 있습니다.',
}));
const db = {
general: {
findFirst: vi.fn(async () => general),
@@ -77,7 +83,7 @@ const buildContext = (blockChangeScout: boolean) => {
findUnique: vi.fn(async () => ({ meta: {} })),
},
worldState: {
findFirst: vi.fn(async () => ({ meta: { block_change_scout: blockChangeScout } })),
findFirst: vi.fn(async () => ({ meta: { block_change_scout: true } })),
},
};
const redisClient = {
@@ -102,14 +108,21 @@ const buildContext = (blockChangeScout: boolean) => {
};
describe('nation scout policy lock', () => {
it('rejects policy changes before daemon dispatch while the scenario lock is enabled', async () => {
const fixture = buildContext(true);
await expect(appRouter.createCaller(fixture.context).nation.setBlockScout({ value: false })).rejects.toMatchObject(
{
code: 'FORBIDDEN',
message: '임관 설정을 바꿀 수 없도록 설정되어 있습니다.',
}
);
expect(fixture.requestCommand).not.toHaveBeenCalled();
it('lets the engine recheck the scenario lock and maps its rejection', async () => {
const fixture = buildContext();
await expect(
appRouter.createCaller(fixture.context).nation.setBlockScout({ value: false })
).rejects.toMatchObject({
code: 'FORBIDDEN',
message: '임관 설정을 바꿀 수 없도록 설정되어 있습니다.',
});
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'setNationSetting',
userId: 'user-1',
generalId: 1,
nationId: 1,
mutation: { kind: 'blockScout', value: false },
});
expect(fixture.context.db.worldState.findFirst).not.toHaveBeenCalled();
});
});
@@ -16,6 +16,7 @@ import {
} from '@sammo-ts/infra';
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
import { scopeHttpIdempotencyKey } from '../src/requestId.js';
import { createGameApiServer } from '../src/server.js';
import { WebPushOutboxWorker } from '../src/services/webPushOutboxWorker.js';
@@ -41,6 +42,12 @@ 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',
] as const;
const fixtureActorUserIds = [userId, noGeneralUserId, sameNationUserId, foreignUserId, ordinaryUserId];
const secret = 'security-http-e2e-secret';
const redisPrefix = `sammo:security-http:${process.pid}`;
const envKeys = [
@@ -303,7 +310,7 @@ const readReservedMutationState = async () => ({
orderBy: { id: 'asc' },
}),
engineInputEvents: await db.inputEvent.findMany({
where: { target: 'ENGINE', requestId: { startsWith: mutationRequestPrefix } },
where: { target: 'ENGINE', actorUserId: { in: fixtureActorUserIds } },
select: { requestId: true, eventType: true, status: true, actorUserId: true },
orderBy: { sequence: 'asc' },
}),
@@ -315,6 +322,31 @@ const readReservedMutationState = async () => ({
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<void> => {
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<Array<{ tableName: string }>>(
`SELECT table_name AS "tableName"
@@ -326,16 +358,17 @@ const readDurableSchemaStateExcludingMatrixApiJournal = async () => {
return Promise.all(
tables.map(async ({ tableName }) => {
const qualifiedTable = `${quotePostgresIdentifier(profileId)}.${quotePostgresIdentifier(tableName)}`;
const matrixApiFilter =
tableName === 'input_event' ? `WHERE NOT (target = 'API' AND request_id LIKE $1)` : '';
const rows = await db.$queryRawUnsafe<Array<{ rowJson: string }>>(
`SELECT to_jsonb(snapshot_row)::text AS "rowJson"
FROM ${qualifiedTable} AS snapshot_row
${matrixApiFilter}
ORDER BY to_jsonb(snapshot_row)::text`,
...(matrixApiFilter ? [`${mutationRequestPrefix}%`] : [])
ORDER BY to_jsonb(snapshot_row)::text`
);
return { tableName, rows: rows.map(({ rowJson }) => rowJson) };
return {
tableName,
rows: rows
.map(({ rowJson }) => rowJson)
.filter((rowJson) => tableName !== 'input_event' || !isMatrixApiInputEvent(rowJson)),
};
})
);
};
@@ -389,12 +422,11 @@ const expectApiInputEvent = async (
procedure: string,
expected: { actorUserId: string; status: 'FAILED' | 'SUCCEEDED' } | null
): Promise<void> => {
const requestId = `${idempotencyKey}:${procedure}`;
const events = await db.inputEvent.findMany({
// beforeEach removes the whole matrix prefix. Query that complete
// namespace so an extra/rewritten API journal row cannot hide behind
// the full-schema snapshot's one explicitly allowed exclusion.
where: { target: 'API', requestId: { startsWith: mutationRequestPrefix } },
// 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,
@@ -417,6 +449,7 @@ const expectApiInputEvent = async (
expect(events).toEqual([]);
return;
}
const requestId = resolveScopedApiRequestId(idempotencyKey, procedure, expected.actorUserId);
expect(events).toEqual([
{
requestId,
@@ -638,7 +671,7 @@ integration('game API security over HTTP transport', () => {
afterAll(async () => {
await server?.app.close();
await closeGatewayStatusStub();
await db?.inputEvent.deleteMany({ where: { requestId: { startsWith: mutationRequestPrefix } } });
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 } } });
@@ -670,7 +703,7 @@ integration('game API security over HTTP transport', () => {
}, 30_000);
beforeEach(async () => {
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: mutationRequestPrefix } } });
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 } } });
@@ -1055,7 +1088,7 @@ integration('game API security over HTTP transport', () => {
});
expect(
await db.inputEvent.count({
where: { target: 'ENGINE', requestId: { startsWith: idempotencyKey } },
where: { target: 'ENGINE', actorUserId: userId },
})
).toBe(0);
await expect.poll(() => db.readModelOutbox.count()).toBe(1);
@@ -1513,8 +1546,9 @@ integration('game API security over HTTP transport', () => {
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: `${idempotencyKey}:turns.reserved.setNation` },
where: { requestId: replayRequestId },
});
const replay = await requestReservedNation(accessToken, idempotencyKey, generalId);
expect(replay.response.status).toBe(409);
@@ -1524,12 +1558,10 @@ integration('game API security over HTTP transport', () => {
expect(await readRealtimeRedisState()).toEqual(replayRedisBefore);
expect(
await db.inputEvent.findUniqueOrThrow({
where: { requestId: `${idempotencyKey}:turns.reserved.setNation` },
where: { requestId: replayRequestId },
})
).toEqual(replayJournalBefore);
expect(await db.inputEvent.count({ where: { requestId: `${idempotencyKey}:turns.reserved.setNation` } })).toBe(
1
);
expect(await db.inputEvent.count({ where: { requestId: replayRequestId } })).toBe(1);
await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', {
actorUserId: userId,
status: 'SUCCEEDED',
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import { buildTournamentKeys } from '../src/tournament/keys.js';
import { TournamentStore } from '../src/tournament/store.js';
import { CorruptTournamentProjectionError, TournamentStore } from '../src/tournament/store.js';
class AtomicMemoryRedis {
readonly events: string[] = [];
@@ -130,4 +130,114 @@ describe('TournamentStore source revision', () => {
await expect(store.getSourceRevision()).resolves.toBe('50');
expect(redis.published).toHaveLength(100);
});
it('distinguishes missing projections from malformed JSON and invalid shapes', async () => {
const redis = new AtomicMemoryRedis();
const keys = buildTournamentKeys('corrupt:default');
const store = new TournamentStore(redis, keys);
await expect(store.getParticipants()).resolves.toEqual([]);
await redis.set(keys.participantsKey, '{broken');
await expect(store.getParticipants()).rejects.toBeInstanceOf(CorruptTournamentProjectionError);
await redis.set(keys.participantsKey, JSON.stringify([{ id: 'not-a-number' }]));
await expect(store.getParticipants()).rejects.toBeInstanceOf(CorruptTournamentProjectionError);
});
it('rejects corrupt settlement flags instead of silently skipping tournament settlement', async () => {
const redis = new AtomicMemoryRedis();
const keys = buildTournamentKeys('corrupt-settlement:default');
const store = new TournamentStore(redis, keys);
const canonicalState = {
stage: 0,
phase: 0,
type: 0,
auto: false,
openYear: 185,
openMonth: 2,
termSeconds: 300,
nextAt: '2026-08-17T00:00:00.000Z',
bettingId: 123,
bettingCloseAt: '2026-08-17T00:05:00.000Z',
winnerId: 7,
bettingSettled: false,
rewardSettled: false,
participantsLockedAt: '2026-08-17T00:01:00.000Z',
lastError: 'retryable fixture',
lastErrorAt: '2026-08-17T00:02:00.000Z',
};
await redis.set(keys.stateKey, JSON.stringify(canonicalState));
await expect(store.getState()).resolves.toEqual(canonicalState);
for (const [field, value] of [
['winnerId', '7'],
['bettingId', '123'],
['rewardSettled', 'yes'],
['bettingSettled', 1],
] as const) {
await redis.set(keys.stateKey, JSON.stringify({ ...canonicalState, [field]: value }));
await expect(store.getState(), field).rejects.toBeInstanceOf(CorruptTournamentProjectionError);
}
});
it('validates known optional participant and match projection fields', async () => {
const redis = new AtomicMemoryRedis();
const keys = buildTournamentKeys('corrupt-optional:default');
const store = new TournamentStore(redis, keys);
const participant = {
id: 7,
name: '관우',
leadership: 90,
strength: 97,
intel: 75,
level: 5,
groupId: 0,
groupNo: 1,
win: 2,
draw: 1,
lose: 0,
gl: 10,
seedRank: 1,
finalRank: 2,
preliminaryGroupId: 0,
preliminaryGroupNo: 1,
preliminaryRank: 2,
preliminaryWin: 2,
preliminaryDraw: 1,
preliminaryLose: 0,
preliminaryGl: 10,
};
const match = {
id: 1,
stage: 7,
roundIndex: 0,
groupId: 0,
attackerId: 7,
defenderId: 8,
winnerId: 7,
log: ['결과'],
logEntries: [
{
phase: 1,
attackerEnergy: 90,
defenderEnergy: 0,
attackerDamage: 10,
defenderDamage: 100,
text: '결과',
},
],
lastEnergy: { attacker: 90, defender: 0 },
};
await redis.set(keys.participantsKey, JSON.stringify([participant]));
await redis.set(keys.matchesKey, JSON.stringify([match]));
await expect(store.getParticipants()).resolves.toEqual([participant]);
await expect(store.getMatches()).resolves.toEqual([match]);
await redis.set(keys.participantsKey, JSON.stringify([{ ...participant, win: '2' }]));
await expect(store.getParticipants()).rejects.toBeInstanceOf(CorruptTournamentProjectionError);
await redis.set(keys.matchesKey, JSON.stringify([{ ...match, lastEnergy: { attacker: '90', defender: 0 } }]));
await expect(store.getMatches()).rejects.toBeInstanceOf(CorruptTournamentProjectionError);
});
});
+21
View File
@@ -295,6 +295,7 @@ describe('troop router permissions and mutations', () => {
});
expect(requestCommand).toHaveBeenCalledWith({
type: 'troopCreate',
userId: 'user-1',
generalId: 1,
troopName: '백마대',
});
@@ -317,11 +318,30 @@ describe('troop router permissions and mutations', () => {
expect(requestCommand).toHaveBeenCalledWith({
type: 'troopCreate',
requestId: 'http-troop-create:troop.create:engine:0:troopCreate',
userId: 'user-1',
generalId: 1,
troopName: '백마대',
});
});
it('maps an ENGINE actor-binding rejection to a forbidden API response', async () => {
const fixture = buildContext({
result: {
type: 'commandRejected',
ok: false,
commandType: 'troopCreate',
reason: '명령 수행 장수의 현재 소유자가 일치하지 않습니다.',
},
});
await expect(
appRouter.createCaller(fixture.context).troop.create({ troopName: '백마대' })
).rejects.toMatchObject({
code: 'FORBIDDEN',
message: '명령 수행 장수의 현재 소유자가 일치하지 않습니다.',
});
});
it('rejects troop creation before daemon dispatch when already assigned or the name is blank', async () => {
const assigned = buildContext({
me: buildGeneral({ troopId: 9 }),
@@ -372,6 +392,7 @@ describe('troop router permissions and mutations', () => {
).resolves.toEqual({ ok: true });
expect(authorized.requestCommand).toHaveBeenCalledWith({
type: 'troopKick',
userId: 'user-1',
generalId: 1,
troopId: 1,
targetGeneralId: 3,
+5 -1
View File
@@ -97,6 +97,7 @@ const buildContext = (options: {
metaDevelCost?: number;
auctionTargets?: string[];
clockTick?: number;
requestId?: string;
}) => {
const auth = options.auth === undefined ? buildAuth() : options.auth;
const general = options.general === undefined ? buildGeneral() : options.general;
@@ -202,6 +203,7 @@ const buildContext = (options: {
battleSim: {} as GameApiContext['battleSim'],
profile: { id: 'che', scenario: 'default', name: 'che:default' },
auth,
...(options.requestId ? { requestId: options.requestId } : {}),
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
@@ -247,13 +249,15 @@ describe('vote router actor and permission boundaries', () => {
it('uses only the general owned by the authenticated user for voting and reward dispatch', async () => {
const owned = buildGeneral({ id: 7, userId: 'user-1', name: '유비' });
const fixture = buildContext({ general: owned, clockTick: 100 });
const fixture = buildContext({ general: owned, clockTick: 100, requestId: 'http-vote-submit' });
await expect(
appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] })
).resolves.toEqual({ ok: true, wonLottery: false });
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'voteReward',
requestId: 'http-vote-submit:vote.submitVote:engine:0:voteReward',
userId: 'user-1',
voteId: 1,
generalId: 7,
selection: [0],