운영 보안을 보존하여 빌드 개선 배포본 통합
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import { PassThrough } from 'node:stream';
|
||||
import type { ServerResponse } from 'node:http';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import { AuthenticatedRealtimeConnection } from '../src/realtime/authenticatedConnection.js';
|
||||
|
||||
const payload = (): GameSessionTokenPayload => ({
|
||||
version: 1,
|
||||
sessionId: 'test-session',
|
||||
profile: 'che:default',
|
||||
issuedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
user: {
|
||||
id: 'test-user',
|
||||
username: 'test',
|
||||
displayName: 'test',
|
||||
roles: ['user'],
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
sanctions: {},
|
||||
});
|
||||
const fixture = (resolve: () => Promise<GameSessionTokenPayload | null>, auth = payload()) => {
|
||||
const response = new PassThrough();
|
||||
const frames: string[] = [];
|
||||
response.on('data', (chunk) => frames.push(String(chunk)));
|
||||
const connection = new AuthenticatedRealtimeConnection(auth, response as unknown as ServerResponse, resolve);
|
||||
return { response, frames, connection };
|
||||
};
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('authenticated realtime connection', () => {
|
||||
it('does not forward an in-flight result after the token is revoked during its work', async () => {
|
||||
const auth = payload();
|
||||
let current: GameSessionTokenPayload | null = auth;
|
||||
const { connection, frames } = fixture(async () => current, auth);
|
||||
connection.enqueue(async () => {
|
||||
current = null;
|
||||
return 'private invalidation';
|
||||
});
|
||||
await vi.waitFor(() => expect(connection.closed).toBe(true));
|
||||
expect(frames).toEqual([]);
|
||||
});
|
||||
it.each(['sessionId', 'profile'] as const)('rejects a replacement %s even for the same user', async (field) => {
|
||||
const auth = payload();
|
||||
const { connection, frames } = fixture(async () => ({ ...auth, [field]: 'replacement' }), auth);
|
||||
connection.enqueue(async () => 'private invalidation');
|
||||
await vi.waitFor(() => expect(connection.closed).toBe(true));
|
||||
expect(frames).toEqual([]);
|
||||
});
|
||||
it('fails closed when authentication storage fails', async () => {
|
||||
const { connection, frames } = fixture(async () => {
|
||||
throw new Error('redis unavailable');
|
||||
});
|
||||
connection.enqueue(async () => 'private invalidation');
|
||||
await vi.waitFor(() => expect(connection.closed).toBe(true));
|
||||
expect(frames).toEqual([]);
|
||||
});
|
||||
it('ends an idle expired stream and releases subscriptions only once', async () => {
|
||||
vi.useFakeTimers();
|
||||
const auth = payload();
|
||||
const { connection } = fixture(async () => auth, auth);
|
||||
const cleanup = vi.fn();
|
||||
connection.onClose(cleanup);
|
||||
await vi.advanceTimersByTimeAsync(60_001);
|
||||
expect(connection.closed).toBe(true);
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
connection.close();
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('bounds queued work while an earlier event is blocked', async () => {
|
||||
const auth = payload();
|
||||
const { connection, frames } = fixture(async () => auth, auth);
|
||||
let release!: () => void;
|
||||
const waiting = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
connection.enqueue(async () => {
|
||||
await waiting;
|
||||
return 'private invalidation';
|
||||
});
|
||||
for (let i = 0; i < 100; i += 1) connection.enqueue(async () => 'private invalidation');
|
||||
expect(connection.closed).toBe(true);
|
||||
release();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(frames).toEqual([]);
|
||||
});
|
||||
it('ends a slow socket instead of retaining further frames', async () => {
|
||||
const auth = payload();
|
||||
const { connection, response } = fixture(async () => auth, auth);
|
||||
response.write = vi.fn(() => false);
|
||||
connection.enqueue(async () => 'event: ping\ndata: {}\n\n');
|
||||
await vi.waitFor(() => expect(connection.closed).toBe(true));
|
||||
expect(response.writableEnded).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -288,6 +288,81 @@ describe('board router actor, nation, and secret permissions', () => {
|
||||
expect(result).toMatchObject({ width: 64, height: 48, format: 'webp', animated: false });
|
||||
});
|
||||
|
||||
it('rejects highly compressed images over the decoded pixel budget before upload', async () => {
|
||||
const upload = vi.fn(async () => ({ publicUrl: 'https://image.example/image.webp' }));
|
||||
const fixture = buildContext({ me: buildGeneral({ officerLevel: 5 }), contentImageUpload: { upload } });
|
||||
const png = await sharp({ create: { width: 4096, height: 4097, channels: 3, background: '#224466' } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
expect(png.length).toBeLessThan(1024 * 1024);
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).board.uploadImage({
|
||||
dataUrl: `data:image/png;base64,${png.toString('base64')}`,
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects excess concurrent image work and recovers when uploads settle', async () => {
|
||||
const pending: Array<() => void> = [];
|
||||
const upload = vi.fn(
|
||||
() =>
|
||||
new Promise<{ publicUrl: string }>((resolve) => {
|
||||
pending.push(() => resolve({ publicUrl: 'https://image.example/image.webp' }));
|
||||
})
|
||||
);
|
||||
const fixture = buildContext({ me: buildGeneral({ officerLevel: 5 }), contentImageUpload: { upload } });
|
||||
const png = await sharp({ create: { width: 64, height: 48, channels: 3, background: '#224466' } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
const caller = appRouter.createCaller(fixture.context);
|
||||
const input = { dataUrl: `data:image/png;base64,${png.toString('base64')}` };
|
||||
const admitted = Array.from({ length: 4 }, () => caller.board.uploadImage(input));
|
||||
try {
|
||||
await vi.waitFor(() => expect(upload).toHaveBeenCalledTimes(4));
|
||||
await expect(caller.board.uploadImage(input)).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
|
||||
} finally {
|
||||
pending.forEach((done) => done());
|
||||
await Promise.all(admitted);
|
||||
}
|
||||
upload.mockImplementation(async () => ({ publicUrl: 'https://image.example/image.webp' }));
|
||||
await expect(caller.board.uploadImage(input)).resolves.toMatchObject({ format: 'webp' });
|
||||
});
|
||||
|
||||
it('rejects SVG before a native image parser or upload is used', async () => {
|
||||
const upload = vi.fn();
|
||||
const fixture = buildContext({ me: buildGeneral({ officerLevel: 5 }), contentImageUpload: { upload } });
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).board.uploadImage({
|
||||
dataUrl: Buffer.from('<svg width="64" height="64"><script>alert(1)</script></svg>').toString('base64'),
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects excessive animation frames while preserving ordinary GIF animation', async () => {
|
||||
const upload = vi.fn(async () => ({ publicUrl: 'https://image.example/image.webp' }));
|
||||
const fixture = buildContext({ me: buildGeneral({ officerLevel: 5 }), contentImageUpload: { upload } });
|
||||
const caller = appRouter.createCaller(fixture.context);
|
||||
const makeGif = (pages: number) =>
|
||||
sharp(Buffer.alloc(pages * 4, 255), {
|
||||
raw: { width: 1, height: pages, channels: 4, pageHeight: 1 },
|
||||
})
|
||||
.gif({ keepDuplicateFrames: true, delay: Array(pages).fill(10) })
|
||||
.toBuffer();
|
||||
const excessive = await makeGif(257);
|
||||
expect((await sharp(excessive, { animated: true }).metadata()).pages).toBe(257);
|
||||
await expect(caller.board.uploadImage({ dataUrl: excessive.toString('base64') })).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
});
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
const ordinary = await makeGif(3);
|
||||
await expect(caller.board.uploadImage({ dataUrl: ordinary.toString('base64') })).resolves.toMatchObject({
|
||||
animated: true,
|
||||
format: 'webp',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves an AVIF editor image when resizing is unnecessary', async () => {
|
||||
const upload = vi.fn(async ({ filename }: { filename: string }) => ({
|
||||
publicUrl: `https://sam-image.hided.net/uploads/core2026/${filename}`,
|
||||
|
||||
@@ -68,6 +68,7 @@ const restoreEnv = (): void => {
|
||||
|
||||
const deleteProfileRedisKeys = async (): Promise<void> => {
|
||||
if (!redis) return;
|
||||
await redis.client.del(`${redisPrefix}:flush:revocation:v1:baseline`);
|
||||
for await (const keys of redis.client.scanIterator({ MATCH: `sammo:game:*:${profileName}:*`, COUNT: 100 })) {
|
||||
if (keys.length > 0) await redis.client.del(keys);
|
||||
}
|
||||
@@ -199,6 +200,8 @@ integration('diplomacy HTML purification over PostgreSQL and HTTP transport', ()
|
||||
if (!created) throw new Error('failed to seed diplomacy HTML access token');
|
||||
accessToken = created.accessToken;
|
||||
|
||||
// 이 격리 fixture는 이미 전환된 Gateway namespace를 합성 cutoff 0으로 준비한다.
|
||||
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:baseline`, '0', { NX: true });
|
||||
server = await createGameApiServer();
|
||||
baseUrl = await server.app.listen({ host: server.config.host, port: server.config.port });
|
||||
}, 30_000);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createServer, type Socket } from 'node:net';
|
||||
import { createClient } from 'redis';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { InMemoryFlushStore, RedisGatewayFlushSubscriber } from '../src/auth/flushStore.js';
|
||||
import { InMemoryFlushStore, RedisFlushStore, RedisGatewayFlushSubscriber } from '../src/auth/flushStore.js';
|
||||
|
||||
describe('RedisGatewayFlushSubscriber', () => {
|
||||
it('reports asynchronous flush handler failures with event context', async () => {
|
||||
@@ -66,3 +68,84 @@ describe('RedisGatewayFlushSubscriber', () => {
|
||||
expect(stopped).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RedisFlushStore authorization state', () => {
|
||||
it('merges startup, persistent and local watermarks without caching an allow result', async () => {
|
||||
let persisted: string | null = null;
|
||||
const mGet = vi.fn(async (keys: string[]) => (keys.length === 1 ? ['100'] : ['100', persisted]));
|
||||
const store = new RedisFlushStore({ mGet }, 'fixture:flush');
|
||||
await store.assertInitialized();
|
||||
expect((await store.getFlushedAt('user-1'))?.getTime()).toBe(100);
|
||||
persisted = '200';
|
||||
expect((await store.getFlushedAt('user-1'))?.getTime()).toBe(200);
|
||||
store.applyFlush({ userId: 'user-1', flushedAt: new Date(300).toISOString() });
|
||||
expect((await store.getFlushedAt('user-1'))?.getTime()).toBe(300);
|
||||
expect(mGet).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
it('retains revocation after local cache eviction and a fresh store instance', async () => {
|
||||
const client = { mGet: async (keys: string[]) => (keys.length === 1 ? ['0'] : ['0', '200']) };
|
||||
const store = new RedisFlushStore(client, 'fixture:flush');
|
||||
for (let i = 0; i < 4100; i++)
|
||||
store.applyFlush({ userId: `user-${i}`, flushedAt: new Date(100).toISOString() });
|
||||
expect((await store.getFlushedAt('user-0'))?.getTime()).toBe(200);
|
||||
expect((await new RedisFlushStore(client, 'fixture:flush').getFlushedAt('user-0'))?.getTime()).toBe(200);
|
||||
});
|
||||
it.each([null, '', 'invalid', '-1', '8640000000000001'])(
|
||||
'rejects missing or corrupt initialization %s',
|
||||
async (baseline) => {
|
||||
const store = new RedisFlushStore({ mGet: async () => [baseline] }, 'fixture:flush');
|
||||
await expect(store.assertInitialized()).rejects.toThrow();
|
||||
}
|
||||
);
|
||||
it('rejects corrupted per-user state instead of treating it as never revoked', async () => {
|
||||
const store = new RedisFlushStore({ mGet: async () => ['0', 'not-a-watermark'] }, 'fixture:flush');
|
||||
await expect(store.getFlushedAt('user-1')).rejects.toThrow();
|
||||
});
|
||||
it('does not perform an offline read and bounds a connected read with an abort signal', async () => {
|
||||
const offlineGet = vi.fn(async () => ['0', null]);
|
||||
const offline = new RedisFlushStore({ isReady: false, mGet: offlineGet }, 'fixture:flush');
|
||||
await expect(offline.getFlushedAt('user')).rejects.toThrow('unavailable');
|
||||
expect(offlineGet).not.toHaveBeenCalled();
|
||||
const mGet = vi.fn(async () => ['0', '100']);
|
||||
const withAbortSignal = vi.fn((_signal: AbortSignal) => ({ mGet }));
|
||||
const store = new RedisFlushStore({ mGet, withAbortSignal }, 'fixture:flush');
|
||||
expect((await store.getFlushedAt('user'))?.getTime()).toBe(100);
|
||||
expect(withAbortSignal.mock.calls[0]?.[0]).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
});
|
||||
|
||||
it('bounds a connected Redis read when the actual TCP peer stops replying', async () => {
|
||||
const sockets = new Set<Socket>();
|
||||
let receivedRead = false;
|
||||
const server = createServer((socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on('close', () => sockets.delete(socket));
|
||||
socket.on('data', (data) => {
|
||||
const request = data.toString();
|
||||
// Real node-redis handshake succeeds; its MGET command receives no reply.
|
||||
if (request.includes('MGET')) {
|
||||
receivedRead = true;
|
||||
return;
|
||||
}
|
||||
const count = [...request.matchAll(/\*\d+\r\n/g)].length;
|
||||
socket.write('+OK\r\n'.repeat(count));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') throw new Error('Expected TCP fixture address');
|
||||
const client = createClient({ socket: { host: '127.0.0.1', port: address.port, reconnectStrategy: false } });
|
||||
try {
|
||||
await client.connect();
|
||||
expect(client.isReady).toBe(true);
|
||||
const start = Date.now();
|
||||
await expect(new RedisFlushStore(client, 'fixture:flush').getFlushedAt('user')).rejects.toThrow();
|
||||
expect(receivedRead).toBe(true);
|
||||
expect(Date.now() - start).toBeGreaterThanOrEqual(1800);
|
||||
expect(Date.now() - start).toBeLessThan(4500);
|
||||
} finally {
|
||||
if (client.isOpen) client.destroy();
|
||||
for (const socket of sockets) socket.destroy();
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import fastify from 'fastify';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { router as gameRouter, sessionActivityProcedure } from '../src/trpc.js';
|
||||
|
||||
const internalFailure = () => {
|
||||
throw new Error('synthetic-db-secret', { cause: new Error('synthetic-cause-secret') });
|
||||
};
|
||||
const badInput = () => {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '잘못된 입력입니다.',
|
||||
cause: new Error('synthetic-private-cause'),
|
||||
});
|
||||
};
|
||||
|
||||
describe('public HTTP error privacy', () => {
|
||||
it('the real router factory hides internal errors and all stacks while preserving validation messages', async () => {
|
||||
const app = fastify();
|
||||
await app.register(fastifyTRPCPlugin, {
|
||||
prefix: '/game',
|
||||
trpcOptions: {
|
||||
router: gameRouter({
|
||||
internal: sessionActivityProcedure.query(internalFailure),
|
||||
invalid: sessionActivityProcedure.query(badInput),
|
||||
}),
|
||||
createContext: () => ({}),
|
||||
},
|
||||
});
|
||||
try {
|
||||
const address = await app.listen({ host: '127.0.0.1', port: 0 });
|
||||
for (const prefix of ['game']) {
|
||||
for (const procedure of ['internal', 'invalid']) {
|
||||
const response = await fetch(`${address}/${prefix}/${procedure}`);
|
||||
const body = (await response.json()) as {
|
||||
error: { message: string; data: { code: string; stack?: string } };
|
||||
};
|
||||
expect(response.status).toBe(procedure === 'internal' ? 500 : 400);
|
||||
expect(body.error.message).toBe(
|
||||
procedure === 'internal' ? 'Internal server error' : '잘못된 입력입니다.'
|
||||
);
|
||||
expect(body.error.data.stack).toBeUndefined();
|
||||
expect(JSON.stringify(body)).not.toContain('synthetic-');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 } }) => ({
|
||||
|
||||
@@ -62,6 +62,7 @@ const deleteProfileRedisKeys = async (): Promise<void> => {
|
||||
if (!redis) {
|
||||
return;
|
||||
}
|
||||
await redis.client.del(`${redisPrefix}:flush:revocation:v1:baseline`);
|
||||
for await (const keys of redis.client.scanIterator({
|
||||
MATCH: `sammo:game:*:${profileName}:*`,
|
||||
COUNT: 100,
|
||||
@@ -155,6 +156,8 @@ integration('nation HTML purification over HTTP transport', () => {
|
||||
}
|
||||
accessToken = created.accessToken;
|
||||
|
||||
// 이 격리 fixture는 이미 전환된 Gateway namespace를 합성 cutoff 0으로 준비한다.
|
||||
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:baseline`, '0', { NX: true });
|
||||
server = await createGameApiServer();
|
||||
baseUrl = await server.app.listen({ host: server.config.host, port: server.config.port });
|
||||
}, 30_000);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import type { ServerResponse } from 'node:http';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import { RealtimeConnectionAdmission } from '../src/realtime/connectionAdmission.js';
|
||||
import { AuthenticatedRealtimeConnection } from '../src/realtime/authenticatedConnection.js';
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('realtime connection admission', () => {
|
||||
it('reserves the process total before authentication and counts live streams too', () => {
|
||||
const budget = new RealtimeConnectionAdmission({ connections: 2, perUser: 2, opening: 2 });
|
||||
const first = budget.begin(vi.fn())!;
|
||||
const second = budget.begin(vi.fn())!;
|
||||
expect(first.reserveUser('actor')).toBe(true);
|
||||
expect(first.startStreaming()).toBe(true);
|
||||
expect(budget.begin(vi.fn())).toBeNull();
|
||||
second.release();
|
||||
const replacement = budget.begin(vi.fn())!;
|
||||
expect(replacement).not.toBeNull();
|
||||
replacement.release();
|
||||
first.release();
|
||||
});
|
||||
|
||||
it('shares the authenticated actor limit while allowing another actor', () => {
|
||||
const budget = new RealtimeConnectionAdmission({ connections: 5, perUser: 1, opening: 5 });
|
||||
const first = budget.begin(vi.fn())!;
|
||||
expect(first.reserveUser('one')).toBe(true);
|
||||
const rejected = budget.begin(vi.fn())!;
|
||||
expect(rejected.reserveUser('one')).toBe(false);
|
||||
expect(rejected.startStreaming()).toBe(false);
|
||||
rejected.release();
|
||||
const other = budget.begin(vi.fn())!;
|
||||
expect(other.reserveUser('two')).toBe(true);
|
||||
first.release();
|
||||
first.release();
|
||||
const replacement = budget.begin(vi.fn())!;
|
||||
expect(replacement.reserveUser('one')).toBe(true);
|
||||
other.release();
|
||||
replacement.release();
|
||||
});
|
||||
|
||||
it('keeps timed-out and disconnected backend work admitted until it actually settles', async () => {
|
||||
vi.useFakeTimers();
|
||||
const budget = new RealtimeConnectionAdmission({ connections: 5, perUser: 5, opening: 2 });
|
||||
const timeout = vi.fn();
|
||||
const stalled = budget.begin(timeout)!;
|
||||
const disconnected = budget.begin(vi.fn())!;
|
||||
expect(stalled.reserveUser('stalled')).toBe(true);
|
||||
disconnected.cancelOpening();
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(timeout).toHaveBeenCalledTimes(1);
|
||||
expect(stalled.canContinue()).toBe(false);
|
||||
expect(stalled.startStreaming()).toBe(false);
|
||||
expect(disconnected.canContinue()).toBe(false);
|
||||
expect(budget.begin(vi.fn())).toBeNull();
|
||||
stalled.release();
|
||||
const recovered = budget.begin(vi.fn())!;
|
||||
expect(recovered.reserveUser('stalled')).toBe(true);
|
||||
expect(recovered.startStreaming()).toBe(true);
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(timeout).toHaveBeenCalledTimes(1);
|
||||
disconnected.release();
|
||||
recovered.release();
|
||||
});
|
||||
|
||||
it('stops pending setup on shutdown and permanently refuses new admissions', () => {
|
||||
const budget = new RealtimeConnectionAdmission();
|
||||
const timeout = vi.fn();
|
||||
const pending = budget.begin(timeout)!;
|
||||
budget.close();
|
||||
budget.close();
|
||||
expect(timeout).toHaveBeenCalledTimes(1);
|
||||
expect(pending.canContinue()).toBe(false);
|
||||
expect(budget.begin(vi.fn())).toBeNull();
|
||||
pending.release();
|
||||
});
|
||||
|
||||
it('keeps a closed stream counted while its in-flight authentication is still pending', async () => {
|
||||
const budget = new RealtimeConnectionAdmission({ connections: 1, perUser: 1, opening: 1 });
|
||||
const slot = budget.begin(vi.fn())!;
|
||||
expect(slot.reserveUser('actor')).toBe(true);
|
||||
expect(slot.startStreaming()).toBe(true);
|
||||
let finish!: () => void;
|
||||
let started!: () => void;
|
||||
const inFlight = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const running = new Promise<void>((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
const auth: GameSessionTokenPayload = {
|
||||
version: 1,
|
||||
sessionId: 'session',
|
||||
profile: 'che:default',
|
||||
issuedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
user: {
|
||||
id: 'actor',
|
||||
username: 'actor',
|
||||
displayName: 'actor',
|
||||
roles: ['user'],
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
sanctions: {},
|
||||
};
|
||||
const response = new PassThrough();
|
||||
const connection = new AuthenticatedRealtimeConnection(
|
||||
auth,
|
||||
response as unknown as ServerResponse,
|
||||
async () => {
|
||||
started();
|
||||
await inFlight;
|
||||
return auth;
|
||||
}
|
||||
);
|
||||
connection.onClose(() => {
|
||||
void connection.whenIdle().then(slot.release, slot.release);
|
||||
});
|
||||
connection.enqueue(async () => 'event: ready\ndata: {}\n\n');
|
||||
await running;
|
||||
connection.close();
|
||||
expect(budget.begin(vi.fn())).toBeNull();
|
||||
finish();
|
||||
await connection.whenIdle();
|
||||
const replacement = budget.begin(vi.fn())!;
|
||||
expect(replacement.reserveUser('actor')).toBe(true);
|
||||
replacement.release();
|
||||
});
|
||||
|
||||
it('enforces the production 512 total and 32 pending openings without accumulating a queue', () => {
|
||||
const budget = new RealtimeConnectionAdmission();
|
||||
const pending = Array.from({ length: 32 }, () => budget.begin(vi.fn())!);
|
||||
expect(pending.every(Boolean)).toBe(true);
|
||||
expect(budget.begin(vi.fn())).toBeNull();
|
||||
for (const slot of pending) slot.release();
|
||||
const live = Array.from({ length: 512 }, (_, index) => {
|
||||
const slot = budget.begin(vi.fn())!;
|
||||
expect(slot.reserveUser(`actor-${index}`)).toBe(true);
|
||||
expect(slot.startStreaming()).toBe(true);
|
||||
return slot;
|
||||
});
|
||||
expect(budget.begin(vi.fn())).toBeNull();
|
||||
live[0]!.release();
|
||||
const replacement = budget.begin(vi.fn())!;
|
||||
expect(replacement.reserveUser('replacement')).toBe(true);
|
||||
expect(replacement.startStreaming()).toBe(true);
|
||||
for (const slot of live) slot.release();
|
||||
replacement.release();
|
||||
});
|
||||
});
|
||||
@@ -36,4 +36,15 @@ describe('remote content image store', () => {
|
||||
expect(String(captured?.input)).toBe(`https://sam-image.hided.net${pathname}`);
|
||||
expect(Object.values(headers)).not.toContain(secret);
|
||||
});
|
||||
it('rejects oversized upload response JSON before trusting its path', async () => {
|
||||
const filename = `${'d'.repeat(32)}.png`;
|
||||
const store = new RemoteContentImageStore(
|
||||
'https://image.example',
|
||||
'https://image.example/public',
|
||||
'synthetic-secret',
|
||||
async () =>
|
||||
new Response(JSON.stringify({ path: `uploads/core2026/${filename}`, extra: 'x'.repeat(20_000) }))
|
||||
);
|
||||
await expect(store.upload({ filename, contentType: 'image/png', body: Buffer.from('x') })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Writable } from 'node:stream';
|
||||
import fastify from 'fastify';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { safeHttpLoggerOptions, safeHttpErrorHandler } from '@sammo-ts/common';
|
||||
|
||||
describe('HTTP log credential boundaries', () => {
|
||||
it('logs static routes/status but no query, unmatched path, header, body, stack or cause secrets', async () => {
|
||||
let output = '';
|
||||
const stream = new Writable({
|
||||
write(chunk, _encoding, done) {
|
||||
output += String(chunk);
|
||||
done();
|
||||
},
|
||||
});
|
||||
const app = fastify({ logger: { ...safeHttpLoggerOptions, stream } });
|
||||
app.setErrorHandler(safeHttpErrorHandler);
|
||||
app.post('/events', async () => {
|
||||
throw Object.assign(new Error('synthetic-error-secret', { cause: new Error('synthetic-cause-secret') }), {
|
||||
code: 'P2002',
|
||||
detail: 'synthetic-detail-secret',
|
||||
});
|
||||
});
|
||||
try {
|
||||
const address = await app.listen({ host: '127.0.0.1', port: 0 });
|
||||
const response = await fetch(`${address}/events?token=synthetic-query-secret`, {
|
||||
method: 'POST',
|
||||
headers: { authorization: 'Bearer synthetic-header-secret', 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ password: 'synthetic-body-secret' }),
|
||||
});
|
||||
expect(await response.json()).toMatchObject({ message: 'Internal server error' });
|
||||
await fetch(`${address}/synthetic-path-secret`);
|
||||
const logs = output
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => JSON.parse(line));
|
||||
expect(logs.some((entry) => entry.req?.url === '/events')).toBe(true);
|
||||
expect(logs.some((entry) => entry.res?.statusCode === 500)).toBe(true);
|
||||
expect(logs.some((entry) => entry.err?.code === 'P2002')).toBe(true);
|
||||
for (const field of ['query', 'header', 'body', 'error', 'cause', 'detail', 'path']) {
|
||||
expect(output).not.toContain(`synthetic-${field}-secret`);
|
||||
}
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import sharp from 'sharp';
|
||||
import { buildGameEventChannel } from '@sammo-ts/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { projectCurrentGeneral } from '../src/router/playAudit/projection.js';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
@@ -68,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]]));
|
||||
|
||||
@@ -172,7 +175,7 @@ const deleteProfileRedisKeys = async (): Promise<void> => {
|
||||
if (!redis) {
|
||||
return;
|
||||
}
|
||||
for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`]) {
|
||||
for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`, `${redisPrefix}:*`]) {
|
||||
for await (const keys of redis.client.scanIterator({
|
||||
MATCH: pattern,
|
||||
COUNT: 100,
|
||||
@@ -416,7 +419,7 @@ const readAccessTelemetryState = async () => ({
|
||||
const readRealtimeRedisState = async (): Promise<Array<[string, string | null]>> => {
|
||||
if (!redis) return [];
|
||||
const keys = new Set<string>();
|
||||
for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`]) {
|
||||
for (const pattern of [`sammo:game:*:${profileName}:*`, `sammo:${profileName}:*`, `${redisPrefix}:*`]) {
|
||||
for await (const batch of redis.client.scanIterator({ MATCH: pattern, COUNT: 100 })) {
|
||||
for (const key of batch) keys.add(key);
|
||||
}
|
||||
@@ -640,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();
|
||||
@@ -742,6 +746,8 @@ integration('game API security over HTTP transport', () => {
|
||||
await redis.connect();
|
||||
accessTokenStore = new RedisAccessTokenStore(redis.client, profileName);
|
||||
|
||||
// 이 격리 fixture는 이미 전환된 Gateway namespace를 합성 cutoff 0으로 준비한다.
|
||||
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:baseline`, '0', { NX: true });
|
||||
server = await createGameApiServer();
|
||||
baseUrl = await server.app.listen({
|
||||
host: server.config.host,
|
||||
@@ -818,6 +824,28 @@ integration('game API security over HTTP transport', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('image upload enforces real HTTP authentication, officer authority and decoded pixel limits', async () => {
|
||||
const input = { dataUrl: 'data:image/png;base64,AA==' };
|
||||
const unauthenticated = await requestTrpc('board.uploadImage', { method: 'POST', input });
|
||||
expect(unauthenticated.response.status).toBe(401);
|
||||
const ordinary = await createAccessToken('image-ordinary', {}, ordinaryUserId);
|
||||
const denied = await requestTrpc('board.uploadImage', { method: 'POST', input, accessToken: ordinary });
|
||||
expect(denied.response.status).toBe(403);
|
||||
const officer = await createAccessToken('image-officer', {});
|
||||
const malformed = await requestTrpc('board.uploadImage', { method: 'POST', input, accessToken: officer });
|
||||
expect(malformed.response.status).toBe(400);
|
||||
const png = await sharp({ create: { width: 4096, height: 4097, channels: 3, background: '#224466' } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
const oversized = await requestTrpc('board.uploadImage', {
|
||||
method: 'POST',
|
||||
accessToken: officer,
|
||||
input: { dataUrl: `data:image/png;base64,${png.toString('base64')}` },
|
||||
});
|
||||
expect(oversized.response.status).toBe(400);
|
||||
expect(oversized.body).toMatchObject({ error: { data: { code: 'BAD_REQUEST' } } });
|
||||
});
|
||||
|
||||
it('accepts an authenticated query from a POST JSON body', async () => {
|
||||
const accessToken = await createAccessToken('json-query-body', {});
|
||||
const general = await requestTrpc('general.me', {
|
||||
@@ -2317,9 +2345,51 @@ integration('game API security over HTTP transport', () => {
|
||||
{
|
||||
decisionId: decisionIds[0]!,
|
||||
ordinal: 0,
|
||||
steps: Array.from({ length: 128 }, (_, sequence) => sequence === 127
|
||||
? { ...step, sequence, kind: 'DECISION_START', reservedAction: '휴식', effectivePolicy: {"schemaVersion":1,"general":{"priority":["징병"],"flags":{"징병":true,"출병":false}},"nation":{"priority":["천도"],"flags":{"천도":true},"values":{"reqNationGold":4321,"reqNationRice":100,"reqHumanWarUrgentGold":100,"reqHumanWarUrgentRice":100,"reqHumanWarRecommandGold":100,"reqHumanWarRecommandRice":100,"reqHumanDevelGold":100,"reqHumanDevelRice":100,"reqNpcWarGold":100,"reqNpcWarRice":100,"reqNpcDevelGold":100,"reqNpcDevelRice":100,"minimumResourceActionAmount":100,"maximumResourceActionAmount":100,"minNpcWarLeadership":100,"minWarCrew":100,"minNpcRecruitCityPopulation":100,"safeRecruitCityPopulationRatio":100,"properWarTrainAtmos":100,"cureThreshold":100},"combatForce":{"1":[2,3]},"supportForce":[4],"developForce":[5],"secret":"decision-secret"},"secret":"decision-secret"} }
|
||||
: ({ ...step, sequence })),
|
||||
steps: Array.from({ length: 128 }, (_, sequence) =>
|
||||
sequence === 127
|
||||
? {
|
||||
...step,
|
||||
sequence,
|
||||
kind: 'DECISION_START',
|
||||
reservedAction: '휴식',
|
||||
effectivePolicy: {
|
||||
schemaVersion: 1,
|
||||
general: { priority: ['징병'], flags: { 징병: true, 출병: false } },
|
||||
nation: {
|
||||
priority: ['천도'],
|
||||
flags: { 천도: true },
|
||||
values: {
|
||||
reqNationGold: 4321,
|
||||
reqNationRice: 100,
|
||||
reqHumanWarUrgentGold: 100,
|
||||
reqHumanWarUrgentRice: 100,
|
||||
reqHumanWarRecommandGold: 100,
|
||||
reqHumanWarRecommandRice: 100,
|
||||
reqHumanDevelGold: 100,
|
||||
reqHumanDevelRice: 100,
|
||||
reqNpcWarGold: 100,
|
||||
reqNpcWarRice: 100,
|
||||
reqNpcDevelGold: 100,
|
||||
reqNpcDevelRice: 100,
|
||||
minimumResourceActionAmount: 100,
|
||||
maximumResourceActionAmount: 100,
|
||||
minNpcWarLeadership: 100,
|
||||
minWarCrew: 100,
|
||||
minNpcRecruitCityPopulation: 100,
|
||||
safeRecruitCityPopulationRatio: 100,
|
||||
properWarTrainAtmos: 100,
|
||||
cureThreshold: 100,
|
||||
},
|
||||
combatForce: { '1': [2, 3] },
|
||||
supportForce: [4],
|
||||
developForce: [5],
|
||||
secret: 'decision-secret',
|
||||
},
|
||||
secret: 'decision-secret',
|
||||
},
|
||||
}
|
||||
: { ...step, sequence }
|
||||
),
|
||||
},
|
||||
{
|
||||
decisionId: decisionIds[0]!,
|
||||
@@ -3530,6 +3600,414 @@ integration('game API security over HTTP transport', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const openRealtime = async (accessToken: string) => {
|
||||
const abort = new AbortController();
|
||||
const response = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${accessToken}` },
|
||||
signal: abort.signal,
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toBe('text/event-stream');
|
||||
const reader = response.body!.getReader();
|
||||
const ready = await reader.read();
|
||||
expect(new TextDecoder().decode(ready.value)).toContain('event: ready');
|
||||
return { abort, reader };
|
||||
};
|
||||
|
||||
it('SSE limits simultaneous openings for one authenticated user', async () => {
|
||||
const actor = `security-http-sse-concurrent-${process.pid}`;
|
||||
const token = await createAccessToken('sse-concurrent', {}, actor);
|
||||
const aborts = Array.from({ length: 10 }, () => new AbortController());
|
||||
try {
|
||||
const responses = await Promise.all(
|
||||
aborts.map((abort) =>
|
||||
fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
signal: abort.signal,
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(responses.filter((response) => response.status === 200)).toHaveLength(8);
|
||||
expect(responses.filter((response) => response.status === 429)).toHaveLength(2);
|
||||
for (const response of responses.filter((item) => item.status === 429)) {
|
||||
expect(response.headers.get('retry-after')).toBe('15');
|
||||
expect(response.headers.get('content-type')).not.toBe('text/event-stream');
|
||||
expect(await response.json()).toEqual({ ok: false, error: 'realtime_connection_limit' });
|
||||
}
|
||||
} finally {
|
||||
for (const abort of aborts) abort.abort();
|
||||
await accessTokenStore.revoke(token);
|
||||
}
|
||||
});
|
||||
|
||||
it('SSE limits the same user across tokens and scopes and recovers after disconnect', async () => {
|
||||
const actor = `security-http-sse-shared-${process.pid}`;
|
||||
const token = await createAccessToken('sse-shared-a', {}, actor);
|
||||
const otherToken = await createAccessToken('sse-shared-b', {}, actor);
|
||||
const otherActorToken = await createAccessToken('sse-independent', {}, `${actor}-other`);
|
||||
const streams: Awaited<ReturnType<typeof openRealtime>>[] = [];
|
||||
const replacementAbort = new AbortController();
|
||||
try {
|
||||
for (let index = 0; index < 8; index += 1) streams.push(await openRealtime(token));
|
||||
const rejected = await fetch(`${baseUrl}${server!.config.eventsPath}?scope=tournament`, {
|
||||
headers: { authorization: `Bearer ${otherToken}` },
|
||||
});
|
||||
expect(rejected.status).toBe(429);
|
||||
expect(await rejected.json()).toEqual({ ok: false, error: 'realtime_connection_limit' });
|
||||
streams.push(await openRealtime(otherActorToken));
|
||||
await streams[0]!.reader.cancel();
|
||||
streams[0]!.abort.abort();
|
||||
let replacement: Response | undefined;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const response = await fetch(`${baseUrl}${server!.config.eventsPath}?scope=tournament`, {
|
||||
headers: { authorization: `Bearer ${otherToken}` },
|
||||
signal: replacementAbort.signal,
|
||||
});
|
||||
if (response.status === 200) replacement = response;
|
||||
else await response.arrayBuffer();
|
||||
return response.status;
|
||||
})
|
||||
.toBe(200);
|
||||
const ready = await replacement!.body!.getReader().read();
|
||||
expect(new TextDecoder().decode(ready.value)).toContain('event: ready');
|
||||
} finally {
|
||||
replacementAbort.abort();
|
||||
for (const stream of streams) stream.abort.abort();
|
||||
await Promise.all([token, otherToken, otherActorToken].map((value) => accessTokenStore.revoke(value)));
|
||||
}
|
||||
});
|
||||
|
||||
it('SSE bounds pending openings after disconnect and backend deadline until real SQL work settles', async () => {
|
||||
if (!/^[A-Za-z0-9_]+$/.test(profileId)) throw new Error('Invalid isolated schema identifier');
|
||||
const actor = `security-http-sse-pending-${process.pid}`;
|
||||
const tokens = await Promise.all(
|
||||
Array.from({ length: 33 }, (_, index) => createAccessToken(`sse-pending-${index}`, {}, `${actor}-${index}`))
|
||||
);
|
||||
const aborts = Array.from({ length: 33 }, () => new AbortController());
|
||||
let unlock!: () => void;
|
||||
const release = new Promise<void>((resolve) => {
|
||||
unlock = resolve;
|
||||
});
|
||||
let ready!: (tx: GamePrisma.TransactionClient) => void;
|
||||
let lockError!: (error: unknown) => void;
|
||||
const locked = new Promise<GamePrisma.TransactionClient>((resolve, reject) => {
|
||||
ready = resolve;
|
||||
lockError = reject;
|
||||
});
|
||||
// Only the guarded, dedicated fixture schema is locked. No runtime or Ref table is touched.
|
||||
const locking = db.$transaction(
|
||||
async (tx) => {
|
||||
await tx.$executeRawUnsafe(`LOCK TABLE "${profileId}"."general" IN ACCESS EXCLUSIVE MODE`);
|
||||
ready(tx);
|
||||
await release;
|
||||
},
|
||||
{ timeout: 20_000 }
|
||||
);
|
||||
void locking.catch(lockError);
|
||||
try {
|
||||
const tx = await locked;
|
||||
const first = fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${tokens[0]!}` },
|
||||
signal: aborts[0]!.signal,
|
||||
}).then(
|
||||
() => false,
|
||||
() => true
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const rows = await tx.$queryRaw<Array<{ count: number }>>`
|
||||
SELECT COUNT(*)::int AS count FROM pg_locks
|
||||
WHERE NOT granted AND relation = ${`${profileId}.general`}::regclass
|
||||
AND database = (SELECT oid FROM pg_database WHERE datname = current_database())
|
||||
`;
|
||||
return rows[0]!.count;
|
||||
},
|
||||
{ timeout: 3000 }
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
const startedAt = Date.now();
|
||||
const waiting = tokens.slice(1, 32).map((token, index) =>
|
||||
fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
signal: aborts[index + 1]!.signal,
|
||||
})
|
||||
);
|
||||
aborts[0]!.abort();
|
||||
expect(await first).toBe(true);
|
||||
const responses = await Promise.all(waiting);
|
||||
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(4500);
|
||||
expect(Date.now() - startedAt).toBeLessThan(8500);
|
||||
for (const response of responses) {
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ ok: false, error: 'realtime_unavailable' });
|
||||
}
|
||||
const refusedAt = Date.now();
|
||||
const refused = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${tokens[32]!}` },
|
||||
signal: aborts[32]!.signal,
|
||||
});
|
||||
expect(refused.status).toBe(503);
|
||||
expect(refused.headers.get('retry-after')).toBe('15');
|
||||
expect(Date.now() - refusedAt).toBeLessThan(2000);
|
||||
await refused.arrayBuffer();
|
||||
unlock();
|
||||
await locking;
|
||||
let recovered: Response | undefined;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const response = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${tokens[32]!}` },
|
||||
signal: aborts[32]!.signal,
|
||||
});
|
||||
if (response.status === 200) recovered = response;
|
||||
else await response.arrayBuffer();
|
||||
return response.status;
|
||||
})
|
||||
.toBe(200);
|
||||
const frame = await recovered!.body!.getReader().read();
|
||||
expect(new TextDecoder().decode(frame.value)).toContain('event: ready');
|
||||
} finally {
|
||||
unlock();
|
||||
for (const abort of aborts) abort.abort();
|
||||
await locking;
|
||||
await Promise.all(tokens.map((token) => accessTokenStore.revoke(token)));
|
||||
}
|
||||
}, 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)}`);
|
||||
expect(queryOnly.status).toBe(401);
|
||||
const foreign = await accessTokenStore.create({ ...buildPayload('sse-foreign', {}), profile: 'other:default' });
|
||||
if (!foreign) throw new Error('SSE foreign token fixture failed');
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${foreign.accessToken}` },
|
||||
});
|
||||
expect(response.status).toBe(401);
|
||||
const stream = await openRealtime(token);
|
||||
stream.abort.abort();
|
||||
} finally {
|
||||
await accessTokenStore.revoke(token);
|
||||
await accessTokenStore.revoke(foreign.accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: 'ban', sanctions: { bannedUntil: '2099-01-01T00:00:00.000Z' } },
|
||||
{ label: 'suspension', sanctions: { suspendedUntil: '2099-01-01T00:00:00.000Z' } },
|
||||
{
|
||||
label: 'profile game restriction',
|
||||
sanctions: { serverRestrictions: { [profileName]: { blockedFeatures: ['game'] } } },
|
||||
},
|
||||
])('SSE rejects $label before opening a stream', async ({ label, sanctions }) => {
|
||||
const token = await createAccessToken(`sse-${label}`, sanctions);
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.headers.get('content-type')).not.toBe('text/event-stream');
|
||||
} finally {
|
||||
await accessTokenStore.revoke(token);
|
||||
}
|
||||
});
|
||||
|
||||
it('SSE closes on idle expiry without forwarding another frame', async () => {
|
||||
const auth = { ...buildPayload('sse-expiry', {}), expiresAt: new Date(Date.now() + 3500).toISOString() };
|
||||
const created = await accessTokenStore.create(auth);
|
||||
if (!created) throw new Error('SSE expiry token fixture failed');
|
||||
const stream = await openRealtime(created.accessToken);
|
||||
try {
|
||||
expect(await stream.reader.read()).toMatchObject({ done: true });
|
||||
expect((await requestTrpc('general.me', { accessToken: created.accessToken })).response.status).toBe(401);
|
||||
} finally {
|
||||
stream.abort.abort();
|
||||
await accessTokenStore.revoke(created.accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('SSE rechecks a revoked token before forwarding a relevant event or refresh grant', async () => {
|
||||
const accessToken = await createAccessToken('sse-revoke', {});
|
||||
const stream = await openRealtime(accessToken);
|
||||
const channel = server!.config.profileName;
|
||||
try {
|
||||
const event = JSON.stringify({ type: 'tournamentChanged' });
|
||||
await redis!.client.publish(buildGameEventChannel(channel), event);
|
||||
const valid = await stream.reader.read();
|
||||
expect(new TextDecoder().decode(valid.value)).toContain('refreshGrant');
|
||||
expect(await accessTokenStore.revoke(accessToken)).toBe(true);
|
||||
await redis!.client.publish(buildGameEventChannel(channel), event);
|
||||
expect(await stream.reader.read()).toMatchObject({ done: true });
|
||||
} finally {
|
||||
stream.abort.abort();
|
||||
await accessTokenStore.revoke(accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('SSE closes an idle flushed user immediately without waiting for heartbeat', async () => {
|
||||
const sseUser = `security-http-sse-flush-${process.pid}`;
|
||||
const created = await accessTokenStore.create(buildPayload('sse-flush', {}, sseUser));
|
||||
if (!created) throw new Error('SSE flush fixture failed');
|
||||
const stream = await openRealtime(created.accessToken);
|
||||
try {
|
||||
await redis!.client.publish(
|
||||
`${redisPrefix}:flush`,
|
||||
JSON.stringify({ userId: sseUser, flushedAt: new Date().toISOString() })
|
||||
);
|
||||
expect(await stream.reader.read()).toMatchObject({ done: true });
|
||||
} finally {
|
||||
stream.abort.abort();
|
||||
await accessTokenStore.revoke(created.accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects access using persisted revocation state without any pubsub delivery', async () => {
|
||||
const actor = `security-http-durable-missed-${process.pid}`;
|
||||
const accessToken = await createAccessToken('durable-missed', {}, actor);
|
||||
try {
|
||||
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(200);
|
||||
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:user:${actor}`, String(Date.now()));
|
||||
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(401);
|
||||
} finally {
|
||||
await accessTokenStore.revoke(accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('retains persisted revocation state across an actual API restart', async () => {
|
||||
const actor = `security-http-durable-restart-${process.pid}`;
|
||||
const accessToken = await createAccessToken('durable-restart', {}, actor);
|
||||
try {
|
||||
const gatewayToken = encryptGameSessionToken(buildPayload('unused-durable-restart', {}, actor), secret);
|
||||
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:user:${actor}`, String(Date.now()));
|
||||
await server!.app.close();
|
||||
server = await createGameApiServer();
|
||||
baseUrl = await server.app.listen({ host: '127.0.0.1', port: 0 });
|
||||
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(401);
|
||||
const exchanged = await requestTrpc('auth.exchangeGatewayToken', {
|
||||
method: 'POST',
|
||||
input: { gatewayToken },
|
||||
});
|
||||
expect(exchanged.response.status).toBe(401);
|
||||
} finally {
|
||||
await accessTokenStore.revoke(accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('SSE closes after persisted revocation even when the flush notification was missed', async () => {
|
||||
const actor = `security-http-durable-sse-${process.pid}`;
|
||||
const accessToken = await createAccessToken('durable-sse', {}, actor);
|
||||
const stream = await openRealtime(accessToken);
|
||||
try {
|
||||
await redis!.client.set(`${redisPrefix}:flush:revocation:v1:user:${actor}`, String(Date.now()));
|
||||
await redis!.client.publish(
|
||||
buildGameEventChannel(profileName),
|
||||
JSON.stringify({ type: 'tournamentChanged' })
|
||||
);
|
||||
expect(await stream.reader.read()).toMatchObject({ done: true });
|
||||
} finally {
|
||||
stream.abort.abort();
|
||||
await accessTokenStore.revoke(accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('denies legacy credentials before the initial cutoff and admits a fresh one afterwards', async () => {
|
||||
const actor = `security-http-durable-cutoff-${process.pid}`;
|
||||
const accessToken = await createAccessToken('durable-cutoff', {}, actor);
|
||||
const key = `${redisPrefix}:flush:revocation:v1:baseline`;
|
||||
try {
|
||||
await redis!.client.set(key, String(Date.now()));
|
||||
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(401);
|
||||
const fresh = await accessTokenStore.create({
|
||||
...buildPayload('fresh-cutoff', {}, actor),
|
||||
issuedAt: new Date(Date.now() + 1).toISOString(),
|
||||
});
|
||||
expect(fresh).not.toBeNull();
|
||||
try {
|
||||
expect((await requestTrpc('auth.status', { accessToken: fresh!.accessToken })).response.status).toBe(
|
||||
200
|
||||
);
|
||||
} finally {
|
||||
await accessTokenStore.revoke(fresh!.accessToken);
|
||||
}
|
||||
} finally {
|
||||
await redis!.client.set(key, '0');
|
||||
await accessTokenStore.revoke(accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed for missing initialization in HTTP, SSE and API startup', async () => {
|
||||
const actor = `security-http-durable-missing-${process.pid}`;
|
||||
const accessToken = await createAccessToken('durable-missing', {}, actor);
|
||||
const key = `${redisPrefix}:flush:revocation:v1:baseline`;
|
||||
try {
|
||||
await redis!.client.del(key);
|
||||
expect((await requestTrpc('auth.status', { accessToken })).response.status).toBe(503);
|
||||
const sse = await fetch(`${baseUrl}${server!.config.eventsPath}`, {
|
||||
headers: { authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
expect(sse.status).toBe(503);
|
||||
await expect(createGameApiServer()).rejects.toThrow('startup refused');
|
||||
} finally {
|
||||
await redis!.client.set(key, '0');
|
||||
await accessTokenStore.revoke(accessToken);
|
||||
}
|
||||
});
|
||||
|
||||
// Flush invalidates every token issued before the user watermark. Keep it
|
||||
// last so this lifecycle assertion cannot invalidate the actor tokens used
|
||||
// by the transport authorization matrix above.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) })]),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user