Files
redefined-designs/backend/tests/unit/routesAreWrapped.test.ts
T
bermudalambandClaude Opus 5 36e6057c6e fix(tests): close the route guard's factory hole and two regex warnings (#307)
Three of the items on the cleanup issue, and the first is the one that mattered.

routesAreWrapped.test.ts could not see a handler built by a factory. `router.post('/x', rotationRoute('left'))` carries no async token of its own, so the guard read those two lines, found nothing to object to, and passed — which is not the same as finding them wrapped. That is how the rotation routes added in #301 went through a test that exists precisely because this convention had already been half-forgotten once, when thirty handlers were added unwrapped after the wrapper existed. It now follows a call to a function declared in the same file and reads its body the same way it reads a registration, so an unwrapped handler inside a factory is an offender. Proved rather than assumed: unwrapping rotationRoute's handler makes the suite fail naming admin.ts, where before it passed.

Only same-file functions are followed, deliberately. app.ts registers express.json(), cookieParser() and uploadsRouter(), none of which is a handler factory and none of which can be resolved from the file being read — treating an unresolvable name as an offender would trade one hole for a permanently red test, so there is a case asserting those are left alone.

The brace and paren walking is now one function rather than two. Adding the factory reader as a near-copy of registrationAt is what a cleanup commit should not do, and the duplicate carried its own cognitive-complexity and loop-counter warnings with it; parameterising the delimiter pair removes both the copy and the warnings it added.

The schema mirror's table regex used `\s*` where kysely-codegen emits exactly two spaces and one after the colon, and `[A-Za-z0-9_]` where `\w` says the same thing. SonarQube flagged both, and the looser form bought nothing and backtracked for it.

An empty status list would have compiled to `in ()`, which is a Postgres syntax error, where the `= ANY($n::text[])` it replaced in #308 was valid and matched nothing. It is unreachable through parseItemFilters, which refuses a list that names nothing — but the obvious guard is wrong in the opposite direction, because dropping the clause entirely would make an empty status filter match every status rather than none, so the empty case is spelled out as false.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 08:26:22 -05:00

247 lines
9.0 KiB
TypeScript

