diff --git a/ERRORS.md b/ERRORS.md index 9c36473b5..25dd44a58 100644 --- a/ERRORS.md +++ b/ERRORS.md @@ -38,3 +38,5 @@ Use this file as a compact memory of recurring AI mistakes. - [2026-07-25] billing: `forceRotateForPlanChange` (week-quota snapshot rotation) was wired only into `handleSubscriptionUpdated`'s plan-change block -> `handleCheckoutCompleted` and both branches of `handleSubscriptionCreated` never rotated the current-week quota snapshot after a mid-week plan activation, so an upgraded org kept the previous plan's weekly quota (often 0 on free→paid) until the next weekly reset; fixed by calling the same non-fatal, logged `forceRotateForPlanChange(organizationId, { preserveUsage: true })` from all three activation call sites, and by making `billing.usage.service.js incrementMeter` read the live plan quota (already fetched for the snapshot write) instead of the potentially-stale `updatedDoc.meterQuota` for overflow decisions — mirrors the existing live-quota display fix in `billing.controller.js`; see pierreb-devkit/Node#3988 - [2026-09-04] deps/engines: `engines.node` declared `">=22.0.0"` while the committed lock was already npm-11-shaped, so `npm ci` under npm 10 (bundled by every Node 22.x) failed mid-install with a confusing `Missing: conventional-commits-filter@6.0.1 from lock file` error instead of a clean engines rejection -> a public package's `engines.node`/`engines.npm` must bound the exact npm major that wrote the lock, not just "a Node version that happens to work today"; pin the toolchain with `.nvmrc` + CI's `setup-node` reading it (`node-version-file`, not a floating `lts/*`) and add `engine-strict=true` to `.npmrc` so an unsupported toolchain fails fast at the engines check instead of at dependency resolution; see pierreb-devkit/Node#4053 - [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 diff --git a/lib/helpers/AppError.js b/lib/helpers/AppError.js index bf865f455..b889a39c4 100644 --- a/lib/helpers/AppError.js +++ b/lib/helpers/AppError.js @@ -8,11 +8,21 @@ const AppErrorCodes = { /** * @desc Custom error class with Node + Express * @param {String} message error - * @param {Object} { status, code } + * @param {Object} options + * @param {*} [options.details] - structured/whitelisted or curated-internal data (see + * `lib/helpers/responses.js#getDescription` — its `details`-derived text is + * production-gated, issue #4059) + * @param {number} [options.status] - HTTP status code + * @param {string} [options.code] - stable domain error code + * @param {string} [options.description] - deliberately-authored, user-facing text a + * throw site chooses explicitly — NOT gated by `getDescription`'s production check + * (same precedence as an explicit `description` argument to `responses.error`; see + * that function's own doc comment). Use this for authored copy a real user should + * see in every environment; use `details` for internal/curated data instead. */ class AppError extends Error { - constructor(message, { details, status, code } = {}) { + constructor(message, { details, status, code, description } = {}) { super(message); // Set HTTP status code this.status = status || 500; @@ -27,6 +37,12 @@ class AppError extends Error { if (details) this.details = details; else this.details = [{ message }]; + // Deliberately-authored, user-facing text — see the constructor's own JSDoc + // above. Only set when the throw site explicitly passes it; everything else + // that reads `err.description` (`getDescription`, `oauthErrorRedirect`) + // already treats an absent value as "fall through to the next source". + if (description) this.description = description; + // Ensures the AppError subclass is sliced out of the // stack trace dump for clarity Error.captureStackTrace(this, this.constructor); diff --git a/lib/helpers/responses.js b/lib/helpers/responses.js index 232391150..242b7d3b5 100644 --- a/lib/helpers/responses.js +++ b/lib/helpers/responses.js @@ -218,6 +218,16 @@ const getHttpStatus = (status, err) => { const getDescription = (description, err, details) => { if (description) return description; if (err?.description) return err.description; + // `details`-derived text gets the SAME environment gate `pickWhitelistedDetails` + // already applies to the `details` field above (issue #4059): outside + // production, resolve the full text below exactly as before — including + // whatever a raw caught exception's `.message` carries at a call site that + // curates `details: { message: err.message }` (several call sites across the + // stack do exactly this). In production none of that text is safe to hand a + // client sight-unseen, so this falls through to the same '' every other + // unmatched `details` shape already returns below — no explicit `description` + // argument or `err.description` is affected, only text sourced from `details`. + if (configHelper.isProd()) return ''; try { if (typeof details === 'string') return details; if (Array.isArray(details)) { diff --git a/lib/helpers/tests/responses.detailsWhitelist.unit.tests.js b/lib/helpers/tests/responses.detailsWhitelist.unit.tests.js index f267864d3..66c66549d 100644 --- a/lib/helpers/tests/responses.detailsWhitelist.unit.tests.js +++ b/lib/helpers/tests/responses.detailsWhitelist.unit.tests.js @@ -463,13 +463,22 @@ describe('responses.error — a throwing `details` getter/Proxy does not crash t * resolution logic itself. Expected strings captured from the pre-fix * behavior directly (see PR #4056 discussion) — this is a behavior-lock, not * a spec derived from reasoning about the new code. + * + * Run under a DEV-GRADE env (issue #4059): these tests lock the RESOLUTION + * LOGIC (which `details` shape wins, how it's turned into a string), which + * still applies unchanged outside production. They used to run under + * `production` because, before #4059, `getDescription` had no environment + * gate at all — production and dev behaved identically. #4059 adds one (see + * the "production-gated" describe block below for that new behavior), so + * asserting full-text resolution now requires a dev-grade env; asserting it + * under `production` would just test the gate, not the resolution logic. */ describe('responses.error — getDescription behavior preservation (finding-4 follow-up must not change these):', () => { let originalNodeEnv; beforeEach(() => { originalNodeEnv = process.env.NODE_ENV; - process.env.NODE_ENV = 'production'; + process.env.NODE_ENV = 'development'; }); afterEach(() => { @@ -544,6 +553,93 @@ describe('responses.error — getDescription behavior preservation (finding-4 fo }); }); +/** + * Unit tests — `getDescription`'s `details`-derived text is production-gated + * (issue #4059, decision 2). Before this, `details.message` (and the string/ + * array-of-{message} shapes) reached `description` in EVERY environment, + * including production — the second of the two leaks #4059 closes (the first + * is the eight raw-caught-error call sites now curated to `details: { message + * }`, covered elsewhere in the suite). "Generic in production" here means the + * same '' every other unmatched `details` shape already falls through to — + * there is no separate placeholder string to invent, mirroring how + * `pickWhitelistedDetails` above drops an unlisted key rather than replacing + * it with something. An explicit `description` argument or `err.description` + * is a call site's own deliberate choice, never sourced from `details` — both + * stay ungated in every environment, asserted below alongside the new gate so + * a regression that over-applies the gate is caught the same run. + */ +describe('responses.error — getDescription details-derived text is production-gated (issue #4059):', () => { + let originalNodeEnv; + + beforeEach(() => { + originalNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + }); + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + }); + + test('a string `details` never reaches `description` in production', () => { + const res = buildRes(); + const err = new AppError('Meter exhausted', { status: 402, details: 'internal: mongo replset primary at 10.0.4.12 unreachable' }); + responses.error(res, 402)(err); + expect(res._body.description).toBe(''); + }); + + test('an array-of-{message} `details` never reaches `description` in production', () => { + const res = buildRes(); + const err = new AppError('Meter exhausted', { status: 402, details: [{ message: 'first' }, { message: 'second' }] }); + responses.error(res, 402)(err); + expect(res._body.description).toBe(''); + }); + + test('an object `details.message` never reaches `description` in production — the exact leak reported in #4059', () => { + const res = buildRes(); + const err = new AppError('Meter exhausted', { + status: 402, + details: { message: 'internal: mongo replset primary at 10.0.4.12 unreachable' }, + }); + responses.error(res, 402)(err); + expect(res._body.description).toBe(''); + }); + + test('a curated call-site shape (details: { message: err.message }, the post-#4059 pattern at the eight call sites) never reaches `description` in production', () => { + const res = buildRes(); + const caught = new Error('ECONNREFUSED 10.0.4.12:27017 — mongo replset primary unreachable'); + const err = new AppError('oAuth, find user failed', { code: 'SERVICE_ERROR', details: { message: caught.message } }); + responses.error(res, 500)(err); + expect(res._body.description).toBe(''); + expect(res._body.details).toBeUndefined(); + }); + + test('an explicit `description` argument still wins over `details` in production — not gated, a call site\'s own deliberate text', () => { + const res = buildRes(); + const err = new AppError('Meter exhausted', { status: 402, details: { message: 'should not be used' } }); + responses.error(res, 402, 'Meter exhausted', 'explicit description')(err); + expect(res._body.description).toBe('explicit description'); + }); + + test('`error.description` still wins over `details` in production when no explicit description argument is given — not gated', () => { + const res = buildRes(); + const err = new AppError('Meter exhausted', { status: 402, details: { message: 'should not be used' } }); + err.description = 'err-level description'; + responses.error(res, 402)(err); + expect(res._body.description).toBe('err-level description'); + }); + + test('a whitelisted key (upgradeUrl/type) still crosses via `details` in production — the gate is on `description` text only, not the existing whitelist mechanism', () => { + const res = buildRes(); + const err = new AppError('Meter exhausted', { + status: 402, + details: { type: 'METER_EXHAUSTED', upgradeUrl: '/billing/plans' }, + }); + responses.error(res, 402)(err); + expect(res._body.description).toBe(''); + expect(res._body.details).toEqual({ type: 'METER_EXHAUSTED', upgradeUrl: '/billing/plans' }); + }); +}); + /** * Unit tests — `buildWhitelist` (issue #3958 review findings 2 & 3): the * config-provided extension to the built-in whitelist must reject diff --git a/modules/auth/controllers/auth.controller.js b/modules/auth/controllers/auth.controller.js index bb9263fef..776f53433 100644 --- a/modules/auth/controllers/auth.controller.js +++ b/modules/auth/controllers/auth.controller.js @@ -12,6 +12,7 @@ import SignupService, { isMailerConfigured, sendVerificationEmail } from '../ser import config from '../../../config/index.js'; import model from '../../../lib/middlewares/model.js'; import responses from '../../../lib/helpers/responses.js'; +import configHelper from '../../../lib/helpers/config.js'; import errors from '../../../lib/helpers/errors.js'; import AppError from '../../../lib/helpers/AppError.js'; import UsersSchema from '../../users/models/users.schema.js'; @@ -99,7 +100,14 @@ const signup = async (req, res) => { const signinAuthenticate = (req, res, next) => { passport.authenticate('local', { session: false }, (err, user, info) => { if (err && err.code === 'ACCOUNT_LOCKED') { - return responses.error(res, 423, 'Account locked', err.details?.message || 'Account is locked. Try again later.')(err); + // Deliberate bypass of `getDescription`'s production gate (issue #4059 + // review item 4), NOT an oversight: `auth.service.js#checkLockout` — the + // ONLY producer of this code — always sets `description` to a + // code-authored, user-facing string (never a caught exception's text), + // same class of value as item 1's two OAuth messages. A real locked-out + // user needs this message in production, so it is read explicitly here + // rather than left to flow through the gated `details.message` path. + return responses.error(res, 423, 'Account locked', err.description || 'Account is locked. Try again later.')(err); } if (err) { return responses.error(res, 500, 'Internal Server Error', errors.getMessage(err))(err); @@ -312,7 +320,13 @@ const checkOAuthUserProfile = async (profil, key, provider) => { const search = await UserService.search(query); if (search.length === 1) return search[0]; } catch (err) { - throw new AppError('oAuth, find user failed', { code: 'SERVICE_ERROR', details: err }); + // Curated, not forwarded wholesale (issue #4059): only `message` — a short, + // human-readable reason — crosses into `details`. `err` here is whatever the + // DB driver/service threw and may carry stack traces, connection metadata or + // other internal fields nobody chose to publish; `message` alone is genuinely + // useful for logs/non-prod debugging and stays safe once `getDescription` + // gates `details.message` to non-production (same issue). + throw new AppError('oAuth, find user failed', { code: 'SERVICE_ERROR', details: { message: err?.message } }); } // 2. Linked identity: match on additionalProvidersData[provider][key] — locals already linked try { @@ -321,7 +335,8 @@ const checkOAuthUserProfile = async (profil, key, provider) => { const search = await UserService.search(query); if (search.length === 1) return search[0]; } catch (err) { - throw new AppError('oAuth, find linked user failed', { code: 'SERVICE_ERROR', details: err }); + // Curated (issue #4059) — see the identical comment on branch 1 above. + throw new AppError('oAuth, find linked user failed', { code: 'SERVICE_ERROR', details: { message: err?.message } }); } // 3. Link on verified email: if a local user exists with the same email AND is // already emailVerified locally AND the OAuth provider vouches for the email, @@ -342,11 +357,15 @@ const checkOAuthUserProfile = async (profil, key, provider) => { // would later fail on the unique-email index with a less actionable error). const existing = await UserService.findByEmail(profil.email); if (existing && !existing.emailVerified) { + // Deliberately-authored, user-facing text (issue #4059 review item 1) — + // passed via `description`, NOT smuggled through `details.message`, so it + // is never subject to `getDescription`'s production gate (mirrors how + // local signup passes its own authored copy as an explicit argument to + // `responses.error`, see `signup` above). `oauthErrorRedirect` reads + // `err.description` with the same precedence. throw new AppError('oAuth, cannot link to unverified local account', { code: 'VALIDATION_ERROR', - details: { - message: 'A pending account with this email is not verified. Verify the original signup first or contact support.', - }, + description: 'A pending account with this email is not verified. Verify the original signup first or contact support.', }); } // If `existing` is emailVerified here, a rare race between the atomic @@ -356,7 +375,8 @@ const checkOAuthUserProfile = async (profil, key, provider) => { // hit the now-linkable state via branch 3. } catch (err) { if (err instanceof AppError) throw err; - throw new AppError('oAuth, link to existing user failed', { code: 'SERVICE_ERROR', details: err }); + // Curated (issue #4059) — see the identical comment on branch 1 above. + throw new AppError('oAuth, link to existing user failed', { code: 'SERVICE_ERROR', details: { message: err?.message } }); } } // 4. No match → create new user @@ -390,9 +410,12 @@ const checkOAuthUserProfile = async (profil, key, provider) => { if (capReached || (!config.sign.up && !oauthInvite)) { // Mirror the local signup endpoint's error shape so clients see the same // `message`/`description` regardless of signup method (see `signup` above). + // Deliberately-authored text via `description` (issue #4059 review item 1) + // — NOT `details.message` — so it reaches the client in every environment, + // same as local signup's explicit `responses.error(...)` argument. throw new AppError('Signup error', { code: 'VALIDATION_ERROR', - details: { message: 'Registration is currently deactivated' }, + description: 'Registration is currently deactivated', }); } const user = { @@ -464,7 +487,11 @@ const checkOAuthUserProfile = async (profil, key, provider) => { return createdUser; } catch (err) { if (err instanceof AppError) throw err; - throw new AppError('oAuth', { code: 'CONTROLLER_ERROR', details: err.details || err }); + // Curated (issue #4059) — see the identical comment on branch 1 above. This + // is the catch-all for branch 4 (capacity/eligibility/Zod/create), so `err` + // may be almost anything non-AppError; `message` alone, same as every other + // branch here, keeps the curation uniform across the whole function. + throw new AppError('oAuth', { code: 'CONTROLLER_ERROR', details: { message: err?.message } }); } }; @@ -480,8 +507,33 @@ const checkOAuthUserProfile = async (profil, key, provider) => { * @returns {void} triggers a 302 redirect to `${baseUrl}/token?...` */ const oauthErrorRedirect = (res, err, fallbackTitle) => { - const title = err?.message || fallbackTitle; - const descriptionFromDetails = typeof err?.details?.message === 'string' ? err.details.message : ''; + // Production-gate the title too (issue #4059 review item 2): an AppError's + // `.message` is a developer-authored label set by a throw site IN THIS + // codebase (e.g. "oAuth, find user failed") — never raw/dynamic text — so it + // stays ungated in every environment, same as before. A non-AppError (a bare + // Error / passport-oauth2's InternalOAuthError / any future producer) carries + // a message this codebase never authored; in production that falls back to + // `fallbackTitle` instead. Today's real producers (passport-oauth2, + // passport-google-oauth20, passport-apple) all wrap the underlying failure + // and keep `.message` a static label, so this was not a live leak — but + // nothing enforced it, and the claim that this function is gated "the same + // way" as `getDescription` was true only for `details.message`, not `title`; + // this closes that gap for a future non-AppError with a dynamic message. + const title = configHelper.isProd() && !(err instanceof AppError) ? fallbackTitle : (err?.message || fallbackTitle); + // This redirect never goes through `responses.error`/`getDescription` — it + // builds its own envelope by hand — so `details.message` needs the SAME + // production gate `getDescription` applies to its own `details`-derived text + // (issue #4059): outside production, the full text a curated `details: { + // message }` may carry (see the four `checkOAuthUserProfile` catch sites + // above) is shown as before; in production it is never safe to read + // sight-unseen, so it resolves to ''. An explicit `err.description` + // (deliberately-authored copy a throw site chooses on purpose — issue #4059 + // review item 1, e.g. the two OAuth messages below) is NEVER gated, same + // precedence as `getDescription`'s own `err?.description` check, and wins + // over the gated details-derived text; `details.message` below then falls + // back to `title`, never blank. + const descriptionFromDetails = !configHelper.isProd() && typeof err?.details?.message === 'string' ? err.details.message : ''; + const description = typeof err?.description === 'string' && err.description ? err.description : descriptionFromDetails; // OAuth callback failures are surfaced as a 302 redirect (not a JSON 422), so // there is no live HTTP status — we embed 422 to match the canonical shape of // a Zod / AppError validation failure elsewhere in the API. @@ -491,11 +543,11 @@ const oauthErrorRedirect = (res, err, fallbackTitle) => { code: 422, status: 422, errorCode: err?.code || 'OAUTH_ERROR', - description: descriptionFromDetails, + description, // Legacy shape — the current Vue `token.view.vue` parser reads `details.message`. // Remove this field once all downstream Vue deploys have adopted the canonical // `responses.error` parser (tracked in Vue issue #4021). - details: { message: descriptionFromDetails || title }, + details: { message: description || title }, }; // Build the redirect URL via the `URL` constructor so the origin + path stay // server-controlled (`getBaseUrl()` resolves from `config.cors.origin`). User diff --git a/modules/auth/services/auth.service.js b/modules/auth/services/auth.service.js index e906095cf..23360e7eb 100644 --- a/modules/auth/services/auth.service.js +++ b/modules/auth/services/auth.service.js @@ -38,10 +38,18 @@ const checkLockout = async (user) => { if (user.lockUntil && user.lockUntil > new Date()) { const remainingMs = user.lockUntil.getTime() - Date.now(); const remainingMin = Math.ceil(remainingMs / 60000); + // Deliberately-authored, user-facing text (issue #4059 review item 4, + // decided the same way as item 1's two OAuth messages) — carried via + // `description`, NOT `details.message`, so `signinAuthenticate` reading it + // is an explicit, intentional bypass of `getDescription`'s production gate, + // not an oversight: this value is ALWAYS code-authored here (never a caught + // exception), and a real locked-out user needs to see it in production too. + // `remainingMs` stays in `details` — structured data, not for direct display. throw new AppError('Account is locked. Try again later.', { code: 'ACCOUNT_LOCKED', status: 423, - details: { message: `Account is locked. Try again in ${remainingMin} minute(s).`, remainingMs }, + description: `Account is locked. Try again in ${remainingMin} minute(s).`, + details: { remainingMs }, }); } // If lock has expired, atomically reset attempts so the user can try again diff --git a/modules/auth/tests/auth.integration.tests.js b/modules/auth/tests/auth.integration.tests.js index 2c29fbcd4..97b366317 100644 --- a/modules/auth/tests/auth.integration.tests.js +++ b/modules/auth/tests/auth.integration.tests.js @@ -1148,7 +1148,10 @@ describe('Auth integration tests:', () => { AuthController.checkOAuthUserProfile(profil, 'sub', 'google'), ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', - details: { message: 'Registration is currently deactivated' }, + // Deliberately-authored copy (issue #4059 review item 1) — carried via + // `description`, not smuggled through `details.message`, so it reaches + // the client in every environment (see `oauthErrorRedirect`). + description: 'Registration is currently deactivated', }); // Ensure no user was persisted const users = await UserService.search({ email }); diff --git a/modules/auth/tests/auth.oauth.detailsCuration.unit.tests.js b/modules/auth/tests/auth.oauth.detailsCuration.unit.tests.js new file mode 100644 index 000000000..c495f28e2 --- /dev/null +++ b/modules/auth/tests/auth.oauth.detailsCuration.unit.tests.js @@ -0,0 +1,475 @@ +/** + * Module dependencies. + */ +import { jest, describe, test, expect, beforeEach, afterEach } from '@jest/globals'; +import { setupAuthControllerMocks } from './fixtures/auth-controller.mock-setup.js'; +// Captured via a plain static import — evaluated once, before any test runs and +// before any `jest.unstable_mockModule` call — so this is genuinely the REAL +// `AppError` class (with `.description` support), never a stub. `loadController` +// below explicitly re-registers this reference as its own "mock" for +// `AppError.js` on every call: `setupAuthControllerMocks` (used by the +// `oauthCallback / oauthErrorRedirect` describe block further down THIS SAME +// file) mocks that same module path with a simplified stub that does NOT set +// `.description`, and jest's ESM module mocking is last-registration-wins per +// resolved specifier, NOT reset just because a later `jest.resetModules()` ran +// without re-mocking it — so `loadController` must win that race explicitly on +// every call, not rely on "just don't mock it". +import RealAppError from '../../../lib/helpers/AppError.js'; + +/** + * Unit tests — issue #4059 and its review follow-up. Proven here by + * EXECUTION, not by reading the source: + * 1. Each of the four `checkOAuthUserProfile` catch sites curates `details` + * to `{ message: err.message }` ONLY — never the raw caught exception, + * which may carry a stack, driver metadata, or other fields nobody chose + * to publish (`auth.controller.js` lines ~315/328/364/473). + * 2. `oauthErrorRedirect` (which never goes through `responses.error`/ + * `getDescription` — it builds its own envelope by hand) gates its + * curated `details.message` read to non-production, mirroring + * `getDescription`'s own gate added by the same issue — confirmed by + * constructing an error carrying obviously-internal text and inspecting + * the actual redirect payload under `NODE_ENV=production`. + * 3. (review item 1) Two deliberately-authored, user-facing OAuth messages + * (branch 3's unverified-account notice, branch 4's registration-closed + * notice) are passed via `AppError`'s `description` option instead of + * `details.message` — an explicit, NEVER-gated channel `oauthErrorRedirect` + * now also reads — so they reach the client in every environment, + * production included, driven through the REAL throw sites end to end. + * 4. (review item 2) `oauthErrorRedirect`'s `title` is now ALSO + * production-gated for a non-AppError `err` (the earlier claim that this + * function was gated "the same way" as `getDescription` was true only for + * `details.message`, not `title` — see ERRORS.md 2026-09-05). + */ + +/** + * Loads a fresh auth.controller.js instance with UserService methods + * stubbable per test (kept local rather than reusing + * auth.oauth.signup.analytics.unit.tests.js's `loadController` — that file's + * per-branch RESOLVE wiring and this suite's per-method REJECT wiring differ + * enough that sharing would need as many options as duplicating). + * `passport` is mocked (not left to resolve the real package) so a test can + * drive a real `checkOAuthUserProfile` throw all the way through the real + * `oauthCallback` → `oauthErrorRedirect` in this SAME module registry — the + * real (unmocked) `AppError` and `configHelper` this file's `checkOAuthUserProfile` + * tests already rely on, which the `description`/title-gate tests below need too + * (a synthetic error built in a DIFFERENT jest module registry, e.g. via + * `setupAuthControllerMocks` below, would not be `instanceof` this registry's + * `AppError`). + * @param {Object} [userServiceOverrides] - per-method jest.fn() overrides merged over safe defaults + * @param {Object} [signOverrides] - merged over the default `config.sign` ({ up: true, in: true }) — + * e.g. `{ up: false }` to exercise branch 4's registration-closed gate + * @returns {Promise<{AuthController: Object, mockPassport: {authenticate: import('@jest/globals').Mock, _strategy: import('@jest/globals').Mock}}>} + */ +const loadController = async (userServiceOverrides = {}, signOverrides = {}) => { + jest.resetModules(); + + const mockPassport = { + authenticate: jest.fn().mockReturnValue(jest.fn()), + _strategy: jest.fn().mockReturnValue(undefined), + }; + jest.unstable_mockModule('passport', () => ({ default: mockPassport })); + + // Explicitly re-win the real class every call — see the top-of-file comment + // on `RealAppError` for why this can't just be "leave it unmocked". + jest.unstable_mockModule('../../../lib/helpers/AppError.js', () => ({ default: RealAppError })); + + jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ + default: { warn: jest.fn(), error: jest.fn(), info: jest.fn() }, + })); + + jest.unstable_mockModule('../../../modules/users/services/users.service.js', () => ({ + default: { + create: jest.fn().mockResolvedValue({ id: 'u1', email: 'new@test.com' }), + search: jest.fn().mockResolvedValue([]), + linkProviderByEmail: jest.fn().mockResolvedValue(null), + findByEmail: jest.fn().mockResolvedValue(null), + count: jest.fn().mockResolvedValue(0), + ...userServiceOverrides, + }, + })); + + jest.unstable_mockModule('../../../modules/auth/services/auth.eligibility.js', () => ({ + default: { registerSignupEligibility: jest.fn(), assertSignupEligible: jest.fn().mockResolvedValue(undefined), _reset: jest.fn() }, + })); + + jest.unstable_mockModule('../../../modules/auth/services/auth.signupCapacity.js', () => ({ + computeSignupCapacity: jest.fn().mockResolvedValue({ cap: null, remaining: null }), + })); + + jest.unstable_mockModule('../../../modules/organizations/services/organizations.service.js', () => ({ + default: { handleSignupOrganization: jest.fn() }, + })); + jest.unstable_mockModule('../../../modules/organizations/services/organizations.crud.service.js', () => ({ + default: { autoSetCurrentOrganization: jest.fn() }, + })); + jest.unstable_mockModule('../../../modules/organizations/services/organizations.membership.service.js', () => ({ + default: { findByUserAndOrganization: jest.fn(), listPendingByUser: jest.fn().mockResolvedValue([]) }, + })); + + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { + sign: { up: true, in: true, ...signOverrides }, // open signup by default — branch 3/4's invite hook is skipped entirely + jwt: { secret: 'test-secret', expiresIn: 3600 }, + cookie: { secure: false, sameSite: 'lax' }, + organizations: { enabled: false }, + app: { title: 'Test', contact: 'test@test.com' }, + }, + })); + + jest.unstable_mockModule('../../../lib/middlewares/model.js', () => ({ + default: { + // Pass the candidate straight through as "validated" — Zod itself is + // not under test here, only that branch 4's create() failure is curated. + getResultFromZod: jest.fn((body) => ({ value: { ...body } })), + checkError: jest.fn(() => false), + }, + })); + + jest.unstable_mockModule('../../../lib/helpers/mailer/index.js', () => ({ + default: { isConfigured: jest.fn().mockReturnValue(false), sendMail: jest.fn() }, + })); + + jest.unstable_mockModule('../../../lib/helpers/responses.js', () => ({ + default: { success: jest.fn().mockReturnValue(jest.fn()), error: jest.fn().mockReturnValue(jest.fn()) }, + })); + + jest.unstable_mockModule('../../../lib/helpers/errors.js', () => ({ + default: { getMessage: jest.fn().mockReturnValue('error') }, + })); + + jest.unstable_mockModule('../../../modules/users/models/users.schema.js', () => ({ + default: { User: {}, SignupUser: {} }, + })); + + jest.unstable_mockModule('../../../lib/middlewares/policy.js', () => ({ + default: { defineAbilityFor: jest.fn().mockResolvedValue({}) }, + })); + + jest.unstable_mockModule('../../../lib/helpers/abilities.js', () => ({ + default: jest.fn().mockReturnValue([]), + })); + + jest.unstable_mockModule('../../../lib/helpers/getBaseUrl.js', () => ({ + default: jest.fn().mockReturnValue('http://localhost:3000'), + })); + + jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({ + default: { identify: jest.fn(), groupIdentify: jest.fn(), capture: jest.fn() }, + })); + + const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js'); + return { AuthController, mockPassport }; +}; + +// An error message shaped like the internal text #4059 reports leaking: +// a driver-level connection failure naming an internal host, no user-facing +// framing at all — nothing a client should ever see. +const INTERNAL_TEXT = 'ECONNREFUSED 10.0.4.12:27017 - mongo replset primary unreachable'; + +/** + * Builds a raw caught exception carrying `INTERNAL_TEXT` as `.message` plus + * several OTHER fields a real driver/HTTP client error might attach (stack + * string, an internal host, a library-specific code) — curation must forward + * `message` alone and drop the rest. + * @returns {Error} + */ +const buildRawInternalError = () => Object.assign(new Error(INTERNAL_TEXT), { + stack: 'Error: internal\n at Connection.connect (/app/node_modules/mongodb/lib/connect.js:42:11)', + code: 'ECONNREFUSED', + host: '10.0.4.12', +}); + +describe('checkOAuthUserProfile — details curation at the four raw-error catch sites (issue #4059):', () => { + test('branch 1 (primary identity search failure) curates details to { message } only', async () => { + const { AuthController } = await loadController({ search: jest.fn().mockRejectedValue(buildRawInternalError()) }); + + let caught; + try { + await AuthController.checkOAuthUserProfile({ providerData: { id: 'p1' } }, 'id', 'google'); + } catch (err) { + caught = err; + } + + expect(caught).toMatchObject({ message: 'oAuth, find user failed', code: 'SERVICE_ERROR' }); + // `message` itself legitimately carries the internal text at THIS layer — + // curation's job is dropping every OTHER field the raw error carried + // (stack, code, host), not redacting `message`. The client-facing leak + // this text would otherwise cause is closed downstream, at the + // `getDescription`/`oauthErrorRedirect` production gate (decision 2, + // covered in its own describe block below) — not here. + expect(caught.details).toEqual({ message: INTERNAL_TEXT }); + expect(Object.keys(caught.details)).toEqual(['message']); + expect(caught.details.stack).toBeUndefined(); + expect(caught.details.code).toBeUndefined(); + expect(caught.details.host).toBeUndefined(); + }); + + test('branch 2 (linked identity search failure) curates details to { message } only', async () => { + const search = jest.fn().mockResolvedValueOnce([]).mockRejectedValueOnce(buildRawInternalError()); + const { AuthController } = await loadController({ search }); + + let caught; + try { + await AuthController.checkOAuthUserProfile({ providerData: { id: 'p1' } }, 'id', 'google'); + } catch (err) { + caught = err; + } + + expect(caught).toMatchObject({ message: 'oAuth, find linked user failed', code: 'SERVICE_ERROR' }); + expect(caught.details).toEqual({ message: INTERNAL_TEXT }); + expect(Object.keys(caught.details)).toEqual(['message']); + }); + + test('branch 3 (link-on-verified-email failure) curates details to { message } only', async () => { + const { AuthController } = await loadController({ + search: jest.fn().mockResolvedValue([]), + linkProviderByEmail: jest.fn().mockRejectedValue(buildRawInternalError()), + }); + + let caught; + try { + await AuthController.checkOAuthUserProfile( + { providerData: { sub: 'p1' }, email: 'user@example.com', emailVerifiedByProvider: true }, + 'sub', + 'google', + ); + } catch (err) { + caught = err; + } + + expect(caught).toMatchObject({ message: 'oAuth, link to existing user failed', code: 'SERVICE_ERROR' }); + expect(caught.details).toEqual({ message: INTERNAL_TEXT }); + expect(Object.keys(caught.details)).toEqual(['message']); + }); + + test('branch 4 (create-new-user failure, the catch-all) curates details to { message } only', async () => { + const { AuthController } = await loadController({ + search: jest.fn().mockResolvedValue([]), + create: jest.fn().mockRejectedValue(buildRawInternalError()), + }); + + let caught; + try { + await AuthController.checkOAuthUserProfile({ providerData: { sub: 'p1' }, firstName: 'A', lastName: 'B' }, 'sub', 'google'); + } catch (err) { + caught = err; + } + + expect(caught).toMatchObject({ message: 'oAuth', code: 'CONTROLLER_ERROR' }); + expect(caught.details).toEqual({ message: INTERNAL_TEXT }); + expect(Object.keys(caught.details)).toEqual(['message']); + }); +}); + +describe('oauthCallback / oauthErrorRedirect — production gate on curated details.message (issue #4059):', () => { + let mockPassport; + let originalNodeEnv; + + beforeEach(() => { + mockPassport = setupAuthControllerMocks(); + originalNodeEnv = process.env.NODE_ENV; + }); + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + }); + + /** + * Drives oauthCallback with `err` as the passport.authenticate() callback + * error and returns the parsed `error` query-param payload from the 302 + * redirect it issues. + * @param {Error} err + * @returns {Promise} the parsed redirect payload + */ + const runOauthCallbackError = async (err) => { + mockPassport._strategy.mockReturnValue({}); + mockPassport.authenticate.mockImplementationOnce((strategy, callback) => () => callback(err, null)); + const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js'); + + const redirectCalls = []; + const req = { params: { strategy: 'google' }, body: {} }; + const res = { cookie() { return this; }, redirect(code, url) { redirectCalls.push({ code, url }); } }; + await AuthController.oauthCallback(req, res, () => {}); + + const parsed = new URL(redirectCalls[0].url); + return JSON.parse(parsed.searchParams.get('error')); + }; + + test('an AppError carrying obviously-internal text in details.message does NOT reach the redirect payload in production', async () => { + const { default: AppError } = await import('../../../lib/helpers/AppError.js'); + const internalErr = new AppError('oAuth, find user failed', { code: 'SERVICE_ERROR', details: { message: INTERNAL_TEXT } }); + + process.env.NODE_ENV = 'production'; + const payload = await runOauthCallbackError(internalErr); + + expect(payload.description).toBe(''); + // Legacy `details.message` field falls back to the safe outer AppError + // message (never blank, never the internal text) once gated. + expect(payload.details).toEqual({ message: 'oAuth, find user failed' }); + expect(JSON.stringify(payload)).not.toContain('10.0.4.12'); + expect(JSON.stringify(payload)).not.toContain('ECONNREFUSED'); + }); + + test('the SAME error, outside production — full details.message text still reaches the redirect payload (unchanged, decision 2 scope)', async () => { + const { default: AppError } = await import('../../../lib/helpers/AppError.js'); + const internalErr = new AppError('oAuth, find user failed', { code: 'SERVICE_ERROR', details: { message: INTERNAL_TEXT } }); + + process.env.NODE_ENV = 'test'; + const payload = await runOauthCallbackError(internalErr); + + expect(payload.description).toBe(INTERNAL_TEXT); + expect(payload.details).toEqual({ message: INTERNAL_TEXT }); + }); + + /** + * Review item 2 — `oauthErrorRedirect`'s `title` was NEVER gated (only + * `details.message` was), so a non-AppError `err` reaching `oauthCallback` + * put its raw `.message` into `payload.message`, `payload.details.message` + * (both via the `title` fallback), AND the redirect URL's `message` query + * param, unaffected by either gate. A plain `Error` — not an `AppError` — is + * exactly that shape; deliberately using this suite's `setupAuthControllerMocks` + * fixture (not `loadController`) since a non-AppError needs no real `AppError` + * class at all. + */ + test('a non-AppError (dynamic message, no `.details`) falls back to fallbackTitle everywhere in production — the ungated-title gap review item 2 closes', async () => { + const dynamicErr = new Error('internal: mongo replset primary at 10.0.4.12 unreachable'); + + process.env.NODE_ENV = 'production'; + mockPassport._strategy.mockReturnValue({}); + mockPassport.authenticate.mockImplementationOnce((strategy, callback) => () => callback(dynamicErr, null)); + const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js'); + + const redirectCalls = []; + const req = { params: { strategy: 'google' }, body: {} }; + const res = { cookie() { return this; }, redirect(code, url) { redirectCalls.push({ code, url }); } }; + await AuthController.oauthCallback(req, res, () => {}); + + const parsed = new URL(redirectCalls[0].url); + const payload = JSON.parse(parsed.searchParams.get('error')); + + expect(parsed.searchParams.get('message')).toBe('oAuth error'); + expect(payload.message).toBe('oAuth error'); + expect(payload.details).toEqual({ message: 'oAuth error' }); + expect(JSON.stringify(payload)).not.toContain('10.0.4.12'); + expect(parsed.toString()).not.toContain('10.0.4.12'); + }); + + test('the SAME non-AppError, outside production — its raw `.message` still surfaces as the title (unchanged)', async () => { + const dynamicErr = new Error('internal: mongo replset primary at 10.0.4.12 unreachable'); + + process.env.NODE_ENV = 'test'; + mockPassport._strategy.mockReturnValue({}); + mockPassport.authenticate.mockImplementationOnce((strategy, callback) => () => callback(dynamicErr, null)); + const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js'); + + const redirectCalls = []; + const req = { params: { strategy: 'google' }, body: {} }; + const res = { cookie() { return this; }, redirect(code, url) { redirectCalls.push({ code, url }); } }; + await AuthController.oauthCallback(req, res, () => {}); + + const parsed = new URL(redirectCalls[0].url); + const payload = JSON.parse(parsed.searchParams.get('error')); + + expect(payload.message).toBe(dynamicErr.message); + }); +}); + +/** + * Review item 1 — two deliberately-authored, user-facing OAuth messages + * (previously smuggled through `details.message`, and therefore blanked by + * the production gate above) must reach the client in EVERY environment, + * production included. Driven end to end through the REAL throw sites (real + * `checkOAuthUserProfile` branches) and the REAL `oauthCallback` → + * `oauthErrorRedirect`, all in the SAME `loadController` module registry, so + * `err instanceof AppError` inside `oauthErrorRedirect` sees the genuine class. + */ +describe('checkOAuthUserProfile → oauthCallback → oauthErrorRedirect — the two authored OAuth messages survive the production gate (issue #4059 review item 1):', () => { + let originalNodeEnv; + + beforeEach(() => { + originalNodeEnv = process.env.NODE_ENV; + }); + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + }); + + /** + * Drives `checkOAuthUserProfile(...checkArgs)` to a genuine throw, then feeds + * that REAL AppError through the REAL `oauthCallback` → `oauthErrorRedirect`. + * @param {Object} AuthController + * @param {Object} mockPassport + * @param {Array} checkArgs - arguments forwarded to `checkOAuthUserProfile` + * @returns {Promise<{messageParam: string, payload: Object}>} + */ + const runRealOAuthError = async (AuthController, mockPassport, checkArgs) => { + let caught; + try { + await AuthController.checkOAuthUserProfile(...checkArgs); + } catch (err) { + caught = err; + } + expect(caught).toBeDefined(); // fail loudly here, not on a confusing downstream assertion, if the branch didn't throw + + mockPassport._strategy.mockReturnValue({}); + mockPassport.authenticate.mockImplementationOnce((strategy, callback) => () => callback(caught, null)); + const redirectCalls = []; + const req = { params: { strategy: 'google' }, body: {} }; + const res = { cookie() { return this; }, redirect(code, url) { redirectCalls.push({ code, url }); } }; + await AuthController.oauthCallback(req, res, () => {}); + + const parsed = new URL(redirectCalls[0].url); + return { messageParam: parsed.searchParams.get('message'), payload: JSON.parse(parsed.searchParams.get('error')) }; + }; + + test('branch 3 (unverified local account) — the authored notice reaches the client in production', async () => { + const { AuthController, mockPassport } = await loadController({ + linkProviderByEmail: jest.fn().mockResolvedValue(null), + findByEmail: jest.fn().mockResolvedValue({ emailVerified: false }), + }); + + process.env.NODE_ENV = 'production'; + const { payload } = await runRealOAuthError(AuthController, mockPassport, [ + { providerData: { sub: 'p1' }, email: 'user@example.com', emailVerifiedByProvider: true }, + 'sub', + 'google', + ]); + + const AUTHORED_MESSAGE = 'A pending account with this email is not verified. Verify the original signup first or contact support.'; + expect(payload.description).toBe(AUTHORED_MESSAGE); + expect(payload.details).toEqual({ message: AUTHORED_MESSAGE }); + }); + + test('branch 4 (registration closed) — the authored notice reaches the client in production', async () => { + const { AuthController, mockPassport } = await loadController({}, { up: false }); + + process.env.NODE_ENV = 'production'; + const { messageParam, payload } = await runRealOAuthError(AuthController, mockPassport, [ + { providerData: { sub: 'p2' }, firstName: 'A', lastName: 'B' }, + 'sub', + 'google', + ]); + + expect(messageParam).toBe('Signup error'); + expect(payload.description).toBe('Registration is currently deactivated'); + expect(payload.details).toEqual({ message: 'Registration is currently deactivated' }); + }); + + test('control — branch 1\'s raw internal DB text still does NOT reach the client in production, proving the description bypass is scoped to these two messages only', async () => { + const rawInternal = Object.assign(new Error(INTERNAL_TEXT), { code: 'ECONNREFUSED', host: '10.0.4.12' }); + const { AuthController, mockPassport } = await loadController({ + search: jest.fn().mockRejectedValue(rawInternal), + }); + + process.env.NODE_ENV = 'production'; + const { payload } = await runRealOAuthError(AuthController, mockPassport, [ + { providerData: { id: 'p1' } }, + 'id', + 'google', + ]); + + expect(payload.description).toBe(''); + expect(JSON.stringify(payload)).not.toContain('10.0.4.12'); + expect(JSON.stringify(payload)).not.toContain('ECONNREFUSED'); + }); +}); diff --git a/modules/auth/tests/auth.signinAuthenticate.unit.tests.js b/modules/auth/tests/auth.signinAuthenticate.unit.tests.js new file mode 100644 index 000000000..6c3b7fc86 --- /dev/null +++ b/modules/auth/tests/auth.signinAuthenticate.unit.tests.js @@ -0,0 +1,90 @@ +/** + * Module dependencies. + */ +import { jest, describe, test, expect, beforeEach, afterEach } from '@jest/globals'; +import { setupAuthControllerMocks } from './fixtures/auth-controller.mock-setup.js'; + +/** + * Unit tests — issue #4059 review item 4. `signinAuthenticate`'s + * `ACCOUNT_LOCKED` branch reads `err.description` directly (a deliberate, + * explicit bypass of `getDescription`'s production gate — decided the SAME + * way as review item 1's two OAuth messages: `auth.service.js#checkLockout`, + * the only producer of this code, always sets a code-authored, user-facing + * string, never a caught exception's text). Zero test coverage existed for + * this path before — these tests exist so a future "fix" that routes it + * through the gate (silently blanking the lockout message in production) + * fails loudly instead of shipping unnoticed. + */ +describe('signinAuthenticate — ACCOUNT_LOCKED description bypass (issue #4059 review item 4):', () => { + let mockPassport; + let originalNodeEnv; + + beforeEach(() => { + mockPassport = setupAuthControllerMocks(); + originalNodeEnv = process.env.NODE_ENV; + }); + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + }); + + /** + * Drives `signinAuthenticate` with `err` as the passport 'local' strategy's + * callback error, and returns the mocked `responses.error` so the caller can + * assert on its call arguments. + * @param {Object} err + * @returns {Promise} + */ + const runSigninAuthenticate = async (err) => { + mockPassport.authenticate.mockImplementationOnce( + (strategy, opts, callback) => () => callback(err, null, null), + ); + const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js'); + const { default: responses } = await import('../../../lib/helpers/responses.js'); + + await AuthController.signinAuthenticate({}, {}, jest.fn()); + return responses.error; + }; + + test('the authored lockout message reaches responses.error even in production — description is NEVER gated for this producer', async () => { + process.env.NODE_ENV = 'production'; + const err = { code: 'ACCOUNT_LOCKED', description: 'Account is locked. Try again in 5 minute(s).' }; + + const mockResponsesError = await runSigninAuthenticate(err); + + expect(mockResponsesError).toHaveBeenCalledWith( + expect.anything(), + 423, + 'Account locked', + 'Account is locked. Try again in 5 minute(s).', + ); + }); + + test('the SAME error, outside production — identical behavior (the bypass is not environment-conditional)', async () => { + process.env.NODE_ENV = 'test'; + const err = { code: 'ACCOUNT_LOCKED', description: 'Account is locked. Try again in 5 minute(s).' }; + + const mockResponsesError = await runSigninAuthenticate(err); + + expect(mockResponsesError).toHaveBeenCalledWith( + expect.anything(), + 423, + 'Account locked', + 'Account is locked. Try again in 5 minute(s).', + ); + }); + + test('falls back to the generic message when `description` is absent (defensive default — the real producer always sets it)', async () => { + process.env.NODE_ENV = 'production'; + const err = { code: 'ACCOUNT_LOCKED' }; + + const mockResponsesError = await runSigninAuthenticate(err); + + expect(mockResponsesError).toHaveBeenCalledWith( + expect.anything(), + 423, + 'Account locked', + 'Account is locked. Try again later.', + ); + }); +}); diff --git a/modules/invitations/tests/invitations.integration.tests.js b/modules/invitations/tests/invitations.integration.tests.js index f6cc04f1d..9ab96a193 100644 --- a/modules/invitations/tests/invitations.integration.tests.js +++ b/modules/invitations/tests/invitations.integration.tests.js @@ -325,7 +325,9 @@ describe('Signup invitations:', () => { AuthController.checkOAuthUserProfile(profil, 'sub', 'google'), ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', - details: { message: 'Registration is currently deactivated' }, + // Deliberately-authored copy (issue #4059 review item 1) — carried via + // `description`, not smuggled through `details.message`. + description: 'Registration is currently deactivated', }); // Ensure no user was persisted @@ -357,7 +359,9 @@ describe('Signup invitations:', () => { AuthController.checkOAuthUserProfile(profil, 'sub', 'google'), ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', - details: { message: 'Registration is currently deactivated' }, + // Deliberately-authored copy (issue #4059 review item 1) — carried via + // `description`, not smuggled through `details.message`. + description: 'Registration is currently deactivated', }); // Ensure no user was persisted diff --git a/modules/uploads/repositories/uploads.repository.js b/modules/uploads/repositories/uploads.repository.js index e3ee5b938..677bb780e 100644 --- a/modules/uploads/repositories/uploads.repository.js +++ b/modules/uploads/repositories/uploads.repository.js @@ -35,7 +35,13 @@ const getStream = (upload) => { try { return bucket.openDownloadStream(upload._id); } catch (err) { - throw new AppError('Uppload: read error', { code: 'REPOSITORY_ERROR', details: err }); + // Curated, not forwarded wholesale (issue #4059): only `message` — a short, + // human-readable reason — crosses into `details`. The raw GridFS/driver + // error may carry stack traces or connection metadata nobody chose to + // publish; `message` alone stays useful for logs/non-prod debugging and + // safe once `getDescription` gates `details.message` to non-production + // (same issue). + throw new AppError('Uppload: read error', { code: 'REPOSITORY_ERROR', details: { message: err?.message } }); } }; @@ -101,7 +107,8 @@ const remove = async (upload) => { const unlinked = await bucket.delete(upload._id); return unlinked; } catch (err) { - throw new AppError('Upload: delete error', { code: 'REPOSITORY_ERROR', details: err }); + // Curated (issue #4059) — see the identical comment on getStream above. + throw new AppError('Upload: delete error', { code: 'REPOSITORY_ERROR', details: { message: err?.message } }); } }; @@ -120,7 +127,8 @@ const deleteMany = async (filter) => { await bucket.delete(upload._id); deletedCount += 1; } catch (err) { - throw new AppError('Upload: delete error', { code: 'REPOSITORY_ERROR', details: err }); + // Curated (issue #4059) — see the identical comment on getStream above. + throw new AppError('Upload: delete error', { code: 'REPOSITORY_ERROR', details: { message: err?.message } }); } } return { deletedCount }; @@ -154,7 +162,8 @@ const purge = async (kind, collection, key) => { await bucket.delete(upload._id); deletedCount += 1; } catch (err) { - throw new AppError('Upload: delete error', { code: 'REPOSITORY_ERROR', details: err }); + // Curated (issue #4059) — see the identical comment on getStream above. + throw new AppError('Upload: delete error', { code: 'REPOSITORY_ERROR', details: { message: err?.message } }); } } return { deletedCount }; diff --git a/modules/uploads/tests/uploads.repository.unit.tests.js b/modules/uploads/tests/uploads.repository.unit.tests.js index 449f3ff1d..551ff0e45 100644 --- a/modules/uploads/tests/uploads.repository.unit.tests.js +++ b/modules/uploads/tests/uploads.repository.unit.tests.js @@ -2,6 +2,10 @@ * Module dependencies. */ import { jest, describe, test, beforeEach, afterEach, expect } from '@jest/globals'; +// REAL (unmocked) — uploads.repository.js never imports responses.js itself, +// so nothing in this file's beforeEach mocks it; used only by the item-5 +// describe block below to prove curation's actual purpose end to end. +import responses from '../../../lib/helpers/responses.js'; /** * Unit tests for uploads.repository.js — remove() no-op semantics and @@ -120,6 +124,104 @@ describe('UploadRepository unit tests:', () => { await expect(UploadRepository.remove(upload)).rejects.toThrow('Upload: delete error'); expect(mockBucket.delete).toHaveBeenCalledWith('abc123'); }); + + // Issue #4059: `details` must be curated to `{ message }` only, never the + // raw caught exception (which may carry a stack, driver metadata, or other + // fields nobody chose to publish) — proven by execution against an error + // shaped like the internal text the issue reports leaking. + test('curates details to { message } only — the raw bucket error is never forwarded wholesale (issue #4059)', async () => { + const upload = { _id: 'abc123', filename: 'present.png' }; + const rawError = Object.assign(new Error('ECONNREFUSED 10.0.4.12:27017 - mongo replset primary unreachable'), { + stack: 'Error: internal\n at Connection.connect (/app/node_modules/mongodb/lib/connect.js:42:11)', + code: 'ECONNREFUSED', + host: '10.0.4.12', + }); + mockBucket.delete.mockRejectedValueOnce(rawError); + + let caught; + try { + await UploadRepository.remove(upload); + } catch (err) { + caught = err; + } + + expect(caught.details).toEqual({ message: rawError.message }); + expect(Object.keys(caught.details)).toEqual(['message']); + expect(caught.details.stack).toBeUndefined(); + expect(caught.details.code).toBeUndefined(); + expect(caught.details.host).toBeUndefined(); + }); + }); + + describe('getStream', () => { + test('curates details to { message } only on a bucket read failure (issue #4059)', () => { + const rawError = Object.assign(new Error('ECONNREFUSED 10.0.4.12:27017 - mongo replset primary unreachable'), { + stack: 'Error: internal\n at Connection.connect (/app/node_modules/mongodb/lib/connect.js:42:11)', + code: 'ECONNREFUSED', + }); + mockBucket.openDownloadStream.mockImplementationOnce(() => { throw rawError; }); + + let caught; + try { + UploadRepository.getStream({ _id: 'abc123' }); + } catch (err) { + caught = err; + } + + expect(caught.message).toBe('Uppload: read error'); + expect(caught.details).toEqual({ message: rawError.message }); + expect(Object.keys(caught.details)).toEqual(['message']); + }); + }); + + describe('deleteMany', () => { + test('curates details to { message } only on a bucket delete failure (issue #4059)', async () => { + // deleteMany() lists candidates via list() -> find().select().sort().exec() + // (a different chain shape than setCandidates()'s .select().lean().cursor()). + mockUploadsModel.find.mockReturnValue({ + select: jest.fn().mockReturnThis(), + sort: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue([{ _id: 'a1', filename: 'a.png' }]), + }); + const rawError = Object.assign(new Error('ECONNREFUSED 10.0.4.12:27017 - mongo replset primary unreachable'), { + stack: 'Error: internal\n at Connection.connect (/app/node_modules/mongodb/lib/connect.js:42:11)', + code: 'ECONNREFUSED', + }); + mockBucket.delete.mockRejectedValueOnce(rawError); + + let caught; + try { + await UploadRepository.deleteMany({ owner: 'u1' }); + } catch (err) { + caught = err; + } + + expect(caught.message).toBe('Upload: delete error'); + expect(caught.details).toEqual({ message: rawError.message }); + expect(Object.keys(caught.details)).toEqual(['message']); + }); + }); + + describe('purge', () => { + test('curates details to { message } only on a bucket delete failure (issue #4059)', async () => { + mockUploadsModel.aggregate = jest.fn().mockResolvedValue([{ _id: 'a1', filename: 'a.png' }]); + const rawError = Object.assign(new Error('ECONNREFUSED 10.0.4.12:27017 - mongo replset primary unreachable'), { + stack: 'Error: internal\n at Connection.connect (/app/node_modules/mongodb/lib/connect.js:42:11)', + code: 'ECONNREFUSED', + }); + mockBucket.delete.mockRejectedValueOnce(rawError); + + let caught; + try { + await UploadRepository.purge('avatar', 'users', 'avatarFilename'); + } catch (err) { + caught = err; + } + + expect(caught.message).toBe('Upload: delete error'); + expect(caught.details).toEqual({ message: rawError.message }); + expect(Object.keys(caught.details)).toEqual(['message']); + }); }); describe('purgeUnreferenced', () => { @@ -249,4 +351,71 @@ describe('UploadRepository unit tests:', () => { expect(counters).toMatchObject({ scanned: 1, referenced: 1, orphaned: 0, deleted: 0 }); }); }); + + /** + * Unit tests — issue #4059 review item 5. The tests above (e.g. `remove`'s + * "curates details to { message } only" test) assert on the SHAPE of the + * thrown `AppError.details` in isolation — reverting a call site to + * `details: err` is already caught there (an extra key fails `toEqual`). + * What none of them prove is curation's actual PURPOSE: bounding what the + * dev-grade envelope (`result.error = safeStringify(error)` in + * `lib/helpers/responses.js`) exposes when `details` still carried + * `code`/`host`-shaped custom properties (a real Node/driver error attaches + * these as plain enumerable own properties — unlike `.stack`/`.message`, + * which V8 defines non-enumerable and so never serialize via + * `JSON.stringify` regardless of curation; verified empirically, not just + * asserted). This drives the REAL `UploadRepository.remove()` call site + * through the REAL (unmocked) `responses.error()` sink and inspects the + * actual dev-grade JSON string a client in a non-production env would + * receive. + */ + describe('details curation actually bounds the dev-grade envelope (issue #4059 review item 5):', () => { + /** + * Minimal Express response double — same shape as responses.js's own test suite. + * @returns {{_status: (number|undefined), _body: (Object|undefined), status: Function, json: Function}} + * the double; `status`/`json` are chainable recorders that capture what the + * sink sent into `_status`/`_body` for assertion. + */ + const buildRes = () => { + const res = { _status: undefined, _body: undefined }; + res.status = (code) => { res._status = code; return res; }; + res.json = (body) => { res._body = body; return res; }; + return res; + }; + + test('a curated call site (remove()) never lets the raw error\'s code/host reach the dev-grade envelope — only the intentionally-preserved message text does', async () => { + const upload = { _id: 'abc123', filename: 'present.png' }; + // Message text carries NO marker strings, so a substring check on the + // whole envelope cleanly distinguishes "message text leaked (intended)" + // from "some OTHER raw field leaked (curation's actual job)". + const rawError = Object.assign(new Error('mongo replset primary unreachable'), { + stack: 'STACK_MARKER_should_never_serialize_via_JSON_stringify_anyway', + code: 'CODE_MARKER_ECONNREFUSED', + host: 'HOST_MARKER_10_0_4_12', + }); + mockBucket.delete.mockRejectedValueOnce(rawError); + + let caught; + try { + await UploadRepository.remove(upload); + } catch (err) { + caught = err; + } + + const res = buildRes(); + responses.error(res, 500)(caught); // NODE_ENV is 'test' here (dev-grade) — result.error is populated + + expect(typeof res._body.error).toBe('string'); + const devBlob = JSON.parse(res._body.error); + // Curation's actual effect: the OTHER raw fields never reached `.details` + // in the first place, so they cannot appear in the dev blob either. + expect(devBlob.details).toEqual({ message: rawError.message }); + expect(JSON.stringify(devBlob)).not.toContain('CODE_MARKER'); + expect(JSON.stringify(devBlob)).not.toContain('HOST_MARKER'); + // Not over-scrubbing either — the curated message text is intentionally + // preserved dev-side (see the call site's own comment on `details: { + // message: err?.message }`). + expect(JSON.stringify(devBlob)).toContain('mongo replset primary unreachable'); + }); + }); }); diff --git a/modules/users/controllers/users.images.controller.js b/modules/users/controllers/users.images.controller.js index 87c186886..23e07e468 100644 --- a/modules/users/controllers/users.images.controller.js +++ b/modules/users/controllers/users.images.controller.js @@ -11,11 +11,18 @@ import UserService from '../services/users.service.js'; * @desc Endpoint to ask the service to update a user profile avatar * @param {Object} req - Express request object * @param {Object} res - Express response object + * @returns {Promise} resolves once the response has been sent; the + * success path replies from inside `req.login`'s callback, so nothing is + * returned to the caller. */ const updateAvatar = async (req, res) => { try { - // catch multerErr - if (req.multerErr) throw new AppError(req.multerErr.message, { code: 'SERVICE_ERROR', details: req.multerErr }); + // catch multerErr — curated to `{ message }` only (issue #4059, ninth site: + // a raw Multer error was forwarded wholesale, the identical anti-pattern the + // other eight call sites were fixed for). Multer attaches its own `code` + // (e.g. `LIMIT_FILE_SIZE`) and `field` on top of `message`; only `message` + // is a deliberate, human-readable reason worth publishing. + if (req.multerErr) throw new AppError(req.multerErr.message, { code: 'SERVICE_ERROR', details: { message: req.multerErr.message } }); // delete old image if (req.user.avatar) await UploadsService.remove({ filename: req.user.avatar }); // update user @@ -34,6 +41,9 @@ const updateAvatar = async (req, res) => { * @desc Endpoint to ask the service to remove a user profile avatar * @param {Object} req - Express request object * @param {Object} res - Express response object + * @returns {Promise} resolves once the response has been sent; the + * success path replies from inside `req.login`'s callback, so nothing is + * returned to the caller. */ const removeAvatar = async (req, res) => { try { diff --git a/modules/users/tests/users.images.controller.unit.tests.js b/modules/users/tests/users.images.controller.unit.tests.js new file mode 100644 index 000000000..ccd598750 --- /dev/null +++ b/modules/users/tests/users.images.controller.unit.tests.js @@ -0,0 +1,64 @@ +/** + * Module dependencies. + */ +import { jest, describe, test, expect } from '@jest/globals'; + +/** + * Loads a fresh users.images.controller.js instance with its dependencies + * mocked. `AppError` and `errors.getMessage` intentionally stay REAL — this + * suite proves the actual curated `details` shape the real `AppError` class + * produces, not a stand-in. + * @returns {Promise<{UsersImagesController: Object, mockResponsesError: import('@jest/globals').Mock, errorSink: import('@jest/globals').Mock}>} + */ +const loadController = async () => { + jest.resetModules(); + + const errorSink = jest.fn(); + const mockResponsesError = jest.fn().mockReturnValue(errorSink); + jest.unstable_mockModule('../../../lib/helpers/responses.js', () => ({ + default: { success: jest.fn().mockReturnValue(jest.fn()), error: mockResponsesError }, + })); + jest.unstable_mockModule('../../../lib/helpers/errors.js', () => ({ + default: { getMessage: jest.fn((err) => err?.message || 'error') }, + })); + jest.unstable_mockModule('../../uploads/services/uploads.service.js', () => ({ + default: { remove: jest.fn().mockResolvedValue(undefined) }, + })); + jest.unstable_mockModule('../services/users.service.js', () => ({ + default: { update: jest.fn() }, + })); + + const { default: UsersImagesController } = await import('../controllers/users.images.controller.js'); + return { UsersImagesController, mockResponsesError, errorSink }; +}; + +/** + * Unit tests — issue #4059 review item 3. `updateAvatar` was the NINTH raw- + * forward site (the issue's "eight call sites" framing missed it): a real + * Multer error (which carries its own `code` like `LIMIT_FILE_SIZE` and a + * `field`, on top of `message`) was handed wholesale to `AppError.details`. + * Curated the same way as the other eight sites: `{ message: err.message }` + * only. + */ +describe('users.images.controller updateAvatar — multerErr curation (issue #4059, ninth site):', () => { + test('curates details to { message } only — the raw Multer error is never forwarded wholesale', async () => { + const { UsersImagesController, errorSink } = await loadController(); + const rawMulterErr = Object.assign(new Error('File too large'), { + code: 'LIMIT_FILE_SIZE', + field: 'avatar', + storageErrors: [], + }); + const req = { multerErr: rawMulterErr, user: {} }; + const res = {}; + + await UsersImagesController.updateAvatar(req, res); + + expect(errorSink).toHaveBeenCalledTimes(1); + const caught = errorSink.mock.calls[0][0]; + expect(caught.details).toEqual({ message: 'File too large' }); + expect(Object.keys(caught.details)).toEqual(['message']); + expect(caught.details.code).toBeUndefined(); + expect(caught.details.field).toBeUndefined(); + expect(caught.details.storageErrors).toBeUndefined(); + }); +});