From 9077c3988261531bdc7fed3390506b6037b8761f Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sat, 5 Sep 2026 14:20:39 +0200 Subject: [PATCH 1/2] fix(billing): pass the AppError, not extracted details, to responses.error billing.requireQuota's catch block extracted `details = err.details` (for branching on the AppError sub-type) and then handed that sub-object, not `err` itself, to responses.error(...)(details) at every 402/429/503 call site. responses.error reads `error.details` off whatever it's given, so it read `details.details` -> always undefined, silently dropping the whitelisted type/upgradeUrl payload from every response this middleware ever sent, in every environment including production. Pass `err` instead; keep the extracted `details` var only for the `?.type === '...'` branching. This also reshapes the dev-only payload.error blob: it now serializes the real AppError, so curated fields moved from the blob's top level to nested under `.details`. Updated the 8 existing tests (across billing.quota.unit.tests.js and billing.webhook.hardening.unit.tests.js) asserting the old flat shape, and added a dedicated NODE_ENV=production test proving payload.details carries type/upgradeUrl (red before this fix, green after). Fixes pierreb-devkit/Node#4062 Claude-Session: https://claude.ai/code/session_0185ELiCjZaBJx8PH4xoSsZb --- ERRORS.md | 1 + .../middlewares/billing.requireQuota.js | 19 +++-- .../billing/tests/billing.quota.unit.tests.js | 76 +++++++++++++++---- .../billing.webhook.hardening.unit.tests.js | 6 +- 4 files changed, 81 insertions(+), 21 deletions(-) diff --git a/ERRORS.md b/ERRORS.md index 25dd44a58..11ff7b9af 100644 --- a/ERRORS.md +++ b/ERRORS.md @@ -40,3 +40,4 @@ Use this file as a compact memory of recurring AI mistakes. - [2026-09-04] docker: #4053 pinned the toolchain for CI and local installs, but the Dockerfile still copied `package*.json`, ran `npm ci`, and only then `COPY . .` — so `.npmrc` (`engine-strict=true`) landed AFTER the install, and `FROM node:lts-slim` floated independently of `.nvmrc`, so an unsupported base image inside Docker still reproduced the confusing lockfile error instead of a clean `EBADENGINE`, and the image could silently drift onto a newer Node major once it enters LTS -> a toolchain fail-fast fix must be verified in every install path, not just the one exercised by CI; copy `.npmrc` alongside `package*.json` before `npm ci` in every installing stage, and pin `FROM` to the `.nvmrc` major with a unit test checking the two against each other instead of a floating tag; see pierreb-devkit/Node#4060 - [2026-09-04] error handling: eight call sites (`auth.controller.js` OAuth catch blocks, `uploads.repository.js` GridFS catch blocks) built `AppError` with `details: err` / `details: err.details || err` — a raw caught exception handed wholesale to the response layer — and `getDescription` (`lib/helpers/responses.js`) read `details.message` into the client-facing `description` field in EVERY environment, including production, so whatever a dependency's error object happened to carry (stack fragments, internal hostnames, driver taxonomy) decided what leaked -> curate `details` at each throw site to an explicit, deliberately-chosen field set (here: `{ message: err.message }` — a short reason, nothing else) instead of forwarding the exception, AND separately production-gate every `details`-derived read (`getDescription`'s string/array/`.message` resolution, plus `auth.controller.js`'s `oauthErrorRedirect` — a SECOND, independent consumer of `details.message` that bypasses `getDescription` entirely by building its own redirect envelope by hand) — NOTE the "same way" wording below turned out to be only partially true, see the 2026-09-05 follow-up entry; curating the call site alone is not sufficient — the curated `message` still flows through the same `details.message` slot every consumer reads, so both the source (call sites) and every sink (each place that reads `details.message`) must be fixed together; see pierreb-devkit/Node#4059 - [2026-09-05] error handling / review follow-up: #4059's review surfaced two gaps in the 2026-09-04 fix -> (1) "eight call sites" undercounted: `users.images.controller.js updateAvatar` forwarded a raw Multer error wholesale via `details: req.multerErr` (`code`/`field`/etc. included), the identical anti-pattern, curated the same way (`details: { message: req.multerErr.message }`); (2) `oauthErrorRedirect` was NOT gated "the same way" as `getDescription` as claimed — only its `details.message` read was production-gated, its `title` (`err?.message || fallbackTitle`) was not, so a future non-AppError with a dynamic `.message` could still leak in production (today's real producers are safe, but nothing enforced it) -> when a fix claims parity with an existing safeguard ("gated the same way"), verify EVERY read the safeguard covers has a matching gate, not just the one that prompted the fix; and re-grep the whole pattern (not just the files named in the originating issue) before trusting a count like "eight call sites" — see the two new tests added for `oauthErrorRedirect`'s title gate and `users.images.controller.js`'s curation; see pierreb-devkit/Node#4059 +- [2026-09-05] billing/error handling: `billing.requireQuota.js`'s catch block extracted `const details = err.details` (for branching on the AppError sub-type) and then passed that extracted sub-object — not `err` itself — into `responses.error(res, status, ...)(details)` at all five call sites (402/429/503) -> `responses.error` reads `error.details` off whatever it is handed, so it read `details.details`, always `undefined`, silently dropping the whitelisted `type`/`upgradeUrl` payload from every response this middleware ever sent, in every environment; fix = pass `err` (keep the extracted `details` var only for the `?.type ===` branching). This also reshaped the dev-only `payload.error` blob: it now serializes the real AppError, so its curated fields moved from the blob's top level to nested under `.details` — eight existing tests across two files (`billing.quota.unit.tests.js`, `billing.webhook.hardening.unit.tests.js`) were asserting the old (buggy) flat shape and needed the same nested-path update; see pierreb-devkit/Node#4062 diff --git a/modules/billing/middlewares/billing.requireQuota.js b/modules/billing/middlewares/billing.requireQuota.js index bb1512a25..a890ed109 100644 --- a/modules/billing/middlewares/billing.requireQuota.js +++ b/modules/billing/middlewares/billing.requireQuota.js @@ -56,26 +56,33 @@ function requireQuota(resource, action) { return next(); } catch (err) { // Map AppError status codes to HTTP responses matching previous behavior. - // Extract denial details from the AppError (may be array or object). + // `details` here is used ONLY to branch on the AppError sub-type (may be + // array or object) — it must never be what gets handed to + // `responses.error(...)` below. That function reads `error.details` + // itself off whatever object it is given, so it needs the AppError + // `err`, not this already-extracted sub-object (issue #4062: passing + // `details` made it read `details.details`, always `undefined`, which + // silently dropped the whitelisted `type`/`upgradeUrl` payload from + // every response this middleware sends). const details = Array.isArray(err.details) ? err.details[0] : err.details; if (err.status === 402) { if (details?.type === 'PAYMENT_PAST_DUE') { - return responses.error(res, 402, 'Payment Required', 'Subscription past due, please update payment')(details); + return responses.error(res, 402, 'Payment Required', 'Subscription past due, please update payment')(err); } if (details?.type === 'METER_EXHAUSTED') { - return responses.error(res, 402, 'Payment Required', 'Meter exhausted')(details); + return responses.error(res, 402, 'Payment Required', 'Meter exhausted')(err); } // Defensive: an unknown 402 sub-type would leak err.message verbatim. // The service only throws known types today, so send the generic phrase // instead — any future 402 type must be mapped explicitly above. - return responses.error(res, 402, 'Payment Required', 'Payment required')(details); + return responses.error(res, 402, 'Payment Required', 'Payment required')(err); } if (err.status === 429) { - return responses.error(res, 429, 'Quota exceeded', 'You have reached the usage limit for this resource')(details); + return responses.error(res, 429, 'Quota exceeded', 'You have reached the usage limit for this resource')(err); } if (err.status === 503) { - return responses.error(res, 503, 'Service Unavailable', 'Billing plan configuration is temporarily unavailable')(details); + return responses.error(res, 503, 'Service Unavailable', 'Billing plan configuration is temporarily unavailable')(err); } return next(err); } diff --git a/modules/billing/tests/billing.quota.unit.tests.js b/modules/billing/tests/billing.quota.unit.tests.js index 0970bbab0..16d24365e 100644 --- a/modules/billing/tests/billing.quota.unit.tests.js +++ b/modules/billing/tests/billing.quota.unit.tests.js @@ -361,12 +361,60 @@ describe('requireQuota middleware:', () => { expect(res.status).toHaveBeenCalledWith(402); const payload = res.json.mock.calls[0][0]; expect(payload.description).toBe('Meter exhausted'); + // The dev-only blob now serializes the AppError itself (issue #4062 fix + // — `responses.error` is handed `err`, not the pre-extracted `details` + // sub-object), so the curated fields live under `.details`, not at the + // blob's top level. const errData = JSON.parse(payload.error); - expect(errData.type).toBe('METER_EXHAUSTED'); - expect(errData.meterUsed).toBe(6000); - expect(errData.meterQuota).toBe(5000); - expect(errData.extrasRemaining).toBe(0); - expect(Array.isArray(errData.packsAvailable)).toBe(true); + expect(errData.details.type).toBe('METER_EXHAUSTED'); + expect(errData.details.meterUsed).toBe(6000); + expect(errData.details.meterQuota).toBe(5000); + expect(errData.details.extrasRemaining).toBe(0); + expect(Array.isArray(errData.details.packsAvailable)).toBe(true); + }); + + // Issue #4062: the test above only ever inspects `payload.error` — the + // raw serialized-error blob that `lib/helpers/responses.js` gates to + // dev-grade environments only (`configHelper.isProd()`). It passed even + // while the middleware handed `responses.error(...)` the already-extracted + // `details` sub-object instead of the AppError itself, because that blob + // is built from whatever object reaches `responses.error`, not from a + // specific `.details` traversal. The field a REAL production client reads + // is `payload.details` (the whitelisted `type`/`upgradeUrl` subset) — + // this test forces `NODE_ENV = 'production'` so `payload.error` cannot + // exist at all, the same production-mode-toggle convention used in + // `lib/helpers/tests/responses.detailsWhitelist.unit.tests.js`. + test('production mode: 402 METER_EXHAUSTED response carries type + upgradeUrl in payload.details, not just the dev-only error blob', async () => { + mockBillingQuotaService.assertCanExecute.mockRejectedValue( + new AppError('Meter exhausted', { + status: 402, + details: { + type: 'METER_EXHAUSTED', + meterUsed: 6000, + meterQuota: 5000, + extrasRemaining: 0, + packsAvailable: [{ packId: 'pack_500k', meterUnits: 500000 }], + upgradeUrl: '/billing/plans', + }, + }), + ); + + const previousNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + try { + await requireQuota('scraps', 'create')(req, res, next); + } finally { + process.env.NODE_ENV = previousNodeEnv; + } + + expect(res.status).toHaveBeenCalledWith(402); + const payload = res.json.mock.calls[0][0]; + // The whitelisted subset a production client actually renders an + // upgrade CTA from — only `type`/`upgradeUrl` survive the whitelist, + // `meterUsed`/`meterQuota`/etc. are intentionally not whitelisted. + expect(payload.details).toEqual({ type: 'METER_EXHAUSTED', upgradeUrl: '/billing/plans' }); + // Production never leaks the raw serialized-error blob. + expect(payload.error).toBeUndefined(); }); test('should return 402 when meter doc is null and plan quota is 0 (no extras)', async () => { @@ -399,7 +447,7 @@ describe('requireQuota middleware:', () => { expect(res.status).toHaveBeenCalledWith(503); const payload = res.json.mock.calls[0][0]; const errData = JSON.parse(payload.error); - expect(errData.type).toBe('PLAN_NOT_CONFIGURED'); + expect(errData.details.type).toBe('PLAN_NOT_CONFIGURED'); }); test('fix #3569: new Free user — 1st scrap passes when plan meterQuota > 0', async () => { @@ -470,7 +518,7 @@ describe('requireQuota middleware:', () => { expect(res.status).toHaveBeenCalledWith(402); const payload = res.json.mock.calls[0][0]; const errData = JSON.parse(payload.error); - expect(errData.type).toBe('PAYMENT_PAST_DUE'); + expect(errData.details.type).toBe('PAYMENT_PAST_DUE'); }); test('should return 402 PAYMENT_PAST_DUE when past_due and grace period elapsed (J+10)', async () => { @@ -487,8 +535,8 @@ describe('requireQuota middleware:', () => { expect(res.status).toHaveBeenCalledWith(402); const payload = res.json.mock.calls[0][0]; const errData = JSON.parse(payload.error); - expect(errData.type).toBe('PAYMENT_PAST_DUE'); - expect(errData.subscriptionStatus).toBe('past_due'); + expect(errData.details.type).toBe('PAYMENT_PAST_DUE'); + expect(errData.details.subscriptionStatus).toBe('past_due'); }); test('should NOT block past_due with no pastDueSince set (service resolves)', async () => { @@ -548,7 +596,7 @@ describe('requireQuota middleware:', () => { expect(res.status).toHaveBeenCalledWith(402); const payload = res.json.mock.calls[0][0]; const errData = JSON.parse(payload.error); - expect(errData.type).toBe('METER_EXHAUSTED'); + expect(errData.details.type).toBe('METER_EXHAUSTED'); }); // ── V8 audit C2: incomplete subscription must be fail-closed ───────────── @@ -574,8 +622,8 @@ describe('requireQuota middleware:', () => { expect(res.status).toHaveBeenCalledWith(402); const payload = res.json.mock.calls[0][0]; const errData = JSON.parse(payload.error); - expect(errData.type).toBe('METER_EXHAUSTED'); - expect(errData.meterQuota).toBe(0); + expect(errData.details.type).toBe('METER_EXHAUSTED'); + expect(errData.details.meterQuota).toBe(0); }); test('V8-C2b: canceled subscription in meter mode → 402 METER_EXHAUSTED with free quota (not paid)', async () => { @@ -599,8 +647,8 @@ describe('requireQuota middleware:', () => { expect(res.status).toHaveBeenCalledWith(402); const payload = res.json.mock.calls[0][0]; const errData = JSON.parse(payload.error); - expect(errData.type).toBe('METER_EXHAUSTED'); - expect(errData.meterQuota).toBe(0); + expect(errData.details.type).toBe('METER_EXHAUSTED'); + expect(errData.details.meterQuota).toBe(0); }); }); }); diff --git a/modules/billing/tests/billing.webhook.hardening.unit.tests.js b/modules/billing/tests/billing.webhook.hardening.unit.tests.js index bc2231854..2f6f5dd16 100644 --- a/modules/billing/tests/billing.webhook.hardening.unit.tests.js +++ b/modules/billing/tests/billing.webhook.hardening.unit.tests.js @@ -594,8 +594,12 @@ describe('requireQuota — fail-closed statuses in meter mode:', () => { expect(next).not.toHaveBeenCalled(); expect(res.status).toHaveBeenCalledWith(402); const payload = res.json.mock.calls[0][0]; + // The dev-only blob now serializes the AppError itself (issue #4062 fix + // — `responses.error` is handed `err`, not the pre-extracted `details` + // sub-object), so the curated fields live under `.details`, not at the + // blob's top level. const errData = JSON.parse(payload.error); - expect(errData.type).toBe('METER_EXHAUSTED'); + expect(errData.details.type).toBe('METER_EXHAUSTED'); }, ); From fc04aac25fb44c72da1747f9bfce8fb56e77fc57 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sat, 5 Sep 2026 15:04:56 +0200 Subject: [PATCH 2/2] test(billing): guard every requireQuota site with a production-mode payload.details assertion - production-mode tests for 402 PAYMENT_PAST_DUE, 402 unmapped sub-type, 429 QUOTA_EXCEEDED, 503 PLAN_NOT_CONFIGURED (METER_EXHAUSTED already had one) - the 429 test now reads upgradeUrl as its name claims, instead of only checking message/code/status via objectContaining - comment on the Array.isArray unwrap in billing.requireQuota.js now states that an array-shaped details yields no payload.details either way (pickWhitelistedDetails drops it), so the branch is dead-but-harmless Claude-Session: https://claude.ai/code/session_0185ELiCjZaBJx8PH4xoSsZb --- .../middlewares/billing.requireQuota.js | 25 ++-- .../billing/tests/billing.quota.unit.tests.js | 108 +++++++++++++++++- 2 files changed, 123 insertions(+), 10 deletions(-) diff --git a/modules/billing/middlewares/billing.requireQuota.js b/modules/billing/middlewares/billing.requireQuota.js index a890ed109..4a4e789e9 100644 --- a/modules/billing/middlewares/billing.requireQuota.js +++ b/modules/billing/middlewares/billing.requireQuota.js @@ -56,14 +56,23 @@ function requireQuota(resource, action) { return next(); } catch (err) { // Map AppError status codes to HTTP responses matching previous behavior. - // `details` here is used ONLY to branch on the AppError sub-type (may be - // array or object) — it must never be what gets handed to - // `responses.error(...)` below. That function reads `error.details` - // itself off whatever object it is given, so it needs the AppError - // `err`, not this already-extracted sub-object (issue #4062: passing - // `details` made it read `details.details`, always `undefined`, which - // silently dropped the whitelisted `type`/`upgradeUrl` payload from - // every response this middleware sends). + // `details` here is used ONLY to branch on the AppError sub-type — it + // must never be what gets handed to `responses.error(...)` below. That + // function reads `error.details` itself off whatever object it is + // given, so it needs the AppError `err`, not this already-extracted + // sub-object (issue #4062: passing `details` made it read + // `details.details`, always `undefined`, which silently dropped the + // whitelisted `type`/`upgradeUrl` payload from every response this + // middleware sends). + // `err.details` may in principle be array-shaped — `AppError` defaults + // `details` to `[{ message }]` when a throw site omits it — hence the + // unwrap below. Unreachable today (every throw in + // billing.quota.service.js passes a plain object), and it wouldn't + // help production either way: `responses.error` reads `err.details` + // directly, not this local `details`, and `pickWhitelistedDetails` + // (lib/helpers/responses.js) returns `undefined` for any array-shaped + // `details` — so an array-shaped `err.details` ships with no + // `payload.details` at all, regardless of which branch below fires. const details = Array.isArray(err.details) ? err.details[0] : err.details; if (err.status === 402) { diff --git a/modules/billing/tests/billing.quota.unit.tests.js b/modules/billing/tests/billing.quota.unit.tests.js index 16d24365e..bb353b72a 100644 --- a/modules/billing/tests/billing.quota.unit.tests.js +++ b/modules/billing/tests/billing.quota.unit.tests.js @@ -251,7 +251,15 @@ describe('requireQuota middleware:', () => { expect(next).toHaveBeenCalled(); }); - test('should return correct error payload with upgradeUrl', async () => { + // Was named "...with upgradeUrl" but never read `upgradeUrl` anywhere in + // the body — only `type`/`message`/`code`/`status`/`description` via + // `objectContaining`, which passes even if `upgradeUrl` is missing + // entirely. Rewritten to actually assert the whitelisted `payload.details` + // subset (issue #4062 review item 2), which doubles as the 429 + // QUOTA_EXCEEDED production-mode assertion (item 1's 429 row) — same + // `NODE_ENV = 'production'` toggle convention as the METER_EXHAUSTED test + // below. + test('production mode: 429 QUOTA_EXCEEDED response carries type + upgradeUrl in payload.details, not just the dev-only error blob', async () => { mockBillingQuotaService.assertCanExecute.mockRejectedValue( new AppError('You have reached the usage limit for this resource', { status: 429, @@ -259,7 +267,13 @@ describe('requireQuota middleware:', () => { }), ); - await requireQuota('scraps', 'execute')(req, res, next); + const previousNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + try { + await requireQuota('scraps', 'execute')(req, res, next); + } finally { + process.env.NODE_ENV = previousNodeEnv; + } expect(res.status).toHaveBeenCalledWith(429); expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ @@ -269,6 +283,11 @@ describe('requireQuota middleware:', () => { status: 429, description: 'You have reached the usage limit for this resource', })); + const payload = res.json.mock.calls[0][0]; + // `resource`/`action`/`limit`/`current` are NOT whitelisted — only + // `type`/`upgradeUrl` survive. + expect(payload.details).toEqual({ type: 'QUOTA_EXCEEDED', upgradeUrl: '/billing/plans' }); + expect(payload.error).toBeUndefined(); }); test('should allow through when no quota is configured for resource (service resolves)', async () => { @@ -417,6 +436,38 @@ describe('requireQuota middleware:', () => { expect(payload.error).toBeUndefined(); }); + // Issue #4062 review item 1: no test previously covered the defensive + // `err.status === 402` fallthrough (an unmapped sub-type — the service + // only throws known types today, but any future 402 type must be mapped + // explicitly or it lands here). Asserts the generic `message`/`description` + // pair (never `err.message` verbatim), that `type` still survives the + // whitelist, and that a non-whitelisted key on `details` (e.g. an + // internal-only hint) is dropped, not leaked. + test('production mode: 402 with an unmapped sub-type falls through to the defensive branch, keeps type in payload.details, drops non-whitelisted keys', async () => { + mockBillingQuotaService.assertCanExecute.mockRejectedValue( + new AppError('Some future 402 reason not yet mapped', { + status: 402, + details: { type: 'SOME_FUTURE_TYPE', internalHint: 'do not leak this' }, + }), + ); + + const previousNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + try { + await requireQuota('scraps', 'create')(req, res, next); + } finally { + process.env.NODE_ENV = previousNodeEnv; + } + + expect(res.status).toHaveBeenCalledWith(402); + const payload = res.json.mock.calls[0][0]; + expect(payload.message).toBe('Payment Required'); + expect(payload.description).toBe('Payment required'); + expect(payload.details).toEqual({ type: 'SOME_FUTURE_TYPE' }); + expect(payload.details.internalHint).toBeUndefined(); + expect(payload.error).toBeUndefined(); + }); + test('should return 402 when meter doc is null and plan quota is 0 (no extras)', async () => { mockBillingQuotaService.assertCanExecute.mockRejectedValue( new AppError('Meter exhausted', { @@ -450,6 +501,32 @@ describe('requireQuota middleware:', () => { expect(errData.details.type).toBe('PLAN_NOT_CONFIGURED'); }); + // Issue #4062 review item 1: the dev-blob test above only ever inspects + // `payload.error` (dev-gated). Same production-mode-toggle convention as + // the METER_EXHAUSTED test above. + test('production mode: 503 PLAN_NOT_CONFIGURED response carries type in payload.details, not just the dev-only error blob', async () => { + mockBillingQuotaService.assertCanExecute.mockRejectedValue( + new AppError('Billing plan configuration is temporarily unavailable', { + status: 503, + details: { type: 'PLAN_NOT_CONFIGURED', planId: 'free' }, + }), + ); + + const previousNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + try { + await requireQuota('scraps', 'create')(req, res, next); + } finally { + process.env.NODE_ENV = previousNodeEnv; + } + + expect(res.status).toHaveBeenCalledWith(503); + const payload = res.json.mock.calls[0][0]; + // `planId` is NOT whitelisted — only `type` survives. + expect(payload.details).toEqual({ type: 'PLAN_NOT_CONFIGURED' }); + expect(payload.error).toBeUndefined(); + }); + test('fix #3569: new Free user — 1st scrap passes when plan meterQuota > 0', async () => { mockBillingQuotaService.assertCanExecute.mockResolvedValue({ degraded: false }); @@ -539,6 +616,33 @@ describe('requireQuota middleware:', () => { expect(errData.details.subscriptionStatus).toBe('past_due'); }); + // Issue #4062 review item 1: the dev-blob test above only ever inspects + // `payload.error` (dev-gated). The field a real production client reads + // is `payload.details` — same production-mode-toggle convention as the + // METER_EXHAUSTED test above. + test('production mode: 402 PAYMENT_PAST_DUE response carries type in payload.details, not just the dev-only error blob', async () => { + mockBillingQuotaService.assertCanExecute.mockRejectedValue( + new AppError('Subscription past due, please update payment', { + status: 402, + details: { type: 'PAYMENT_PAST_DUE', message: 'Subscription past due, please update payment', subscriptionStatus: 'past_due' }, + }), + ); + + const previousNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + try { + await requireQuota('scraps', 'create')(req, res, next); + } finally { + process.env.NODE_ENV = previousNodeEnv; + } + + expect(res.status).toHaveBeenCalledWith(402); + const payload = res.json.mock.calls[0][0]; + // `message`/`subscriptionStatus` are NOT whitelisted — only `type` survives. + expect(payload.details).toEqual({ type: 'PAYMENT_PAST_DUE' }); + expect(payload.error).toBeUndefined(); + }); + test('should NOT block past_due with no pastDueSince set (service resolves)', async () => { mockBillingQuotaService.assertCanExecute.mockResolvedValue({ degraded: false });