GitNexus/gitnexus/test/unit/rate-limit.test.ts
mengkaka 79543c8f83
feat(storage): add configurable index storage and content retention tiers (#3060)
* feat(storage): add configurable index storage and content retention tiers

Rebase #3060 onto current origin/main. Keep GITNEXUS_STORAGE_PATH,
GITNEXUS_STORAGE_ROOT, and GITNEXUS_CONTENT_RETENTION, and fold in
main's FTS skip, embed-session, and help-text updates.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Keep legacy registry rows on the local storage fallback, resolve
symlinks before the destructive-path guard, and align hook lookup
with CLI branch slugs, branch-slot metadata, and longest-path match.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Only list swept upload directories after a successful removal so
callers cannot treat a permission or transient rm failure as gone.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Document that getStoragePath may consult registered storage while
this module still does not mutate the global registry.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(storage): close review findings for external indexes and retention

Re-inspect ownership under the analyze lock, fail-closed when the
registry file is missing, and keep skip-git hook discovery plus
retention fields on HTTP/MCP list surfaces. /api/file stays 410
unless contentRetention is full.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* Address PR review feedback (#3060)

Treat lock-only index dirs as empty, honor HTTP --force storage policy, and prefer registered plus branch-aware slots in hooks and augment.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Keep hook fallbacks inside the current worktree, compare foreign-local slots canonically, and make storage fixtures survive ownership validation.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix macOS hook test expecting realpath'd registry paths.

resolveHookRepo returns the written registry path, not a filesystem realpath, so the assertion must match that.

* Address gitnexus-check warnings on hook install docs and slot tests.

The Cursor troubleshooting list omitted registry-query.cjs, and the writable-slot test only checked that isDirectory exists instead of that the path is a directory.

* Align the HTTP catalog source-scan with skippable resolveRepo validation.

resolveRepo lists fresh repos with validate: options.validateStorage !== false so DELETE can skip prune; the test still required a literal validate: true.

* Harden storage path sinks so CodeQL path-injection and ReDoS alerts clear.

Contain every filesystem probe inside the resolved storage slot with the inline path.relative idiom, reject filesystem-root slots, and trim slot basenames in linear time.

* Settle bridge stamps before writing so CI size/mtime matches stay stable.

LadybugDB can still flush into bridge.lbug after close+rename; persist whole-millisecond mtimes and wait for consecutive stats to agree so a freshly written pair matches.

* Type the settled bridge stat as fs.Stats so tsc does not see bigint.

Awaited<ReturnType<typeof fsp.stat>> collapsed the bigint overload and broke prepare/typecheck on CI.

* Keep the bridge mtime stamp exact so same-size swaps still fail the pair check.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Wrap the bridge stamp predicate so prettier --check stays green.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Require a quiet interval before stamping a settled bridge file.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Reuse shared storage and settle helpers instead of local copies.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-12 20:31:55 +00:00

451 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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';
import {
DEFAULT_TRUST_PROXY,
TRUST_PROXY_ENV,
resolveTrustProxy,
} from '../../src/server/middleware.js';
import { _captureLogger, type LoggerCapture } from '../../src/core/logger.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('DELETE /api/repo resolves the registry row without pruning unusable storage', () => {
expect(apiSource).toMatch(
/resolveRepo\(repoName,\s*false,\s*undefined,\s*\{\s*validateStorage:\s*false\s*\}\)/,
);
expect(apiSource).toMatch(
/listRegisteredRepos\(\{\s*validate:\s*options\.validateStorage !== false,\s*\}\)/,
);
});
it('GET /api/repo is wired with createRouteLimiter', () => {
expect(apiSource).toMatch(/app\.get\('\/api\/repo',\s*createRouteLimiter\(/);
});
it('GET /api/repos is wired with createRouteLimiter', () => {
// Carries a limiter because it spawns a `git rev-list` per registered repo
// to answer freshness — an unauthenticated GET whose cost scales with the
// number of indexed repos. Pinned here so dropping the limiter fails a test
// before it has to be caught by CodeQL (#3232).
expect(apiSource).toMatch(/app\.get\('\/api\/repos',\s*createRouteLimiter\(/);
});
it('POST /api/analyze is wired with createRouteLimiter', () => {
// Tolerate Prettier wrapping the registration across lines (it does once
// the route carries extra middleware like requireTrustedOrigin).
expect(apiSource).toMatch(/app\.post\(\s*'\/api\/analyze',\s*createRouteLimiter\(/);
});
it('POST /api/embed is wired with createRouteLimiter', () => {
// Tolerate Prettier wrapping the registration across lines (it does once
// the route carries extra middleware like requireTrustedOrigin).
expect(apiSource).toMatch(/app\.post\(\s*'\/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('does not register a bare wildcard OPTIONS route under Express 5', () => {
expect(apiSource).not.toContain("app.options('*'");
expect(apiSource).not.toMatch(/app\.options\(\s*'\/\*'/);
});
// Source-level because createServer listens and cannot be built here. Kept
// deliberately loose: the effective-value describe below covers resolution,
// so all this has to pin down is that createServer routes the env var
// through resolveTrustProxy rather than setting a literal.
it('createServer reads trust proxy from GITNEXUS_TRUST_PROXY', () => {
expect(apiSource).toMatch(
/app\.set\(\s*'trust proxy'\s*,\s*resolveTrustProxy\([^)]*TRUST_PROXY_ENV/,
);
});
it('does not register Express-4-only app.options("*") (Express 5 path-to-regexp)', () => {
expect(apiSource).not.toMatch(/app\.options\(\s*'\*'/);
expect(apiSource).not.toMatch(/app\.options\(\s*'\/\*'/);
});
it('sets PNA header middleware before cors (preflight must include Allow-Private-Network)', () => {
expect(apiSource).toMatch(
/Access-Control-Allow-Private-Network[\s\S]*?app\.use\(\s*\n?\s*cors\(/,
);
});
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\)/);
});
});
// The effective `trust proxy` Express ends up with, rather than the text of the
// line that sets it. `createServer` listens and installs signal handlers, so it
// cannot be built here; this mirrors its one `app.set` expression instead.
describe('trust proxy — effective value from GITNEXUS_TRUST_PROXY', () => {
const saved = process.env[TRUST_PROXY_ENV];
// The rejection cases below warn; capture keeps them out of the suite output.
let cap: LoggerCapture;
beforeEach(() => {
cap = _captureLogger();
});
afterEach(() => {
cap.restore();
if (saved === undefined) delete process.env[TRUST_PROXY_ENV];
else process.env[TRUST_PROXY_ENV] = saved;
});
const effectiveTrustProxy = (value: string | undefined): unknown => {
if (value === undefined) delete process.env[TRUST_PROXY_ENV];
else process.env[TRUST_PROXY_ENV] = value;
const app = express();
app.set('trust proxy', resolveTrustProxy(process.env[TRUST_PROXY_ENV]));
return app.get('trust proxy');
};
it('defaults to the loopback-scoped list when the env var is unset', () => {
expect(DEFAULT_TRUST_PROXY).toBe('loopback, linklocal, uniquelocal');
expect(effectiveTrustProxy(undefined)).toBe('loopback, linklocal, uniquelocal');
});
it('carries a configured hop count through to Express', () => {
expect(effectiveTrustProxy('3')).toBe(3);
});
it('carries a configured proxy list through to Express', () => {
expect(effectiveTrustProxy('10.0.0.0/8, 127.0.0.1')).toBe('10.0.0.0/8, 127.0.0.1');
});
// Express 5 compiles `trust proxy` inside `app.set`, so an unvalidated bad
// value would throw during createServer instead of resolving to a default.
it('never hands Express a value it rejects', () => {
expect(() => effectiveTrustProxy('garbage')).not.toThrow();
expect(effectiveTrustProxy('garbage')).toBe(DEFAULT_TRUST_PROXY);
expect(effectiveTrustProxy('9'.repeat(400))).toBe(DEFAULT_TRUST_PROXY);
});
// The behaviour the describe block below demonstrates is why: `true` cannot
// reach Express through the env var at all.
it('never hands Express `true`', () => {
expect(effectiveTrustProxy('true')).toBe(DEFAULT_TRUST_PROXY);
expect(effectiveTrustProxy('yes')).toBe(DEFAULT_TRUST_PROXY);
expect(effectiveTrustProxy('on')).toBe(DEFAULT_TRUST_PROXY);
});
});
// Why resolveTrustProxy rejects `true`: Express then reads the leftmost
// X-Forwarded-For entry, which the client controls, so rotating it hands the
// limiter a fresh key per request — unbounded, in front of the two routes that
// spawn workers. A hop count reads from the right instead and is immune. Both
// apps below see the same requests; only the setting differs. `true` is set
// directly here, since the env var can no longer produce it.
describe('trust proxy — a rotating X-Forwarded-For defeats `true` but not a hop count', () => {
const buildProxiedApp = (trustProxy: boolean | number): Express => {
const app = express();
app.set('trust proxy', trustProxy);
app.get('/test/file', createRouteLimiter({ windowMs: 2000, limit: 2 }), async (_req, res) => {
const content = await fs.readFile(tmpFile, 'utf-8');
res.json({ content });
});
return app;
};
// Leftmost entry rotates per request; the rightmost (the hop the loopback
// proxy claims to have received from) stays fixed.
const statusesUnderRotatingForwardedFor = async (app: Express): Promise<number[]> => {
const { server, baseUrl } = await startServer(app);
try {
const statuses: number[] = [];
for (let i = 1; i <= 4; i++) {
const res = await fetch(`${baseUrl}/test/file`, {
headers: { 'X-Forwarded-For': `203.0.113.${i}, 198.51.100.7` },
});
statuses.push(res.status);
}
return statuses;
} finally {
await stopServer(server);
}
};
it('collapses to a single limiter key with a hop count of 1', async () => {
expect(await statusesUnderRotatingForwardedFor(buildProxiedApp(1))).toEqual([
200, 200, 429, 429,
]);
});
it('gets a fresh limiter key per request with `true`', async () => {
expect(await statusesUnderRotatingForwardedFor(buildProxiedApp(true))).toEqual([
200, 200, 200, 200,
]);
});
});