diff --git a/backend/__tests__/unit/services/pgBootService.sentryWrap.test.js b/backend/__tests__/unit/services/pgBootService.sentryWrap.test.js new file mode 100644 index 000000000..61f020834 --- /dev/null +++ b/backend/__tests__/unit/services/pgBootService.sentryWrap.test.js @@ -0,0 +1,130 @@ +/** + * TASK-172 — the PREMISE behind #1958's wrap clause, checked against the real + * library instead of a stand-in. + * + * THE GAP THIS CLOSES. `pgBootService.test.js` proves the wrap clause works: + * it hands `routerIsMounted` a layer whose handle it has wrapped by hand + * (`wrapLikeSentry`). It cannot do better, because jest owns module loading and + * `@sentry/node` patches express through a require-hook — so in-suite + * `layer.handle === router` is TRUE even with Sentry initialised. The + * consequence is that the suite pins the FIX and never the REASON: if a Sentry + * upgrade stops exposing `.stack` on the wrapper, every case stays green and + * production goes back to refusing a pod that has the PG routes mounted, which + * is the rollout hang of 2026-09-27 (deploy `4e60f240`: `deployment.apps/backend` + * did not become ready within 8m, `/api/pg/messages` 401 on a pod whose + * `/ready` said "not mounted"). + * + * HOW IT ESCAPES THE TIER. The probe is a spawned plain-`node` child + * (`__tests__/utils/sentryLayerWrapProbe.js`), so the module registry is the + * real one: `Sentry.init()` runs before `require('express')` and the patch + * lands. `process.execPath` is used, so the child runs whatever node the suite + * runs — the premise is checked under the interpreter that would run it. + * + * WHY THREE ASSERTIONS AND NOT ONE. "It reads true" can be true for the wrong + * reason, so the probe reports the shape of the instrumentation as well as the + * answer, and the suite pins both directions of the failure: + * + * - `wrapped` false under Sentry → the check is VACUOUS. Identity alone + * answers the question, so the suite would pass with the clause deleted and + * a future reader would be told the clause is load-bearing when it is not. + * (Or the instrumentation failed to install in the child, which is worth + * knowing too — the message names both.) + * - `wrapped` true, `handleHasStack` false → the exact production break: the + * clause can no longer match a mounted router, so readiness refuses a pod + * that has chat. Red here is a deploy-blocking regression caught pre-merge + * instead of at rollout. + * - no-wrong-answer direction: an unmounted router must be false, and a + * router mounted on a DIFFERENT app must be false. The clause WIDENS a + * match (it accepts `handle.stack === routerStack`), so this is the + * direction that would silently start reporting "mounted" for a router the + * app does not serve. + * + * The `PROBE_SENTRY=0` run is the positive control: identical construction with + * no instrumentation. `wrapped` must be false there, which is what makes the + * instrumented run's `wrapped: true` attributable to Sentry rather than to the + * express version — and what proves the probe can see a difference rather than + * reporting a blind null. + */ +const { spawnSync } = require('child_process'); +const path = require('path'); + +const PROBE = path.join(__dirname, '..', '..', 'utils', 'sentryLayerWrapProbe.js'); +const BACKEND_ROOT = path.join(__dirname, '..', '..', '..'); + +const runProbe = (withSentry) => { + const proc = spawnSync(process.execPath, [PROBE], { + cwd: BACKEND_ROOT, + encoding: 'utf8', + timeout: 120000, + env: { ...process.env, PROBE_SENTRY: withSentry ? '1' : '0' }, + }); + + const lines = (proc.stdout || '').split('\n').map((l) => l.trim()).filter(Boolean); + let result = null; + try { + result = JSON.parse(lines[lines.length - 1]); + } catch (error) { + result = null; + } + + // Reported rather than asserted here, so each case can name what it expected + // and the reader gets the child's own output when the probe could not run. + return { + result, + status: proc.status, + signal: proc.signal, + stderr: (proc.stderr || '').trim(), + stdout: (proc.stdout || '').trim(), + }; +}; + +const describeProbe = (label, run) => { + const detail = `[${label}] exit=${run.status} signal=${run.signal} stdout=${run.stdout} stderr=${run.stderr}`; + if (!run.result) throw new Error(`the probe produced no JSON result. ${detail}`); + if (run.result.error) { + throw new Error(`the probe threw in the child: ${run.result.error}. ${detail}`); + } + return run.result; +}; + +describe('routerIsMounted under real @sentry/node instrumentation (TASK-172)', () => { + let plain; + let instrumented; + + beforeAll(() => { + plain = runProbe(false); + instrumented = runProbe(true); + }); + + it('control: with no instrumentation the layer handle IS the router, and the answer is true', () => { + const result = describeProbe('control', plain); + expect(result.sentry).toBe(false); + // The control is what gives the instrumented run meaning: if this were true + // there too, the probe would be blind to the patch rather than measuring it. + expect(result.wrapped).toBe(false); + expect(result.self).toBe(true); + }); + + it('instrumentation really replaces the layer handle, so this suite is not vacuous', () => { + const result = describeProbe('instrumented', instrumented); + expect(result.sentry).toBe(true); + expect(result.layerFound).toBe(true); + expect(result.wrapped).toBe(true); + expect(result.handleName).not.toBe('router'); + // The property #1958's clause keys on. If this goes false, the clause + // cannot match and readiness refuses a pod that has the routes mounted — + // the production shape this file exists to catch before a rollout. + expect(result.handleHasStack).toBe(true); + }); + + it('answers true for a mounted router while instrumentation has wrapped its layer', () => { + const result = describeProbe('instrumented', instrumented); + expect(result.self).toBe(true); + }); + + it('answers false for a router that is not mounted, and for one mounted on another app', () => { + const result = describeProbe('instrumented', instrumented); + expect(result.unmounted).toBe(false); + expect(result.foreign).toBe(false); + }); +}); diff --git a/backend/__tests__/utils/sentryLayerWrapProbe.js b/backend/__tests__/utils/sentryLayerWrapProbe.js new file mode 100644 index 000000000..c0839b63a --- /dev/null +++ b/backend/__tests__/utils/sentryLayerWrapProbe.js @@ -0,0 +1,103 @@ +/** + * TASK-172 — the real-Sentry proof of the premise behind #1958's wrap clause. + * + * WHY THIS IS A SPAWNED CHILD AND NOT A SUITE. Jest cannot host this check. + * `@sentry/node` patches `express` through OpenTelemetry's require-hook, which + * installs itself on `Module._load` when `Sentry.init()` runs; jest owns module + * loading instead, so express is never patched in-suite and + * `layer.handle === router` stays TRUE there. That is exactly why + * `pgBootService.test.js` has to fake the wrapper with `wrapLikeSentry` — and + * exactly why the faked wrapper pins the FIX and can never pin the PREMISE. + * The premise is a fact about the real library, so it needs a real `node`. + * + * It lives under `__tests__/utils/` because jest's `testMatch` collects + * everything under `__tests__/` and would otherwise fail this directory-shaped + * way ("your test suite must contain at least one test" — the same reason + * `utils/` is already ignored in `backend/jest.config.js`). This file is data + * for a test, never a test. + * + * WHAT IT MEASURES. Three facts, because "the guard reads true" alone can be + * true for the wrong reason: + * 1. `wrapped` — did instrumentation actually replace the layer handle with a + * wrapper? If this is false, the suite is vacuous: identity alone answers + * the question, and a future Sentry that stops wrapping would silently + * retire the premise while this probe kept reporting `self: true`. + * 2. `handleHasStack` — does the wrapper expose the router's own stack array? + * That is the property #1958's clause keys on. + * 3. `self` / `unmounted` / `foreign` — the real `routerIsMounted` answering + * true for the mounted router, false for a router that is not mounted, and + * false for an app that mounted a DIFFERENT router (the false-positive + * direction, which matters because the clause widens a match). + * + * `PROBE_SENTRY=0` runs the identical construction with no instrumentation, as + * the positive control: `wrapped` must be false there, so the difference in + * `self` is attributable to Sentry rather than to the express version. + * + * Prints one JSON line and always exits 0; the suite asserts on the fields, so + * a crash is reported as a missing result with stderr attached rather than as + * an opaque non-zero status. + */ +const withSentry = process.env.PROBE_SENTRY === '1'; +const out = { sentry: withSentry }; + +try { + // ts-node first: nothing else may load express before Sentry.init(). + require('ts-node').register({ + transpileOnly: true, + skipProject: true, + compilerOptions: { module: 'commonjs', target: 'es2020', esModuleInterop: true }, + }); + + if (withSentry) { + const Sentry = require('@sentry/node'); + Sentry.init({ + // A syntactically valid DSN with a non-resolving host: init() must run for + // the express instrumentation to install, and no event should ever leave. + dsn: 'https://0123456789abcdef0123456789abcdef@example.invalid/1', + tracesSampleRate: 0, + }); + } + + const express = require('express'); + // A literal path, both so eslint can see the target and so ts-node's hook is + // the only thing that has to understand the extension. + const { routerIsMounted } = require('../../services/pgBootService.ts'); + + const app = express(); + const router = express.Router(); + const other = express.Router(); + const neverMounted = express.Router(); + router.get('/', (req, res) => res.json({ ok: true })); + other.get('/', (req, res) => res.json({ ok: true })); + app.use('/api/pg/messages', router); + app.use('/api/other', other); + + // A second app that mounts only the other router: asking it about `router` + // must be false, whether or not the layers are wrapped. + const foreignApp = express(); + foreignApp.use('/api/other', other); + + const stackOf = (a) => (a._router && a._router.stack) || (a.router && a.router.stack) || []; + const layerFor = (a, target) => stackOf(a).find( + (l) => l.handle === target || (l.handle && l.handle.stack === target.stack), + ); + + const layer = layerFor(app, router); + const foreignLayer = layerFor(foreignApp, other); + + out.layerFound = Boolean(layer); + out.wrapped = Boolean(layer) && layer.handle !== router; + out.handleType = layer ? typeof layer.handle : null; + out.handleName = (layer && layer.handle && layer.handle.name) || null; + out.handleHasStack = Boolean(layer && layer.handle && layer.handle.stack); + out.foreignWrapped = Boolean(foreignLayer) && foreignLayer.handle !== other; + out.self = routerIsMounted(app, router); + out.unmounted = routerIsMounted(app, neverMounted); + out.foreign = routerIsMounted(foreignApp, router); +} catch (error) { + out.error = (error && error.message) || String(error); + out.stack = (error && error.stack) || null; +} + +process.stdout.write(`${JSON.stringify(out)}\n`); +process.exit(0);