Skip to content

Commit 2e85b4f

Browse files
CryptoJonesAaron K. Clarkclaude
authored
feat(env): hard-fail at startup when NODE_ENV=production + DB_PASSWORD empty (#119)
A missing DB_PASSWORD in production is operator error: the pod starts, /healthz reports degraded (db: down), and load balancers that key off http-200 from /healthz risk routing traffic to a broken process anyway. Better to refuse to start so systemd/k8s catch the misconfiguration before traffic flips. Behavior: - NODE_ENV unset / development / test → warn + keep going. Matches the existing scaffolding/test ergonomics (the test suite itself runs with empty DB_PASSWORD). - NODE_ENV=production with empty DB_PASSWORD → log to stderr and `process.exit(1)`. systemd: unit fails. k8s: pod CrashLoopBackoff. Either way, traffic doesn't reach the broken process. Tests - New `tests/unit/env-validation.test.js` spawns child processes in each of the four matrix slots (env unset / dev / prod-empty / prod-set) and asserts exit code + stderr substring. - Full suite: 484 pass / 9 skip (was 480/9 — +4 new cases). Co-authored-by: Aaron K. Clark <akclark@thenetwerk.net> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b1b3553 commit 2e85b4f

2 files changed

Lines changed: 67 additions & 0 deletions

File tree

app/config/env.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,23 @@ const env = {
2222
};
2323

2424
if (!env.password) {
25+
// In development, an empty password is normal (running tests
26+
// without a live DB, scaffolding a fresh repo, etc.) — warn and
27+
// keep going.
28+
//
29+
// In production, an empty password means the operator forgot to
30+
// wire credentials. We hard-fail here rather than at first DB
31+
// query so the misconfiguration surfaces during process startup
32+
// (where systemd/k8s catch it and won't flip traffic), not after
33+
// the load balancer has already sent the pod 200/health checks.
34+
if (process.env.NODE_ENV === 'production') {
35+
console.error(
36+
'[env] DB_PASSWORD is empty and NODE_ENV=production. ' +
37+
'Refusing to start. Set DB_PASSWORD via your process manager, ' +
38+
'container orchestrator, or shell.'
39+
);
40+
process.exit(1);
41+
}
2542
console.warn(
2643
'[env] DB_PASSWORD is empty. Set it in .env (development) or via your ' +
2744
'environment (production). Connections will likely fail without it.'

tests/unit/env-validation.test.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright 2026 Aaron K. Clark
3+
//
4+
// Tests for `app/config/env.js` empty-password handling. The dev
5+
// case warns and lets the process keep going (useful for tests +
6+
// scaffolding); the prod case hard-fails so systemd/k8s catch the
7+
// misconfiguration before traffic flips.
8+
9+
import { describe, test, expect } from 'vitest';
10+
import { spawnSync } from 'node:child_process';
11+
import { resolve } from 'node:path';
12+
13+
const ENV_MODULE = resolve(__dirname, '../../app/config/env.js');
14+
15+
function run(env) {
16+
return spawnSync(process.execPath, ['-e', `require(${JSON.stringify(ENV_MODULE)})`], {
17+
// Force the child to ignore the parent's DB_PASSWORD so we
18+
// can test the empty-password path in isolation.
19+
env: { PATH: process.env.PATH || '', ...env },
20+
encoding: 'utf8',
21+
});
22+
}
23+
24+
describe('env validation: empty DB_PASSWORD', () => {
25+
test('NODE_ENV unset → warn + exit 0 (dev/test ergonomics)', () => {
26+
const r = run({});
27+
expect(r.status).toBe(0);
28+
expect(r.stderr).toMatch(/DB_PASSWORD is empty/i);
29+
expect(r.stderr).not.toMatch(/Refusing to start/i);
30+
});
31+
32+
test('NODE_ENV=development → warn + exit 0', () => {
33+
const r = run({ NODE_ENV: 'development' });
34+
expect(r.status).toBe(0);
35+
expect(r.stderr).toMatch(/DB_PASSWORD is empty/i);
36+
});
37+
38+
test('NODE_ENV=production → hard-fail with exit 1', () => {
39+
const r = run({ NODE_ENV: 'production' });
40+
expect(r.status).toBe(1);
41+
expect(r.stderr).toMatch(/NODE_ENV=production/);
42+
expect(r.stderr).toMatch(/Refusing to start/i);
43+
});
44+
45+
test('NODE_ENV=production + DB_PASSWORD set → no warning, exit 0', () => {
46+
const r = run({ NODE_ENV: 'production', DB_PASSWORD: 'real-password' });
47+
expect(r.status).toBe(0);
48+
expect(r.stderr).not.toMatch(/DB_PASSWORD/);
49+
});
50+
});

0 commit comments

Comments
 (0)