fix: 특수 유저 커맨드의 Ref 호환 경계를 보강한다
수뇌 국가 설정과 NPC 정책을 actor-bound ENGINE mutation으로 옮기고, 추방·등용·점령·멸망·아이템 폐기의 특수 분기를 Ref와 맞춘다. 요청 ID를 사용자·프로필별로 격리하고 토너먼트 손상 projection을 fail-closed하며 실제 DB 및 Ref 차등 회귀를 보강한다.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user