194 lines
6.8 KiB
TypeScript
194 lines
6.8 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import express, { type RequestHandler } from 'express';
|
|
import request from 'supertest';
|
|
import {
|
|
functionsWriteLimiter,
|
|
deploymentsWriteLimiter,
|
|
computeWriteLimiter,
|
|
DEFAULT_WRITE_ENDPOINT_LIMITS,
|
|
applyWriteEndpointLimits,
|
|
resetWriteEndpointLimitsToDefaults,
|
|
type WriteLimiterCategory,
|
|
} from '@/api/middlewares/rate-limiters.js';
|
|
|
|
// express-rate-limit keeps in-memory state PER LIMITER INSTANCE. Since each
|
|
// test builds a fresh app but reuses the same exported limiter, we reset the
|
|
// bucket between tests so each test exercises the limiter freshly. The
|
|
// default supertest remote address is "::ffff:127.0.0.1".
|
|
const DEFAULT_KEY = '::ffff:127.0.0.1';
|
|
|
|
function resetLimiter(limiter: RequestHandler): void {
|
|
(limiter as unknown as { resetKey: (k: string) => void }).resetKey(DEFAULT_KEY);
|
|
}
|
|
|
|
function buildApp(limiter: RequestHandler) {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.post('/x', limiter, (_req, res) => {
|
|
res.json({ ok: true });
|
|
});
|
|
return app;
|
|
}
|
|
|
|
const limiters: Array<[name: string, category: WriteLimiterCategory, limiter: RequestHandler]> = [
|
|
['functionsWriteLimiter', 'functions', functionsWriteLimiter],
|
|
['deploymentsWriteLimiter', 'deployments', deploymentsWriteLimiter],
|
|
['computeWriteLimiter', 'compute', computeWriteLimiter],
|
|
];
|
|
|
|
describe.each(limiters)('%s', (_name, category, limiter) => {
|
|
const budget = DEFAULT_WRITE_ENDPOINT_LIMITS[category];
|
|
|
|
beforeEach(() => {
|
|
resetWriteEndpointLimitsToDefaults();
|
|
resetLimiter(limiter);
|
|
});
|
|
|
|
it(`allows up to ${budget} POSTs in 5min from a single IP`, async () => {
|
|
const app = buildApp(limiter);
|
|
for (let i = 0; i < budget; i++) {
|
|
await request(app).post('/x').send({}).expect(200);
|
|
}
|
|
});
|
|
|
|
it(`rejects POST #${budget + 1} with 429`, async () => {
|
|
const app = buildApp(limiter);
|
|
for (let i = 0; i < budget; i++) {
|
|
await request(app).post('/x').send({}).expect(200);
|
|
}
|
|
const r = await request(app).post('/x').send({});
|
|
expect(r.status).toBe(429);
|
|
});
|
|
});
|
|
|
|
describe('per-category buckets are independent', () => {
|
|
beforeEach(() => {
|
|
resetWriteEndpointLimitsToDefaults();
|
|
resetLimiter(functionsWriteLimiter);
|
|
resetLimiter(deploymentsWriteLimiter);
|
|
resetLimiter(computeWriteLimiter);
|
|
});
|
|
|
|
it('exhausting functions does not affect deployments or compute', async () => {
|
|
const fnApp = buildApp(functionsWriteLimiter);
|
|
for (let i = 0; i < DEFAULT_WRITE_ENDPOINT_LIMITS.functions; i++) {
|
|
await request(fnApp).post('/x').send({}).expect(200);
|
|
}
|
|
await request(fnApp).post('/x').send({}).expect(429);
|
|
|
|
// Other categories still have a full budget.
|
|
await request(buildApp(deploymentsWriteLimiter)).post('/x').send({}).expect(200);
|
|
await request(buildApp(computeWriteLimiter)).post('/x').send({}).expect(200);
|
|
});
|
|
});
|
|
|
|
describe('within a category the bucket is shared across routes', () => {
|
|
const budget = DEFAULT_WRITE_ENDPOINT_LIMITS.deployments;
|
|
|
|
beforeEach(() => {
|
|
resetWriteEndpointLimitsToDefaults();
|
|
resetLimiter(deploymentsWriteLimiter);
|
|
});
|
|
|
|
it('two routes mounting the same limiter share one budget', async () => {
|
|
// Mirrors how index.routes.ts and env-vars.routes.ts both mount
|
|
// deploymentsWriteLimiter — calls to either route count toward the
|
|
// same per-IP budget.
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.post('/a', deploymentsWriteLimiter, (_req, res) => res.json({ ok: true }));
|
|
app.post('/b', deploymentsWriteLimiter, (_req, res) => res.json({ ok: true }));
|
|
|
|
// Spread the budget across the two routes.
|
|
for (let i = 0; i < budget; i++) {
|
|
const path = i % 2 === 0 ? '/a' : '/b';
|
|
await request(app).post(path).send({}).expect(200);
|
|
}
|
|
// The next call to either route is rejected.
|
|
await request(app).post('/a').send({}).expect(429);
|
|
await request(app).post('/b').send({}).expect(429);
|
|
});
|
|
});
|
|
|
|
describe('INSFORGE_DISABLE_WRITE_RATE_LIMIT bypass', () => {
|
|
const ORIGINAL = process.env.INSFORGE_DISABLE_WRITE_RATE_LIMIT;
|
|
const budget = DEFAULT_WRITE_ENDPOINT_LIMITS.functions;
|
|
|
|
beforeEach(() => {
|
|
resetWriteEndpointLimitsToDefaults();
|
|
resetLimiter(functionsWriteLimiter);
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (ORIGINAL === undefined) {
|
|
delete process.env.INSFORGE_DISABLE_WRITE_RATE_LIMIT;
|
|
} else {
|
|
process.env.INSFORGE_DISABLE_WRITE_RATE_LIMIT = ORIGINAL;
|
|
}
|
|
});
|
|
|
|
it('lets unlimited POSTs through when set to "1"', async () => {
|
|
process.env.INSFORGE_DISABLE_WRITE_RATE_LIMIT = '1';
|
|
const app = buildApp(functionsWriteLimiter);
|
|
// Well past the per-category cap; would normally 429 long before this.
|
|
for (let i = 0; i < budget * 3; i++) {
|
|
await request(app).post('/x').send({}).expect(200);
|
|
}
|
|
});
|
|
|
|
it('does not bypass for other truthy values like "true"', async () => {
|
|
process.env.INSFORGE_DISABLE_WRITE_RATE_LIMIT = 'true';
|
|
const app = buildApp(functionsWriteLimiter);
|
|
for (let i = 0; i < budget; i++) {
|
|
await request(app).post('/x').send({}).expect(200);
|
|
}
|
|
await request(app).post('/x').send({}).expect(429);
|
|
});
|
|
});
|
|
|
|
describe('applyWriteEndpointLimits override', () => {
|
|
beforeEach(() => {
|
|
resetWriteEndpointLimitsToDefaults();
|
|
resetLimiter(functionsWriteLimiter);
|
|
resetLimiter(deploymentsWriteLimiter);
|
|
});
|
|
|
|
afterEach(() => {
|
|
resetWriteEndpointLimitsToDefaults();
|
|
resetLimiter(functionsWriteLimiter);
|
|
resetLimiter(deploymentsWriteLimiter);
|
|
});
|
|
|
|
it('takes effect on the next request without rebuilding the limiter', async () => {
|
|
applyWriteEndpointLimits({ functions: 2 });
|
|
const app = buildApp(functionsWriteLimiter);
|
|
await request(app).post('/x').send({}).expect(200);
|
|
await request(app).post('/x').send({}).expect(200);
|
|
await request(app).post('/x').send({}).expect(429);
|
|
});
|
|
|
|
it('only overrides the categories present in the partial config', async () => {
|
|
applyWriteEndpointLimits({ functions: 3 });
|
|
// deployments retained its default 25-request budget.
|
|
const depApp = buildApp(deploymentsWriteLimiter);
|
|
for (let i = 0; i < DEFAULT_WRITE_ENDPOINT_LIMITS.deployments; i++) {
|
|
await request(depApp).post('/x').send({}).expect(200);
|
|
}
|
|
await request(depApp).post('/x').send({}).expect(429);
|
|
});
|
|
|
|
it('ignores invalid values and keeps the previous (or default) budget', async () => {
|
|
applyWriteEndpointLimits({
|
|
functions: -1 as unknown as number,
|
|
compute: 'lots' as unknown as number,
|
|
deployments: 1.5 as unknown as number,
|
|
});
|
|
// None of the malformed values should have stuck — defaults remain.
|
|
const fnApp = buildApp(functionsWriteLimiter);
|
|
for (let i = 0; i < DEFAULT_WRITE_ENDPOINT_LIMITS.functions; i++) {
|
|
await request(fnApp).post('/x').send({}).expect(200);
|
|
}
|
|
await request(fnApp).post('/x').send({}).expect(429);
|
|
});
|
|
});
|