refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,181 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {InMemoryCacheService} from '@fluxer/rate_limit/src/InMemoryCacheService';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
describe('InMemoryCacheService', () => {
let cache: InMemoryCacheService;
beforeEach(() => {
cache = new InMemoryCacheService();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('get', () => {
it('should return null for non-existent keys', async () => {
const result = await cache.get('non-existent-key');
expect(result).toBeNull();
});
it('should return the stored value for existing keys', async () => {
await cache.set('test-key', {value: 42});
const result = await cache.get<{value: number}>('test-key');
expect(result).toEqual({value: 42});
});
it('should return null for expired entries', async () => {
await cache.set('expiring-key', {data: 'test'}, 5);
vi.advanceTimersByTime(6000);
const result = await cache.get('expiring-key');
expect(result).toBeNull();
});
it('should return value for non-expired entries', async () => {
await cache.set('valid-key', {data: 'test'}, 10);
vi.advanceTimersByTime(5000);
const result = await cache.get<{data: string}>('valid-key');
expect(result).toEqual({data: 'test'});
});
it('should remove expired entries from cache on get', async () => {
await cache.set('remove-test', 'value', 1);
vi.advanceTimersByTime(2000);
await cache.get('remove-test');
vi.advanceTimersByTime(0);
const secondGet = await cache.get('remove-test');
expect(secondGet).toBeNull();
});
});
describe('set', () => {
it('should store primitive values', async () => {
await cache.set('string-key', 'hello');
await cache.set('number-key', 123);
await cache.set('boolean-key', true);
expect(await cache.get('string-key')).toBe('hello');
expect(await cache.get('number-key')).toBe(123);
expect(await cache.get('boolean-key')).toBe(true);
});
it('should store object values', async () => {
const obj = {nested: {value: [1, 2, 3]}};
await cache.set('object-key', obj);
expect(await cache.get('object-key')).toEqual(obj);
});
it('should store array values', async () => {
const arr = [1, 'two', {three: 3}];
await cache.set('array-key', arr);
expect(await cache.get('array-key')).toEqual(arr);
});
it('should overwrite existing values', async () => {
await cache.set('overwrite-key', 'first');
await cache.set('overwrite-key', 'second');
expect(await cache.get('overwrite-key')).toBe('second');
});
it('should store values without TTL indefinitely', async () => {
await cache.set('no-ttl-key', 'persistent');
vi.advanceTimersByTime(100000000);
expect(await cache.get('no-ttl-key')).toBe('persistent');
});
it('should calculate correct expiration time based on TTL', async () => {
await cache.set('ttl-key', 'value', 60);
vi.advanceTimersByTime(59999);
expect(await cache.get('ttl-key')).toBe('value');
vi.advanceTimersByTime(2);
expect(await cache.get('ttl-key')).toBeNull();
});
});
describe('delete', () => {
it('should remove existing entries', async () => {
await cache.set('delete-key', 'value');
await cache.delete('delete-key');
expect(await cache.get('delete-key')).toBeNull();
});
it('should not throw when deleting non-existent keys', async () => {
await expect(cache.delete('non-existent')).resolves.toBeUndefined();
});
it('should only delete the specified key', async () => {
await cache.set('key1', 'value1');
await cache.set('key2', 'value2');
await cache.delete('key1');
expect(await cache.get('key1')).toBeNull();
expect(await cache.get('key2')).toBe('value2');
});
});
describe('edge cases', () => {
it('should handle empty string keys', async () => {
await cache.set('', 'empty-key-value');
expect(await cache.get('')).toBe('empty-key-value');
});
it('should handle null values', async () => {
await cache.set('null-value', null);
expect(await cache.get('null-value')).toBeNull();
});
it('should handle undefined values', async () => {
await cache.set('undefined-value', undefined);
expect(await cache.get('undefined-value')).toBeUndefined();
});
it('should handle zero TTL', async () => {
await cache.set('zero-ttl', 'value', 0);
expect(await cache.get('zero-ttl')).toBe('value');
});
it('should handle very large TTL values', async () => {
await cache.set('large-ttl', 'value', Number.MAX_SAFE_INTEGER);
expect(await cache.get('large-ttl')).toBe('value');
});
it('should handle special characters in keys', async () => {
const specialKey = 'key:with:colons/and/slashes?and=query&params';
await cache.set(specialKey, 'special');
expect(await cache.get(specialKey)).toBe('special');
});
it('should handle concurrent operations', async () => {
const operations = [];
for (let i = 0; i < 100; i++) {
operations.push(cache.set(`concurrent-${i}`, i));
}
await Promise.all(operations);
const results = await Promise.all(Array.from({length: 100}, (_, i) => cache.get(`concurrent-${i}`)));
for (let i = 0; i < 100; i++) {
expect(results[i]).toBe(i);
}
});
});
});

View File

@@ -0,0 +1,112 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {throwKVRequiredError} from '@fluxer/rate_limit/src/KVRequiredError';
import {describe, expect, it} from 'vitest';
describe('throwKVRequiredError', () => {
it('should throw an error with the service name', () => {
expect(() =>
throwKVRequiredError({
serviceName: 'fluxer_api',
configPath: 'internal.kv.url',
}),
).toThrow('fluxer_api running in standalone mode requires KV-backed rate limiting');
});
it('should include the config path in the error message', () => {
expect(() =>
throwKVRequiredError({
serviceName: 'TestService',
configPath: 'config.kv.connection_string',
}),
).toThrow('config.kv.connection_string is not set');
});
it('should include the default hint when fluxerServerHint is not provided', () => {
expect(() =>
throwKVRequiredError({
serviceName: 'TestService',
configPath: 'internal.kv',
}),
).toThrow(
'If running in fluxer_server mode, ensure setInjectedKVProvider() is called before service initialization',
);
});
it('should include custom hint when fluxerServerHint is provided', () => {
expect(() =>
throwKVRequiredError({
serviceName: 'fluxer_gateway',
configPath: 'internal.kv.url',
fluxerServerHint: 'the gateway is initialized after KV setup',
}),
).toThrow('If running in fluxer_server mode, ensure the gateway is initialized after KV setup');
});
it('should construct complete error message with all parts', () => {
let errorMessage = '';
try {
throwKVRequiredError({
serviceName: 'fluxer_admin',
configPath: 'admin.kv.endpoint',
fluxerServerHint: 'KV is properly configured',
});
} catch (error) {
if (error instanceof Error) {
errorMessage = error.message;
}
}
expect(errorMessage).toContain('fluxer_admin running in standalone mode requires KV-backed rate limiting');
expect(errorMessage).toContain('admin.kv.endpoint is not set');
expect(errorMessage).toContain(
'Standalone services MUST have internal.kv configured for distributed rate limiting',
);
expect(errorMessage).toContain('If running in fluxer_server mode, ensure KV is properly configured');
});
it('should always throw (never return)', () => {
const fn = () =>
throwKVRequiredError({
serviceName: 'test',
configPath: 'test.path',
});
expect(fn).toThrow(Error);
});
it('should handle empty service name', () => {
expect(() =>
throwKVRequiredError({
serviceName: '',
configPath: 'internal.kv',
}),
).toThrow('running in standalone mode requires KV-backed rate limiting');
});
it('should handle empty config path', () => {
expect(() =>
throwKVRequiredError({
serviceName: 'TestService',
configPath: '',
}),
).toThrow('is not set');
});
});

View File

@@ -0,0 +1,440 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {HttpStatus} from '@fluxer/constants/src/HttpConstants';
import {InMemoryCacheService} from '@fluxer/rate_limit/src/InMemoryCacheService';
import type {IRateLimitService} from '@fluxer/rate_limit/src/IRateLimitService';
import {
createRateLimitMiddleware,
type RateLimitMiddlewareConfig,
type RateLimitMiddlewareOptions,
setRateLimitHeaders,
} from '@fluxer/rate_limit/src/middleware/RateLimitMiddleware';
import {RateLimitService} from '@fluxer/rate_limit/src/RateLimitService';
import {Hono} from 'hono';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
describe('RateLimitMiddleware', () => {
let app: Hono;
let rateLimitService: IRateLimitService;
beforeEach(() => {
vi.useFakeTimers();
const cache = new InMemoryCacheService();
rateLimitService = new RateLimitService(cache);
app = new Hono();
});
afterEach(() => {
vi.useRealTimers();
});
function setupApp(config: RateLimitMiddlewareConfig, options?: Partial<RateLimitMiddlewareOptions>) {
const middleware = createRateLimitMiddleware({
rateLimitService,
config,
getClientIdentifier: (req) => req.headers.get('X-Forwarded-For') ?? '127.0.0.1',
...options,
});
app.use('*', middleware);
app.get('/test', (c) => c.json({message: 'success'}));
app.get('/_health', (c) => c.json({status: 'ok'}));
app.post('/api/messages', (c) => c.json({created: true}));
}
describe('basic rate limiting', () => {
it('should allow requests within the limit', async () => {
setupApp({
enabled: true,
limit: 5,
windowMs: 60000,
});
const response = await app.request('/test', {
headers: {'X-Forwarded-For': '192.168.1.1'},
});
expect(response.status).toBe(200);
const body = (await response.json()) as {message: string};
expect(body.message).toBe('success');
});
it('should block requests exceeding the limit', async () => {
setupApp({
enabled: true,
limit: 3,
windowMs: 60000,
});
for (let i = 0; i < 3; i++) {
await app.request('/test', {
headers: {'X-Forwarded-For': '192.168.1.1'},
});
}
const response = await app.request('/test', {
headers: {'X-Forwarded-For': '192.168.1.1'},
});
expect(response.status).toBe(HttpStatus.TOO_MANY_REQUESTS);
const body = (await response.json()) as {error: string; retryAfter: number};
expect(body.error).toBe('Rate limit exceeded');
expect(body.retryAfter).toBeGreaterThan(0);
});
it('should track different clients separately', async () => {
setupApp({
enabled: true,
limit: 2,
windowMs: 60000,
});
await app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.1'}});
await app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.1'}});
const blockedResponse = await app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.1'}});
const otherClientResponse = await app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.2'}});
expect(blockedResponse.status).toBe(HttpStatus.TOO_MANY_REQUESTS);
expect(otherClientResponse.status).toBe(200);
});
});
describe('rate limit headers', () => {
it('should include X-RateLimit-Limit header', async () => {
setupApp({
enabled: true,
limit: 10,
windowMs: 60000,
});
const response = await app.request('/test', {
headers: {'X-Forwarded-For': '192.168.1.1'},
});
expect(response.headers.get('X-RateLimit-Limit')).toBe('10');
});
it('should include X-RateLimit-Remaining header', async () => {
setupApp({
enabled: true,
limit: 10,
windowMs: 60000,
});
const response1 = await app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.1'}});
const response2 = await app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.1'}});
expect(response1.headers.get('X-RateLimit-Remaining')).toBe('9');
expect(response2.headers.get('X-RateLimit-Remaining')).toBe('8');
});
it('should include X-RateLimit-Reset header', async () => {
setupApp({
enabled: true,
limit: 10,
windowMs: 60000,
});
const response = await app.request('/test', {
headers: {'X-Forwarded-For': '192.168.1.1'},
});
const resetHeader = response.headers.get('X-RateLimit-Reset');
expect(resetHeader).not.toBeNull();
const resetTime = parseInt(resetHeader!, 10);
expect(resetTime).toBeGreaterThan(Math.floor(Date.now() / 1000));
});
it('should include Retry-After header when rate limited', async () => {
setupApp({
enabled: true,
limit: 1,
windowMs: 60000,
});
await app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.1'}});
const response = await app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.1'}});
expect(response.status).toBe(HttpStatus.TOO_MANY_REQUESTS);
const retryAfter = response.headers.get('Retry-After');
expect(retryAfter).not.toBeNull();
expect(parseInt(retryAfter!, 10)).toBeGreaterThan(0);
});
});
describe('skipPaths configuration', () => {
it('should skip rate limiting for exact path matches', async () => {
setupApp({
enabled: true,
limit: 1,
windowMs: 60000,
skipPaths: ['/_health'],
});
await app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.1'}});
const healthResponse1 = await app.request('/_health', {headers: {'X-Forwarded-For': '192.168.1.1'}});
const healthResponse2 = await app.request('/_health', {headers: {'X-Forwarded-For': '192.168.1.1'}});
const healthResponse3 = await app.request('/_health', {headers: {'X-Forwarded-For': '192.168.1.1'}});
expect(healthResponse1.status).toBe(200);
expect(healthResponse2.status).toBe(200);
expect(healthResponse3.status).toBe(200);
});
it('should skip rate limiting for prefix path matches', async () => {
setupApp({
enabled: true,
limit: 1,
windowMs: 60000,
skipPaths: ['/api/'],
});
await app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.1'}});
const blockedTest = await app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.1'}});
const apiResponse = await app.request('/api/messages', {
method: 'POST',
headers: {'X-Forwarded-For': '192.168.1.1'},
});
expect(blockedTest.status).toBe(HttpStatus.TOO_MANY_REQUESTS);
expect(apiResponse.status).toBe(200);
});
it('should still rate limit non-skipped paths', async () => {
setupApp({
enabled: true,
limit: 1,
windowMs: 60000,
skipPaths: ['/_health'],
});
await app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.1'}});
const response = await app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.1'}});
expect(response.status).toBe(HttpStatus.TOO_MANY_REQUESTS);
});
});
describe('disabled rate limiting', () => {
it('should bypass rate limiting when disabled', async () => {
setupApp({
enabled: false,
limit: 1,
windowMs: 60000,
});
const responses = await Promise.all([
app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.1'}}),
app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.1'}}),
app.request('/test', {headers: {'X-Forwarded-For': '192.168.1.1'}}),
]);
expect(responses.every((r) => r.status === 200)).toBe(true);
});
});
describe('null rate limit service', () => {
it('should bypass rate limiting when service is null', async () => {
const middleware = createRateLimitMiddleware({
rateLimitService: null,
config: {
enabled: true,
limit: 1,
windowMs: 60000,
},
getClientIdentifier: () => '127.0.0.1',
});
const testApp = new Hono();
testApp.use('*', middleware);
testApp.get('/test', (c) => c.json({ok: true}));
const responses = await Promise.all([
testApp.request('/test'),
testApp.request('/test'),
testApp.request('/test'),
]);
expect(responses.every((r) => r.status === 200)).toBe(true);
});
it('should support lazy service provider function', async () => {
let callCount = 0;
const serviceProvider = () => {
callCount++;
return rateLimitService;
};
const middleware = createRateLimitMiddleware({
rateLimitService: serviceProvider,
config: {
enabled: true,
limit: 5,
windowMs: 60000,
},
getClientIdentifier: () => '127.0.0.1',
});
const testApp = new Hono();
testApp.use('*', middleware);
testApp.get('/test', (c) => c.json({ok: true}));
await testApp.request('/test');
await testApp.request('/test');
expect(callCount).toBe(2);
});
it('should handle service provider returning null', async () => {
const middleware = createRateLimitMiddleware({
rateLimitService: () => null,
config: {
enabled: true,
limit: 1,
windowMs: 60000,
},
getClientIdentifier: () => '127.0.0.1',
});
const testApp = new Hono();
testApp.use('*', middleware);
testApp.get('/test', (c) => c.json({ok: true}));
const responses = await Promise.all([testApp.request('/test'), testApp.request('/test')]);
expect(responses.every((r) => r.status === 200)).toBe(true);
});
});
describe('custom bucket naming', () => {
it('should use custom bucket name from getBucketName', async () => {
const middleware = createRateLimitMiddleware({
rateLimitService,
config: {
enabled: true,
limit: 2,
windowMs: 60000,
},
getClientIdentifier: (req) => req.headers.get('X-Forwarded-For') ?? '127.0.0.1',
getBucketName: (identifier, c) => `${identifier}:${c.req.method}:${c.req.path}`,
});
const testApp = new Hono();
testApp.use('*', middleware);
testApp.get('/resource', (c) => c.json({ok: true}));
testApp.post('/resource', (c) => c.json({created: true}));
await testApp.request('/resource', {method: 'GET', headers: {'X-Forwarded-For': '1.1.1.1'}});
await testApp.request('/resource', {method: 'GET', headers: {'X-Forwarded-For': '1.1.1.1'}});
const getBlocked = await testApp.request('/resource', {method: 'GET', headers: {'X-Forwarded-For': '1.1.1.1'}});
const postAllowed = await testApp.request('/resource', {method: 'POST', headers: {'X-Forwarded-For': '1.1.1.1'}});
expect(getBlocked.status).toBe(HttpStatus.TOO_MANY_REQUESTS);
expect(postAllowed.status).toBe(200);
});
});
describe('custom onRateLimitExceeded handler', () => {
it('should use custom response when rate limit exceeded', async () => {
const middleware = createRateLimitMiddleware({
rateLimitService,
config: {
enabled: true,
limit: 1,
windowMs: 60000,
},
getClientIdentifier: () => '127.0.0.1',
onRateLimitExceeded: (c, retryAfter) => {
return c.json(
{
code: 'RATE_LIMITED',
message: 'Slow down!',
retry_after: retryAfter,
},
HttpStatus.TOO_MANY_REQUESTS,
);
},
});
const testApp = new Hono();
testApp.use('*', middleware);
testApp.get('/test', (c) => c.json({ok: true}));
await testApp.request('/test');
const response = await testApp.request('/test');
expect(response.status).toBe(HttpStatus.TOO_MANY_REQUESTS);
const body = (await response.json()) as {code: string; message: string; retry_after: number};
expect(body.code).toBe('RATE_LIMITED');
expect(body.message).toBe('Slow down!');
expect(body.retry_after).toBeGreaterThan(0);
});
});
});
describe('setRateLimitHeaders', () => {
it('should set all required headers', () => {
const headers: Record<string, string> = {};
const ctx = {
header: (name: string, value: string) => {
headers[name] = value;
},
};
const resetTime = new Date(Date.now() + 60000);
setRateLimitHeaders(ctx, 100, 75, resetTime);
expect(headers['X-RateLimit-Limit']).toBe('100');
expect(headers['X-RateLimit-Remaining']).toBe('75');
expect(headers['X-RateLimit-Reset']).toBe(Math.floor(resetTime.getTime() / 1000).toString());
});
it('should handle zero remaining', () => {
const headers: Record<string, string> = {};
const ctx = {
header: (name: string, value: string) => {
headers[name] = value;
},
};
const resetTime = new Date(Date.now() + 30000);
setRateLimitHeaders(ctx, 10, 0, resetTime);
expect(headers['X-RateLimit-Remaining']).toBe('0');
});
it('should convert reset time to unix seconds', () => {
const headers: Record<string, string> = {};
const ctx = {
header: (name: string, value: string) => {
headers[name] = value;
},
};
const resetTimeMs = 1700000000000;
const resetTime = new Date(resetTimeMs);
setRateLimitHeaders(ctx, 50, 25, resetTime);
expect(headers['X-RateLimit-Reset']).toBe('1700000000');
});
});

View File

@@ -0,0 +1,623 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {InMemoryCacheService} from '@fluxer/rate_limit/src/InMemoryCacheService';
import {createInMemoryRateLimitService, RateLimitService} from '@fluxer/rate_limit/src/RateLimitService';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
describe('RateLimitService (GCRA)', () => {
let cache: InMemoryCacheService;
let service: RateLimitService;
beforeEach(() => {
cache = new InMemoryCacheService();
service = new RateLimitService(cache);
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-27T12:00:00.000Z'));
});
afterEach(() => {
vi.useRealTimers();
});
describe('checkLimit - basic behavior', () => {
it('should allow first request', async () => {
const config = {
identifier: 'user:123',
maxAttempts: 5,
windowMs: 5000,
};
const result = await service.checkLimit(config);
expect(result.allowed).toBe(true);
expect(result.limit).toBe(5);
expect(result.remaining).toBe(4);
expect(result.resetTime).toBeInstanceOf(Date);
});
it('should decrement remaining with each request', async () => {
const config = {
identifier: 'user:123',
maxAttempts: 5,
windowMs: 5000,
};
const r1 = await service.checkLimit(config);
const r2 = await service.checkLimit(config);
const r3 = await service.checkLimit(config);
expect(r1.remaining).toBe(4);
expect(r2.remaining).toBe(3);
expect(r3.remaining).toBe(2);
});
it('should allow burst up to limit', async () => {
const config = {
identifier: 'user:burst',
maxAttempts: 5,
windowMs: 5000,
};
const results = [];
for (let i = 0; i < 5; i++) {
results.push(await service.checkLimit(config));
}
expect(results.every((r) => r.allowed)).toBe(true);
expect(results[4].remaining).toBe(0);
});
it('should block when burst exhausted', async () => {
const config = {
identifier: 'user:blocked',
maxAttempts: 5,
windowMs: 5000,
};
for (let i = 0; i < 5; i++) {
await service.checkLimit(config);
}
const blocked = await service.checkLimit(config);
expect(blocked.allowed).toBe(false);
expect(blocked.remaining).toBe(0);
});
it('should isolate different identifiers', async () => {
const config1 = {identifier: 'user:1', maxAttempts: 2, windowMs: 5000};
const config2 = {identifier: 'user:2', maxAttempts: 2, windowMs: 5000};
await service.checkLimit(config1);
await service.checkLimit(config1);
const user1Blocked = await service.checkLimit(config1);
const user2Allowed = await service.checkLimit(config2);
expect(user1Blocked.allowed).toBe(false);
expect(user2Allowed.allowed).toBe(true);
});
});
describe('checkLimit - GCRA token refill', () => {
it('should allow request after one emission interval', async () => {
const config = {
identifier: 'user:refill',
maxAttempts: 5,
windowMs: 5000,
};
for (let i = 0; i < 5; i++) {
await service.checkLimit(config);
}
const blocked = await service.checkLimit(config);
expect(blocked.allowed).toBe(false);
vi.advanceTimersByTime(1000);
const allowed = await service.checkLimit(config);
expect(allowed.allowed).toBe(true);
});
it('should refill tokens gradually over time', async () => {
const config = {
identifier: 'user:gradual',
maxAttempts: 10,
windowMs: 10000,
};
for (let i = 0; i < 10; i++) {
await service.checkLimit(config);
}
vi.advanceTimersByTime(3000);
const r1 = await service.checkLimit(config);
const r2 = await service.checkLimit(config);
const r3 = await service.checkLimit(config);
const r4 = await service.checkLimit(config);
expect(r1.allowed).toBe(true);
expect(r2.allowed).toBe(true);
expect(r3.allowed).toBe(true);
expect(r4.allowed).toBe(false);
});
it('should allow sustained traffic at steady rate', async () => {
const config = {
identifier: 'user:steady',
maxAttempts: 5,
windowMs: 5000,
};
for (let i = 0; i < 5; i++) {
await service.checkLimit(config);
}
for (let i = 0; i < 10; i++) {
vi.advanceTimersByTime(1000);
const result = await service.checkLimit(config);
expect(result.allowed).toBe(true);
}
});
it('should fully reset after window duration with no requests', async () => {
const config = {
identifier: 'user:full-reset',
maxAttempts: 5,
windowMs: 5000,
};
for (let i = 0; i < 5; i++) {
await service.checkLimit(config);
}
vi.advanceTimersByTime(5001);
const results = [];
for (let i = 0; i < 5; i++) {
results.push(await service.checkLimit(config));
}
expect(results.every((r) => r.allowed)).toBe(true);
});
});
describe('retry-after correctness', () => {
it('should return valid retry-after when blocked', async () => {
const config = {
identifier: 'retry:basic',
maxAttempts: 5,
windowMs: 5000,
};
for (let i = 0; i < 5; i++) {
await service.checkLimit(config);
}
const blocked = await service.checkLimit(config);
expect(blocked.allowed).toBe(false);
expect(blocked.retryAfter).toBe(1);
expect(blocked.retryAfterDecimal).toBeCloseTo(1, 1);
expect(Number.isFinite(blocked.retryAfter)).toBe(true);
expect(Number.isNaN(blocked.retryAfter)).toBe(false);
});
it('should have retry-after that decreases as time passes', async () => {
const config = {
identifier: 'retry:decrease',
maxAttempts: 2,
windowMs: 10000,
};
await service.checkLimit(config);
await service.checkLimit(config);
const blocked1 = await service.checkLimit(config);
expect(blocked1.retryAfter).toBe(5);
vi.advanceTimersByTime(2000);
const blocked2 = await service.checkLimit(config);
expect(blocked2.retryAfter).toBe(3);
});
it('should allow request after waiting retry-after duration', async () => {
const config = {
identifier: 'retry:wait',
maxAttempts: 3,
windowMs: 6000,
};
await service.checkLimit(config);
await service.checkLimit(config);
await service.checkLimit(config);
const blocked = await service.checkLimit(config);
expect(blocked.allowed).toBe(false);
vi.advanceTimersByTime(blocked.retryAfter! * 1000);
const allowed = await service.checkLimit(config);
expect(allowed.allowed).toBe(true);
});
it('should have minimum retry-after of 1 second', async () => {
const config = {
identifier: 'retry:minimum',
maxAttempts: 10,
windowMs: 1000,
};
for (let i = 0; i < 10; i++) {
await service.checkLimit(config);
}
const blocked = await service.checkLimit(config);
expect(blocked.retryAfter).toBeGreaterThanOrEqual(1);
});
});
describe('reset time correctness', () => {
it('should return reset time in the future when allowed', async () => {
const config = {
identifier: 'reset:allowed',
maxAttempts: 5,
windowMs: 5000,
};
const result = await service.checkLimit(config);
const now = Date.now();
expect(result.resetTime.getTime()).toBeGreaterThan(now);
});
it('should return valid reset time when blocked', async () => {
const config = {
identifier: 'reset:blocked',
maxAttempts: 2,
windowMs: 10000,
};
await service.checkLimit(config);
await service.checkLimit(config);
const blocked = await service.checkLimit(config);
const now = Date.now();
expect(blocked.resetTime.getTime()).toBeGreaterThan(now);
expect(Number.isFinite(blocked.resetTime.getTime())).toBe(true);
});
it('should produce valid X-RateLimit-Reset timestamp', async () => {
const config = {
identifier: 'reset:header',
maxAttempts: 3,
windowMs: 30000,
};
await service.checkLimit(config);
const result = await service.checkLimit(config);
const resetTimestamp = Math.floor(result.resetTime.getTime() / 1000);
const nowTimestamp = Math.floor(Date.now() / 1000);
expect(resetTimestamp).toBeGreaterThan(nowTimestamp);
expect(resetTimestamp).toBeLessThanOrEqual(nowTimestamp + 30);
});
});
describe('checkBucketLimit', () => {
it('should track by bucket name', async () => {
const config = {limit: 10, windowMs: 60000};
const result = await service.checkBucketLimit('api:messages', config);
expect(result.allowed).toBe(true);
expect(result.limit).toBe(10);
});
it('should isolate different buckets', async () => {
const config = {limit: 2, windowMs: 5000};
await service.checkBucketLimit('bucket:a', config);
await service.checkBucketLimit('bucket:a', config);
const aBlocked = await service.checkBucketLimit('bucket:a', config);
const bAllowed = await service.checkBucketLimit('bucket:b', config);
expect(aBlocked.allowed).toBe(false);
expect(bAllowed.allowed).toBe(true);
});
});
describe('checkGlobalLimit', () => {
it('should use 1 second window', async () => {
const limit = 5;
for (let i = 0; i < 5; i++) {
await service.checkGlobalLimit('ip:test', limit);
}
const blocked = await service.checkGlobalLimit('ip:test', limit);
expect(blocked.allowed).toBe(false);
expect(blocked.global).toBe(true);
vi.advanceTimersByTime(200);
const allowed = await service.checkGlobalLimit('ip:test', limit);
expect(allowed.allowed).toBe(true);
});
it('should include global flag', async () => {
const result = await service.checkGlobalLimit('ip:flag', 10);
expect(result.global).toBe(true);
});
});
describe('resetLimit', () => {
it('should clear rate limit state', async () => {
const config = {
identifier: 'user:reset',
maxAttempts: 2,
windowMs: 60000,
};
await service.checkLimit(config);
await service.checkLimit(config);
const blocked = await service.checkLimit(config);
expect(blocked.allowed).toBe(false);
await service.resetLimit('user:reset');
const allowed = await service.checkLimit(config);
expect(allowed.allowed).toBe(true);
expect(allowed.remaining).toBe(1);
});
});
describe('edge cases', () => {
it('should handle limit of 1', async () => {
const config = {
identifier: 'edge:one',
maxAttempts: 1,
windowMs: 5000,
};
const first = await service.checkLimit(config);
expect(first.allowed).toBe(true);
expect(first.remaining).toBe(0);
const second = await service.checkLimit(config);
expect(second.allowed).toBe(false);
});
it('should handle very large limits', async () => {
const config = {
identifier: 'edge:large',
maxAttempts: 1000000,
windowMs: 60000,
};
const result = await service.checkLimit(config);
expect(result.allowed).toBe(true);
expect(result.remaining).toBeGreaterThan(999990);
});
it('should handle very short windows', async () => {
const config = {
identifier: 'edge:short',
maxAttempts: 5,
windowMs: 100,
};
for (let i = 0; i < 5; i++) {
await service.checkLimit(config);
}
const blocked = await service.checkLimit(config);
expect(blocked.allowed).toBe(false);
vi.advanceTimersByTime(25);
const allowed = await service.checkLimit(config);
expect(allowed.allowed).toBe(true);
});
it('should handle concurrent requests', async () => {
const config = {
identifier: 'edge:concurrent',
maxAttempts: 10,
windowMs: 60000,
};
const results = await Promise.all([
service.checkLimit(config),
service.checkLimit(config),
service.checkLimit(config),
service.checkLimit(config),
service.checkLimit(config),
]);
const allowedCount = results.filter((r) => r.allowed).length;
expect(allowedCount).toBe(5);
});
it('should handle special characters in identifier', async () => {
const config = {
identifier: 'user:test@example.com:action:post:/api/v1/messages',
maxAttempts: 5,
windowMs: 60000,
};
const result = await service.checkLimit(config);
expect(result.allowed).toBe(true);
});
it('should handle corrupted cache data', async () => {
await cache.set('ratelimit:corrupted', {invalid: 'data'}, 60);
const config = {
identifier: 'corrupted',
maxAttempts: 5,
windowMs: 60000,
};
const result = await service.checkLimit(config);
expect(result.allowed).toBe(true);
});
it('should handle NaN in cache', async () => {
await cache.set('ratelimit:nan', {tat: NaN}, 60);
const config = {
identifier: 'nan',
maxAttempts: 5,
windowMs: 60000,
};
const result = await service.checkLimit(config);
expect(result.allowed).toBe(true);
});
it('should handle Infinity in cache', async () => {
await cache.set('ratelimit:inf', {tat: Infinity}, 60);
const config = {
identifier: 'inf',
maxAttempts: 5,
windowMs: 60000,
};
const result = await service.checkLimit(config);
expect(result.allowed).toBe(true);
});
});
describe('GCRA algorithm verification', () => {
it('should enforce emission interval after burst', async () => {
const config = {
identifier: 'gcra:emission',
maxAttempts: 5,
windowMs: 5000,
};
for (let i = 0; i < 5; i++) {
await service.checkLimit(config);
}
const blocked = await service.checkLimit(config);
expect(blocked.allowed).toBe(false);
vi.advanceTimersByTime(999);
const stillBlocked = await service.checkLimit(config);
expect(stillBlocked.allowed).toBe(false);
vi.advanceTimersByTime(2);
const allowed = await service.checkLimit(config);
expect(allowed.allowed).toBe(true);
});
it('should track theoretical arrival time correctly', async () => {
const config = {
identifier: 'gcra:tat',
maxAttempts: 10,
windowMs: 10000,
};
const r1 = await service.checkLimit(config);
expect(r1.resetTime.getTime()).toBe(Date.now() + 1000);
await service.checkLimit(config);
const r2 = await service.checkLimit(config);
expect(r2.resetTime.getTime()).toBe(Date.now() + 3000);
});
it('should handle time advancing past TAT', async () => {
const config = {
identifier: 'gcra:past-tat',
maxAttempts: 5,
windowMs: 5000,
};
await service.checkLimit(config);
await service.checkLimit(config);
vi.advanceTimersByTime(10000);
const result = await service.checkLimit(config);
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(4);
});
});
describe('remaining count accuracy', () => {
it('should show correct remaining after partial refill', async () => {
const config = {
identifier: 'remaining:partial',
maxAttempts: 10,
windowMs: 10000,
};
for (let i = 0; i < 10; i++) {
await service.checkLimit(config);
}
vi.advanceTimersByTime(5000);
const result = await service.checkLimit(config);
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(4);
});
});
});
describe('createInMemoryRateLimitService', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should return null when disabled', () => {
const service = createInMemoryRateLimitService(false);
expect(service).toBeNull();
});
it('should return RateLimitService when enabled', () => {
const service = createInMemoryRateLimitService(true);
expect(service).toBeInstanceOf(RateLimitService);
});
it('should be functional', async () => {
const service = createInMemoryRateLimitService(true)!;
const result = await service.checkLimit({
identifier: 'test',
maxAttempts: 5,
windowMs: 60000,
});
expect(result.allowed).toBe(true);
});
});