Skip to content

Commit 93f23e0

Browse files
fix(tests): guard jest globalSetup against non-test NODE_ENV (#3476) (#3488)
* fix(tests): guard jest globalSetup against non-test NODE_ENV (#3476) Downstream projects (ism_node, trawl_node, comes_node, montaine_node) export `NODE_ENV=<project>` in their shell to pick up their config. Running jest directly (`npx jest`, IDE runner) bypasses the `cross-env NODE_ENV=test` wrapper used by the npm `test*` scripts, and `scripts/jest.globalSetup.js` unconditionally called `dropDatabase()` against `config.db.uri` resolved from that env. Real incident (2026-04-20, ism_node): `NODE_ENV=ism npx jest ...` wiped 16 collections in 22ms with no confirmation. Guards added in globalSetup (defense-in-depth): 1. Refuse to drop when `NODE_ENV !== 'test'`, log clear refusal. 2. Refuse to drop when the resolved DB name does not match `/test/i`, catching cases where `config.db.uri` is overridden to a non-test DB. Both checks exit without connecting; tests run against existing state. Tests: 5 new unit tests cover project-env leak, undefined NODE_ENV, non-test DB name, happy path, and Mongo-unreachable fallback. Added `scripts/tests/**` to `testMatch` so this path is picked up. Fixes #3476 * fix(tests): address CodeRabbit — JSDoc, NODE_ENV restore, disconnect - scripts/jest.globalSetup.js: add JSDoc @returns on the default export; wrap dropDatabase in try/finally so mongoose.disconnect() runs even when the drop rejects (no dangling connection). - scripts/tests/jest.globalSetup.unit.tests.js: afterEach now deletes NODE_ENV when the original was undefined (process.env coerces to the literal string "undefined" otherwise); add a 6th test covering the disconnect-on-drop-failure path.
1 parent ef1f6de commit 93f23e0

3 files changed

Lines changed: 161 additions & 3 deletions

File tree

jest.config.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,11 @@ export default {
172172
// testLocationInResults: false,
173173

174174
// The glob patterns Jest uses to detect test files
175-
testMatch: ['<rootDir>/modules/*/tests/**/*.js', '<rootDir>/lib/**/tests/**/*.js'],
175+
testMatch: [
176+
'<rootDir>/modules/*/tests/**/*.js',
177+
'<rootDir>/lib/**/tests/**/*.js',
178+
'<rootDir>/scripts/tests/**/*.js',
179+
],
176180

177181
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
178182
testPathIgnorePatterns: ['/node_modules/', '/tests/fixtures/'],

scripts/jest.globalSetup.js

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,50 @@
22
* Jest global setup - drops the test database before the test suite runs.
33
* Replaces the gulp dropDB task that previously ran before jest.
44
* Silently skips if MongoDB is unreachable (e.g. fresh CI environment).
5+
*
6+
* Safety guards (#3476):
7+
* 1. Refuse to drop anything when `NODE_ENV !== 'test'`. Protects downstream
8+
* projects that export `NODE_ENV=<project>` in their shell and invoke
9+
* jest directly (e.g. `npx jest`, IDE runners) bypassing the
10+
* `cross-env NODE_ENV=test` wrapper used by the npm `test*` scripts.
11+
* 2. Belt-and-suspenders: also refuse when the resolved DB name does not
12+
* contain `test` (case-insensitive). Catches leaked overrides of
13+
* `config.db.uri` that point at a non-test database.
14+
*
15+
* Both checks log a clear refusal reason and exit without connecting.
516
*/
617
import mongoose from 'mongoose';
718

19+
/**
20+
* Jest global setup entry point — drops the test database before the suite runs.
21+
* Enforces the NODE_ENV + DB-name guards described at the top of this file.
22+
* @returns {Promise<void>} Resolves once the guard check completes and, when
23+
* guards pass and Mongo is reachable, once the database has been dropped and
24+
* the connection closed. Never rejects — a Mongo failure is swallowed so tests
25+
* can still run against existing state (e.g. fresh CI without mongo).
26+
*/
827
export default async () => {
28+
if (process.env.NODE_ENV !== 'test') {
29+
console.warn(
30+
`[jest.globalSetup] Refusing to drop DB: NODE_ENV is "${process.env.NODE_ENV}", expected "test". Skipping drop.`,
31+
);
32+
return;
33+
}
934
try {
1035
const config = (await import('../config/index.js')).default;
36+
const dbName = new URL(config.db.uri).pathname.replace(/^\//, '');
37+
if (!/test/i.test(dbName)) {
38+
console.warn(
39+
`[jest.globalSetup] Refusing to drop "${dbName}": name does not match /test/i. Aborting.`,
40+
);
41+
return;
42+
}
1143
await mongoose.connect(config.db.uri);
12-
await mongoose.connection.dropDatabase();
13-
await mongoose.disconnect();
44+
try {
45+
await mongoose.connection.dropDatabase();
46+
} finally {
47+
await mongoose.disconnect();
48+
}
1449
} catch {
1550
// MongoDB unreachable or drop failed — tests will run against existing state
1651
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* Unit tests for jest globalSetup safety guards (#3476).
3+
*
4+
* The globalSetup runs BEFORE any test suite, so its own behaviour can't be
5+
* observed from inside a test. These tests exercise the module as a plain
6+
* async function — the same entry point jest uses — while mocking mongoose
7+
* and config to avoid touching a real database.
8+
*/
9+
import { jest, describe, test, expect, beforeEach, afterEach } from '@jest/globals';
10+
11+
const connect = jest.fn(async () => {});
12+
const dropDatabase = jest.fn(async () => {});
13+
const disconnect = jest.fn(async () => {});
14+
15+
jest.unstable_mockModule('mongoose', () => ({
16+
default: {
17+
connect,
18+
disconnect,
19+
connection: { dropDatabase },
20+
},
21+
}));
22+
23+
describe('scripts/jest.globalSetup safety guards', () => {
24+
const originalNodeEnv = process.env.NODE_ENV;
25+
let warnSpy;
26+
27+
beforeEach(() => {
28+
connect.mockClear();
29+
dropDatabase.mockClear();
30+
disconnect.mockClear();
31+
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
32+
});
33+
34+
afterEach(() => {
35+
warnSpy.mockRestore();
36+
// Restore the original NODE_ENV — `process.env` coerces values to strings,
37+
// so assigning `undefined` would leave the literal string "undefined". Use
38+
// `delete` when the variable was originally absent.
39+
if (originalNodeEnv === undefined) delete process.env.NODE_ENV;
40+
else process.env.NODE_ENV = originalNodeEnv;
41+
jest.resetModules();
42+
});
43+
44+
test('refuses to drop when NODE_ENV is not "test" (project env leak)', async () => {
45+
process.env.NODE_ENV = 'ism';
46+
const { default: globalSetup } = await import('../jest.globalSetup.js');
47+
48+
await globalSetup();
49+
50+
expect(connect).not.toHaveBeenCalled();
51+
expect(dropDatabase).not.toHaveBeenCalled();
52+
expect(disconnect).not.toHaveBeenCalled();
53+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('NODE_ENV is "ism"'));
54+
});
55+
56+
test('refuses to drop when NODE_ENV is undefined', async () => {
57+
delete process.env.NODE_ENV;
58+
const { default: globalSetup } = await import('../jest.globalSetup.js');
59+
60+
await globalSetup();
61+
62+
expect(dropDatabase).not.toHaveBeenCalled();
63+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('NODE_ENV is "undefined"'));
64+
});
65+
66+
test('refuses to drop when resolved DB name does not contain "test"', async () => {
67+
process.env.NODE_ENV = 'test';
68+
jest.unstable_mockModule('../../config/index.js', () => ({
69+
default: { db: { uri: 'mongodb://localhost:27017/ProductionDb' } },
70+
}));
71+
const { default: globalSetup } = await import('../jest.globalSetup.js');
72+
73+
await globalSetup();
74+
75+
expect(connect).not.toHaveBeenCalled();
76+
expect(dropDatabase).not.toHaveBeenCalled();
77+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Refusing to drop "ProductionDb"'));
78+
});
79+
80+
test('drops database when NODE_ENV=test and DB name contains "test" (case-insensitive)', async () => {
81+
process.env.NODE_ENV = 'test';
82+
jest.unstable_mockModule('../../config/index.js', () => ({
83+
default: { db: { uri: 'mongodb://localhost:27017/NodeTest' } },
84+
}));
85+
const { default: globalSetup } = await import('../jest.globalSetup.js');
86+
87+
await globalSetup();
88+
89+
expect(connect).toHaveBeenCalledWith('mongodb://localhost:27017/NodeTest');
90+
expect(dropDatabase).toHaveBeenCalledTimes(1);
91+
expect(disconnect).toHaveBeenCalledTimes(1);
92+
});
93+
94+
test('swallows errors when MongoDB is unreachable', async () => {
95+
process.env.NODE_ENV = 'test';
96+
connect.mockRejectedValueOnce(new Error('ECONNREFUSED'));
97+
jest.unstable_mockModule('../../config/index.js', () => ({
98+
default: { db: { uri: 'mongodb://localhost:27017/NodeTest' } },
99+
}));
100+
const { default: globalSetup } = await import('../jest.globalSetup.js');
101+
102+
await expect(globalSetup()).resolves.toBeUndefined();
103+
expect(dropDatabase).not.toHaveBeenCalled();
104+
});
105+
106+
test('disconnects even when dropDatabase() fails (no dangling connection)', async () => {
107+
process.env.NODE_ENV = 'test';
108+
dropDatabase.mockRejectedValueOnce(new Error('drop failed'));
109+
jest.unstable_mockModule('../../config/index.js', () => ({
110+
default: { db: { uri: 'mongodb://localhost:27017/NodeTest' } },
111+
}));
112+
const { default: globalSetup } = await import('../jest.globalSetup.js');
113+
114+
await expect(globalSetup()).resolves.toBeUndefined();
115+
expect(connect).toHaveBeenCalledTimes(1);
116+
expect(dropDatabase).toHaveBeenCalledTimes(1);
117+
expect(disconnect).toHaveBeenCalledTimes(1);
118+
});
119+
});

0 commit comments

Comments
 (0)