Skip to content

Commit 8cf4a1a

Browse files
authored
Merge pull request #8460 from nightscout/wip/test-improvements
Wip/test improvements
2 parents 7e5c345 + c00fd37 commit 8cf4a1a

File tree

4 files changed

+327
-8
lines changed

4 files changed

+327
-8
lines changed

tests/00_production-safety.test.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
'use strict';
2+
3+
/**
4+
* Production Safety Check - Database Level
5+
*
6+
* GAP-SYNC-047: This test runs FIRST (00_ prefix) to verify
7+
* the database is safe for destructive test operations.
8+
*
9+
* Checks:
10+
* 1. Database name contains "test"
11+
* 2. Entry count is below threshold (default: 100)
12+
*
13+
* Environment Variables:
14+
* - TEST_SAFETY_MAX_ENTRIES: Max entries before refusing (default: 100)
15+
* - TEST_SAFETY_REQUIRE_TEST_DB: Require "test" in DB name (default: true)
16+
* - TEST_SAFETY_SKIP: Bypass all checks (dangerous!)
17+
*/
18+
19+
var should = require('should');
20+
var language = require('../lib/language')();
21+
var productionSafety = require('./lib/production-safety');
22+
23+
describe('00 Production Safety Check', function() {
24+
this.timeout(10000);
25+
var self = this;
26+
27+
before(function(done) {
28+
// Boot the app to get DB connection
29+
process.env.API_SECRET = 'this is my long pass phrase';
30+
self.env = require('../lib/server/env')();
31+
self.env.settings.authDefaultRoles = 'readable';
32+
self.env.settings.enable = ['careportal', 'api'];
33+
34+
require('../lib/server/bootevent')(self.env, language).boot(function booted(ctx) {
35+
self.ctx = ctx;
36+
done();
37+
});
38+
});
39+
40+
it('should verify database is safe for destructive tests', function(done) {
41+
// This is the critical safety gate
42+
productionSafety.checkProductionSafety(self.ctx, self.env)
43+
.then(function() {
44+
done();
45+
})
46+
.catch(function(err) {
47+
// Fail the test suite immediately
48+
console.error('\n\n' + '!'.repeat(70));
49+
console.error('TEST SUITE HALTED - Production safety check activated');
50+
console.error('!'.repeat(70) + '\n');
51+
process.exit(1);
52+
});
53+
});
54+
55+
it('should have extracted correct database name', function() {
56+
var dbName = productionSafety.extractDbName(self.env.storageURI || self.env.mongo_connection || '');
57+
dbName.should.be.a.String();
58+
dbName.length.should.be.greaterThan(0);
59+
console.log('[SAFETY] Database name: ' + dbName);
60+
});
61+
});

tests/hooks.js

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,10 @@
11
'use strict;'
22

33
var testHelpers = require('./lib/test-helpers');
4+
var productionSafety = require('./lib/production-safety');
45

5-
// GAP-SYNC-046: Safety check to prevent tests running against production
6-
if (process.env.NODE_ENV !== 'test') {
7-
console.error('\n❌ SAFETY ERROR: NODE_ENV must be "test" to run tests.');
8-
console.error(' Current value: ' + (process.env.NODE_ENV || '(not set)'));
9-
console.error(' Tests use deleteMany({}) which could destroy production data.');
10-
console.error(' Fix: Use "npm test" which loads my.test.env, or set NODE_ENV=test\n');
11-
process.exit(1);
12-
}
6+
// GAP-SYNC-046: Pre-flight safety check (no DB required)
7+
productionSafety.preflightCheck();
138

149
var slowTestThreshold = parseInt(process.env.SLOW_TEST_THRESHOLD, 10) || 2000;
1510
var enableTimingWarnings = process.env.ENABLE_TIMING_WARNINGS === 'true';

