From 683e784c1c78ea2fb6a1d7ef9747d918c6eac309 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Mon, 15 Jun 2026 09:11:21 +0200 Subject: [PATCH 1/6] fix(config): export isProd/isDevEnv predicate --- lib/helpers/config.js | 26 +++++- .../tests/config.envPredicate.unit.tests.js | 85 +++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 lib/helpers/tests/config.envPredicate.unit.tests.js diff --git a/lib/helpers/config.js b/lib/helpers/config.js index 86d20dbc8..775dbc25b 100644 --- a/lib/helpers/config.js +++ b/lib/helpers/config.js @@ -80,9 +80,31 @@ const isJwtSecretWeak = (secret) => !secret || secret.trim() === '' || secret.le /** * Safe envs where a weak / default secret is tolerated (warn only). + * Also the single source of truth for the dev-vs-prod hardening predicate: + * any NODE_ENV outside this set is treated as a production-grade deployment. */ const DEV_ENVS = new Set(['development', 'test', 'local']); +/** + * @desc Predicate — is the given env a known development-grade env? + * Used to gate production hardening. The deployment model runs apps under + * arbitrary NODE_ENV labels, so "dev" is an explicit allow-list (DEV_ENVS), + * never a literal `=== 'development'` check. + * @param {string} [env=process.env.NODE_ENV] - environment name (read at call time) + * @returns {boolean} true when env is one of development/test/local + */ +const isDevEnv = (env = process.env.NODE_ENV ?? 'development') => DEV_ENVS.has(env); + +/** + * @desc Predicate — should production hardening apply to the given env? + * Inverse of {@link isDevEnv}: true for `production` AND for any non-dev label + * (e.g. a deployment env name), so hardening is secure-by-default off any + * non-dev env, not just the literal `production`. + * @param {string} [env=process.env.NODE_ENV] - environment name (read at call time) + * @returns {boolean} true when env is NOT one of development/test/local + */ +const isProd = (env = process.env.NODE_ENV ?? 'development') => !DEV_ENVS.has(env); + /** * @desc Validate JWT secret strength. * - In non-dev/non-test environments: throw (fail-closed) when the secret is @@ -102,7 +124,7 @@ const validateJwtSecret = (config) => { const message = '+ Important warning: JWT secret is empty, too short (< 32 chars), or set to a known default placeholder. Set a strong secret via DEVKIT_NODE_jwt_secret.'; - if (DEV_ENVS.has(env)) { + if (isDevEnv(env)) { console.log(chalk.red(message)); return; } @@ -213,6 +235,8 @@ export default { validateDomainIsSet, JWT_DEFAULT_SECRETS, isJwtSecretWeak, + isDevEnv, + isProd, validateJwtSecret, initSecureMode, initGlobalConfigFiles, diff --git a/lib/helpers/tests/config.envPredicate.unit.tests.js b/lib/helpers/tests/config.envPredicate.unit.tests.js new file mode 100644 index 000000000..129ddd34d --- /dev/null +++ b/lib/helpers/tests/config.envPredicate.unit.tests.js @@ -0,0 +1,85 @@ +/** + * Module dependencies. + */ +import { describe, test, expect } from '@jest/globals'; +import configHelper from '../config.js'; + +/** + * Unit tests for the environment predicate — isDevEnv / isProd. + * + * These are the shared production-hardening predicate. The deployment model runs + * downstream apps as NODE_ENV={projectName} (any non-dev label), so hardening must + * key off "is this NOT a known dev env" rather than the literal "production". + */ +describe('config helper — environment predicate (isDevEnv / isProd):', () => { + describe('isDevEnv', () => { + test('returns true for development', () => { + expect(configHelper.isDevEnv('development')).toBe(true); + }); + + test('returns true for test', () => { + expect(configHelper.isDevEnv('test')).toBe(true); + }); + + test('returns true for local', () => { + expect(configHelper.isDevEnv('local')).toBe(true); + }); + + test('returns false for production', () => { + expect(configHelper.isDevEnv('production')).toBe(false); + }); + + test('returns false for an arbitrary project env label', () => { + expect(configHelper.isDevEnv('someproject')).toBe(false); + }); + }); + + describe('isProd', () => { + test('returns true for production', () => { + expect(configHelper.isProd('production')).toBe(true); + }); + + test('returns true for an arbitrary project env label (downstream deployment model)', () => { + expect(configHelper.isProd('someproject')).toBe(true); + }); + + test('returns false for development', () => { + expect(configHelper.isProd('development')).toBe(false); + }); + + test('returns false for test', () => { + expect(configHelper.isProd('test')).toBe(false); + }); + + test('returns false for local', () => { + expect(configHelper.isProd('local')).toBe(false); + }); + }); + + describe('default argument reads process.env.NODE_ENV at call time', () => { + test('isDevEnv() honors NODE_ENV mutated after import', () => { + const original = process.env.NODE_ENV; + try { + process.env.NODE_ENV = 'production'; + expect(configHelper.isDevEnv()).toBe(false); + expect(configHelper.isProd()).toBe(true); + process.env.NODE_ENV = 'development'; + expect(configHelper.isDevEnv()).toBe(true); + expect(configHelper.isProd()).toBe(false); + } finally { + process.env.NODE_ENV = original; + } + }); + + test('isProd() defaults to development (dev) when NODE_ENV is unset', () => { + const original = process.env.NODE_ENV; + try { + delete process.env.NODE_ENV; + expect(configHelper.isProd()).toBe(false); + expect(configHelper.isDevEnv()).toBe(true); + } finally { + process.env.NODE_ENV = original; + } + }); + }); +}); From 5688f5fce23c01dab202114dcab4862b6049afe7 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Mon, 15 Jun 2026 09:11:25 +0200 Subject: [PATCH 2/6] fix(responses): stop leaking error objects in non-dev envs --- lib/helpers/responses.js | 7 +- .../tests/responses.errorLeak.unit.tests.js | 65 +++++++++++++++++++ lib/services/express.js | 20 ++++-- .../tests/express.errorroutes.unit.tests.js | 33 ++++++++++ 4 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 lib/helpers/tests/responses.errorLeak.unit.tests.js diff --git a/lib/helpers/responses.js b/lib/helpers/responses.js index f475494e5..377faac7d 100644 --- a/lib/helpers/responses.js +++ b/lib/helpers/responses.js @@ -1,3 +1,5 @@ +import configHelper from './config.js'; + /** * @desc Function res success * @param {Object} res - Express response object @@ -97,7 +99,10 @@ const error = (res, httpStatus, message, description) => (error = {}) => { errorCode: getErrorCode(error), description: getDescription(description, error), }; - if (process.env.NODE_ENV !== 'production') result.error = safeStringify(error); + // Only expose the serialized raw error in dev-grade envs (development/test/local). + // Any other NODE_ENV (production or a deployment env label) gets the generic + // envelope only — prevents internal detail leaks under the downstream run model. + if (!configHelper.isProd()) result.error = safeStringify(error); res.status(status).json(result); return result; }; diff --git a/lib/helpers/tests/responses.errorLeak.unit.tests.js b/lib/helpers/tests/responses.errorLeak.unit.tests.js new file mode 100644 index 000000000..2819f74f1 --- /dev/null +++ b/lib/helpers/tests/responses.errorLeak.unit.tests.js @@ -0,0 +1,65 @@ +/** + * Module dependencies. + */ +import { describe, test, expect, afterEach } from '@jest/globals'; +import responses from '../responses.js'; + +/** + * Build a minimal Express response double that captures status + json body. + * @returns {{status: Function, json: Function, _status: number, _body: object}} + */ +const buildRes = () => { + const res = { + _status: undefined, + _body: undefined, + status(code) { this._status = code; return this; }, + json(body) { this._body = body; return this; }, + }; + return res; +}; + +/** + * Unit tests — responses.error must NOT serialize the raw error object into the + * client payload outside of dev/test/local. The deployment model runs apps under + * arbitrary NODE_ENV labels, so the leak gate keys off the dev-env predicate, not + * the literal `production`. + */ +describe('responses.error — error-object leak gating:', () => { + const ORIGINAL_ENV = process.env.NODE_ENV; + + afterEach(() => { + process.env.NODE_ENV = ORIGINAL_ENV; + }); + + test('does NOT include serialized error in body under a project (non-dev) env', () => { + process.env.NODE_ENV = 'someproject'; + const res = buildRes(); + responses.error(res, 500, undefined, undefined)(new Error('secret internal detail')); + expect(res._body).toBeDefined(); + expect(res._body.error).toBeUndefined(); + // The generic envelope fields stay present. + expect(res._body.type).toBe('error'); + expect(res._body.status).toBe(500); + }); + + test('does NOT include serialized error in body under production', () => { + process.env.NODE_ENV = 'production'; + const res = buildRes(); + responses.error(res, 500)(new Error('secret internal detail')); + expect(res._body.error).toBeUndefined(); + }); + + test('DOES include serialized error in body under development (debugging aid)', () => { + process.env.NODE_ENV = 'development'; + const res = buildRes(); + responses.error(res, 500)(new Error('debuggable detail')); + expect(typeof res._body.error).toBe('string'); + }); + + test('DOES include serialized error in body under test', () => { + process.env.NODE_ENV = 'test'; + const res = buildRes(); + responses.error(res, 500)(new Error('debuggable detail')); + expect(typeof res._body.error).toBe('string'); + }); +}); diff --git a/lib/services/express.js b/lib/services/express.js index ad3006123..94a2d031f 100644 --- a/lib/services/express.js +++ b/lib/services/express.js @@ -18,6 +18,7 @@ import YAML from 'js-yaml'; import redoc from 'redoc-express'; import config from '../../config/index.js'; +import configHelper from '../helpers/config.js'; import guidesHelper from '../helpers/guides.js'; import redactUrl from '../helpers/redactUrl.js'; import logger from './logger.js'; @@ -75,7 +76,11 @@ const redocCustomCss = ` * @returns {void} */ const initSwagger = (app) => { - if (config.swagger.enable) { + // Secure-by-default: the API docs surface (/api/spec.json + /api/docs) is + // UNAUTHENTICATED, so it is only mounted in dev-grade envs. Any production-grade + // env (the literal `production` OR a deployment env label) skips it even when + // config.swagger.enable is still truthy — opt-OUT by default in non-dev. + if (config.swagger.enable && !configHelper.isProd()) { if (!config.files.swagger || config.files.swagger.length === 0) { logger.warn('[swagger] no swagger files configured — skipping API docs'); return; @@ -332,8 +337,11 @@ const initModulesServerRoutes = async (app) => { const initErrorRoutes = (app) => { /** * Express 4-arity error handler. - * In production: returns a generic 500 to prevent leaking internal error details (#3726). - * In non-production: includes err.message and err.code for debugging. + * In production-grade envs: returns a generic 500 to prevent leaking internal + * error details (#3726). In dev-grade envs (development/test/local): includes + * err.message and err.code for debugging. "Production-grade" is any env outside + * the dev allow-list, covering the downstream run model where apps boot under a + * deployment env label rather than the literal `production`. * @param {Error} err - The error object caught by Express * @param {import('express').Request} req - Express request object * @param {import('express').Response} res - Express response object @@ -343,9 +351,9 @@ const initErrorRoutes = (app) => { app.use((err, req, res, next) => { if (!err) return next(); logger.error('Unhandled express error', { error: err }); - // In production never leak internal error details (message, code) to clients. - // Detailed errors are still logged above for observability. - if (process.env.NODE_ENV === 'production') { + // In any production-grade env never leak internal error details (message, code) + // to clients. Detailed errors are still logged above for observability. + if (configHelper.isProd()) { return res.status(err.status || 500).send({ message: 'Internal Server Error' }); } res.status(err.status || 500).send({ diff --git a/lib/services/tests/express.errorroutes.unit.tests.js b/lib/services/tests/express.errorroutes.unit.tests.js index 584265191..97063fd1f 100644 --- a/lib/services/tests/express.errorroutes.unit.tests.js +++ b/lib/services/tests/express.errorroutes.unit.tests.js @@ -110,6 +110,39 @@ describe('express initErrorRoutes — prod error leak guard (#3726):', () => { expect(JSON.stringify(body)).not.toContain('email_1'); }); + test('in a project (non-dev) env: responds with generic body — no err.message/code leak', async () => { + // Regression guard for the env-gate defect class: hardening must key off the + // dev-env allow-list, not the literal `production`. A deployment env label + // (e.g. "someproject") is a production-grade env and must not leak details. + process.env.NODE_ENV = 'someproject'; + const initErrorRoutes = await getInitErrorRoutes(); + + let errorHandler; + const app = { + use: jest.fn((handler) => { errorHandler = handler; }), + }; + initErrorRoutes(app); + + const internalErr = new Error('E11000 duplicate key index: email_1'); + internalErr.code = 11000; + internalErr.status = 500; + + const sendPayloads = []; + const res = { + status: jest.fn().mockReturnThis(), + send: jest.fn((body) => { sendPayloads.push(body); return res; }), + }; + + await errorHandler(internalErr, {}, res, jest.fn()); + + expect(res.status).toHaveBeenCalledWith(500); + const body = sendPayloads[0]; + expect(body.message).toBe('Internal Server Error'); + expect(body.code).toBeUndefined(); + expect(JSON.stringify(body)).not.toContain('E11000'); + expect(JSON.stringify(body)).not.toContain('email_1'); + }); + test('in non-production (development): responds with detailed err.message + err.code', async () => { process.env.NODE_ENV = 'development'; const initErrorRoutes = await getInitErrorRoutes(); From 3d07d3c212456d40535f7ac3283fa9b7e2592346 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Mon, 15 Jun 2026 09:11:41 +0200 Subject: [PATCH 3/6] fix(config): activate api+billingPlans rate limiters via base layer --- .../tests/rateLimiter.baseLayer.unit.tests.js | 52 +++++++++++++++++++ .../config/billing.development.config.js | 14 +++++ .../organizations.development.config.js | 13 +++++ 3 files changed, 79 insertions(+) create mode 100644 lib/middlewares/tests/rateLimiter.baseLayer.unit.tests.js diff --git a/lib/middlewares/tests/rateLimiter.baseLayer.unit.tests.js b/lib/middlewares/tests/rateLimiter.baseLayer.unit.tests.js new file mode 100644 index 000000000..5e31805a9 --- /dev/null +++ b/lib/middlewares/tests/rateLimiter.baseLayer.unit.tests.js @@ -0,0 +1,52 @@ +/** + * Module dependencies. + */ +import { describe, test, expect } from '@jest/globals'; +import organizationsDevConfig from '../../../modules/organizations/config/organizations.development.config.js'; +import billingDevConfig from '../../../modules/billing/config/billing.development.config.js'; +import authDevConfig from '../../../modules/auth/config/auth.development.config.js'; + +/** + * Config-layering regression guard for the rate-limiter env-gate defect. + * + * The rate-limiter middleware is presence-driven: `limiters.` returns a real + * limiter iff `config.rateLimit.` exists in merged config, else a no-op. + * Previously `rateLimit.api` and `rateLimit.billingPlans` existed ONLY in + * `config/defaults/production.config.js` (literal `NODE_ENV=production`) and + * `test.config.js`, so under the downstream run model (NODE_ENV=) they + * were undefined → no-op → the public Stripe-fanout and membership-request routes + * ran unthrottled. + * + * Fix: each profile lives in its owning module's *.development.config.js — a base + * (Layer 1) layer that ALWAYS merges regardless of NODE_ENV, mirroring how the + * `auth` profile is provided. Stricter caps stay as production-config overrides. + * + * A base-layer profile is a structural contract; assert its presence + shape so a + * future refactor that drops it (re-opening the defect) fails loudly. + */ +describe('rate-limiter base-layer profiles (env-gate config-layering):', () => { + /** + * Assert a profile is a usable express-rate-limit options object. + * @param {object} profile - a config.rateLimit. profile + */ + const expectUsableProfile = (profile) => { + expect(profile).toBeDefined(); + expect(typeof profile).toBe('object'); + expect(Number.isInteger(profile.windowMs)).toBe(true); + expect(profile.windowMs).toBeGreaterThan(0); + expect(Number.isInteger(profile.max)).toBe(true); + expect(profile.max).toBeGreaterThan(0); + }; + + test('auth profile already lives in the auth base layer (reference pattern)', () => { + expectUsableProfile(authDevConfig.rateLimit.auth); + }); + + test('api profile lives in the organizations base layer (always merges)', () => { + expectUsableProfile(organizationsDevConfig.rateLimit.api); + }); + + test('billingPlans profile lives in the billing base layer (always merges)', () => { + expectUsableProfile(billingDevConfig.rateLimit.billingPlans); + }); +}); diff --git a/modules/billing/config/billing.development.config.js b/modules/billing/config/billing.development.config.js index 7ad20b2af..6047321c2 100644 --- a/modules/billing/config/billing.development.config.js +++ b/modules/billing/config/billing.development.config.js @@ -183,6 +183,20 @@ const config = { packs: {}, }, }, + rateLimit: { + // Public, unauthenticated /api/billing/plans route that fans out to Stripe on + // cache miss. Lives in this base layer so the profile is present — and the + // limiter active — under EVERY env, not only the literal `production`; a missing + // profile means a no-op limiter (Stripe-API-quota DoS surface). Stricter caps + // are applied in config/defaults/production.config.js as an override. + billingPlans: { + windowMs: 60 * 1000, // 1 min + max: 300, // lenient in dev; production overrides to a stricter cap + message: { message: 'Too many requests, please try again later.' }, + standardHeaders: true, + legacyHeaders: false, + }, + }, }; export default config; diff --git a/modules/organizations/config/organizations.development.config.js b/modules/organizations/config/organizations.development.config.js index c00d11bc0..7a1f2807f 100644 --- a/modules/organizations/config/organizations.development.config.js +++ b/modules/organizations/config/organizations.development.config.js @@ -25,6 +25,19 @@ const config = { 'wanadoo.fr', 'bbox.fr', ], }, + rateLimit: { + // General authenticated-API limiter (e.g. the membership-request route). Lives + // in this base layer so the profile is present — and the limiter active — under + // EVERY env, not only the literal `production`. Stricter caps are applied in + // config/defaults/production.config.js as an override. + api: { + windowMs: 15 * 60 * 1000, // 15 min + max: 1000, // lenient in dev; production overrides to a stricter cap + message: { message: 'Too many requests, please try again later.' }, + standardHeaders: true, + legacyHeaders: false, + }, + }, }; export default config; From cc0ca3335175b792339b3cf7d16f3e4c63059ffb Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Mon, 15 Jun 2026 09:11:48 +0200 Subject: [PATCH 4/6] fix(config): gate swagger docs + mongoose debug off non-dev envs --- config/defaults/production.config.js | 6 + lib/services/mongoose.js | 15 ++- lib/services/tests/express.docs.unit.tests.js | 12 ++ .../tests/express.docsEnvGate.unit.tests.js | 118 ++++++++++++++++++ .../tests/mongoose.debugGate.unit.tests.js | 59 +++++++++ 5 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 lib/services/tests/express.docsEnvGate.unit.tests.js create mode 100644 lib/services/tests/mongoose.debugGate.unit.tests.js diff --git a/config/defaults/production.config.js b/config/defaults/production.config.js index a8eb4a56f..0b848d8c7 100644 --- a/config/defaults/production.config.js +++ b/config/defaults/production.config.js @@ -2,6 +2,12 @@ const config = { app: { title: 'Devkit Node - Production Environment', }, + // Secure-by-default: keep the unauthenticated API docs surface off in production. + // The runtime gate in lib/services/express.js (isProd) already prevents mounting + // docs in any non-dev env; this flag makes the intent explicit at the config layer. + swagger: { + enable: false, + }, api: { host: '0.0.0.0', port: 4200, diff --git a/lib/services/mongoose.js b/lib/services/mongoose.js index c1884a89a..1c501429f 100644 --- a/lib/services/mongoose.js +++ b/lib/services/mongoose.js @@ -6,8 +6,20 @@ import chalk from 'chalk'; import mongoose from 'mongoose'; import path from 'path'; import config from '../../config/index.js'; +import configHelper from '../helpers/config.js'; import logger from './logger.js'; +/** + * @desc Resolve the effective mongoose `debug` flag. + * Query logging is enabled only when BOTH the config opt-in is set AND the env is + * dev-grade (development/test/local). Any production-grade env (the literal + * `production` OR a deployment env label) forces it off so verbose query logs — + * which can include collection/field/value detail — never run in production. + * @param {object} [cfg=config] - application configuration object + * @returns {boolean} effective debug flag + */ +const resolveDebug = (cfg = config) => Boolean(cfg?.db?.debug) && configHelper.isDevEnv(); + /** * Load all mongoose related models */ @@ -38,7 +50,7 @@ const connect = async () => { if (mongoOptions.sslKey) mongoOptions.sslKey = path.resolve(mongoOptions.sslKey); await mongoose.connect(config.db.uri, mongoOptions); - mongoose.set('debug', config.db.debug); + mongoose.set('debug', resolveDebug(config)); logger.info(chalk.yellow('Connected to MongoDB.')); return mongoose; @@ -62,4 +74,5 @@ export default { loadModels, connect, disconnect, + resolveDebug, }; diff --git a/lib/services/tests/express.docs.unit.tests.js b/lib/services/tests/express.docs.unit.tests.js index 7b35b3653..76a4c0b46 100644 --- a/lib/services/tests/express.docs.unit.tests.js +++ b/lib/services/tests/express.docs.unit.tests.js @@ -80,6 +80,12 @@ describe('express initSwagger — core/doc/index.yml OpenAPI baseline (T13 Fix B default: { readFileSync: jest.fn().mockReturnValue('mocked') }, readFileSync: jest.fn().mockReturnValue('mocked'), })); + // express.js gates initSwagger behind configHelper.isProd(). Mock the helper so + // the env gate is deterministic (dev-grade → docs mount) without loading the + // real config helper (which pulls in glob and would trip the partial fs mock). + jest.unstable_mockModule('../../helpers/config.js', () => ({ + default: { isProd: jest.fn().mockReturnValue(false), isDevEnv: jest.fn().mockReturnValue(true) }, + })); jest.unstable_mockModule('js-yaml', () => ({ default: { load: jest.fn().mockReturnValue(mockCoreYamlDoc) }, load: jest.fn().mockReturnValue(mockCoreYamlDoc), @@ -227,6 +233,12 @@ describe('express initSwagger — Redoc theme (issue #3686):', () => { default: { readFileSync: jest.fn().mockReturnValue('mocked') }, readFileSync: jest.fn().mockReturnValue('mocked'), })); + // express.js gates initSwagger behind configHelper.isProd(). Mock the helper so + // the env gate is deterministic (dev-grade → docs mount) without loading the + // real config helper (which pulls in glob and would trip the partial fs mock). + jest.unstable_mockModule('../../helpers/config.js', () => ({ + default: { isProd: jest.fn().mockReturnValue(false), isDevEnv: jest.fn().mockReturnValue(true) }, + })); jest.unstable_mockModule('js-yaml', () => ({ default: { load: jest.fn().mockReturnValue(mockYamlDoc) }, load: jest.fn().mockReturnValue(mockYamlDoc), diff --git a/lib/services/tests/express.docsEnvGate.unit.tests.js b/lib/services/tests/express.docsEnvGate.unit.tests.js new file mode 100644 index 000000000..1dad5be81 --- /dev/null +++ b/lib/services/tests/express.docsEnvGate.unit.tests.js @@ -0,0 +1,118 @@ +/** + * Module dependencies. + */ +import { jest, beforeEach, afterEach, describe, test, expect } from '@jest/globals'; + +/** + * Unit tests — initSwagger must be secure-by-default: API docs (/api/spec.json + + * /api/docs) are mounted ONLY in dev-grade envs (development/test/local). Under any + * production-grade env (the literal `production` OR a deployment env label), the + * unauthenticated docs surface must NOT be mounted, even if config.swagger.enable + * is still truthy. This closes the env-gate defect where docs were exposed + * downstream because the gate keyed off the literal `production`. + */ +describe('express initSwagger — env gate (docs off in non-dev envs):', () => { + let originalNodeEnv; + + const mockYamlDoc = { + openapi: '3.0.0', + info: { title: 'Test API', version: '1.0.0', description: 'Test' }, + paths: {}, + }; + + const baseConfig = { + swagger: { enable: true }, + files: { swagger: ['/fake/swagger.yaml'], guides: [] }, + app: { title: 'Test API', description: 'Test', url: 'https://example.com' }, + domain: 'https://example.com', + }; + + const buildMockApp = () => { + const routes = {}; + return { get: (path, handler) => { routes[path] = handler; }, _routes: routes }; + }; + + beforeEach(() => { + originalNodeEnv = process.env.NODE_ENV; + jest.resetModules(); + jest.unstable_mockModule('fs', () => ({ + default: { readFileSync: jest.fn().mockReturnValue('mocked') }, + readFileSync: jest.fn().mockReturnValue('mocked'), + })); + jest.unstable_mockModule('js-yaml', () => ({ + default: { load: jest.fn().mockReturnValue(mockYamlDoc) }, + load: jest.fn().mockReturnValue(mockYamlDoc), + })); + jest.unstable_mockModule('../../helpers/guides.js', () => ({ + default: { loadGuides: jest.fn().mockReturnValue([]), mergeGuidesIntoSpec: jest.fn() }, + })); + jest.unstable_mockModule('../logger.js', () => ({ + default: { warn: jest.fn(), info: jest.fn(), error: jest.fn() }, + })); + // Mock the config helper with a faithful env predicate reading NODE_ENV at call + // time — this exercises the real gate semantics (dev allow-list) while avoiding + // loading the real helper (which pulls in glob and trips the partial fs mock). + const DEV_ENVS = new Set(['development', 'test', 'local']); + jest.unstable_mockModule('../../helpers/config.js', () => ({ + default: { + isProd: (env = process.env.NODE_ENV ?? 'development') => !DEV_ENVS.has(env), + isDevEnv: (env = process.env.NODE_ENV ?? 'development') => DEV_ENVS.has(env), + }, + })); + }); + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + jest.restoreAllMocks(); + }); + + /** + * Run initSwagger under a given NODE_ENV with config.swagger.enable left truthy. + * @param {string} env - NODE_ENV value to set before init + * @returns {Promise} the registered routes map + */ + const initUnderEnv = async (env) => { + process.env.NODE_ENV = env; + jest.unstable_mockModule('../../../config/index.js', () => ({ default: baseConfig })); + const { default: expressService } = await import('../express.js'); + const app = buildMockApp(); + expressService.initSwagger(app); + return app._routes; + }; + + test('does NOT mount /api/spec.json or /api/docs under a project (non-dev) env', async () => { + const routes = await initUnderEnv('someproject'); + expect(routes['/api/spec.json']).toBeUndefined(); + expect(routes['/api/docs']).toBeUndefined(); + }); + + test('does NOT mount docs under the literal production env', async () => { + const routes = await initUnderEnv('production'); + expect(routes['/api/spec.json']).toBeUndefined(); + expect(routes['/api/docs']).toBeUndefined(); + }); + + test('DOES mount docs under development (config.swagger.enable === true)', async () => { + const routes = await initUnderEnv('development'); + expect(typeof routes['/api/spec.json']).toBe('function'); + expect(typeof routes['/api/docs']).toBe('function'); + }); + + test('DOES mount docs under test (dev-grade env)', async () => { + const routes = await initUnderEnv('test'); + expect(typeof routes['/api/spec.json']).toBe('function'); + expect(typeof routes['/api/docs']).toBe('function'); + }); + + test('does NOT mount docs in dev when config.swagger.enable is false', async () => { + process.env.NODE_ENV = 'development'; + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { ...baseConfig, swagger: { enable: false } }, + })); + const { default: expressService } = await import('../express.js'); + const app = buildMockApp(); + expressService.initSwagger(app); + expect(app._routes['/api/spec.json']).toBeUndefined(); + expect(app._routes['/api/docs']).toBeUndefined(); + }); +}); diff --git a/lib/services/tests/mongoose.debugGate.unit.tests.js b/lib/services/tests/mongoose.debugGate.unit.tests.js new file mode 100644 index 000000000..9479f852d --- /dev/null +++ b/lib/services/tests/mongoose.debugGate.unit.tests.js @@ -0,0 +1,59 @@ +/** + * Module dependencies. + */ +import { jest, beforeEach, afterEach, describe, test, expect } from '@jest/globals'; + +/** + * Unit tests — mongoose query debug logging must be enabled ONLY in dev-grade envs + * (development/test/local). Under any production-grade env (the literal `production` + * OR a deployment env label) query logging must never run, even if config.db.debug + * is truthy. Closes the env-gate defect where verbose query logging could leak + * collection/field/value detail into downstream production logs. + */ +describe('mongoose service — debug env gate:', () => { + let originalNodeEnv; + let resolveDebug; + + beforeEach(async () => { + originalNodeEnv = process.env.NODE_ENV; + jest.resetModules(); + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { db: { uri: 'mongodb://127.0.0.1:27017/NodeTest', debug: true, options: {} }, files: { mongooseModels: [] } }, + })); + jest.unstable_mockModule('../logger.js', () => ({ + default: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, + })); + const mod = await import('../mongoose.js'); + resolveDebug = mod.default.resolveDebug; + }); + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + jest.restoreAllMocks(); + }); + + test('debug stays ENABLED in development when config.db.debug is true', () => { + process.env.NODE_ENV = 'development'; + expect(resolveDebug({ db: { debug: true } })).toBe(true); + }); + + test('debug stays ENABLED in test when config.db.debug is true', () => { + process.env.NODE_ENV = 'test'; + expect(resolveDebug({ db: { debug: true } })).toBe(true); + }); + + test('debug is DISABLED under a project (non-dev) env even when config.db.debug is true', () => { + process.env.NODE_ENV = 'someproject'; + expect(resolveDebug({ db: { debug: true } })).toBe(false); + }); + + test('debug is DISABLED under the literal production env even when config.db.debug is true', () => { + process.env.NODE_ENV = 'production'; + expect(resolveDebug({ db: { debug: true } })).toBe(false); + }); + + test('debug is DISABLED in development when config.db.debug is false', () => { + process.env.NODE_ENV = 'development'; + expect(resolveDebug({ db: { debug: false } })).toBe(false); + }); +}); From eeaeab3b6aa891d300fd3a69de2a22505ef0a394 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Mon, 15 Jun 2026 09:26:40 +0200 Subject: [PATCH 5/6] test(config): harden env-gate test isolation + cover local env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture NODE_ENV in beforeEach/afterEach (safe restore even if a sibling test mutates it before the file loads). Document the inline DEV_ENVS set in express.docsEnvGate must stay in sync with lib/helpers/config.js. Add local-env positive cases to docsEnvGate (mounts docs) and debugGate (debug true) — mirrors the existing development cases. --- lib/helpers/tests/responses.errorLeak.unit.tests.js | 10 +++++++--- lib/services/tests/express.docsEnvGate.unit.tests.js | 7 +++++++ lib/services/tests/mongoose.debugGate.unit.tests.js | 5 +++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/lib/helpers/tests/responses.errorLeak.unit.tests.js b/lib/helpers/tests/responses.errorLeak.unit.tests.js index 2819f74f1..3699ef90c 100644 --- a/lib/helpers/tests/responses.errorLeak.unit.tests.js +++ b/lib/helpers/tests/responses.errorLeak.unit.tests.js @@ -1,7 +1,7 @@ /** * Module dependencies. */ -import { describe, test, expect, afterEach } from '@jest/globals'; +import { describe, test, expect, beforeEach, afterEach } from '@jest/globals'; import responses from '../responses.js'; /** @@ -25,10 +25,14 @@ const buildRes = () => { * the literal `production`. */ describe('responses.error — error-object leak gating:', () => { - const ORIGINAL_ENV = process.env.NODE_ENV; + let originalNodeEnv; + + beforeEach(() => { + originalNodeEnv = process.env.NODE_ENV; + }); afterEach(() => { - process.env.NODE_ENV = ORIGINAL_ENV; + process.env.NODE_ENV = originalNodeEnv; }); test('does NOT include serialized error in body under a project (non-dev) env', () => { diff --git a/lib/services/tests/express.docsEnvGate.unit.tests.js b/lib/services/tests/express.docsEnvGate.unit.tests.js index 1dad5be81..2d3fbd169 100644 --- a/lib/services/tests/express.docsEnvGate.unit.tests.js +++ b/lib/services/tests/express.docsEnvGate.unit.tests.js @@ -52,6 +52,7 @@ describe('express initSwagger — env gate (docs off in non-dev envs):', () => { // Mock the config helper with a faithful env predicate reading NODE_ENV at call // time — this exercises the real gate semantics (dev allow-list) while avoiding // loading the real helper (which pulls in glob and trips the partial fs mock). + // NOTE: this set MUST stay in sync with DEV_ENVS in lib/helpers/config.js. const DEV_ENVS = new Set(['development', 'test', 'local']); jest.unstable_mockModule('../../helpers/config.js', () => ({ default: { @@ -104,6 +105,12 @@ describe('express initSwagger — env gate (docs off in non-dev envs):', () => { expect(typeof routes['/api/docs']).toBe('function'); }); + test('DOES mount docs under local (dev-grade env)', async () => { + const routes = await initUnderEnv('local'); + expect(typeof routes['/api/spec.json']).toBe('function'); + expect(typeof routes['/api/docs']).toBe('function'); + }); + test('does NOT mount docs in dev when config.swagger.enable is false', async () => { process.env.NODE_ENV = 'development'; jest.unstable_mockModule('../../../config/index.js', () => ({ diff --git a/lib/services/tests/mongoose.debugGate.unit.tests.js b/lib/services/tests/mongoose.debugGate.unit.tests.js index 9479f852d..57ca798c4 100644 --- a/lib/services/tests/mongoose.debugGate.unit.tests.js +++ b/lib/services/tests/mongoose.debugGate.unit.tests.js @@ -42,6 +42,11 @@ describe('mongoose service — debug env gate:', () => { expect(resolveDebug({ db: { debug: true } })).toBe(true); }); + test('debug stays ENABLED in local when config.db.debug is true', () => { + process.env.NODE_ENV = 'local'; + expect(resolveDebug({ db: { debug: true } })).toBe(true); + }); + test('debug is DISABLED under a project (non-dev) env even when config.db.debug is true', () => { process.env.NODE_ENV = 'someproject'; expect(resolveDebug({ db: { debug: true } })).toBe(false); From 0c83b9b4f982fb1a7ef4c23cea9d685f2583d681 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Mon, 15 Jun 2026 10:15:52 +0200 Subject: [PATCH 6/6] docs(config): align JSDoc defaults + add @returns to test helpers (CR pass-1) - Clarify that isDevEnv/isProd default to 'development' when NODE_ENV is unset - Add @returns {void} to expectUsableProfile in rateLimiter.baseLayer tests - Add JSDoc to buildMockApp helper in express.docsEnvGate tests --- lib/helpers/config.js | 4 ++-- lib/middlewares/tests/rateLimiter.baseLayer.unit.tests.js | 1 + lib/services/tests/express.docsEnvGate.unit.tests.js | 4 ++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/helpers/config.js b/lib/helpers/config.js index 775dbc25b..cac549892 100644 --- a/lib/helpers/config.js +++ b/lib/helpers/config.js @@ -90,7 +90,7 @@ const DEV_ENVS = new Set(['development', 'test', 'local']); * Used to gate production hardening. The deployment model runs apps under * arbitrary NODE_ENV labels, so "dev" is an explicit allow-list (DEV_ENVS), * never a literal `=== 'development'` check. - * @param {string} [env=process.env.NODE_ENV] - environment name (read at call time) + * @param {string} [env=process.env.NODE_ENV??'development'] - environment name (read at call time); defaults to 'development' when NODE_ENV is unset * @returns {boolean} true when env is one of development/test/local */ const isDevEnv = (env = process.env.NODE_ENV ?? 'development') => DEV_ENVS.has(env); @@ -100,7 +100,7 @@ const isDevEnv = (env = process.env.NODE_ENV ?? 'development') => DEV_ENVS.has(e * Inverse of {@link isDevEnv}: true for `production` AND for any non-dev label * (e.g. a deployment env name), so hardening is secure-by-default off any * non-dev env, not just the literal `production`. - * @param {string} [env=process.env.NODE_ENV] - environment name (read at call time) + * @param {string} [env=process.env.NODE_ENV??'development'] - environment name (read at call time); defaults to 'development' when NODE_ENV is unset * @returns {boolean} true when env is NOT one of development/test/local */ const isProd = (env = process.env.NODE_ENV ?? 'development') => !DEV_ENVS.has(env); diff --git a/lib/middlewares/tests/rateLimiter.baseLayer.unit.tests.js b/lib/middlewares/tests/rateLimiter.baseLayer.unit.tests.js index 5e31805a9..4d1f0f3ea 100644 --- a/lib/middlewares/tests/rateLimiter.baseLayer.unit.tests.js +++ b/lib/middlewares/tests/rateLimiter.baseLayer.unit.tests.js @@ -28,6 +28,7 @@ describe('rate-limiter base-layer profiles (env-gate config-layering):', () => { /** * Assert a profile is a usable express-rate-limit options object. * @param {object} profile - a config.rateLimit. profile + * @returns {void} */ const expectUsableProfile = (profile) => { expect(profile).toBeDefined(); diff --git a/lib/services/tests/express.docsEnvGate.unit.tests.js b/lib/services/tests/express.docsEnvGate.unit.tests.js index 2d3fbd169..d6a5d2fbf 100644 --- a/lib/services/tests/express.docsEnvGate.unit.tests.js +++ b/lib/services/tests/express.docsEnvGate.unit.tests.js @@ -27,6 +27,10 @@ describe('express initSwagger — env gate (docs off in non-dev envs):', () => { domain: 'https://example.com', }; + /** + * Build a minimal mock Express app that records registered GET routes. + * @returns {{ get: Function, _routes: Object }} mock app + */ const buildMockApp = () => { const routes = {}; return { get: (path, handler) => { routes[path] = handler; }, _routes: routes };