mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-22 00:31:17 +00:00
296 lines
12 KiB
TypeScript
296 lines
12 KiB
TypeScript
/**
|
||
* Tests for createRouteLimiter and the integration shape used by api.ts.
|
||
*
|
||
* Closes the U4 test gap (CodeQL js/missing-rate-limiting). Without these,
|
||
* a refactor that drops the limiter middleware from any route would silently
|
||
* regress and CodeQL would re-fire — but no test would fail before reaching
|
||
* CI.
|
||
*
|
||
* Two layers of coverage:
|
||
* 1. Helper unit tests — createRouteLimiter returns distinct middleware
|
||
* per call, has the right signature, exposes the right error shape.
|
||
* 2. Integration tests — mount the same factory on a tiny isolated express
|
||
* app that does fs.readFile (the exact CodeQL sink class) and prove the
|
||
* 429 fires after the configured limit. windowMs (2 000 ms) is generous
|
||
* enough that 4 sequential requests fit inside one window even on slow
|
||
* Windows CI runners; each test uses a fresh limiter so counter state
|
||
* never carries between tests.
|
||
*/
|
||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||
import express, { type Express } from 'express';
|
||
import http from 'node:http';
|
||
import path from 'node:path';
|
||
import fs from 'node:fs/promises';
|
||
import os from 'node:os';
|
||
import { createRouteLimiter } from '../../src/server/validation.js';
|
||
|
||
let tmpFile: string;
|
||
|
||
beforeAll(async () => {
|
||
// Real fs.readFile target so the route does the same kind of FS work
|
||
// the production routes do — keeps the test honest about what it covers.
|
||
tmpFile = path.join(
|
||
await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-ratelimit-')),
|
||
'fixture.txt',
|
||
);
|
||
await fs.writeFile(tmpFile, 'hello\n', 'utf-8');
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await fs.rm(path.dirname(tmpFile), { recursive: true, force: true });
|
||
});
|
||
|
||
// Build a fresh app + server per test so counter state never carries between
|
||
// tests. windowMs = 2 000 ms gives ample headroom for Windows CI where
|
||
// sequential loopback HTTP requests can take 50–80 ms each.
|
||
const buildApp = (limit: number, windowMs = 2000): Express => {
|
||
const app = express();
|
||
app.set('trust proxy', 'loopback, linklocal, uniquelocal');
|
||
app.get('/test/file', createRouteLimiter({ windowMs, limit }), async (_req, res) => {
|
||
const content = await fs.readFile(tmpFile, 'utf-8');
|
||
res.json({ content });
|
||
});
|
||
return app;
|
||
};
|
||
|
||
const startServer = (app: Express): Promise<{ server: http.Server; baseUrl: string }> =>
|
||
new Promise((resolve) => {
|
||
const server = app.listen(0, '127.0.0.1', () => {
|
||
const addr = server.address();
|
||
const baseUrl = typeof addr === 'object' && addr ? `http://127.0.0.1:${addr.port}` : '';
|
||
resolve({ server, baseUrl });
|
||
});
|
||
});
|
||
|
||
const stopServer = (server: http.Server): Promise<void> =>
|
||
new Promise((resolve) => server.close(() => resolve()));
|
||
|
||
describe('createRouteLimiter — defaults', () => {
|
||
it('returns a different middleware instance per call (independent counters)', () => {
|
||
const a = createRouteLimiter();
|
||
const b = createRouteLimiter();
|
||
expect(a).not.toBe(b);
|
||
});
|
||
|
||
it('produces a callable express RequestHandler', () => {
|
||
const limiter = createRouteLimiter();
|
||
expect(typeof limiter).toBe('function');
|
||
// express middleware signature is (req, res, next) — 3 args.
|
||
expect(limiter.length).toBe(3);
|
||
});
|
||
|
||
// Regression guard for #1360 — createRouteLimiter must not throw
|
||
// ERR_ERL_KEY_GEN_IPV6. The validation fires at construction time
|
||
// (inside `rateLimit()`), so a simple `createRouteLimiter()` call is
|
||
// the canary: if the keyGenerator references `req.ip` without using
|
||
// `ipKeyGenerator`, the `rateLimit()` constructor throws before the
|
||
// middleware is ever invoked.
|
||
it('does not throw ERR_ERL_KEY_GEN_IPV6 on construction (#1360)', () => {
|
||
expect(() => createRouteLimiter()).not.toThrow();
|
||
});
|
||
});
|
||
|
||
describe('createRouteLimiter — integration with a real route', () => {
|
||
let server: http.Server;
|
||
let baseUrl: string;
|
||
|
||
beforeEach(async () => {
|
||
({ server, baseUrl } = await startServer(buildApp(3)));
|
||
});
|
||
|
||
afterEach(async () => {
|
||
await stopServer(server);
|
||
});
|
||
|
||
// The exact regression guard CodeQL would re-fire if a maintainer
|
||
// dropped createRouteLimiter from any of the 4 protected routes:
|
||
// without the limiter, max+1 requests all return 200.
|
||
it('lets max requests through and rejects the next one with 429', async () => {
|
||
for (let i = 1; i <= 3; i++) {
|
||
const res = await fetch(`${baseUrl}/test/file`);
|
||
expect(res.status).toBe(200);
|
||
}
|
||
const res = await fetch(`${baseUrl}/test/file`);
|
||
expect(res.status).toBe(429);
|
||
const body = await res.json();
|
||
expect(body.error).toContain('Too many');
|
||
});
|
||
|
||
it('emits draft-7 RateLimit response header (combined form), not legacy X-RateLimit-*', async () => {
|
||
const res = await fetch(`${baseUrl}/test/file`);
|
||
expect(res.status).toBe(200);
|
||
// draft-7: single combined `RateLimit` header in `limit=N, remaining=N, reset=N` shape,
|
||
// NO individual `X-RateLimit-*` legacy keys.
|
||
const rateLimitHeader = res.headers.get('ratelimit');
|
||
expect(rateLimitHeader).toMatch(/limit=\d+/);
|
||
expect(rateLimitHeader).toMatch(/remaining=\d+/);
|
||
expect(rateLimitHeader).toMatch(/reset=\d+/);
|
||
expect(res.headers.get('x-ratelimit-limit')).toBeNull();
|
||
});
|
||
|
||
it('429 response body uses the project { error } JSON shape', async () => {
|
||
// Trip the limiter.
|
||
for (let i = 1; i <= 3; i++) await fetch(`${baseUrl}/test/file`);
|
||
const res = await fetch(`${baseUrl}/test/file`);
|
||
expect(res.status).toBe(429);
|
||
const body = await res.json();
|
||
expect(body).toEqual({ error: expect.stringContaining('Too many') });
|
||
});
|
||
|
||
it('429 response includes a Retry-After header so clients can back off', async () => {
|
||
for (let i = 1; i <= 3; i++) await fetch(`${baseUrl}/test/file`);
|
||
const res = await fetch(`${baseUrl}/test/file`);
|
||
expect(res.status).toBe(429);
|
||
const retryAfter = res.headers.get('retry-after');
|
||
expect(retryAfter).toBeTruthy();
|
||
// express-rate-limit v8 emits Retry-After in integer-seconds form. The
|
||
// RFC also allows HTTP-date, but ERL does not use that shape; if a
|
||
// future version switches, this assertion needs an HTTP-date branch.
|
||
const seconds = Number(retryAfter);
|
||
expect(Number.isFinite(seconds) && seconds >= 0).toBe(true);
|
||
});
|
||
|
||
it('window resets after windowMs — counter does not carry across windows', async () => {
|
||
// Trip the limiter.
|
||
for (let i = 1; i <= 3; i++) await fetch(`${baseUrl}/test/file`);
|
||
const tripped = await fetch(`${baseUrl}/test/file`);
|
||
expect(tripped.status).toBe(429);
|
||
// Wait for the window to roll over (2 000 ms window + 200 ms margin).
|
||
await new Promise((r) => setTimeout(r, 2200));
|
||
const reset = await fetch(`${baseUrl}/test/file`);
|
||
expect(reset.status).toBe(200);
|
||
});
|
||
});
|
||
|
||
// Behavioral pin replacing the prior `expect(DEFAULT_RATE_LIMIT_RPM).toBe(60)`
|
||
// constant assertion — that test pinned the magic number, this test pins the
|
||
// observable contract that the production default does not 429 at typical
|
||
// interactive load.
|
||
describe('createRouteLimiter — production default', () => {
|
||
it('default policy permits 60 requests in a minute (no opts override)', async () => {
|
||
// Build an app that uses the production-default limiter (no opts override).
|
||
// 60 requests is well under the default 60 rpm/IP, so all should pass.
|
||
// Going to 61 would 429 but takes the full window to test deterministically;
|
||
// the contract we want pinned here is "default does not throttle interactive
|
||
// use" — the 429 path is already covered by the integration tests above.
|
||
const { server, baseUrl } = await startServer(
|
||
(() => {
|
||
const app = express();
|
||
app.set('trust proxy', 'loopback, linklocal, uniquelocal');
|
||
app.get('/test/file', createRouteLimiter(), async (_req, res) => {
|
||
const content = await fs.readFile(tmpFile, 'utf-8');
|
||
res.json({ content });
|
||
});
|
||
return app;
|
||
})(),
|
||
);
|
||
try {
|
||
// Send 60 requests — all should succeed under the default policy.
|
||
for (let i = 1; i <= 60; i++) {
|
||
const res = await fetch(`${baseUrl}/test/file`);
|
||
if (res.status !== 200) {
|
||
throw new Error(`request ${i}/60 returned ${res.status} under default policy`);
|
||
}
|
||
}
|
||
} finally {
|
||
await stopServer(server);
|
||
}
|
||
});
|
||
});
|
||
|
||
// Production-wiring assertions — proves each of the 4 protected routes in
|
||
// api.ts actually has rate-limit middleware. Closes the gap reviewers flagged
|
||
// where a maintainer could drop createRouteLimiter from a route and no test
|
||
// would fail (only CodeQL would re-fire next scan).
|
||
//
|
||
// Walks the express router stack on a real createServer-built app, finds
|
||
// each protected route by method+path, and asserts the middleware chain
|
||
// includes the express-rate-limit handler. This is intentionally a
|
||
// structural check (not behavioral) — the behavioral guarantees are
|
||
// covered by the integration tests above.
|
||
describe('production routes — rate-limit middleware wiring', () => {
|
||
// Small structural check that does not require booting the full server
|
||
// (which depends on LadybugDB, MCP transport, fork(), etc.). We grep the
|
||
// api.ts source for the createRouteLimiter call adjacent to each route
|
||
// registration. If a future refactor drops the call, the regex no longer
|
||
// matches and the test fails.
|
||
//
|
||
// This is admittedly a light-weight check, but it is enough to catch the
|
||
// single most likely regression (someone removes the middleware while
|
||
// editing the route handler) without dragging in the full server boot.
|
||
|
||
let apiSource: string;
|
||
|
||
beforeAll(async () => {
|
||
apiSource = await fs.readFile(
|
||
path.join(__dirname, '..', '..', 'src', 'server', 'api.ts'),
|
||
'utf-8',
|
||
);
|
||
});
|
||
|
||
it('GET /api/file is wired with createRouteLimiter', () => {
|
||
expect(apiSource).toMatch(/app\.get\('\/api\/file',\s*createRouteLimiter\(/);
|
||
});
|
||
|
||
it('GET /api/grep is wired with createRouteLimiter', () => {
|
||
expect(apiSource).toMatch(/app\.get\('\/api\/grep',\s*createRouteLimiter\(/);
|
||
});
|
||
|
||
it('DELETE /api/repo is wired with createRouteLimiter', () => {
|
||
expect(apiSource).toMatch(/app\.delete\('\/api\/repo',\s*createRouteLimiter\(/);
|
||
});
|
||
|
||
it('POST /api/analyze is wired with createRouteLimiter', () => {
|
||
expect(apiSource).toMatch(/app\.post\('\/api\/analyze',\s*createRouteLimiter\(/);
|
||
});
|
||
|
||
it('POST /api/embed is wired with createRouteLimiter', () => {
|
||
expect(apiSource).toMatch(/app\.post\('\/api\/embed',\s*createRouteLimiter\(/);
|
||
});
|
||
|
||
it('SPA fallback is wired with createRouteLimiter', () => {
|
||
expect(apiSource).toMatch(/app\.get\(SPA_FALLBACK_REGEX,\s*createRouteLimiter\(/);
|
||
});
|
||
|
||
it('GET /api/health is registered (Docker healthcheck, #1147)', () => {
|
||
expect(apiSource).toMatch(/app\.get\('\/api\/health',\s*\(_req,\s*res\)\s*=>/);
|
||
});
|
||
|
||
it('createServer wires trust proxy to loopback/linklocal/uniquelocal', () => {
|
||
expect(apiSource).toMatch(
|
||
/app\.set\(\s*'trust proxy'\s*,\s*'loopback,\s*linklocal,\s*uniquelocal'\s*\)/,
|
||
);
|
||
});
|
||
|
||
it('embed route flushes WAL via flushWAL, not inline executeQuery (#1376)', () => {
|
||
// The embed handler must call the consolidated helper, not hand-roll
|
||
// its own try/catch around executeQuery('CHECKPOINT').
|
||
expect(apiSource).toMatch(/await flushWAL\(\)/);
|
||
expect(apiSource).not.toMatch(/executeQuery\('CHECKPOINT'\)/);
|
||
});
|
||
});
|
||
|
||
// Structural guard for #1360 — validates that the validation module uses
|
||
// `ipKeyGenerator` so IPv6 addresses are normalised to their /56 subnet.
|
||
// Without this, each IPv6 address gets an independent counter and the
|
||
// rate-limit is trivially bypassed. The construction-time test above
|
||
// catches the same regression behaviourally; this source-grep test catches
|
||
// it structurally so the failure message is immediately obvious.
|
||
describe('validation.ts — IPv6 key normalisation (#1360)', () => {
|
||
let validationSource: string;
|
||
|
||
beforeAll(async () => {
|
||
validationSource = await fs.readFile(
|
||
path.join(__dirname, '..', '..', 'src', 'server', 'validation.ts'),
|
||
'utf-8',
|
||
);
|
||
});
|
||
|
||
it('imports ipKeyGenerator from express-rate-limit', () => {
|
||
expect(validationSource).toMatch(/import.*ipKeyGenerator.*from\s+'express-rate-limit'/);
|
||
});
|
||
|
||
it('keyGenerator body calls ipKeyGenerator', () => {
|
||
expect(validationSource).toMatch(/ipKeyGenerator\(ip\)/);
|
||
});
|
||
});
|