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([]); }); });