import test from 'node:test'; import assert from 'node:assert/strict'; import Fastify from 'fastify'; import { createHttpOptions, registerHttpOperations, parseTrustProxy, logSafeError } from '../src/server/http-operations.js'; import { gameRoute } from '../src/server/game-api.js'; import { registerAdmin } from '../src/server/admin.js'; test('session limits precede handlers, ignore spoofed forwarding and expire', async t => { let now = 0, calls = 0; const app = Fastify(createHttpOptions({ environment: 'test' })); t.after(() => app.close()); registerHttpOperations(app, { now: () => now }); gameRoute(app, 'POST', '/api/game-sessions', () => { calls++; return {}; }); for (let i = 0; i < 30; i++) assert.equal((await app.inject({ method: 'POST', url: '/api/game-sessions', headers: { 'x-forwarded-for': `192.0.2.${i}` } })).statusCode, 200); now = 1001; const limited = await app.inject({ method: 'POST', url: '/api/game-sessions?secret=x' }); assert.equal(limited.statusCode, 429); assert.equal(limited.headers['retry-after'], '59'); assert.match(limited.json().message, /다시/); assert.equal(calls, 30); now = 60000; assert.equal((await app.inject({ method: 'POST', url: '/api/game-sessions' })).statusCode, 200); }); test('global budget allows shared IP polling and limits all API routes', async t => { const app = Fastify(createHttpOptions({ environment: 'test' })); t.after(() => app.close()); registerHttpOperations(app, { now: () => 0 }); app.get('/api/poll', () => ({})); app.get('/health', () => ({ status: 'ok' })); for (let i = 0; i < 1200; i++) assert.equal((await app.inject('/api/poll')).statusCode, 200); assert.equal((await app.inject('/api/unknown')).statusCode, 429); assert.equal((await app.inject('/health')).statusCode, 200); }); test('admin HTML and API share a budget before authentication', async t => { const app = Fastify(createHttpOptions({ environment: 'test' })); t.after(() => app.close()); registerHttpOperations(app, { now: () => 0 }); registerAdmin(app, {} as any, {} as any, {} as any, { user: 'test', password: 'password' }, '.'); for (let i = 0; i < 60; i++) assert.equal((await app.inject(i % 2 ? '/admin/analytics' : '/api/admin/analytics/live')).statusCode, 401); assert.equal((await app.inject('/api/admin/analytics/report')).statusCode, 429); }); test('explicit loopback proxy trusts only the nearest untrusted hop', async t => { const app = Fastify(createHttpOptions({ environment: 'test', trustProxy: parseTrustProxy('loopback') })); t.after(() => app.close()); registerHttpOperations(app, { now: () => 0 }); app.post('/api/game-sessions', request => ({ ip: request.ip })); for (let i = 0; i < 30; i++) assert.equal((await app.inject({ method: 'POST', url: '/api/game-sessions', remoteAddress: '127.0.0.1', headers: { 'x-forwarded-for': `198.51.100.${i}, 192.0.2.1` } })).statusCode, 200); assert.equal((await app.inject({ method: 'POST', url: '/api/game-sessions', headers: { 'x-forwarded-for': '192.0.2.1' } })).statusCode, 429); const direct = await app.inject({ method: 'POST', url: '/api/game-sessions', remoteAddress: '192.0.2.2', headers: { 'x-forwarded-for': '192.0.2.1' } }); assert.equal(direct.json().ip, '192.0.2.2'); assert.equal(parseTrustProxy(undefined), false); for (const unsafe of ['true', '*', '0.0.0.0/0', '::/0', 'loopback,']) assert.throws(() => parseTrustProxy(unsafe)); }); test('bounded IP storage fails closed and recovers after expiry', async t => { let now = 0; const app = Fastify(createHttpOptions({ environment: 'test' })); t.after(() => app.close()); registerHttpOperations(app, { now: () => now, maxIps: 2 }); app.get('/api/poll', () => ({})); for (const ip of ['192.0.2.1', '192.0.2.2']) assert.equal((await app.inject({ url: '/api/poll', remoteAddress: ip })).statusCode, 200); assert.equal((await app.inject({ url: '/api/poll', remoteAddress: '192.0.2.3' })).statusCode, 429); now = 60000; assert.equal((await app.inject({ url: '/api/poll', remoteAddress: '192.0.2.3' })).statusCode, 200); }); test('unexpected HTTP, game, readiness and background errors never expose secrets', async t => { const lines: string[] = [], secret = 'VERY_SECRET_CREDENTIAL'; const options = createHttpOptions({ environment: 'production' }); const app = Fastify({ ...options, logger: { ...options.logger as object, stream: { write: (line: string) => lines.push(line) } } }); t.after(() => app.close()); registerHttpOperations(app, { ready: async () => { throw new Error(secret); } }); const fail = () => { throw Object.assign(new Error(secret), { code: secret, name: secret, cause: { password: secret } }); }; app.post('/api/plain/:id', fail); gameRoute(app, 'POST', '/api/game/:id', fail); for (const path of ['plain', 'game']) { const response = await app.inject({ method: 'POST', url: `/api/${path}/${secret}?token=${secret}`, headers: { authorization: `Bearer ${secret}`, 'x-request-id': secret }, payload: { password: secret } }); assert.equal(response.statusCode, 500); assert.ok(!response.body.includes(secret)); } assert.equal((await app.inject('/ready')).statusCode, 503); logSafeError(app.log, 'ranked_background', new Error(secret)); assert.ok(!lines.join('').includes(secret)); const errors = lines.map(line => JSON.parse(line)).filter(line => line.level === 50); assert.equal(errors.length, 4); assert.equal(errors[0].route, '/api/plain/:id'); }); test('readiness succeeds and malformed bodies receive sanitized client errors', async t => { const app = Fastify(createHttpOptions({ environment: 'test' })); t.after(() => app.close()); registerHttpOperations(app, { ready: async () => {} }); app.post('/api/body', () => ({})); assert.equal((await app.inject('/ready')).statusCode, 200); const response = await app.inject({ method: 'POST', url: '/api/body', headers: { 'content-type': 'application/json' }, payload: '{"secret":"UNSAFE' }); assert.equal(response.statusCode, 400); assert.ok(!response.body.includes('UNSAFE')); });