/** * 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 => 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); }); }); 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('createServer wires trust proxy to loopback/linklocal/uniquelocal', () => { expect(apiSource).toMatch( /app\.set\(\s*'trust proxy'\s*,\s*'loopback,\s*linklocal,\s*uniquelocal'\s*\)/, ); }); });