mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
chore(repo): remove dead code and useless tests (#3375)
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user