kimi-code / packages /kap-server /test /rateLimit.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
4e23b01 verified
Raw History Blame Contribute Delete
6.21 kB
import Fastify, { type FastifyInstance } from 'fastify';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createAuthHook } from '../src/middleware/auth';
import {
createAuthFailureLimiter,
type AuthFailureLimiter,
} from '../src/middleware/rateLimit';
import type { IAuthTokenService } from '../src/services/auth/authTokenService';
const TOKEN = 'test-token';
const IP_A = '203.0.113.10';
const IP_B = '203.0.113.11';
function fixedImpl(): IAuthTokenService {
return {
_serviceBrand: undefined,
getToken: () => TOKEN,
isValid: async (candidate) => candidate === TOKEN,
};
}
function buildApp(limiter?: AuthFailureLimiter): FastifyInstance {
const app = Fastify({ trustProxy: true });
app.addHook('onRequest', createAuthHook(fixedImpl(), { limiter }));
app.get('/api/v1/sessions', async () => ({ ok: true }));
return app;
}
function badToken(ip: string): { method: 'GET'; url: string; headers: Record<string, string> } {
return {
method: 'GET',
url: '/api/v1/sessions',
headers: { 'x-forwarded-for': ip, authorization: 'Bearer wrong-token' },
};
}
describe('createAuthHook rate limiting', () => {
let app: FastifyInstance;
let limiter: AuthFailureLimiter;
beforeEach(async () => {
limiter = createAuthFailureLimiter({ maxFailures: 3, windowMs: 60_000, banMs: 60_000 });
app = buildApp(limiter);
await app.ready();
});
afterEach(async () => {
await app.close();
limiter.dispose();
});
it('returns 401 for the first N failures, then 429 on the (N+1)th from the same IP', async () => {
expect((await app.inject(badToken(IP_A))).statusCode).toBe(401);
expect((await app.inject(badToken(IP_A))).statusCode).toBe(401);
expect((await app.inject(badToken(IP_A))).statusCode).toBe(401);
const fourth = await app.inject(badToken(IP_A));
expect(fourth.statusCode).toBe(429);
const body = fourth.json() as Record<string, unknown>;
expect(body['code']).toBe(42901);
expect(body['msg']).toBe('Too many failed auth attempts');
});
it('does not ban a different IP that has not hit the threshold', async () => {
await app.inject(badToken(IP_A));
await app.inject(badToken(IP_A));
await app.inject(badToken(IP_A));
expect((await app.inject(badToken(IP_A))).statusCode).toBe(429);
expect((await app.inject(badToken(IP_B))).statusCode).toBe(401);
});
it('returns 429 to a banned IP even when it presents a valid token', async () => {
await app.inject(badToken(IP_A));
await app.inject(badToken(IP_A));
await app.inject(badToken(IP_A));
expect((await app.inject(badToken(IP_A))).statusCode).toBe(429);
const valid = await app.inject({
method: 'GET',
url: '/api/v1/sessions',
headers: { 'x-forwarded-for': IP_A, authorization: `Bearer ${TOKEN}` },
});
expect(valid.statusCode).toBe(429);
});
it('never returns 429 when no limiter is wired (loopback behavior)', async () => {
const noLimiterApp = buildApp(undefined);
await noLimiterApp.ready();
try {
for (let i = 0; i < 10; i += 1) {
expect((await noLimiterApp.inject(badToken(IP_A))).statusCode).toBe(401);
}
} finally {
await noLimiterApp.close();
}
});
});
describe('createAuthHook bypass policy (URL encoding)', () => {
let app: FastifyInstance;
beforeEach(async () => {
app = buildApp(undefined);
await app.ready();
});
afterEach(async () => {
await app.close();
});
it('requires a token for percent-encoded /api/ paths', async () => {
for (const url of ['/%61pi/v1/sessions', '/%61%70%69/v1/sessions']) {
const res = await app.inject({ method: 'GET', url });
expect(res.statusCode).toBe(401);
}
});
it('serves percent-encoded /api/ paths with a valid token', async () => {
const res = await app.inject({
method: 'GET',
url: '/%61pi/v1/sessions',
headers: { authorization: `Bearer ${TOKEN}` },
});
expect(res.statusCode).toBe(200);
});
it('requires a token for percent-encoded meta documents', async () => {
const res = await app.inject({ method: 'GET', url: '/%6fpenapi.json' });
expect(res.statusCode).toBe(401);
});
it('still bypasses non-API paths without a token', async () => {
const res = await app.inject({ method: 'GET', url: '/index.html' });
expect(res.statusCode).toBe(404);
});
it('still bypasses the healthz probe when percent-encoded', async () => {
const res = await app.inject({ method: 'GET', url: '/%61pi/v1/healthz' });
expect(res.statusCode).toBe(404);
});
});
describe('createAuthFailureLimiter (unit)', () => {
afterEach(() => {
vi.useRealTimers();
});
it('bans a source at the threshold and clears the ban after banMs', () => {
vi.useFakeTimers();
const limiter = createAuthFailureLimiter({ maxFailures: 2, windowMs: 1_000, banMs: 500 });
try {
expect(limiter.isBanned('1.2.3.4')).toBe(false);
limiter.recordFailure('1.2.3.4');
expect(limiter.isBanned('1.2.3.4')).toBe(false);
limiter.recordFailure('1.2.3.4');
expect(limiter.isBanned('1.2.3.4')).toBe(true);
vi.advanceTimersByTime(499);
expect(limiter.isBanned('1.2.3.4')).toBe(true);
vi.advanceTimersByTime(1);
expect(limiter.isBanned('1.2.3.4')).toBe(false);
} finally {
limiter.dispose();
}
});
it('resets the failure count once the window elapses', () => {
vi.useFakeTimers();
const limiter = createAuthFailureLimiter({ maxFailures: 2, windowMs: 1_000, banMs: 500 });
try {
limiter.recordFailure('5.5.5.5');
vi.advanceTimersByTime(1_001);
limiter.recordFailure('5.5.5.5');
expect(limiter.isBanned('5.5.5.5')).toBe(false);
} finally {
limiter.dispose();
}
});
it('tracks sources independently', () => {
vi.useFakeTimers();
const limiter = createAuthFailureLimiter({ maxFailures: 1, windowMs: 1_000, banMs: 500 });
try {
limiter.recordFailure('9.9.9.9');
expect(limiter.isBanned('9.9.9.9')).toBe(true);
expect(limiter.isBanned('8.8.8.8')).toBe(false);
} finally {
limiter.dispose();
}
});
});