import { readdirSync, readFileSync } from 'fs';
import { join } from 'path';
// Express 4 does not forward a rejected promise from an async handler, so an
// unwrapped async route never responds at all — the request hangs until the
// client gives up, nothing reaches the error middleware, and nothing is logged
// as a failure. That is the shape of the 2026-08-17 incident that put an empty
// storefront in front of customers.
//
// `asyncRoute` fixes it, but a convention only holds while everyone remembers
// it, and this one was already half-forgotten once: 30 handlers were added
// unwrapped after the wrapper existed. This test is the part that remembers.
//
// Express 5 forwards rejections natively. When the project upgrades, delete
// `asyncRoute` and delete this test with it.
const SRC = join(__dirname, '..', '..', 'src');
// `router.get(`, `app.use(`, and friends — where a handler gets registered.
// Any `*Router` name counts, not just `router`: cartCheckout.ts registers the
// PayPal webhook on a second router, and matching only the common name is how
// that one stayed unwrapped while everything around it was audited.
const REGISTRATION = /\b(?:app|\w*[Rr]outer)\.(?:get|post|put|patch|delete|all|use)\s*\(/g;
/**
* Returns the text from `start` to the delimiter matching the one it opens
* with, counting depth. Quotes and comments are skipped so a route path like
* `'/items/:id'`, or a brace inside a string, cannot throw the count off.
*
* Parameterised over the delimiter pair because two callers need the same walk:
* a registration is bounded by parens and a function body by braces, and #307
* added the second one as a near-copy of the first before this was factored.
*/
function matchedSpan(source: string, start: number, open: string, close: string): string {
let depth = 0;
for (let i = start; i < source.length; i++) {
const c = source[i];
if (c === "'" || c === '"' || c === '`') {
i = skipString(source, i);
continue;
}
if (c === '/' && source[i + 1] === '/') {
i = source.indexOf('\n', i);
if (i === -1) break;
continue;
}
if (c === '/' && source[i + 1] === '*') {
i = source.indexOf('*/', i) + 1;
continue;
}
if (c === open) depth++;
if (c === close) {
depth--;
if (depth === 0) return source.slice(start, i + 1);
}
}
return source.slice(start);
}
/**
* The full text of the registration call starting at `open`, the index of its
* `(`.
*/
function registrationAt(source: string, open: number): string {
return matchedSpan(source, open, '(', ')');
}
/** Returns the index of the closing quote of the string opening at `start`. */
function skipString(source: string, start: number): number {
const quote = source[start];
for (let i = start + 1; i < source.length; i++) {
if (source[i] === '\\') {
i++;
continue;
}
if (source[i] === quote) return i;
}
return source.length;
}
/** The body of the function declared at `declared`, braces included. */
function bodyOf(source: string, declared: number): string {
const start = source.indexOf('{', declared);
return start === -1 ? '' : matchedSpan(source, start, '{', '}');
}
/**
* The bodies of any handler factories a registration calls.
*
* `router.post('/x', rotationRoute('left'))` carries no `async` token of its
* own, so the token check below passes it by finding nothing at all. That is
* exactly how the two rotation routes added in #301 sailed through a guard that
* exists because this convention had already been half-forgotten once — it did
* not find them wrapped, it found nothing and said yes. The handler lives in
* the factory, so the factory is what has to be read.
*
* Only functions declared in the same file are followed. `express.json()` and
* `cookieParser()` are imported and are not handler factories at all; there is
* no honest way to resolve those textually, and guessing at them would trade
* this hole for false positives on every library call in app.ts.
*/
function factoryBodies(source: string, call: string): string[] {
const bodies: string[] = [];
for (const [, name] of call.matchAll(/\b([a-zA-Z_]\w*)\s*\(/g)) {
if (name === 'asyncRoute') continue;
const declared = new RegExp(`function\\s+${name}\\s*\\(`).exec(source);
if (!declared) continue;
bodies.push(bodyOf(source, declared.index));
}
return bodies;
}
/** Whether `text` contains an `async` that is not directly behind `asyncRoute(`. */
function hasUnwrappedAsync(text: string): boolean {
for (const found of text.matchAll(/\basync\b/g)) {
if (!text.slice(0, found.index!).trimEnd().endsWith('asyncRoute(')) return true;
}
return false;
}
/**
* Every `async` inside a registration must sit directly behind `asyncRoute(`,
* and so must every `async` inside a factory that registration calls.
* Checking the token rather than the line catches a handler whose `async`
* lands on its own line, which a line-oriented grep would wave through.
*/
function unwrappedHandlers(source: string): string[] {
const offenders: string[] = [];
for (const match of source.matchAll(REGISTRATION)) {
const open = match.index! + match[0].length - 1;
const call = registrationAt(source, open);
const suspect = [call, ...factoryBodies(source, call)];
if (suspect.some(hasUnwrappedAsync)) {
offenders.push(`line ${lineOf(source, match.index!)}: ${match[0].trim()}`);
}
}
return offenders;
}
function lineOf(source: string, index: number): number {
return source.slice(0, index).split('\n').length;
}
const routeFiles = readdirSync(join(SRC, 'routes'))
.filter((f) => f.endsWith('.ts'))
.map((f) => join('routes', f));
describe.each([...routeFiles, 'app.ts'])('%s', (relative) => {
it('wraps every async handler in asyncRoute', () => {
const source = readFileSync(join(SRC, relative), 'utf8');
expect(unwrappedHandlers(source)).toEqual([]);
});
});
describe('async middleware', () => {
// `attachCustomer` is mounted globally, so a rejection there hangs every
// request in the application — including the routes that are wrapped
// correctly. It is the one handler whose failure is not contained.
it('wraps attachCustomer where app.ts mounts it', () => {
const source = readFileSync(join(SRC, 'app.ts'), 'utf8');
expect(source).toContain('app.use(asyncRoute(attachCustomer))');
});
});
// Keeps the helper honest: if the paren-walking ever silently stops finding
// registrations, the tests above would pass by finding nothing at all.
describe('the guard itself', () => {
it('finds a bare async handler', () => {
const bad = `router.get('/', requireCustomer, async (req, res) => { res.json({}); });`;
expect(unwrappedHandlers(bad)).toHaveLength(1);
});
it('accepts a wrapped one', () => {
const good = `router.get('/', requireCustomer, asyncRoute(async (req, res) => { res.json({}); }));`;
expect(unwrappedHandlers(good)).toEqual([]);
});
it('sees an async handler that starts on a later line', () => {
const bad = `router.post(\n '/items/:id',\n uploadImages,\n async (req, res) => { res.json({}); }\n);`;
expect(unwrappedHandlers(bad)).toHaveLength(1);
});
it('is not fooled by a paren inside a route path', () => {
const bad = `router.get('/odd(path', async (req, res) => { res.json({}); });`;
expect(unwrappedHandlers(bad)).toHaveLength(1);
});
it('leaves a synchronous handler alone', () => {
const sync = `router.get('/', (req, res) => { res.json({}); });`;
expect(unwrappedHandlers(sync)).toEqual([]);
});
// The hole #307 closed. A registration built by a factory carries no `async`
// of its own, so before this the guard looked at these two lines, found
// nothing to object to, and passed — which is not the same as finding them
// wrapped. The rotation routes in #301 were the first factory in src/routes
// and went through exactly this way.
it('follows a factory to the handler it returns', () => {
const bad = `
function rotationRoute(direction) {
return async (req, res) => { res.json({ direction }); };
}
router.post('/rotate-left', rotationRoute('left'));`;
expect(unwrappedHandlers(bad)).toHaveLength(1);
});
it('accepts a factory that returns a wrapped handler', () => {
const good = `
function rotationRoute(direction) {
return asyncRoute(async (req, res) => { res.json({ direction }); });
}
router.post('/rotate-left', rotationRoute('left'));`;
expect(unwrappedHandlers(good)).toEqual([]);
});
// Imported calls are left alone deliberately. app.ts registers
// `express.json()`, `cookieParser()` and `uploadsRouter()`, none of which is
// a handler factory and none of which can be read from this file — treating
// an unresolvable name as an offender would trade one hole for a permanently
// red test.
it('ignores a call it cannot resolve in the same file', () => {
const imported = `router.use('/uploads', uploadsRouter());`;
expect(unwrappedHandlers(imported)).toEqual([]);
});
});