test: 실제 Game API 명령 전수 근거를 고정한다

This commit is contained in:
2026-08-24 16:30:48 +00:00
parent cca2b3d925
commit a9e2431115
17 changed files with 1138 additions and 48 deletions
+123
View File
@@ -82,10 +82,14 @@ const buildContext = (options: {
requestId?: string;
transaction?: ReturnType<typeof vi.fn>;
clockTick?: number;
daemonResult?: Awaited<ReturnType<TurnDaemonTransport['requestCommand']>>;
}) => {
const auth = options.auth === undefined ? buildAuth() : options.auth;
const general = options.general === undefined ? buildGeneral() : options.general;
const requestCommand = vi.fn(async (command: { type: string }) => {
if (options.daemonResult !== undefined) {
return options.daemonResult;
}
if (command.type === 'auctionOpen') {
return {
type: 'auctionOpen' as const,
@@ -279,6 +283,38 @@ describe('auction router actor and permission boundaries', () => {
});
});
it('opens a sell-rice auction with only the authenticated actor and a stable ENGINE request identity', async () => {
const transaction = vi.fn(async () => {
throw new Error('API transaction must not run');
});
const fixture = buildContext({ requestId: 'http-auction-open-sell', transaction });
const input = {
amount: 1000,
closeTurnCnt: 3,
startBidAmount: 500,
finishBidAmount: 2000,
userId: 'forged-user',
generalId: 999,
};
await expect(appRouter.createCaller(fixture.context).auction.openSellRice(input)).resolves.toMatchObject({
auctionId: 91,
});
expect(transaction).not.toHaveBeenCalled();
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'auctionOpen',
requestId: 'http-auction-open-sell:auction.openSellRice:engine:0:auctionOpen',
auctionType: 'SELL_RICE',
userId: 'user-1',
generalId: 7,
amount: 1000,
closeTurnCnt: 3,
startBidAmount: 500,
finishBidAmount: 2000,
});
});
it('rejects auction mutations after unification before sending a daemon command', async () => {
const fixture = buildContext({ isUnited: 0, isunited: 2 });
const caller = appRouter.createCaller(fixture.context);
@@ -426,4 +462,91 @@ describe('auction router actor and permission boundaries', () => {
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '금이 부족합니다.' });
expect(rejected.requestCommand).not.toHaveBeenCalled();
});
it('bids rice through the authenticated actor and preserves the sell-rice ENGINE request identity', async () => {
const queryRaw = async (query: GamePrisma.Sql) => {
const text = sqlText(query);
if (text.includes('FROM auction') && text.includes('WHERE id =')) {
return [
{
id: 31,
type: 'SELL_RICE',
targetCode: '100',
hostGeneralId: 88,
detail: { title: '금 100 경매', amount: 100, startBidAmount: 500, isReverse: false },
status: 'OPEN',
closeAt: new Date('2026-07-27T00:00:00.000Z'),
closeTick: 200n,
},
];
}
if (text.includes('FROM auction_bid')) {
return [];
}
return [];
};
const fixture = buildContext({
general: buildGeneral({ id: 7, userId: 'user-1', rice: 1_500 }),
queryRaw,
requestId: 'http-auction-bid-sell',
clockTick: 100,
});
const input = { auctionId: 31, amount: 500, userId: 'forged-user', generalId: 999 };
await expect(appRouter.createCaller(fixture.context).auction.bidSellRice(input)).resolves.toEqual({ ok: true });
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'auctionBid',
requestId: 'http-auction-bid-sell:auction.bidSellRice:engine:0:auctionBid',
userId: 'user-1',
auctionId: 31,
generalId: 7,
amount: 500,
acceptedGameTick: 100,
tryExtendCloseDate: true,
});
});
it('maps a rejected sell-rice bid without trusting client actor fields', async () => {
const queryRaw = async (query: GamePrisma.Sql) => {
const text = sqlText(query);
if (text.includes('FROM auction') && text.includes('WHERE id =')) {
return [
{
id: 31,
type: 'SELL_RICE',
targetCode: '100',
hostGeneralId: 88,
detail: { amount: 100, startBidAmount: 500, isReverse: false },
status: 'OPEN',
closeAt: new Date('2026-07-27T00:00:00.000Z'),
closeTick: 200n,
},
];
}
if (text.includes('FROM auction_bid')) {
return [];
}
return [];
};
const fixture = buildContext({
general: buildGeneral({ rice: 1_500 }),
queryRaw,
clockTick: 100,
daemonResult: {
type: 'auctionBid',
ok: false,
auctionId: 31,
reason: '입찰이 취소되었습니다.',
},
});
const input = { auctionId: 31, amount: 500, userId: 'forged-user', generalId: 999 };
await expect(appRouter.createCaller(fixture.context).auction.bidSellRice(input)).rejects.toMatchObject({
code: 'CONFLICT',
message: '입찰이 취소되었습니다.',
});
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'user-1', generalId: 7, auctionId: 31 })
);
});
});
@@ -3,17 +3,25 @@ import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { appRouter } from '../src/router.js';
const classifications = {
durableJournal: [
'betting.bet',
'diplomacy.destroyLetter',
'diplomacy.respondLetter',
'diplomacy.rollbackLetter',
'diplomacy.sendLetter',
'inherit.checkOwner',
'messages.delete',
'messages.respond',
'messages.send',
'turns.repeatGeneral',
'turns.setGeneral',
'turns.setGeneralBulk',
'turns.shiftGeneral',
'turns.reserved.repeatGeneral',
'turns.reserved.setGeneral',
'turns.reserved.setGeneralBulk',
'turns.reserved.setNation',
'turns.reserved.setNationBulk',
'turns.reserved.shiftGeneral',
'vote.closePoll',
'vote.createPoll',
'vote.submitVote',
@@ -23,16 +31,10 @@ const classifications = {
explicitNoRealtimeConsumer: [
'board.writeArticle',
'board.writeComment',
'diplomacy.destroyLetter',
'diplomacy.respondLetter',
'diplomacy.rollbackLetter',
'diplomacy.sendLetter',
'join.listPossessCandidates',
'messages.readLatest',
'turns.repeatNation',
'turns.setNation',
'turns.setNationBulk',
'turns.shiftNation',
'turns.reserved.repeatNation',
'turns.reserved.shiftNation',
'vote.addComment',
],
engineOwned: [
@@ -109,40 +111,71 @@ const listTypeScriptFiles = (directory: string): string[] =>
return entry.isFile() && entry.name.endsWith('.ts') ? [target] : [];
});
const extractMutationNames = (file: string): string[] => {
const source = readFileSync(file, 'utf8');
const names: string[] = [];
for (const mutation of source.matchAll(/\.mutation\s*\(/gu)) {
const prefix = source.slice(0, mutation.index);
const propertyCandidates = [...prefix.matchAll(/^ {4,8}([A-Za-z][A-Za-z0-9]*):/gmu)];
const exportedCandidates = [...prefix.matchAll(/^export const ([A-Za-z][A-Za-z0-9]*)\s*=/gmu)];
const property = propertyCandidates.at(-1);
const exported = exportedCandidates.at(-1);
const propertyIndex = property?.index ?? -1;
const exportedIndex = exported?.index ?? -1;
const name = propertyIndex > exportedIndex ? property?.[1] : exported?.[1];
if (!name) throw new Error(`Could not resolve mutation name in ${file}`);
names.push(name);
const countDeclaredMutations = (file: string): number =>
[...readFileSync(file, 'utf8').matchAll(/\.mutation\s*\(/gu)].length;
interface RuntimeProcedureDef {
type: string;
middlewares: readonly unknown[];
}
const readRuntimeProcedureDef = (procedure: unknown): RuntimeProcedureDef => {
if (typeof procedure !== 'function') {
throw new Error('Mounted tRPC procedure is not callable.');
}
return names;
const definition: unknown = Reflect.get(procedure, '_def');
if (typeof definition !== 'object' || definition === null) {
throw new Error('Mounted tRPC procedure has no runtime definition.');
}
const type: unknown = Reflect.get(definition, 'type');
const middlewares: unknown = Reflect.get(definition, 'middlewares');
if (typeof type !== 'string' || !Array.isArray(middlewares)) {
throw new Error('Mounted tRPC procedure has an unexpected runtime definition.');
}
return { type, middlewares };
};
const routePrefix = (file: string): string => {
const relative = path.relative(routerRoot, file);
const [top] = relative.split(path.sep);
if (!top) throw new Error(`Could not resolve router prefix for ${file}`);
return top.endsWith('.ts') ? path.basename(top, '.ts') : top;
};
const mountedProcedureDefs = new Map(
Object.entries(appRouter._def.procedures).map(
([name, procedure]) => [name, readRuntimeProcedureDef(procedure)] as const
)
);
const mountedMutationNames = (): string[] =>
[...mountedProcedureDefs]
.filter(([, definition]) => definition.type === 'mutation')
.map(([name]) => name)
.sort();
describe('game-api direct mutation journal inventory', () => {
it('requires every router mutation to retain an explicit ownership and realtime classification', () => {
const actual = listTypeScriptFiles(routerRoot)
.flatMap((file) => extractMutationNames(file).map((name) => `${routePrefix(file)}.${name}`))
.sort();
const actual = mountedMutationNames();
const declaredCount = listTypeScriptFiles(routerRoot).reduce(
(total, file) => total + countDeclaredMutations(file),
0
);
const classified = Object.values(classifications).flat().sort();
// Runtime router shape is authoritative for the public path. The raw declaration
// count independently catches mutations that were added to a router but never mounted.
expect(declaredCount).toBe(actual.length);
expect(new Set(classified).size).toBe(classified.length);
expect(classified).toHaveLength(87);
expect(actual).toEqual(classified);
});
it('keeps every mounted mutation authenticated except the two explicit session bootstrap paths', () => {
// auth.status is the smallest mounted procedure that carries the shared
// requireAuthMiddleware. Composed procedures retain the same middleware identity.
const authMiddleware = mountedProcedureDefs.get('auth.status')?.middlewares[0];
expect(authMiddleware).toBeDefined();
const unauthenticated = [...mountedProcedureDefs]
.filter(([, definition]) => definition.type === 'mutation')
.filter(([, definition]) => !definition.middlewares.includes(authMiddleware))
.map(([name]) => name)
.sort();
expect(unauthenticated).toEqual(['auth.exchangeGatewayToken', 'public.recordAccess']);
});
});
@@ -773,6 +773,49 @@ describe('in-game my information ownership', () => {
});
});
it('dispatches vacation for the session-owned general with a stable ENGINE request identity', async () => {
const transaction = vi.fn(async () => {
throw new Error('API transaction must not run');
});
const requestCommand = vi.fn(async () => ({
type: 'vacation' as const,
ok: true as const,
generalId: 17,
}));
const fixture = createContext({
me: buildGeneral({ id: 17, userId: 'user-7' }),
requestCommand,
requestId: 'http-general-vacation',
transaction,
});
await expect(appRouter.createCaller(fixture.context).general.vacation()).resolves.toEqual({ ok: true });
expect(transaction).not.toHaveBeenCalled();
expect(requestCommand).toHaveBeenCalledWith({
type: 'vacation',
requestId: 'http-general-vacation:general.vacation:engine:0:vacation',
userId: 'user-7',
generalId: 17,
});
expect(fixture.db.general.findFirst).toHaveBeenCalledWith({ where: { userId: 'user-7' } });
});
it('maps the authoritative vacation rejection without an API-side mutation', async () => {
const requestCommand = vi.fn(async () => ({
type: 'vacation' as const,
ok: false as const,
generalId: 7,
reason: '자동 턴 사용 중에는 휴가할 수 없습니다.',
}));
const fixture = createContext({ requestCommand });
await expect(appRouter.createCaller(fixture.context).general.vacation()).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: '자동 턴 사용 중에는 휴가할 수 없습니다.',
});
expect(fixture.db.general.update).not.toHaveBeenCalled();
});
it('gets the server-owned pre-start deletion status without accepting a general id', async () => {
const requestCommand = vi.fn(async () => ({
type: 'ensureDieOnPrestartStatus' as const,
+53
View File
@@ -112,6 +112,8 @@ const buildContext = (options: {
configConst?: Record<string, unknown>;
configMap?: Record<string, unknown>;
daemonResult?: TurnDaemonCommandResult;
requestId?: string;
transaction?: ReturnType<typeof vi.fn>;
}) => {
const auth = options.auth === undefined ? buildAuth() : options.auth;
const general = options.general === undefined ? buildGeneral() : options.general;
@@ -171,6 +173,7 @@ const buildContext = (options: {
throw new Error(`Unexpected raw query in inherit router fixture: ${sql}`);
});
const db = {
...(options.transaction ? { $transaction: options.transaction } : {}),
$queryRaw: queryRaw,
worldState: {
findFirst: vi.fn(async () => activeWorldState),
@@ -228,6 +231,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,
@@ -640,6 +644,55 @@ describe('inherit router actor and permission boundaries', () => {
expect(fixture.logCreate).not.toHaveBeenCalled();
});
it('buys a random unique only for the authenticated owner with a stable ENGINE request identity', async () => {
const transaction = vi.fn(async () => {
throw new Error('API transaction must not run');
});
const fixture = buildContext({
auth: buildAuth('user-2'),
general: buildGeneral({ id: 17, userId: 'user-2' }),
requestId: 'http-inherit-random-unique',
transaction,
daemonResult: {
type: 'inheritanceAction',
ok: true,
action: 'buyRandomUnique',
generalId: 17,
remainPoint: 9_000,
},
});
await expect(appRouter.createCaller(fixture.context).inherit.buyRandomUnique()).resolves.toEqual({ ok: true });
expect(transaction).not.toHaveBeenCalled();
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'inheritanceAction',
requestId: 'http-inherit-random-unique:inherit.buyRandomUnique:engine:0:inheritanceAction',
userId: 'user-2',
input: { action: 'buyRandomUnique' },
});
expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled();
});
it('maps a random-unique daemon rejection without applying API-side inheritance changes', async () => {
const fixture = buildContext({
daemonResult: {
type: 'inheritanceAction',
ok: false,
action: 'buyRandomUnique',
code: 'BAD_REQUEST',
reason: '충분한 유산 포인트를 가지고 있지 않습니다.',
},
});
await expect(appRouter.createCaller(fixture.context).inherit.buyRandomUnique()).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: '충분한 유산 포인트를 가지고 있지 않습니다.',
});
expect(fixture.pointUpsert).not.toHaveBeenCalled();
expect(fixture.logCreate).not.toHaveBeenCalled();
});
it('reveals a target owner to the caller without using the caller general id from input', async () => {
const fixture = buildContext({
inheritancePoint: 1500,
@@ -0,0 +1,147 @@
import { existsSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { appRouter } from '../src/router.js';
const workspaceRoot = fileURLToPath(new URL('../../../', import.meta.url));
const manifestPath = path.join(workspaceRoot, 'docs/architecture/game-api-mutation-evidence.tsv');
const columns = [
'route',
'owner_boundary',
'ref_basis',
'actor_source',
'strongest_evidence',
'evidence_path',
'remaining_gap',
] as const;
type Column = (typeof columns)[number];
type ManifestRow = Record<Column, string>;
const allowedOwnerBoundaries = new Set([
'durable-journal',
'engine-owned',
'explicit-no-realtime-consumer',
'external-upload',
'mixed-saga',
'operational',
'read-only-mutation-transport',
'redis-projection',
'separate-access-journal',
'session-only',
]);
const allowedRefBases = new Set(['direct-endpoint', 'domain-command', 'core-only', 'read-only-transport']);
const allowedActorSources = new Set([
'gateway-token-user',
'optional-session-db-general',
'session-admin-role',
'session-user',
'session-user-db-general',
'session-user-engine-general',
]);
const allowedEvidenceLevels = new Set(['dynamic-ref', 'actual-db', 'redis', 'endpoint-unit', 'source-only']);
const expectedOwnerCounts: Record<string, number> = {
'durable-journal': 19,
'engine-owned': 38,
'explicit-no-realtime-consumer': 7,
'external-upload': 1,
'mixed-saga': 9,
operational: 3,
'read-only-mutation-transport': 2,
'redis-projection': 6,
'separate-access-journal': 1,
'session-only': 1,
};
const parseManifest = (): ManifestRow[] => {
const [header, ...lines] = readFileSync(manifestPath, 'utf8').trimEnd().split(/\r?\n/u);
if (header !== columns.join('\t')) {
throw new Error(`Unexpected mutation evidence manifest header: ${header ?? '<empty>'}`);
}
return lines.map((line, index) => {
const values = line.split('\t');
if (values.length !== columns.length || values.some((value) => value.length === 0)) {
throw new Error(`Invalid mutation evidence row at line ${index + 2}.`);
}
return Object.fromEntries(columns.map((column, valueIndex) => [column, values[valueIndex]])) as ManifestRow;
});
};
interface RuntimeProcedureDef {
type: string;
}
const readRuntimeProcedureDef = (procedure: unknown): RuntimeProcedureDef => {
if (typeof procedure !== 'function') {
throw new Error('Mounted tRPC procedure is not callable.');
}
const definition: unknown = Reflect.get(procedure, '_def');
if (typeof definition !== 'object' || definition === null) {
throw new Error('Mounted tRPC procedure has no runtime definition.');
}
const type: unknown = Reflect.get(definition, 'type');
if (typeof type !== 'string') {
throw new Error('Mounted tRPC procedure has an unexpected runtime definition.');
}
return { type };
};
const mountedMutationNames = (): string[] =>
Object.entries(appRouter._def.procedures)
.filter(([, procedure]) => readRuntimeProcedureDef(procedure).type === 'mutation')
.map(([name]) => name)
.sort();
describe('game-api mutation evidence manifest', () => {
it('lists every mounted mutation exactly once', () => {
const rows = parseManifest();
const manifestRoutes = rows.map(({ route }) => route);
expect(rows).toHaveLength(87);
expect(new Set(manifestRoutes).size).toBe(manifestRoutes.length);
expect(manifestRoutes).toEqual([...manifestRoutes].sort());
expect(manifestRoutes).toEqual(mountedMutationNames());
});
it('retains the bounded ownership taxonomy and allowed evidence vocabulary', () => {
const rows = parseManifest();
const ownerCounts = Object.fromEntries(
[...allowedOwnerBoundaries].map((owner) => [
owner,
rows.filter(({ owner_boundary }) => owner_boundary === owner).length,
])
);
expect(ownerCounts).toEqual(expectedOwnerCounts);
for (const row of rows) {
expect(allowedOwnerBoundaries.has(row.owner_boundary), row.route).toBe(true);
expect(allowedRefBases.has(row.ref_basis), row.route).toBe(true);
expect(allowedActorSources.has(row.actor_source), row.route).toBe(true);
expect(allowedEvidenceLevels.has(row.strongest_evidence), row.route).toBe(true);
expect(row.remaining_gap, row.route).toMatch(/^[a-z0-9-]+$/u);
expect(row.evidence_path.startsWith('app/') || row.evidence_path.startsWith('tools/')).toBe(true);
expect(existsSync(path.join(workspaceRoot, row.evidence_path)), row.route).toBe(true);
}
});
it('records the only unauthenticated mutation boundaries explicitly', () => {
const rowsByRoute = new Map(parseManifest().map((row) => [row.route, row]));
expect(rowsByRoute.get('auth.exchangeGatewayToken')?.actor_source).toBe('gateway-token-user');
expect(rowsByRoute.get('public.recordAccess')?.actor_source).toBe('optional-session-db-general');
const otherPublicActors = [...rowsByRoute]
.filter(([route]) => route !== 'auth.exchangeGatewayToken' && route !== 'public.recordAccess')
.filter(([, row]) => !row.actor_source.startsWith('session-'))
.map(([route]) => route);
expect(otherPublicActors).toEqual([]);
});
});
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import type { GameApiContext } from '../src/context.js';
import { appRouter } from '../src/router.js';
const context = {
auth: null,
generalAccessTracking: true,
db: {},
profile: { id: 'che', name: 'che:default', scenario: 'default' },
profileStatusSource: { get: async () => 'RUNNING' as const },
} as unknown as GameApiContext;
describe('public.recordAccess endpoint', () => {
it('keeps anonymous page telemetry as an accepted no-op outside input_event', async () => {
await expect(appRouter.createCaller(context).public.recordAccess({ page: 'traffic' })).resolves.toEqual({
recorded: false,
});
});
it('rejects page names outside the server-owned Ref access inventory', async () => {
await expect(
appRouter.createCaller(context).public.recordAccess({ page: 'forged-page' } as never)
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
});
});
+105
View File
@@ -397,6 +397,111 @@ describe('tournament router permissions and mutations', () => {
await expect(adminCaller.tournament.getAdminStatus()).resolves.toEqual({ ok: true });
});
it('applies the admin role boundary to every tournament mutation', async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
const general = buildGeneral(1, 'user-1');
const caller = appRouter.createCaller(
buildContext({ redis, transport, generals: [general], userId: 'user-1', roles: ['user'] })
);
const state = {
stage: 1,
phase: 0,
type: 0,
auto: false,
openYear: 193,
openMonth: 1,
termSeconds: 60,
nextAt: '2026-07-26T01:00:00.000Z',
};
await expect(caller.tournament.setState(state)).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(caller.tournament.patchState({ phase: 1 })).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(caller.tournament.setParticipants([])).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(caller.tournament.setMatches([])).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(caller.tournament.setBettingEntries([])).rejects.toMatchObject({ code: 'FORBIDDEN' });
await expect(caller.tournament.seedParticipants({ generalIds: [general.id] })).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await expect(caller.tournament.cancel()).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('validates and executes every profile-scoped tournament admin mutation', async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
const general = buildGeneral(1, 'user-1');
const rival = buildGeneral(2, 'user-2');
const caller = appRouter.createCaller(
buildContext({
redis,
transport,
generals: [general, rival],
userId: 'user-1',
roles: ['admin.tournament:che:default'],
})
);
const state = {
stage: 6,
phase: 0,
type: 0,
auto: false,
openYear: 193,
openMonth: 1,
termSeconds: 60,
nextAt: '2026-07-26T01:00:00.000Z',
bettingCloseAt: '2099-01-01T00:00:00.000Z',
};
await expect(caller.tournament.setState(state)).resolves.toEqual({ ok: true });
await expect(caller.tournament.patchState({ phase: 2 })).resolves.toEqual({ ok: true });
await expect(
caller.tournament.setParticipants([
{
id: general.id,
name: general.name,
leadership: general.leadership,
strength: general.strength,
intel: general.intel,
level: 5,
groupId: 0,
},
])
).resolves.toEqual({ ok: true, count: 1 });
await expect(
caller.tournament.setMatches([
{
id: 1,
stage: 7,
roundIndex: 0,
attackerId: general.id,
defenderId: rival.id,
},
])
).resolves.toEqual({ ok: true, count: 1 });
await expect(
caller.tournament.setBettingEntries([
{ generalId: general.id, targetId: rival.id, amount: 100 },
])
).resolves.toEqual({ ok: true, count: 1 });
await expect(caller.tournament.seedParticipants({ generalIds: [general.id, rival.id] })).resolves.toEqual({
ok: true,
count: 2,
});
await expect(caller.tournament.cancel()).resolves.toEqual({ ok: true });
expect(transport.commands).toContainEqual({
type: 'tournamentRefund',
refunds: [{ generalId: general.id, amount: 100 }],
reason: 'cancel',
});
await expect(caller.tournament.getState()).resolves.toMatchObject({ stage: 0, phase: 0, auto: false });
await expect(caller.tournament.getSnapshot()).resolves.toMatchObject({
participants: [],
matches: [],
betCount: 0,
});
});
it('returns the legacy tournament rank ordering only to a user who owns a general', async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
+91
View File
@@ -324,6 +324,97 @@ describe('troop router permissions and mutations', () => {
});
});
it('joins a troop with the session-owned general and a stable ENGINE request identity', async () => {
const transaction = vi.fn(async () => {
throw new Error('API transaction must not run');
});
const fixture = buildContext({
me: buildGeneral({ id: 17, userId: 'user-1', troopId: 0 }),
requestId: 'http-troop-join',
transaction,
result: { type: 'troopJoin', ok: true, generalId: 17, troopId: 9 },
});
const input = { troopId: 9, userId: 'forged-user', generalId: 999 };
await expect(appRouter.createCaller(fixture.context).troop.join(input)).resolves.toEqual({ ok: true });
expect(transaction).not.toHaveBeenCalled();
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'troopJoin',
requestId: 'http-troop-join:troop.join:engine:0:troopJoin',
userId: 'user-1',
generalId: 17,
troopId: 9,
});
});
it('maps an authoritative troop-join rejection without trusting client actor fields', async () => {
const fixture = buildContext({
me: buildGeneral({ id: 17, userId: 'user-1', troopId: 0 }),
result: {
type: 'troopJoin',
ok: false,
generalId: 17,
troopId: 9,
reason: '다른 국가의 부대입니다.',
},
});
const input = { troopId: 9, userId: 'forged-user', generalId: 999 };
await expect(appRouter.createCaller(fixture.context).troop.join(input)).rejects.toMatchObject({
code: 'PRECONDITION_FAILED',
message: '다른 국가의 부대입니다.',
});
expect(fixture.requestCommand).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'user-1', generalId: 17, troopId: 9 })
);
});
it('exits a troop with the session-owned general and a stable ENGINE request identity', async () => {
const transaction = vi.fn(async () => {
throw new Error('API transaction must not run');
});
const fixture = buildContext({
me: buildGeneral({ id: 17, userId: 'user-1', troopId: 9 }),
requestId: 'http-troop-exit',
transaction,
result: { type: 'troopExit', ok: true, generalId: 17, wasLeader: false },
});
await expect(appRouter.createCaller(fixture.context).troop.exit()).resolves.toEqual({
ok: true,
wasLeader: false,
});
expect(transaction).not.toHaveBeenCalled();
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'troopExit',
requestId: 'http-troop-exit:troop.exit:engine:0:troopExit',
userId: 'user-1',
generalId: 17,
});
});
it('maps an authoritative troop-exit rejection for the session-owned general', async () => {
const fixture = buildContext({
me: buildGeneral({ id: 17, userId: 'user-1', troopId: 0 }),
result: {
type: 'troopExit',
ok: false,
generalId: 17,
reason: '부대에 소속되어 있지 않습니다.',
},
});
await expect(appRouter.createCaller(fixture.context).troop.exit()).rejects.toMatchObject({
code: 'PRECONDITION_FAILED',
message: '부대에 소속되어 있지 않습니다.',
});
expect(fixture.requestCommand).toHaveBeenCalledWith({
type: 'troopExit',
userId: 'user-1',
generalId: 17,
});
});
it('maps an ENGINE actor-binding rejection to a forbidden API response', async () => {
const fixture = buildContext({
result: {