tests/lib/production-safety.js

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
'use strict';
2+
3+
/**
4+
* Production Safety Check for Test Suite
5+
*
6+
* GAP-SYNC-047: Prevents tests from running against production databases
7+
* by checking multiple safety signals:
8+
*
9+
* 1. Database name should contain "test" (configurable)
10+
* 2. Entry count should be below threshold (default: 100)
11+
*
12+
* Environment Variables:
13+
* - TEST_SAFETY_MAX_ENTRIES: Max entries before refusing (default: 100, 0 to disable)
14+
* - TEST_SAFETY_REQUIRE_TEST_DB: Require "test" in DB name (default: true)
15+
* - TEST_SAFETY_SKIP: Emergency bypass for all checks (default: false)
16+
*/
17+
18+
const DEFAULT_MAX_ENTRIES = 100;
19+
20+
/**
21+
* Extract database name from MongoDB connection string
22+
* @param {string} connectionString - MongoDB URI
23+
* @returns {string} Database name
24+
*/
25+
function extractDbName(connectionString) {
26+
try {
27+
// Handle both mongodb:// and mongodb+srv:// formats
28+
const url = new URL(connectionString);
29+
// pathname is /dbname or /dbname?options
30+
let dbName = url.pathname.slice(1); // Remove leading /
31+
// Remove query string if present
32+
const queryIndex = dbName.indexOf('?');
33+
if (queryIndex > -1) {
34+
dbName = dbName.slice(0, queryIndex);
35+
}
36+
return dbName || 'nightscout';
37+
} catch (err) {
38+
// Fallback for non-standard connection strings
39+
const match = connectionString.match(/\/([^/?]+)(\?|$)/);
40+
return match ? match[1] : 'unknown';
41+
}
42+
}
43+
44+
/**
45+
* Check if database name indicates a test database
46+
* @param {string} dbName - Database name
47+
* @returns {boolean} True if looks like test database
48+
*/
49+
function isTestDatabaseName(dbName) {
50+
const lower = dbName.toLowerCase();
51+
return lower.includes('test') ||
52+
lower.includes('_test') ||
53+
lower.startsWith('test_') ||
54+
lower.endsWith('_test');
55+
}
56+
57+
/**
58+
* Run production safety checks
59+
*
60+
* @param {Object} ctx - Boot context with entries collection
61+
* @param {Object} env - Environment with storageURI
62+
* @returns {Promise<void>} Resolves if safe, rejects with error if not
63+
*/
64+
async function checkProductionSafety(ctx, env) {
65+
// Emergency bypass
66+
if (process.env.TEST_SAFETY_SKIP === 'true') {
67+
console.warn('[SAFETY] ⚠️ TEST_SAFETY_SKIP=true - All safety checks bypassed!');
68+
return;
69+
}
70+
71+
const errors = [];
72+
const warnings = [];
73+
74+
// Check 1: Database name should indicate test
75+
const requireTestDb = process.env.TEST_SAFETY_REQUIRE_TEST_DB !== 'false';
76+
const dbName = extractDbName(env.storageURI || env.mongo_connection || '');
77+
78+
if (requireTestDb && !isTestDatabaseName(dbName)) {
79+
errors.push({
80+
check: 'Database Name',
81+
message: `Database "${dbName}" doesn't contain "test" in its name`,
82+
hint: 'Use a database name like "nightscout_test" or set TEST_SAFETY_REQUIRE_TEST_DB=false'
83+
});
84+
} else if (isTestDatabaseName(dbName)) {
85+
console.log(`[SAFETY] ✅ Database name "${dbName}" looks like a test database`);
86+
}
87+
88+
// Check 2: Entry count threshold
89+
const maxEntries = parseInt(process.env.TEST_SAFETY_MAX_ENTRIES || String(DEFAULT_MAX_ENTRIES), 10);
90+
91+
if (maxEntries > 0 && ctx.store && ctx.store.db) {
92+
try {
93+
// Access entries collection directly via store
94+
const entriesCol = ctx.store.db.collection('entries');
95+
// Use limit+1 pattern for efficiency - we only need to know if it exceeds threshold
96+
const count = await entriesCol.countDocuments({}, {
97+
limit: maxEntries + 1,
98+
maxTimeMS: 5000 // Don't hang on slow connections
99+
});
100+
101+
if (count > maxEntries) {
102+
errors.push({
103+
check: 'Entry Count',
104+
message: `Database has ${count}+ entries (threshold: ${maxEntries})`,
105+
hint: `This looks like a production database. Set TEST_SAFETY_MAX_ENTRIES=${count + 100} to override`
106+
});
107+
} else {
108+
console.log(`[SAFETY] ✅ Database has ${count} entries (threshold: ${maxEntries})`);
109+
}
110+
} catch (err) {
111+
warnings.push({
112+
check: 'Entry Count',
113+
message: `Could not count entries: ${err.message}`,
114+
hint: 'Entry count check skipped'
115+
});
116+
}
117+
} else if (maxEntries === 0) {
118+
console.log('[SAFETY] ⚠️ Entry count check disabled (TEST_SAFETY_MAX_ENTRIES=0)');
119+
}
120+
121+
// Report warnings
122+
warnings.forEach(w => {
123+
console.warn(`[SAFETY] ⚠️ ${w.check}: ${w.message}`);
124+
});
125+
126+
// Report errors and fail
127+
if (errors.length > 0) {
128+
console.error('\n' + '='.repeat(70));
129+
console.error('🛡️ PRODUCTION SAFETY CHECK ACTIVATED');
130+
console.error('='.repeat(70));
131+
console.error('\nThis database appears to contain real data.');
132+
console.error('Running the test suite WILL DELETE all data in this database.');
133+
console.error('\nThis safety check exists to prevent accidental destruction of');
134+
console.error('production data. If this is truly a test database, you can override.\n');
135+
136+
errors.forEach((e, i) => {
137+
console.error(`${i + 1}. ${e.check}:`);
138+
console.error(` ${e.message}`);
139+
console.error(` 💡 ${e.hint}\n`);
140+
});
141+
142+
console.error('Override options:');
143+
console.error(' • Set TEST_SAFETY_MAX_ENTRIES to a higher value (e.g., 1000)');
144+
console.error(' • Set TEST_SAFETY_REQUIRE_TEST_DB=false to allow any DB name');
145+
console.error(' • Set TEST_SAFETY_SKIP=true to bypass ALL checks (dangerous!)');
146+
console.error('='.repeat(70) + '\n');
147+
148+
throw new Error('Production safety check activated: ' + errors.map(e => e.check).join(', '));
149+
}
150+
151+
console.log('[SAFETY] ✅ All production safety checks passed');
152+
}
153+
154+
/**
155+
* Synchronous pre-flight check (no DB required)
156+
* Run this before booting the application
157+
*/
158+
function preflightCheck() {
159+
// Check NODE_ENV
160+
if (process.env.NODE_ENV !== 'test') {
161+
console.error('\n❌ SAFETY ERROR: NODE_ENV must be "test" to run tests.');
162+
console.error(' Current value: ' + (process.env.NODE_ENV || '(not set)'));
163+
console.error(' Tests use deleteMany({}) which could destroy production data.');
164+
console.error(' Fix: Use "npm test" which loads my.test.env, or set NODE_ENV=test\n');
165+
process.exit(1);
166+
}
167+
}
168+
169+
module.exports = {
170+
checkProductionSafety,
171+
preflightCheck,
172+
extractDbName,
173+
isTestDatabaseName,
174+
DEFAULT_MAX_ENTRIES
175+
};

