test: 실제 Game API 명령 전수 근거를 고정한다
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user