chore(repo): remove dead code and useless tests (#3375)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-10 21:43:29 +02:00
1 parent 7e177a1c90
commit 1cd62eb661
1391 files changed
+783 -120415

No files matched your search

@@ -1,34 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createErrorHandler as createFluxerErrorHandler} from '@fluxer/errors/src/ErrorHandler';
import {resolveRoutePattern} from '@fluxer/errors/src/error_handling/RoutePattern';
import type {Context, ErrorHandler} from 'hono';
export interface ErrorHandlerOptions {
includeStack?: boolean;
logger?: (error: Error, context: Context) => void;
captureException?: (error: Error, context?: Record<string, unknown>) => void;
}
export function createErrorHandler(options: ErrorHandlerOptions = {}): ErrorHandler {
const {includeStack = false, logger, captureException} = options;
const logError =
logger || captureException
? (error: Error, context: Context) => {
if (logger) {
logger(error, context);
}
if (captureException) {
captureException(error, {
path: resolveRoutePattern(context),
method: context.req.method,
status: context.res?.status,
});
}
}
: undefined;
return createFluxerErrorHandler({
includeStack,
logError,
});
}
@@ -4,14 +4,8 @@ import type {CacheHeadersOptions} from '@fluxer/hono/src/middleware/CacheHeaders
import {cacheHeaders} from '@fluxer/hono/src/middleware/CacheHeaders';
import type {CorsOptions} from '@fluxer/hono/src/middleware/Cors';
import {cors} from '@fluxer/hono/src/middleware/Cors';
import type {ErrorHandlerOptions} from '@fluxer/hono/src/middleware/ErrorHandler';
import {createErrorHandler} from '@fluxer/hono/src/middleware/ErrorHandler';
import type {RateLimitOptions, RateLimitService} from '@fluxer/hono/src/middleware/RateLimit';
import {rateLimit} from '@fluxer/hono/src/middleware/RateLimit';
import type {RequestIdOptions} from '@fluxer/hono/src/middleware/RequestId';
import {requestId} from '@fluxer/hono/src/middleware/RequestId';
import type {LogFunction, RequestLoggerOptions} from '@fluxer/hono/src/middleware/RequestLogger';
import {requestLogger} from '@fluxer/hono/src/middleware/RequestLogger';
import {fluxerVersionHeader} from '@fluxer/hono/src/middleware/VersionHeader';
import type {Env, Hono, MiddlewareHandler} from 'hono';
@@ -19,26 +13,9 @@ interface MiddlewareStackOptions {
requestId?: RequestIdOptions;
cors?: CorsOptions;
cacheHeaders?: CacheHeadersOptions | false;
logger?: Omit<RequestLoggerOptions, 'log'> & {
log?: LogFunction;
};
rateLimit?: RateLimitOptions & {
service?: RateLimitService;
};
errorHandler?: ErrorHandlerOptions;
customMiddleware?: Array<MiddlewareHandler>;
}
interface ApplyMiddlewareStackOptions extends MiddlewareStackOptions {
skipRequestId?: boolean;
skipCors?: boolean;
skipCacheHeaders?: boolean;
skipLogger?: boolean;
skipRateLimit?: boolean;
skipErrorHandler?: boolean;
}
function createStandardMiddlewareStack(options: MiddlewareStackOptions = {}): Array<MiddlewareHandler> {
function createStandardMiddlewareStack(options: MiddlewareStackOptions): Array<MiddlewareHandler> {
const stack: Array<MiddlewareHandler> = [fluxerVersionHeader()];
if (options.requestId) {
stack.push(requestId(options.requestId));
@@ -49,44 +26,11 @@ function createStandardMiddlewareStack(options: MiddlewareStackOptions = {}): Ar
if (options.cacheHeaders !== false) {
stack.push(cacheHeaders(options.cacheHeaders ?? {}));
}
if (options.logger?.log) {
stack.push(
requestLogger({
log: options.logger.log,
skip: options.logger.skip,
}),
);
}
if (options.rateLimit && options.rateLimit.enabled !== false && options.rateLimit.service) {
stack.push(rateLimit(options.rateLimit));
}
if (options.customMiddleware) {
stack.push(...options.customMiddleware);
}
return stack;
}
function buildStackOptions(options: ApplyMiddlewareStackOptions): MiddlewareStackOptions {
return {
requestId: options.skipRequestId ? undefined : options.requestId,
cors: options.skipCors ? undefined : options.cors,
cacheHeaders: options.skipCacheHeaders ? false : options.cacheHeaders,
logger: options.skipLogger ? undefined : options.logger,
rateLimit: options.skipRateLimit ? undefined : options.rateLimit,
customMiddleware: options.customMiddleware,
};
}
export function applyMiddlewareStack<E extends Env = Env>(
app: Hono<E>,
options: ApplyMiddlewareStackOptions = {},
): void {
const stack = createStandardMiddlewareStack(buildStackOptions(options));
for (const middleware of stack) {
export function applyMiddlewareStack<E extends Env = Env>(app: Hono<E>, options: MiddlewareStackOptions = {}): void {
for (const middleware of createStandardMiddlewareStack(options)) {
app.use('*', middleware);
}
if (!options.skipErrorHandler) {
const errorHandler = createErrorHandler(options.errorHandler ?? {});
app.onError(errorHandler);
}
}
-98
View File
@@ -1,98 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {matchesAnyPathPattern} from '@fluxer/hono/src/middleware/utils/PathMatchers';
import {extractClientIp} from '@fluxer/ip_utils/src/ClientIp';
import type {MiddlewareHandler} from 'hono';
export interface RateLimitResult {
allowed: boolean;
limit: number;
remaining: number;
resetTime: Date;
retryAfter?: number;
}
export interface RateLimitService {
checkLimit(params: {identifier: string; maxAttempts: number; windowMs: number}): Promise<RateLimitResult>;
}
type KeyGenerator = (request: Request) => string | null | Promise<string | null>;
export interface RateLimitOptions {
enabled?: boolean;
skipPaths?: Array<string>;
service?: RateLimitService;
maxAttempts?: number;
windowMs?: number;
keyGenerator?: KeyGenerator;
onLimitExceeded?: (identifier: string, path: string) => void;
trustClientIpHeader?: boolean;
clientIpHeaderName?: string;
}
function getClientIp(req: Request, trustClientIpHeader?: boolean, clientIpHeaderName?: string): string | null {
return extractClientIp(req, {trustClientIpHeader, clientIpHeaderName});
}
function createDefaultKeyGenerator(trustClientIpHeader?: boolean, clientIpHeaderName?: string): KeyGenerator {
return function defaultKeyGenerator(req: Request): string | null {
return getClientIp(req, trustClientIpHeader, clientIpHeaderName);
};
}
export function rateLimit(options: RateLimitOptions = {}): MiddlewareHandler {
const {
enabled = true,
skipPaths = ['/_health', '/metrics'],
service,
maxAttempts = 100,
windowMs = 60000,
keyGenerator,
onLimitExceeded,
trustClientIpHeader = false,
clientIpHeaderName,
} = options;
const resolvedKeyGenerator = keyGenerator ?? createDefaultKeyGenerator(trustClientIpHeader, clientIpHeaderName);
return async (c, next) => {
if (!enabled || !service) {
await next();
return;
}
const path = c.req.path;
if (matchesAnyPathPattern(path, skipPaths)) {
await next();
return;
}
const identifier = await resolvedKeyGenerator(c.req.raw);
if (!identifier) {
await next();
return;
}
const result = await service.checkLimit({
identifier,
maxAttempts,
windowMs,
});
c.header('X-RateLimit-Limit', result.limit.toString());
c.header('X-RateLimit-Remaining', result.remaining.toString());
c.header('X-RateLimit-Reset', Math.floor(result.resetTime.getTime() / 1000).toString());
if (!result.allowed) {
if (result.retryAfter !== undefined) {
c.header('Retry-After', result.retryAfter.toString());
}
if (onLimitExceeded) {
onLimitExceeded(identifier, path);
}
return c.json(
{
error: 'Too Many Requests',
message: 'Rate limit exceeded',
retryAfter: result.retryAfter,
},
429,
);
}
await next();
return;
};
}
@@ -5,27 +5,6 @@ import {Hono} from 'hono';
import {describe, expect, test} from 'vitest';
describe('CORS Middleware', () => {
describe('wildcard origin', () => {
test('sets Access-Control-Allow-Origin to * for wildcard origins', async () => {
const app = new Hono();
app.use('*', cors({origins: '*'}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
headers: {origin: 'https://example.com'},
});
expect(response.status).toBe(200);
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
});
test('does not set Vary header for wildcard origins', async () => {
const app = new Hono();
app.use('*', cors({origins: '*'}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
headers: {origin: 'https://example.com'},
});
expect(response.headers.get('Vary')).toBeNull();
});
});
describe('specific origins', () => {
test('allows requests from whitelisted origin', async () => {
const app = new Hono();
@@ -49,158 +28,5 @@ describe('CORS Middleware', () => {
expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull();
expect(response.headers.get('Vary')).toBe('Origin');
});
test('does not set origin header when no origin header in request', async () => {
const app = new Hono();
app.use('*', cors({origins: ['https://allowed.com']}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.status).toBe(200);
expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull();
});
});
describe('credentials', () => {
test('sets Access-Control-Allow-Credentials when credentials is true', async () => {
const app = new Hono();
app.use('*', cors({credentials: true}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.headers.get('Access-Control-Allow-Credentials')).toBe('true');
});
test('does not set Access-Control-Allow-Credentials when credentials is false', async () => {
const app = new Hono();
app.use('*', cors({credentials: false}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.headers.get('Access-Control-Allow-Credentials')).toBeNull();
});
});
describe('preflight requests (OPTIONS)', () => {
test('responds to preflight with 204 status', async () => {
const app = new Hono();
app.use('*', cors());
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
method: 'OPTIONS',
headers: {origin: 'https://example.com'},
});
expect(response.status).toBe(204);
});
test('sets Access-Control-Allow-Methods header on preflight', async () => {
const app = new Hono();
app.use('*', cors({methods: ['GET', 'POST', 'PUT']}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
method: 'OPTIONS',
});
expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET, POST, PUT');
});
test('uses default methods when not specified', async () => {
const app = new Hono();
app.use('*', cors());
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
method: 'OPTIONS',
});
const methods = response.headers.get('Access-Control-Allow-Methods');
expect(methods).toContain('GET');
expect(methods).toContain('POST');
expect(methods).toContain('PUT');
expect(methods).toContain('DELETE');
expect(methods).toContain('OPTIONS');
});
test('sets Access-Control-Allow-Headers header on preflight', async () => {
const app = new Hono();
app.use('*', cors({allowedHeaders: ['Content-Type', 'Authorization', 'X-Custom-Header']}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
method: 'OPTIONS',
});
expect(response.headers.get('Access-Control-Allow-Headers')).toBe('Content-Type, Authorization, X-Custom-Header');
});
test('sets Access-Control-Max-Age header on preflight', async () => {
const app = new Hono();
app.use('*', cors({maxAge: 3600}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
method: 'OPTIONS',
});
expect(response.headers.get('Access-Control-Max-Age')).toBe('3600');
});
test('uses default maxAge of 86400', async () => {
const app = new Hono();
app.use('*', cors());
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
method: 'OPTIONS',
});
expect(response.headers.get('Access-Control-Max-Age')).toBe('86400');
});
});
describe('exposed headers', () => {
test('sets Access-Control-Expose-Headers when exposedHeaders is provided', async () => {
const app = new Hono();
app.use('*', cors({exposedHeaders: ['X-Custom-Header', 'X-Another-Header']}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.headers.get('Access-Control-Expose-Headers')).toBe('X-Custom-Header, X-Another-Header');
});
test('does not set Access-Control-Expose-Headers when empty', async () => {
const app = new Hono();
app.use('*', cors({exposedHeaders: []}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.headers.get('Access-Control-Expose-Headers')).toBeNull();
});
test('sets exposed headers on both preflight and regular requests', async () => {
const app = new Hono();
app.use('*', cors({exposedHeaders: ['X-Custom-Header']}));
app.get('/test', (c) => c.json({ok: true}));
const preflightResponse = await app.request('/test', {method: 'OPTIONS'});
expect(preflightResponse.headers.get('Access-Control-Expose-Headers')).toBe('X-Custom-Header');
const regularResponse = await app.request('/test');
expect(regularResponse.headers.get('Access-Control-Expose-Headers')).toBe('X-Custom-Header');
});
});
describe('enabled option', () => {
test('skips CORS when enabled is false', async () => {
const app = new Hono();
app.use('*', cors({enabled: false}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
headers: {origin: 'https://example.com'},
});
expect(response.status).toBe(200);
expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull();
});
test('processes CORS when enabled is true', async () => {
const app = new Hono();
app.use('*', cors({enabled: true, origins: '*'}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
headers: {origin: 'https://example.com'},
});
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
});
test('enabled defaults to true', async () => {
const app = new Hono();
app.use('*', cors());
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {
headers: {origin: 'https://example.com'},
});
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
});
});
describe('integration with routes', () => {
test('works with multiple routes', async () => {
const app = new Hono();
app.use('*', cors({origins: '*'}));
app.get('/api/users', (c) => c.json({users: []}));
app.post('/api/users', (c) => c.json({created: true}));
const getResponse = await app.request('/api/users');
expect(getResponse.headers.get('Access-Control-Allow-Origin')).toBe('*');
const postResponse = await app.request('/api/users', {method: 'POST'});
expect(postResponse.headers.get('Access-Control-Allow-Origin')).toBe('*');
});
});
});
@@ -4,9 +4,6 @@ import {
createMetricsMiddleware,
createRegisteredMetricsHandler,
registerCounter,
registerGauge,
registerHistogram,
registerMetricsSection,
} from '@fluxer/hono/src/middleware/Metrics';
import {Hono} from 'hono';
import {describe, expect, test} from 'vitest';
@@ -16,175 +13,14 @@ function requestMetrics(app: Hono, remoteAddress = '127.0.0.1') {
}
function createTestApp() {
const {middleware, metricsHandler, state} = createMetricsMiddleware('test');
const {middleware, metricsHandler} = createMetricsMiddleware('test');
const app = new Hono();
app.use('*', middleware);
app.get('/_metrics', metricsHandler);
app.get('/_health', (c) => c.text('OK'));
app.get('/_healthz', (c) => c.text('OK'));
app.get('/users', (c) => c.json({users: []}, 200));
app.post('/users', (c) => c.json({created: true}, 201));
app.get('/error', (c) => c.json({error: 'Server Error'}, 500));
app.get('/bad', (c) => c.json({error: 'Bad Request'}, 400));
return {app, state};
return {app};
}
describe('Metrics Middleware', () => {
describe('request counting', () => {
test('increments request counter on each request', async () => {
const {app} = createTestApp();
await app.request('/users');
await app.request('/users');
const res = await requestMetrics(app);
const body = await res.text();
expect(body).toContain('fluxer_test_http_requests_total{method="GET",status="2xx"} 2');
});
test('tracks different methods separately', async () => {
const {app} = createTestApp();
await app.request('/users');
await app.request('/users', {method: 'POST'});
const res = await requestMetrics(app);
const body = await res.text();
expect(body).toContain('fluxer_test_http_requests_total{method="GET",status="2xx"} 1');
expect(body).toContain('fluxer_test_http_requests_total{method="POST",status="2xx"} 1');
});
test('tracks different status classes separately', async () => {
const {app} = createTestApp();
await app.request('/users');
await app.request('/bad');
await app.request('/error');
const res = await requestMetrics(app);
const body = await res.text();
expect(body).toContain('status="2xx"');
expect(body).toContain('status="4xx"');
expect(body).toContain('status="5xx"');
});
});
describe('error counting', () => {
test('increments error counter on 5xx responses', async () => {
const {app} = createTestApp();
await app.request('/error');
await app.request('/error');
const res = await requestMetrics(app);
const body = await res.text();
expect(body).toContain('fluxer_test_http_errors_total{method="GET"} 2');
});
test('does not count 4xx as errors', async () => {
const {app} = createTestApp();
await app.request('/bad');
const res = await requestMetrics(app);
const body = await res.text();
expect(body).not.toContain('fluxer_test_http_errors_total{method="GET"}');
});
});
describe('duration histogram', () => {
test('records request duration', async () => {
const {app} = createTestApp();
await app.request('/users');
const res = await requestMetrics(app);
const body = await res.text();
expect(body).toContain('fluxer_test_http_request_duration_seconds_count 1');
expect(body).toContain('fluxer_test_http_request_duration_seconds_sum');
expect(body).toContain('fluxer_test_http_request_duration_seconds_bucket{le="0.005"}');
expect(body).toContain('fluxer_test_http_request_duration_seconds_bucket{le="+Inf"} 1');
});
test('accumulates across multiple requests', async () => {
const {app} = createTestApp();
await app.request('/users');
await app.request('/users');
await app.request('/users');
const res = await requestMetrics(app);
const body = await res.text();
expect(body).toContain('fluxer_test_http_request_duration_seconds_count 3');
});
});
describe('uptime gauge', () => {
test('reports uptime in seconds', async () => {
const {app} = createTestApp();
const res = await requestMetrics(app);
const body = await res.text();
expect(body).toContain('# TYPE fluxer_test_uptime_seconds gauge');
expect(body).toMatch(/fluxer_test_uptime_seconds \d/);
});
});
describe('path skipping', () => {
test('skips /_health requests', async () => {
const {app} = createTestApp();
await app.request('/_health');
await app.request('/_health');
const res = await requestMetrics(app);
const body = await res.text();
expect(body).not.toContain('method="GET",status="2xx"');
});
test('skips /_healthz requests', async () => {
const {app} = createTestApp();
await app.request('/_healthz');
const res = await requestMetrics(app);
const body = await res.text();
expect(body).not.toContain('method="GET",status="2xx"');
});
test('skips /_metrics requests', async () => {
const {app} = createTestApp();
const res = await requestMetrics(app);
const body = await res.text();
expect(body).not.toContain('method="GET",status="2xx"');
});
test('does not skip normal paths', async () => {
const {app} = createTestApp();
await app.request('/users');
const res = await requestMetrics(app);
const body = await res.text();
expect(body).toContain('method="GET",status="2xx"');
});
});
describe('metrics endpoint', () => {
test('returns correct content type', async () => {
const {app} = createTestApp();
const res = await requestMetrics(app);
expect(res.headers.get('Content-Type')).toBe('text/plain; version=0.0.4; charset=utf-8');
});
test('returns 200 status', async () => {
const {app} = createTestApp();
const res = await requestMetrics(app);
expect(res.status).toBe(200);
});
test('includes HELP and TYPE annotations', async () => {
const {app} = createTestApp();
const res = await requestMetrics(app);
const body = await res.text();
expect(body).toContain('# HELP fluxer_test_http_requests_total Total HTTP requests');
expect(body).toContain('# TYPE fluxer_test_http_requests_total counter');
expect(body).toContain('# HELP fluxer_test_http_request_duration_seconds HTTP request duration in seconds');
expect(body).toContain('# TYPE fluxer_test_http_request_duration_seconds histogram');
expect(body).toContain('# HELP fluxer_test_http_errors_total Total HTTP 5xx errors');
expect(body).toContain('# TYPE fluxer_test_http_errors_total counter');
expect(body).toContain('# HELP fluxer_test_uptime_seconds Process uptime in seconds');
expect(body).toContain('# TYPE fluxer_test_uptime_seconds gauge');
});
test('renders default counter value when no requests made', async () => {
const {app} = createTestApp();
const res = await requestMetrics(app);
const body = await res.text();
expect(body).toContain('fluxer_test_http_requests_total 0');
expect(body).toContain('fluxer_test_http_errors_total 0');
});
});
describe('loopback restriction', () => {
test('serves metrics to an IPv4 loopback peer', async () => {
const {app} = createTestApp();
@@ -246,59 +82,7 @@ describe('Metrics Middleware', () => {
});
});
describe('service name prefix', () => {
test('uses provided service name in metric names', async () => {
const {middleware, metricsHandler} = createMetricsMiddleware('gateway');
const app = new Hono();
app.use('*', middleware);
app.get('/_metrics', metricsHandler);
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
const res = await requestMetrics(app);
const body = await res.text();
expect(body).toContain('fluxer_gateway_http_requests_total');
expect(body).toContain('fluxer_gateway_http_request_duration_seconds');
expect(body).toContain('fluxer_gateway_http_errors_total');
expect(body).toContain('fluxer_gateway_uptime_seconds');
});
});
describe('registered sections', () => {
test('appends registered sections until they are unregistered', async () => {
const {app} = createTestApp();
const unregister = registerMetricsSection(() => '# TYPE fluxer_test_extra gauge\nfluxer_test_extra 7');
expect(await (await requestMetrics(app)).text()).toContain('fluxer_test_extra 7');
unregister();
expect(await (await requestMetrics(app)).text()).not.toContain('fluxer_test_extra');
});
});
describe('registered metrics', () => {
test('renders registered counters, gauges and histograms after the http metrics', async () => {
const counter = registerCounter('fluxer_test_registered_total', 'Registered counter');
counter.inc('kind="a"');
counter.inc('kind="a"', 2);
registerGauge('fluxer_test_registered_gauge', 'Registered gauge', () => 7);
registerHistogram('fluxer_test_registered_seconds', 'Registered histogram', [1, 5]).observe(2);
const {app} = createTestApp();
const body = await (await requestMetrics(app)).text();
expect(body).toContain('# TYPE fluxer_test_registered_total counter');
expect(body).toContain('fluxer_test_registered_total{kind="a"} 3');
expect(body).toContain('fluxer_test_registered_gauge 7');
expect(body).toContain('fluxer_test_registered_seconds_bucket{le="5"} 1');
expect(body.indexOf('fluxer_test_uptime_seconds')).toBeLessThan(body.indexOf('fluxer_test_registered_total'));
});
test('returns the same metric when registered twice', () => {
const first = registerCounter('fluxer_test_registered_twice_total', 'Registered twice');
expect(registerCounter('fluxer_test_registered_twice_total', 'Registered twice')).toBe(first);
});
test('refuses to register one name as two types', () => {
registerCounter('fluxer_test_registered_clash', 'Clash');
expect(() => registerGauge('fluxer_test_registered_clash', 'Clash', () => 0)).toThrow();
});
test('serves only registered metrics from the standalone handler', async () => {
registerCounter('fluxer_test_standalone_total', 'Standalone counter').inc();
const app = new Hono();
@@ -1,292 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {RateLimitOptions, RateLimitResult, RateLimitService} from '@fluxer/hono/src/middleware/RateLimit';
import {rateLimit} from '@fluxer/hono/src/middleware/RateLimit';
import {Hono} from 'hono';
import {describe, expect, test, vi} from 'vitest';
const TEST_IP = '192.168.1.100';
const IP_HEADERS = {'x-forwarded-for': TEST_IP};
function createMockRateLimitService(result: Partial<RateLimitResult> = {}): RateLimitService {
const defaultResult: RateLimitResult = {
allowed: true,
limit: 100,
remaining: 99,
resetTime: new Date(Date.now() + 60000),
...result,
};
return {
checkLimit: vi.fn().mockResolvedValue(defaultResult),
};
}
function createAppWithRateLimit(options: RateLimitOptions = {}) {
const app = new Hono();
app.use('*', rateLimit({trustClientIpHeader: true, ...options}));
return app;
}
describe('RateLimit Middleware', () => {
describe('enabled option', () => {
test('skips rate limiting when enabled is false', async () => {
const service = createMockRateLimitService();
const app = createAppWithRateLimit({enabled: false, service});
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {headers: IP_HEADERS});
expect(response.status).toBe(200);
expect(service.checkLimit).not.toHaveBeenCalled();
});
test('skips rate limiting when service is not provided', async () => {
const app = createAppWithRateLimit({enabled: true, service: undefined});
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {headers: IP_HEADERS});
expect(response.status).toBe(200);
});
test('applies rate limiting when enabled and service provided', async () => {
const service = createMockRateLimitService();
const app = createAppWithRateLimit({enabled: true, service});
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test', {headers: IP_HEADERS});
expect(service.checkLimit).toHaveBeenCalled();
});
});
describe('skip paths', () => {
test('skips default health paths', async () => {
const service = createMockRateLimitService();
const app = createAppWithRateLimit({service});
app.get('/_health', (c) => c.json({ok: true}));
app.get('/metrics', (c) => c.json({ok: true}));
await app.request('/_health', {headers: IP_HEADERS});
await app.request('/metrics', {headers: IP_HEADERS});
expect(service.checkLimit).not.toHaveBeenCalled();
});
test('skips custom paths', async () => {
const service = createMockRateLimitService();
const app = createAppWithRateLimit({skipPaths: ['/public'], service});
app.get('/public', (c) => c.json({ok: true}));
await app.request('/public', {headers: IP_HEADERS});
expect(service.checkLimit).not.toHaveBeenCalled();
});
test('skips paths with wildcard patterns', async () => {
const service = createMockRateLimitService();
const app = createAppWithRateLimit({skipPaths: ['/static/*'], service});
app.get('/static/file.js', (c) => c.json({ok: true}));
app.get('/static/images/logo.png', (c) => c.json({ok: true}));
await app.request('/static/file.js', {headers: IP_HEADERS});
await app.request('/static/images/logo.png', {headers: IP_HEADERS});
expect(service.checkLimit).not.toHaveBeenCalled();
});
test('applies rate limit to non-skipped paths', async () => {
const service = createMockRateLimitService();
const app = createAppWithRateLimit({skipPaths: ['/public'], service});
app.get('/api/users', (c) => c.json({ok: true}));
await app.request('/api/users', {headers: IP_HEADERS});
expect(service.checkLimit).toHaveBeenCalled();
});
});
describe('unknown client IP', () => {
test('skips rate limiting when client IP cannot be determined', async () => {
const service = createMockRateLimitService();
const app = new Hono();
app.use('*', rateLimit({service, trustClientIpHeader: false}));
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.status).toBe(200);
expect(service.checkLimit).not.toHaveBeenCalled();
});
test('skips rate limiting when IP header is missing and header trust is enabled', async () => {
const service = createMockRateLimitService();
const app = createAppWithRateLimit({service});
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.status).toBe(200);
expect(service.checkLimit).not.toHaveBeenCalled();
});
});
describe('rate limit headers', () => {
test('sets X-RateLimit-Limit header', async () => {
const service = createMockRateLimitService({limit: 100});
const app = createAppWithRateLimit({service});
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {headers: IP_HEADERS});
expect(response.headers.get('X-RateLimit-Limit')).toBe('100');
});
test('sets X-RateLimit-Remaining header', async () => {
const service = createMockRateLimitService({remaining: 42});
const app = createAppWithRateLimit({service});
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {headers: IP_HEADERS});
expect(response.headers.get('X-RateLimit-Remaining')).toBe('42');
});
test('sets X-RateLimit-Reset header as unix timestamp', async () => {
const resetTime = new Date(Date.now() + 60000);
const service = createMockRateLimitService({resetTime});
const app = createAppWithRateLimit({service});
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {headers: IP_HEADERS});
const reset = response.headers.get('X-RateLimit-Reset');
expect(reset).toBe(Math.floor(resetTime.getTime() / 1000).toString());
});
});
describe('rate limit exceeded', () => {
test('returns 429 when rate limit exceeded', async () => {
const service = createMockRateLimitService({allowed: false, remaining: 0});
const app = createAppWithRateLimit({service});
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {headers: IP_HEADERS});
expect(response.status).toBe(429);
});
test('returns error message when rate limit exceeded', async () => {
const service = createMockRateLimitService({allowed: false});
const app = createAppWithRateLimit({service});
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {headers: IP_HEADERS});
const body = (await response.json()) as {
error: string;
message: string;
};
expect(body.error).toBe('Too Many Requests');
expect(body.message).toBe('Rate limit exceeded');
});
test('sets Retry-After header when retryAfter is provided', async () => {
const service = createMockRateLimitService({allowed: false, retryAfter: 60});
const app = createAppWithRateLimit({service});
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {headers: IP_HEADERS});
expect(response.headers.get('Retry-After')).toBe('60');
});
test('includes retryAfter in response body', async () => {
const service = createMockRateLimitService({allowed: false, retryAfter: 30});
const app = createAppWithRateLimit({service});
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {headers: IP_HEADERS});
const body = (await response.json()) as {
retryAfter: number;
};
expect(body.retryAfter).toBe(30);
});
test('calls onLimitExceeded callback when provided', async () => {
const onLimitExceeded = vi.fn();
const service = createMockRateLimitService({allowed: false});
const app = createAppWithRateLimit({service, onLimitExceeded});
app.get('/api/test', (c) => c.json({ok: true}));
await app.request('/api/test', {headers: IP_HEADERS});
expect(onLimitExceeded).toHaveBeenCalledWith(TEST_IP, '/api/test');
});
});
describe('key generation', () => {
test('uses default key generator based on IP', async () => {
const service = createMockRateLimitService();
const app = createAppWithRateLimit({service});
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test', {headers: IP_HEADERS});
expect(service.checkLimit).toHaveBeenCalledWith(
expect.objectContaining({
identifier: TEST_IP,
}),
);
});
test('uses custom key generator when provided', async () => {
const keyGenerator = vi.fn().mockReturnValue('custom-key');
const service = createMockRateLimitService();
const app = createAppWithRateLimit({service, keyGenerator});
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(keyGenerator).toHaveBeenCalled();
expect(service.checkLimit).toHaveBeenCalledWith(
expect.objectContaining({
identifier: 'custom-key',
}),
);
});
test('async key generator is supported', async () => {
const keyGenerator = vi.fn().mockResolvedValue('async-key');
const service = createMockRateLimitService();
const app = createAppWithRateLimit({service, keyGenerator});
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test');
expect(service.checkLimit).toHaveBeenCalledWith(
expect.objectContaining({
identifier: 'async-key',
}),
);
});
test('skips rate limiting when custom key generator returns null', async () => {
const keyGenerator = vi.fn().mockReturnValue(null);
const service = createMockRateLimitService();
const app = createAppWithRateLimit({service, keyGenerator});
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test');
expect(response.status).toBe(200);
expect(service.checkLimit).not.toHaveBeenCalled();
});
});
describe('rate limit parameters', () => {
test('passes maxAttempts to service', async () => {
const service = createMockRateLimitService();
const app = createAppWithRateLimit({service, maxAttempts: 50});
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test', {headers: IP_HEADERS});
expect(service.checkLimit).toHaveBeenCalledWith(
expect.objectContaining({
maxAttempts: 50,
}),
);
});
test('uses default maxAttempts of 100', async () => {
const service = createMockRateLimitService();
const app = createAppWithRateLimit({service});
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test', {headers: IP_HEADERS});
expect(service.checkLimit).toHaveBeenCalledWith(
expect.objectContaining({
maxAttempts: 100,
}),
);
});
test('passes windowMs to service', async () => {
const service = createMockRateLimitService();
const app = createAppWithRateLimit({service, windowMs: 30000});
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test', {headers: IP_HEADERS});
expect(service.checkLimit).toHaveBeenCalledWith(
expect.objectContaining({
windowMs: 30000,
}),
);
});
test('uses default windowMs of 60000', async () => {
const service = createMockRateLimitService();
const app = createAppWithRateLimit({service});
app.get('/test', (c) => c.json({ok: true}));
await app.request('/test', {headers: IP_HEADERS});
expect(service.checkLimit).toHaveBeenCalledWith(
expect.objectContaining({
windowMs: 60000,
}),
);
});
});
describe('allowed requests', () => {
test('allows request and calls next when under limit', async () => {
const service = createMockRateLimitService({allowed: true, remaining: 50});
const app = createAppWithRateLimit({service});
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {headers: IP_HEADERS});
expect(response.status).toBe(200);
const body = (await response.json()) as {
ok: boolean;
};
expect(body.ok).toBe(true);
});
test('sets rate limit headers even for allowed requests', async () => {
const service = createMockRateLimitService({allowed: true, limit: 100, remaining: 75});
const app = createAppWithRateLimit({service});
app.get('/test', (c) => c.json({ok: true}));
const response = await app.request('/test', {headers: IP_HEADERS});
expect(response.headers.get('X-RateLimit-Limit')).toBe('100');
expect(response.headers.get('X-RateLimit-Remaining')).toBe('75');
});
});
});