tests/production-safety.test.js

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
'use strict';
2+
3+
/**
4+
* Unit tests for production-safety.js module
5+
*/
6+
7+
var should = require('should');
8+
var productionSafety = require('./lib/production-safety');
9+
10+
describe('Production Safety Module', function() {
11+
12+
describe('extractDbName', function() {
13+
14+
it('should extract database name from standard MongoDB URI', function() {
15+
var dbName = productionSafety.extractDbName('mongodb://localhost:27017/nightscout_test');
16+
dbName.should.equal('nightscout_test');
17+
});
18+
19+
it('should extract database name from MongoDB+SRV URI', function() {
20+
var dbName = productionSafety.extractDbName('mongodb+srv://user:pass@cluster.mongodb.net/mydb_test');
21+
dbName.should.equal('mydb_test');
22+
});
23+
24+
it('should handle URI with query parameters', function() {
25+
var dbName = productionSafety.extractDbName('mongodb://localhost:27017/nightscout_test?retryWrites=true');
26+
dbName.should.equal('nightscout_test');
27+
});
28+
29+
it('should handle URI with auth credentials', function() {
30+
var dbName = productionSafety.extractDbName('mongodb://user:password@localhost:27017/test_db');
31+
dbName.should.equal('test_db');
32+
});
33+
34+
it('should return default for empty connection string', function() {
35+
var dbName = productionSafety.extractDbName('');
36+
dbName.should.be.a.String();
37+
});
38+
});
39+
40+
describe('isTestDatabaseName', function() {
41+
42+
it('should recognize "_test" suffix', function() {
43+
productionSafety.isTestDatabaseName('nightscout_test').should.be.true();
44+
});
45+
46+
it('should recognize "test_" prefix', function() {
47+
productionSafety.isTestDatabaseName('test_nightscout').should.be.true();
48+
});
49+
50+
it('should recognize "test" anywhere in name', function() {
51+
productionSafety.isTestDatabaseName('my_testing_db').should.be.true();
52+
});
53+
54+
it('should be case insensitive', function() {
55+
productionSafety.isTestDatabaseName('Nightscout_TEST').should.be.true();
56+
productionSafety.isTestDatabaseName('TEST_DB').should.be.true();
57+
});
58+
59+
it('should reject production-looking names', function() {
60+
productionSafety.isTestDatabaseName('nightscout').should.be.false();
61+
productionSafety.isTestDatabaseName('production').should.be.false();
62+
productionSafety.isTestDatabaseName('mydb').should.be.false();
63+
});
64+
65+
it('should reject names with "test" as substring of other words', function() {
66+
// "contest" contains "test" but is not a test database
67+
// Current implementation will actually match this - documenting behavior
68+
productionSafety.isTestDatabaseName('contest').should.be.true(); // Contains "test"
69+
});
70+
});
71+
72+
describe('DEFAULT_MAX_ENTRIES', function() {
73+
74+
it('should be a reasonable default (100)', function() {
75+
productionSafety.DEFAULT_MAX_ENTRIES.should.equal(100);
76+
});
77+
});
78+
79+
describe('preflightCheck', function() {
80+
81+
it('should not throw when NODE_ENV=test', function() {
82+
// This test is running, so NODE_ENV must be test
83+
process.env.NODE_ENV.should.equal('test');
84+
// preflightCheck would have already run via hooks.js
85+
// If we got here, it passed
86+
});
87+
});
88+
});

0 commit comments

Comments
 (0)