fix: 공개 API와 토너먼트의 사용자·장수 권한 경계를 강화한다

This commit is contained in:
2026-10-10 01:56:31 +09:00
parent 5771701007
commit 4e668b9fb6
31 changed files with 889 additions and 238 deletions
@@ -387,7 +387,7 @@ describe('general access tracking', () => {
db,
generalAccessTracking: true,
requestId: 'access-boundary-test',
profile: { id: 'che:default', name: 'che' },
profile: { id: 'che', name: 'che:default', scenario: 'default' },
profileStatusSource,
} as unknown as GameApiContext;
@@ -26,7 +26,7 @@ const buildContext = (executeRaw = vi.fn(async (_query: unknown) => 1), token =
auth: token,
db: { $executeRaw: executeRaw },
generalAccessTracking: true,
profile: { id: 'che:default', name: 'che', scenario: 'default' },
profile: { id: 'che', name: 'che:default', scenario: 'default' },
}) as unknown as GameApiContext;
const activityRouter = router({
@@ -31,6 +31,7 @@ const buildContext = (
) =>
({
auth: options.auth === undefined ? auth : options.auth,
profile: { id: 'che', name: 'che:default', scenario: 'default' },
db: {
general: {
findFirst: vi.fn(async () => ({
@@ -36,6 +36,7 @@ type LogQuery = {
const buildContext = (findMany: (query: LogQuery) => Promise<Array<{ id: number; text: string }>>) =>
({
auth,
profile: { id: 'che', name: 'che:default', scenario: 'default' },
db: {
general: {
findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => ({
+2 -2
View File
@@ -20,7 +20,7 @@ const profile: GameProfile = {
const auth: GameSessionTokenPayload = {
version: 1,
profile: 'che',
profile: 'che:default',
issuedAt: '2026-07-26T00:00:00.000Z',
expiresAt: '2026-07-27T00:00:00.000Z',
sessionId: 'ranking-session',
@@ -239,7 +239,7 @@ const buildContext = (options?: {
turnDaemon: new InMemoryTurnDaemonTransport(),
battleSim: new InMemoryBattleSimTransport(),
profile: selectedProfile,
auth: options?.authenticated === false ? null : auth,
auth: options?.authenticated === false ? null : { ...auth, profile: selectedProfile.name },
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
@@ -70,6 +70,7 @@ const envKeys = [
'GATEWAY_REDIS_PREFIX',
'GATEWAY_INTERNAL_API_URL',
'GAME_UPLOAD_DIR',
'API_ALLOWED_ORIGINS',
] as const;
const originalEnv = new Map(envKeys.map((key) => [key, process.env[key]]));
@@ -642,6 +643,7 @@ integration('game API security over HTTP transport', () => {
process.env.GATEWAY_REDIS_PREFIX = redisPrefix;
process.env.GATEWAY_INTERNAL_API_URL = await listenGatewayStatusStub();
process.env.GAME_UPLOAD_DIR = uploadDir;
process.env.API_ALLOWED_ORIGINS = 'https://sam.hided.net';
const connector = createGamePostgresConnector({ url: dedicatedTarget.databaseUrl });
await connector.connect();
@@ -3774,6 +3776,58 @@ integration('game API security over HTTP transport', () => {
}
}, 15_000);
it('rejects an untrusted browser Origin before an authenticated logout mutation', async () => {
const token = await createAccessToken('origin-mutation', {});
const response = await fetch(`${baseUrl}/trpc/auth.logout`, {
method: 'POST',
headers: {
authorization: `Bearer ${token}`,
origin: 'https://untrusted.example',
'content-type': 'application/json',
},
body: JSON.stringify({}),
});
expect(response.status).toBe(403);
expect(response.headers.get('access-control-allow-origin')).toBeNull();
expect(await accessTokenStore.get(token)).not.toBeNull();
for (const origin of ['https://sam.hided.net', 'https://untrusted.example', 'null']) {
const preflight = await fetch(`${baseUrl}/trpc/auth.logout`, {
method: 'OPTIONS',
headers: {
origin,
'access-control-request-method': 'POST',
'access-control-request-headers': 'authorization,content-type',
},
});
expect(preflight.status).toBe(origin === 'https://sam.hided.net' ? 204 : 403);
expect(preflight.headers.get('access-control-allow-origin')).toBe(
origin === 'https://sam.hided.net' ? origin : null
);
}
await accessTokenStore.revoke(token);
});
it('rejects a same-namespace foreign-profile token over tRPC before any DB mutation', async () => {
const foreign = await accessTokenStore.create({
...buildPayload('trpc-foreign', {}),
profile: 'other:default',
});
if (!foreign) throw new Error('Foreign token fixture failed');
const before = await readReservedMutationState();
try {
const response = await fetch(
`${baseUrl}/trpc/turns.reserved.getGeneral?input=${encodeURIComponent(JSON.stringify({ generalId }))}`,
{
headers: { authorization: `Bearer ${foreign.accessToken}` },
}
);
expect(response.status).toBe(401);
expect(await readReservedMutationState()).toEqual(before);
} finally {
await accessTokenStore.revoke(foreign.accessToken);
}
});
it('SSE accepts header auth and rejects query or cross-profile credentials', async () => {
const token = await createAccessToken('sse-query', {});
const queryOnly = await fetch(`${baseUrl}${server!.config.eventsPath}?token=${encodeURIComponent(token)}`);
+99 -35
View File
@@ -76,35 +76,21 @@ class TournamentTransport implements TurnDaemonTransport {
async requestCommand(command: TurnDaemonCommand): Promise<TurnDaemonCommandResult | null> {
this.commands.push(command);
if (command.type === 'adjustGeneralResources') {
const adjustment = command.adjustments[0];
if (!adjustment) {
return { type: 'adjustGeneralResources', ok: false, reason: '조정 대상이 없습니다.' };
if (command.type === 'tournamentAdjustGeneral') {
if (command.betGoldDelta !== undefined && this.failNextRankUpdate) {
this.failNextRankUpdate = false;
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: 'rank update failed' };
}
const nextGold = (this.gold.get(adjustment.generalId) ?? 0) + (adjustment.goldDelta ?? 0);
if (nextGold < (adjustment.minGoldAfter ?? 0)) {
return { type: 'adjustGeneralResources', ok: false, reason: '자원이 부족합니다.' };
const nextGold = (this.gold.get(command.generalId) ?? 0) + command.goldDelta;
if (nextGold < command.minGoldAfter) {
return { type: command.type, ok: false, code: 'BAD_REQUEST', reason: '자원이 부족합니다.' };
}
this.gold.set(adjustment.generalId, nextGold);
return {
type: 'adjustGeneralResources',
ok: true,
processed: 1,
missing: 0,
totalGoldDelta: adjustment.goldDelta ?? 0,
totalRiceDelta: 0,
};
this.gold.set(command.generalId, nextGold);
return { type: command.type, ok: true, generalId: command.generalId };
}
if (command.type === 'setMySetting') {
return { type: 'setMySetting', ok: true, generalId: command.generalId };
}
if (command.type === 'adjustGeneralMeta') {
if (this.failNextRankUpdate) {
this.failNextRankUpdate = false;
return { type: 'adjustGeneralMeta', ok: false, reason: 'rank update failed' };
}
return { type: 'adjustGeneralMeta', ok: true, processed: 1, missing: 0 };
}
return null;
}
@@ -404,12 +390,12 @@ describe('tournament router permissions and mutations', () => {
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
expect(transport.gold.get(general.id)).toBe(1_800);
expect(transport.commands.filter((command) => command.type === 'adjustGeneralResources')).toHaveLength(1);
expect(transport.commands.filter((command) => command.type === 'tournamentAdjustGeneral')).toHaveLength(1);
expect(transport.commands).toContainEqual(
expect.objectContaining({
type: 'adjustGeneralResources',
type: 'tournamentAdjustGeneral',
requestId: 'http:tournament-join:tournamentJoin:resources',
reason: 'tournamentJoin',
userId: 'user-1',
})
);
expect(outerApiTransaction).not.toHaveBeenCalled();
@@ -459,9 +445,12 @@ describe('tournament router permissions and mutations', () => {
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
expect(transport.gold.get(general.id)).toBe(1_936);
expect(transport.commands).toContainEqual({
type: 'adjustGeneralResources',
type: 'tournamentAdjustGeneral',
userId: 'user-1',
reason: 'tournamentJoin',
adjustments: [{ generalId: general.id, goldDelta: -64, minGoldAfter: 0 }],
generalId: general.id,
goldDelta: -64,
minGoldAfter: 0,
});
});
@@ -534,21 +523,96 @@ describe('tournament router permissions and mutations', () => {
expect((await caller.tournament.getBettingSummary()).myAmount).toBe(600);
expect(transport.commands).toContainEqual(
expect.objectContaining({
type: 'adjustGeneralResources',
type: 'tournamentAdjustGeneral',
requestId: 'http:suspended-tournament-bet:tournamentBet:resources',
reason: 'tournamentBet',
userId: 'user-1',
})
);
expect(outerApiTransaction).not.toHaveBeenCalled();
expect(transport.commands).toContainEqual(
expect.objectContaining({
type: 'adjustGeneralMeta',
requestId: 'http:suspended-tournament-bet:tournamentBet:rank',
reason: 'tournamentBet',
type: 'tournamentAdjustGeneral',
userId: 'user-1',
betGoldDelta: 600,
})
);
});
it("keeps the per-user cap across possession and separates a previous owner's bets", async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
const general = buildGeneral(1, 'user-2', 3_000);
transport.gold.set(general.id, general.gold);
await setTournamentFixture(redis, {
stage: 6,
phase: 0,
type: 0,
auto: true,
openYear: 193,
openMonth: 1,
termSeconds: 60,
nextAt: '2026-07-26T01:00:00.000Z',
bettingCloseAt: '2099-01-01T00:00:00.000Z',
});
await redis.set(
'sammo:che:default:tournament:betting',
JSON.stringify([
{ generalId: 1, userId: 'user-1', targetId: 11, amount: 900 },
{ generalId: 99, userId: 'user-2', targetId: 12, amount: 800 },
{ generalId: 98, userId: null, targetId: 11, amount: 100 },
])
);
const caller = appRouter.createCaller(
buildContext({ redis, transport, generals: [general], userId: 'user-2' })
);
expect(await caller.tournament.getBettingSummary()).toMatchObject({
myAmount: 800,
myTotals: { 12: 800 },
totalAmount: 1_800,
});
await expect(caller.tournament.placeBet({ targetId: 11, amount: 300 })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
expect(transport.commands).toHaveLength(0);
await expect(caller.tournament.placeBet({ targetId: 11, amount: 200 })).resolves.toEqual({ ok: true });
expect(await caller.tournament.getBettingSummary()).toMatchObject({
myAmount: 1_000,
myTotals: { 11: 200, 12: 800 },
});
expect(transport.gold.get(1)).toBe(2_800);
});
it('does not infer a legacy bet actor from the current general owner', async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
const general = buildGeneral(1, 'user-2', 3_000);
transport.gold.set(1, 3_000);
await setTournamentFixture(redis, {
stage: 6,
phase: 0,
type: 0,
auto: true,
openYear: 193,
openMonth: 1,
termSeconds: 60,
nextAt: '2026-07-26T01:00:00.000Z',
bettingCloseAt: '2099-01-01T00:00:00.000Z',
});
await redis.set(
'sammo:che:default:tournament:betting',
JSON.stringify([{ generalId: 1, targetId: 11, amount: 900 }])
);
const caller = appRouter.createCaller(
buildContext({ redis, transport, generals: [general], userId: 'user-2' })
);
expect(await caller.tournament.getBettingSummary()).toMatchObject({ myAmount: 0, totalAmount: 900 });
await expect(caller.tournament.placeBet({ targetId: 11, amount: 10 })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
expect(transport.commands).toHaveLength(0);
expect(transport.gold.get(1)).toBe(3_000);
});
it('rejects a tournament bet during reconciliation without debiting gold', async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
@@ -796,7 +860,7 @@ describe('tournament router permissions and mutations', () => {
expect(snapshot.sourceRevision).toBe('41');
});
it('refunds gold when the tournament bet rank update fails', async () => {
it('does not debit gold when the atomic tournament adjustment is rejected', async () => {
const redis = new MemoryRedis();
const transport = new TournamentTransport();
const general = buildGeneral(1, 'user-1', 2_000);
@@ -818,7 +882,7 @@ describe('tournament router permissions and mutations', () => {
);
await expect(caller.tournament.placeBet({ targetId: 11, amount: 100 })).rejects.toMatchObject({
code: 'INTERNAL_SERVER_ERROR',
code: 'BAD_REQUEST',
});
expect(transport.gold.get(general.id)).toBe(2_000);
await expect(caller.tournament.getBettingSummary()).resolves.toMatchObject({
+55 -30
View File
@@ -53,7 +53,15 @@ const isRecord = (value: unknown): value is Record<string, unknown> => typeof va
const createNoopDaemonTransport = (): TurnDaemonTransport => ({
sendCommand: async () => 'ok',
requestCommand: async () => null,
requestCommand: async (command) =>
command.type === 'tournamentSeedNpcBets'
? {
type: command.type,
ok: true,
processedGeneralIds: command.bets.map((bet) => bet.generalId),
skippedGeneralIds: [],
}
: null,
requestStatus: async () => null,
});
@@ -440,10 +448,10 @@ describe('tournament worker (in-memory)', () => {
const commands: TurnDaemonCommand[] = [];
const daemonTransport: TurnDaemonTransport = {
...createNoopDaemonTransport(),
sendCommand: async (command) => {
requestCommand: async (command) => {
expect((await store.getState())?.stage).toBe(5);
commands.push(command);
return 'ok';
return createNoopDaemonTransport().requestCommand(command);
},
};
const opened = await applyPreBattleStage(store, prisma, state, 'opening-seed', daemonTransport);
@@ -455,21 +463,14 @@ describe('tournament worker (in-memory)', () => {
expect(new Set(bets.slice(1).map((bet) => bet.targetId)).size).toBeGreaterThan(1);
expect(commands).toEqual([
{
type: 'adjustGeneralResources',
requestId: 'tournament:123:npc-bet:resources',
reason: 'tournamentNpcBet',
adjustments: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, goldDelta: -amount })),
},
{
type: 'adjustGeneralMeta',
requestId: 'tournament:123:npc-bet:meta',
reason: 'tournamentNpcBet',
adjustments: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, metaDelta: { betgold: amount } })),
type: 'tournamentSeedNpcBets',
requestId: 'tournament:123:npc-bet:atomic',
bets: [3, 4, 5, 6, 7].map((generalId) => ({ generalId, amount })),
},
]);
await seedNpcBets({ prisma, store, state: opened, baseSeed: 'opening-seed', daemonTransport });
expect(await store.getBettingEntries()).toEqual(bets);
expect(commands).toHaveLength(2);
expect(commands).toHaveLength(1);
// A fresh projection with the same seed and DB inputs has the same choices.
await store.setBettingEntries([bets[0]!]);
await seedNpcBets({
@@ -482,6 +483,32 @@ describe('tournament worker (in-memory)', () => {
expect(await store.getBettingEntries()).toEqual(bets);
});
it('publishes only NPC bets acknowledged after the current-owner check', async () => {
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-owned-after-plan'));
const state = createTournamentState({
stage: 5,
bettingId: 789,
npcBettingPlan: [
{ generalId: 3, userId: null, targetId: 11, amount: 10 },
{ generalId: 4, userId: null, targetId: 12, amount: 10 },
],
});
await store.setState(state);
await store.setMatches([{ id: 1, stage: 7, roundIndex: 0, attackerId: 11, defenderId: 12 }]);
const transport: TurnDaemonTransport = {
...createNoopDaemonTransport(),
requestCommand: async (command) => {
expect(command.type).toBe('tournamentSeedNpcBets');
expect((await store.getState())?.stage).toBe(5);
expect(await store.getBettingEntries()).toEqual([]);
return { type: 'tournamentSeedNpcBets', ok: true, processedGeneralIds: [4], skippedGeneralIds: [3] };
},
};
const opened = await applyPreBattleStage(store, createPrismaMock({}), state, 'seed', transport);
expect(opened.stage).toBe(6);
expect(await store.getBettingEntries()).toEqual([{ generalId: 4, userId: null, targetId: 12, amount: 10 }]);
});
it('retries an opening query failure without advancing stage or changing the betting identity', async () => {
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-opening-retry'));
const state = createTournamentState({ stage: 5 });
@@ -509,7 +536,7 @@ describe('tournament worker (in-memory)', () => {
expect(await store.getBettingEntries()).toHaveLength(1);
});
it('reuses durable command identities after a partial enqueue failure', async () => {
it('reuses durable command identities after an uncertain atomic command response', async () => {
const store = new TournamentStore(new MemoryRedis(), buildTournamentKeys('npc-enqueue-retry'));
const state = createTournamentState({ stage: 5, bettingId: 456 });
await store.setState(state);
@@ -523,15 +550,15 @@ describe('tournament worker (in-memory)', () => {
let fail = true;
const transport: TurnDaemonTransport = {
...createNoopDaemonTransport(),
sendCommand: async (command) => {
requestCommand: async (command) => {
commands.push(command);
if (command.type === 'adjustGeneralMeta' && fail) {
if (command.type === 'tournamentSeedNpcBets' && fail) {
fail = false;
// The accepted debit may already have committed before retry.
prisma.general.findMany = async () => [];
throw new Error('enqueue unavailable');
}
return 'ok';
return createNoopDaemonTransport().requestCommand(command);
},
};
await expect(applyPreBattleStage(store, prisma, state, 'seed', transport)).rejects.toThrow(
@@ -539,16 +566,14 @@ describe('tournament worker (in-memory)', () => {
);
expect(await store.getState()).toMatchObject({
stage: 5,
npcBettingPlan: [{ generalId: 3, targetId: expect.any(Number), amount: 10 }],
npcBettingPlan: [{ generalId: 3, userId: null, targetId: expect.any(Number), amount: 10 }],
});
expect(await store.getBettingEntries()).toEqual([]);
await applyPreBattleStage(store, prisma, (await store.getState())!, 'seed', transport);
expect(commands.slice(2)).toEqual(commands.slice(0, 2));
expect(commands.slice(1)).toEqual(commands.slice(0, 1));
expect(commands.map((command) => command.requestId)).toEqual([
'tournament:456:npc-bet:resources',
'tournament:456:npc-bet:meta',
'tournament:456:npc-bet:resources',
'tournament:456:npc-bet:meta',
'tournament:456:npc-bet:atomic',
'tournament:456:npc-bet:atomic',
]);
expect(await store.getBettingEntries()).toHaveLength(1);
});
@@ -577,7 +602,10 @@ describe('tournament worker (in-memory)', () => {
commands.push(command);
return 'ok';
},
requestCommand: async () => null,
requestCommand: async (command) => {
commands.push(command);
return createNoopDaemonTransport().requestCommand(command);
},
requestStatus: async () => null,
};
@@ -598,11 +626,8 @@ describe('tournament worker (in-memory)', () => {
expect(rankedGeneralIds.size).toBeGreaterThanOrEqual(10);
expect(commands).toContainEqual(
expect.objectContaining({
type: 'adjustGeneralMeta',
reason: 'tournamentNpcBet',
adjustments: expect.arrayContaining([
expect.objectContaining({ metaDelta: { betgold: expect.any(Number) } }),
]),
type: 'tournamentSeedNpcBets',
bets: expect.arrayContaining([expect.objectContaining({ amount: expect.any(Number) })]),
})
);
}