Files
TREK/server/tests/unit/nest/idempotency.interceptor.test.ts
T
Maurice 8e5fdaa877 fix(server): tighten the guard, audit and token paths
Permissions cached an empty map before it had loaded, so a DB error during
the load cached permissive defaults and every later check answered from that
cache - fail-open, and it never expired. The rate limiter grew its per-IP map
forever; the GC the legacy version had was dropped in the fold. Idempotency
did a check-then-insert, so two concurrent duplicates both ran the handler.

Audit rows recorded whatever X-Forwarded-For said, which the client writes,
and the request-body debug logger redacted by exact key match - it missed
refresh tokens, api keys and PKCE verifiers. FORCE_HTTPS built its redirect
from the client's Host header. CSP asked for unsafe-eval on top of the
wasm-unsafe-eval it actually needs.

The OAuth deny path redirected to an unvalidated URI, ws-token minting had
no rate limit at all, and PKCE was skipped when code_verifier was absent
rather than refused. An MCP session resumed with a token whose scopes had
since been reduced kept the capability set it was created with, and the
session cap was checked before the async registration that increments it.

Plugin frame containment was lexical, so a symlink out of the directory was
never noticed, and instance config accepted keys the manifest never
declared.
2026-08-23 11:59:12 +02:00

254 lines
9.6 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import { HttpException } from '@nestjs/common';
import type { CallHandler, ExecutionContext } from '@nestjs/common';
import { from, of, lastValueFrom } from 'rxjs';
import { IdempotencyInterceptor } from '../../../src/nest/common/idempotency.interceptor';
import type { DatabaseService } from '../../../src/nest/database/database.service';
type ReqShape = {
method: string;
headers: Record<string, string>;
path?: string;
user?: { id: number };
};
function makeRes() {
const res = {
statusCode: 200,
status: vi.fn((code: number) => {
res.statusCode = code;
return res;
}),
json: vi.fn((body: unknown) => body),
};
return res;
}
function ctx(req: ReqShape, res: ReturnType<typeof makeRes>): ExecutionContext {
return {
switchToHttp: () => ({ getRequest: () => req, getResponse: () => res }),
} as unknown as ExecutionContext;
}
function handler(result: unknown): CallHandler & { handle: ReturnType<typeof vi.fn> } {
return { handle: vi.fn(() => of(result)) };
}
function makeDb(overrides: Partial<DatabaseService> = {}): DatabaseService {
return { get: vi.fn(), run: vi.fn(), ...overrides } as unknown as DatabaseService;
}
describe('IdempotencyInterceptor (parity with the legacy applyIdempotency middleware)', () => {
it('passes a GET through without touching the store', async () => {
const db = makeDb();
const h = handler('weather');
const out = await lastValueFrom(
new IdempotencyInterceptor(db).intercept(ctx({ method: 'GET', headers: {} }, makeRes()), h),
);
expect(out).toBe('weather');
expect(h.handle).toHaveBeenCalled();
expect(db.get).not.toHaveBeenCalled();
});
it('passes a mutating request without a key through', async () => {
const db = makeDb();
const h = handler('done');
await lastValueFrom(
new IdempotencyInterceptor(db).intercept(ctx({ method: 'POST', headers: {}, user: { id: 1 } }, makeRes()), h),
);
expect(h.handle).toHaveBeenCalled();
expect(db.get).not.toHaveBeenCalled();
});
it('passes through when there is no authenticated user', async () => {
const db = makeDb();
const h = handler('done');
await lastValueFrom(
new IdempotencyInterceptor(db).intercept(ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' } }, makeRes()), h),
);
expect(h.handle).toHaveBeenCalled();
expect(db.get).not.toHaveBeenCalled();
});
it('rejects an over-long key with the exact legacy 400 body', () => {
const db = makeDb();
const h = handler('done');
const run = () =>
new IdempotencyInterceptor(db).intercept(
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'x'.repeat(129) }, user: { id: 1 } }, makeRes()),
h,
);
expect(run).toThrow(HttpException);
try {
run();
} catch (err) {
const e = err as HttpException;
expect(e.getStatus()).toBe(400);
expect(e.getResponse()).toEqual({ error: 'X-Idempotency-Key exceeds maximum length of 128 characters' });
}
expect(h.handle).not.toHaveBeenCalled();
});
it('replays a cached response and skips the handler', async () => {
const db = makeDb({ get: vi.fn().mockReturnValue({ status_code: 201, response_body: '{"id":5}' }) });
const res = makeRes();
const h = handler('should-not-run');
const out = await lastValueFrom(
new IdempotencyInterceptor(db).intercept(
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: 1 } }, res),
h,
),
);
expect(res.status).toHaveBeenCalledWith(201);
expect(out).toEqual({ id: 5 });
expect(h.handle).not.toHaveBeenCalled();
expect(db.get).toHaveBeenCalledWith(
expect.stringContaining('idempotency_keys'),
'k', 1, 'POST', '/api/categories',
);
});
it('captures a successful JSON response under the key', async () => {
const run = vi.fn();
const db = makeDb({ get: vi.fn().mockReturnValue(undefined), run });
const res = makeRes();
const h = handler({ created: true });
await lastValueFrom(
new IdempotencyInterceptor(db).intercept(
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: 1 } }, res),
h,
),
);
// Simulate Nest serialising the handler result through the wrapped res.json.
res.statusCode = 201;
res.json({ created: true });
expect(run).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenCalledWith(
expect.stringContaining('INSERT OR IGNORE INTO idempotency_keys'),
'k', 1, 'POST', '/api/categories', 201, '{"created":true}', expect.any(Number),
);
});
it('does not cache a non-2xx response', async () => {
const run = vi.fn();
const db = makeDb({ get: vi.fn().mockReturnValue(undefined), run });
const res = makeRes();
const h = handler({ error: 'bad' });
await lastValueFrom(
new IdempotencyInterceptor(db).intercept(
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: 1 } }, res),
h,
),
);
res.statusCode = 400;
res.json({ error: 'bad' });
expect(run).not.toHaveBeenCalled();
});
it('does not cache a body that exceeds the 256 KiB cap', async () => {
const run = vi.fn();
const db = makeDb({ get: vi.fn().mockReturnValue(undefined), run });
const res = makeRes();
const big = { blob: 'x'.repeat(300 * 1024) };
const h = handler(big);
await lastValueFrom(
new IdempotencyInterceptor(db).intercept(
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: 1 } }, res),
h,
),
);
res.statusCode = 200;
res.json(big);
expect(run).not.toHaveBeenCalled();
});
it('swallows a storage failure so the response still succeeds', async () => {
const run = vi.fn(() => {
throw new Error('db is locked');
});
const db = makeDb({ get: vi.fn().mockReturnValue(undefined), run });
const res = makeRes();
const h = handler({ ok: true });
await lastValueFrom(
new IdempotencyInterceptor(db).intercept(
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: 1 } }, res),
h,
),
);
res.statusCode = 201;
const returned = res.json({ ok: true });
expect(run).toHaveBeenCalledTimes(1);
expect(returned).toEqual({ ok: true });
});
it('makes an overlapping replay of the same key wait, then answers it from the first response', async () => {
// The row only appears once the first request answers, so without the
// in-flight map both of these would miss the SELECT and both would run.
const rows = new Map<string, { status_code: number; response_body: string }>();
const db = makeDb({
get: vi.fn((_sql: string, ...params: unknown[]) => rows.get(String(params[0]))) as unknown as DatabaseService['get'],
run: vi.fn((_sql: string, ...params: unknown[]) => {
rows.set(String(params[0]), { status_code: Number(params[4]), response_body: String(params[5]) });
return {} as never;
}) as unknown as DatabaseService['run'],
});
const interceptor = new IdempotencyInterceptor(db);
// The first handler is still running when the second request arrives.
let finish!: (value: unknown) => void;
const slow = { handle: vi.fn(() => from(new Promise((resolve) => { finish = resolve; }))) };
const firstRes = makeRes();
const first = lastValueFrom(interceptor.intercept(ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/places', user: { id: 1 } }, firstRes), slow));
const secondHandler = handler({ id: 'second' });
const secondRes = makeRes();
const second = lastValueFrom(interceptor.intercept(ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/places', user: { id: 1 } }, secondRes), secondHandler));
firstRes.statusCode = 201;
firstRes.json({ id: 'first' });
finish({ id: 'first' });
expect(await first).toEqual({ id: 'first' });
expect(await second).toEqual({ id: 'first' });
expect(secondHandler.handle).not.toHaveBeenCalled();
expect(secondRes.status).toHaveBeenCalledWith(201);
});
it('runs the waiting request itself when the first one cached nothing', async () => {
const db = makeDb({ get: vi.fn().mockReturnValue(undefined), run: vi.fn() });
const interceptor = new IdempotencyInterceptor(db);
let finish!: (value: unknown) => void;
const slow = { handle: vi.fn(() => from(new Promise((resolve) => { finish = resolve; }))) };
const first = lastValueFrom(interceptor.intercept(
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/places', user: { id: 1 } }, makeRes()),
slow,
));
const secondHandler = handler({ id: 'second' });
const second = lastValueFrom(interceptor.intercept(
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/places', user: { id: 1 } }, makeRes()),
secondHandler,
));
finish({ id: 'first' });
await first;
expect(await second).toEqual({ id: 'second' });
expect(secondHandler.handle).toHaveBeenCalled();
});
it('treats a PATCH as a mutating method', async () => {
const db = makeDb({ get: vi.fn().mockReturnValue(undefined), run: vi.fn() });
const res = makeRes();
const h = handler('done');
await lastValueFrom(
new IdempotencyInterceptor(db).intercept(
ctx({ method: 'PATCH', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories/1', user: { id: 1 } }, res),
h,
),
);
expect(db.get).toHaveBeenCalled();
expect(h.handle).toHaveBeenCalled();
});
});