fix: 특수 유저 커맨드의 Ref 호환 경계를 보강한다
수뇌 국가 설정과 NPC 정책을 actor-bound ENGINE mutation으로 옮기고, 추방·등용·점령·멸망·아이템 폐기의 특수 분기를 Ref와 맞춘다. 요청 ID를 사용자·프로필별로 격리하고 토너먼트 손상 projection을 fail-closed하며 실제 DB 및 Ref 차등 회귀를 보강한다.
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { TurnDaemonCommand } from '@sammo-ts/common';
|
||||
|
||||
import type { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
|
||||
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||
|
||||
const buildActorBoundCommands = (userId = 'old-owner'): TurnDaemonCommand[] => [
|
||||
{ type: 'troopCreate', requestId: 'troopCreate', userId, generalId: 7, troopName: '백마대' },
|
||||
{ type: 'troopJoin', requestId: 'troopJoin', userId, generalId: 7, troopId: 8 },
|
||||
{ type: 'troopExit', requestId: 'troopExit', userId, generalId: 7 },
|
||||
{ type: 'troopKick', requestId: 'troopKick', userId, generalId: 7, troopId: 7, targetGeneralId: 8 },
|
||||
{ type: 'troopRename', requestId: 'troopRename', userId, generalId: 7, troopId: 7, troopName: '신대' },
|
||||
{ type: 'vacation', requestId: 'vacation', userId, generalId: 7 },
|
||||
{ type: 'setMySetting', requestId: 'setMySetting', userId, generalId: 7, settings: { tnmt: 1 } },
|
||||
{ type: 'dropItem', requestId: 'dropItem', userId, generalId: 7, itemType: 'weapon' },
|
||||
{
|
||||
type: 'auctionOpen',
|
||||
requestId: 'auctionOpen',
|
||||
userId,
|
||||
generalId: 7,
|
||||
auctionType: 'BUY_RICE',
|
||||
amount: 1_000,
|
||||
closeTurnCnt: 3,
|
||||
startBidAmount: 100,
|
||||
finishBidAmount: 500,
|
||||
},
|
||||
{ type: 'auctionBid', requestId: 'auctionBid', userId, generalId: 7, auctionId: 1, amount: 200 },
|
||||
{
|
||||
type: 'changePermission',
|
||||
requestId: 'changePermission',
|
||||
userId,
|
||||
generalId: 7,
|
||||
isAmbassador: true,
|
||||
targetGeneralIds: [],
|
||||
},
|
||||
{ type: 'kick', requestId: 'kick', userId, generalId: 7, destGeneralId: 8 },
|
||||
{
|
||||
type: 'appoint',
|
||||
requestId: 'appoint',
|
||||
userId,
|
||||
generalId: 7,
|
||||
destGeneralId: 8,
|
||||
destCityId: 1,
|
||||
officerLevel: 4,
|
||||
},
|
||||
{ type: 'voteReward', requestId: 'voteReward', userId, generalId: 7, voteId: 1, selection: [0] },
|
||||
];
|
||||
|
||||
const buildReadOnlyWorld = (ownerUserId: string) => {
|
||||
const mutation = vi.fn();
|
||||
const world = {
|
||||
getGeneralById: vi.fn(() => ({ id: 7, userId: ownerUserId })),
|
||||
updateGeneral: mutation,
|
||||
updateNation: mutation,
|
||||
createTroop: mutation,
|
||||
updateTroop: mutation,
|
||||
removeTroop: mutation,
|
||||
pushLog: mutation,
|
||||
queueMessage: mutation,
|
||||
} as unknown as InMemoryTurnWorld;
|
||||
return { world, mutation };
|
||||
};
|
||||
|
||||
describe('authenticated actor-bound command registry and execution', () => {
|
||||
it.each(['horse', 'weapon', 'book', 'item'] as const)(
|
||||
'accepts the Ref equipment slot %s for dropItem',
|
||||
(itemType) => {
|
||||
expect(
|
||||
normalizeTurnDaemonCommand({
|
||||
requestId: `drop-item:${itemType}`,
|
||||
sentAt: '2026-08-24T00:00:00.000Z',
|
||||
command: { type: 'dropItem', userId: 'user-7', generalId: 7, itemType },
|
||||
})
|
||||
).toEqual({
|
||||
type: 'dropItem',
|
||||
requestId: `drop-item:${itemType}`,
|
||||
userId: 'user-7',
|
||||
generalId: 7,
|
||||
itemType,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['armor', '', 0, null])('rejects the invalid dropItem slot %j at the daemon boundary', (itemType) => {
|
||||
expect(
|
||||
normalizeTurnDaemonCommand({
|
||||
requestId: 'drop-item:invalid-slot',
|
||||
sentAt: '2026-08-24T00:00:00.000Z',
|
||||
command: {
|
||||
type: 'dropItem',
|
||||
userId: 'user-7',
|
||||
generalId: 7,
|
||||
itemType,
|
||||
} as unknown as TurnDaemonCommand,
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects every actor-bound queue payload that omits userId', () => {
|
||||
for (const command of buildActorBoundCommands()) {
|
||||
const {
|
||||
userId: _userId,
|
||||
requestId: _requestId,
|
||||
...payload
|
||||
} = command as unknown as Record<string, unknown>;
|
||||
expect(
|
||||
normalizeTurnDaemonCommand({
|
||||
requestId: `missing-user:${command.type}`,
|
||||
sentAt: '2026-08-24T00:00:00.000Z',
|
||||
command: payload as TurnDaemonCommand,
|
||||
}),
|
||||
command.type
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects all stale-owner commands before any world mutation', async () => {
|
||||
const commands = buildActorBoundCommands();
|
||||
const { world, mutation } = buildReadOnlyWorld('new-owner');
|
||||
const eventTypes = new Map(commands.map((command) => [command.requestId, command.type]));
|
||||
const db = {
|
||||
inputEvent: {
|
||||
findUnique: vi.fn(async ({ where }: { where: { requestId: string } }) => ({
|
||||
actorUserId: 'old-owner',
|
||||
target: 'ENGINE',
|
||||
eventType: eventTypes.get(where.requestId),
|
||||
})),
|
||||
},
|
||||
};
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
|
||||
for (const command of commands) {
|
||||
await expect(handler.handle(command, { db: db as never }), command.type).resolves.toMatchObject({
|
||||
type: 'commandRejected',
|
||||
ok: false,
|
||||
commandType: command.type,
|
||||
});
|
||||
}
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing event', null],
|
||||
['actor mismatch', { actorUserId: 'other-owner', target: 'ENGINE', eventType: 'vacation' }],
|
||||
['target mismatch', { actorUserId: 'old-owner', target: 'API', eventType: 'vacation' }],
|
||||
['event type mismatch', { actorUserId: 'old-owner', target: 'ENGINE', eventType: 'dropItem' }],
|
||||
])('returns commandRejected for %s without looking up or mutating the general', async (_label, event) => {
|
||||
const { world, mutation } = buildReadOnlyWorld('old-owner');
|
||||
const getGeneralById = world.getGeneralById as ReturnType<typeof vi.fn>;
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
const result = await handler.handle(
|
||||
{ type: 'vacation', requestId: 'vacation', userId: 'old-owner', generalId: 7 },
|
||||
{
|
||||
db: {
|
||||
inputEvent: { findUnique: vi.fn(async () => event) },
|
||||
} as never,
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ type: 'commandRejected', ok: false, commandType: 'vacation' });
|
||||
expect(getGeneralById).not.toHaveBeenCalled();
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves direct in-memory invocation when no command database is supplied', async () => {
|
||||
const updateGeneral = vi.fn();
|
||||
const world = {
|
||||
getGeneralById: vi.fn(() => ({ id: 7, userId: 'current-owner', meta: { killturn: 12 } })),
|
||||
getState: vi.fn(() => ({ meta: { killturn: 24, autorun_user: {} } })),
|
||||
updateGeneral,
|
||||
} as unknown as InMemoryTurnWorld;
|
||||
const handler = createTurnDaemonCommandHandler({ world });
|
||||
|
||||
await expect(handler.handle({ type: 'vacation', userId: 'different-owner', generalId: 7 })).resolves.toEqual({
|
||||
type: 'vacation',
|
||||
ok: true,
|
||||
generalId: 7,
|
||||
});
|
||||
expect(updateGeneral).toHaveBeenCalledWith(7, { meta: { killturn: 72 } });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user