It documented 18 of 67, so anyone setting up a deployment had to find the rest by reading source — including CORS_ORIGINS, REDIS_URL, NODE_ENV and the whole Clinical Assistant retrieval group. Each entry now shows the default the code falls back to, so an unset line reads as "this default" rather than "broken". Also documents the OpenBao switch: the entrypoint only fetches secrets when OPENBAO_ADDR is set, a compose or env_file value always beats the OpenBao copy, and turning it off means unsetting that one variable and putting the keys here — with the command to list the key names without printing values. A test now derives the set of variables from source, so this cannot drift again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
152 lines
7.9 KiB
JavaScript
152 lines
7.9 KiB
JavaScript
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
function read(relativePath) {
|
|
return fs.readFileSync(path.join(__dirname, '..', relativePath), 'utf8');
|
|
}
|
|
|
|
test('redactor removes PHI and common secret patterns from logs', () => {
|
|
const { redact } = require('../src/utils/redact');
|
|
const input = 'Authorization: Bearer abc.def.ghi password=secret123 token=tok_1234567890 email test@example.com MRN 123456789';
|
|
const output = redact(input);
|
|
|
|
assert.doesNotMatch(output, /abc\.def\.ghi/);
|
|
assert.doesNotMatch(output, /secret123/);
|
|
assert.doesNotMatch(output, /tok_1234567890/);
|
|
assert.doesNotMatch(output, /test@example\.com/);
|
|
assert.doesNotMatch(output, /123456789/);
|
|
assert.match(output, /\[REDACTED\]|\[JWT\]/);
|
|
});
|
|
|
|
test('URL safety helper blocks private network targets', () => {
|
|
const { isPrivateIp } = require('../src/utils/urlSafety');
|
|
|
|
assert.equal(isPrivateIp('127.0.0.1'), true);
|
|
assert.equal(isPrivateIp('10.0.0.5'), true);
|
|
assert.equal(isPrivateIp('172.16.0.1'), true);
|
|
assert.equal(isPrivateIp('192.168.1.10'), true);
|
|
assert.equal(isPrivateIp('169.254.169.254'), true);
|
|
assert.equal(isPrivateIp('100.64.0.1'), true);
|
|
assert.equal(isPrivateIp('::1'), true);
|
|
assert.equal(isPrivateIp('fe80::1'), true);
|
|
assert.equal(isPrivateIp('8.8.8.8'), false);
|
|
assert.equal(isPrivateIp('2606:4700:4700::1111'), false);
|
|
});
|
|
|
|
test('Nextcloud/WebDAV routes enforce SSRF guard and redirect blocking', () => {
|
|
const nextcloud = read('src/routes/nextcloud.js');
|
|
const learningAI = read('src/routes/learningAI.js');
|
|
|
|
assert.match(nextcloud, /assertSafeHttpsUrl\(cleanUrl, 'Nextcloud URL'\)/);
|
|
assert.match(nextcloud, /assertSafeHttpsUrl\(user\.nextcloud_url, 'Nextcloud URL'\)/);
|
|
assert.match(nextcloud, /maxRedirects: 0/);
|
|
assert.match(nextcloud, /encodeURIComponent\(username\)/);
|
|
assert.match(learningAI, /assertSafeHttpsUrl\(user\.nextcloud_url, 'Nextcloud URL'\)/);
|
|
assert.match(learningAI, /maxRedirects: 0/);
|
|
assert.match(learningAI, /encodeURIComponent\(user\.nextcloud_user\)/);
|
|
});
|
|
|
|
test('logs and audits avoid unbounded limits and PHI-prone details', () => {
|
|
const logs = read('src/routes/logs.js');
|
|
const encounters = read('src/routes/encounters.js');
|
|
const documents = read('src/routes/documents.js');
|
|
const learningAI = read('src/routes/learningAI.js');
|
|
|
|
assert.match(logs, /function clampLimit/);
|
|
assert.match(logs, /clampLimit\(req\.query\.limit, 50, 200\)/);
|
|
assert.match(logs, /logger\.warn\('client_error'/);
|
|
assert.match(logs, /redact\(trimField\(e\.stack, 1200\)\)/);
|
|
assert.doesNotMatch(logs, /console\.error\('\[CLIENT ERROR\]'/);
|
|
|
|
assert.doesNotMatch(encounters, /Saved encounter: ' \+ \(label/);
|
|
assert.doesNotMatch(encounters, /Loaded encounter: ' \+ \(row\.label/);
|
|
assert.doesNotMatch(documents, /Uploaded: ' \+ \(req\.file/);
|
|
assert.doesNotMatch(learningAI, /Char context/);
|
|
});
|
|
|
|
// adminMiddleware only checks req.user.role; without authMiddleware having run,
|
|
// req.user is undefined. These routes were protected only because adminConfig
|
|
// happens to be mounted on /api/admin first and guards the whole path.
|
|
test('admin routers state their own authentication, not mount order', () => {
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const root = path.join(__dirname, '..');
|
|
for (const file of ['adminMilestones.js', 'admin.js', 'adminConfig.js', 'adminDocs.js']) {
|
|
const src = fs.readFileSync(path.join(root, 'src/routes', file), 'utf8');
|
|
assert.match(src, /router\.use\(authMiddleware\);/, file + ' authenticates every route it serves');
|
|
}
|
|
const auth = fs.readFileSync(path.join(root, 'src/middleware/auth.js'), 'utf8');
|
|
assert.match(auth, /async function adminMiddleware\(req, res, next\) \{\s*\n\s*if \(!req\.user \|\| req\.user\.role !== 'admin'\)/,
|
|
'and the role check stays a role check, so it fails closed on its own');
|
|
});
|
|
|
|
// Three S3 schemes grew separately — S3_*, GENERATED_IMAGES_S3_*, and later
|
|
// audio backups — which is why pointing the app at a different MinIO meant
|
|
// hunting through three files. One resolver answers it for every purpose.
|
|
test('object storage resolves the same way for every purpose', () => {
|
|
const storage = require('../src/utils/objectStorage');
|
|
|
|
// One endpoint plus a bucket name per purpose is enough for all of them.
|
|
const shared = { S3_ENDPOINT: 'https://s3.example.com', S3_ACCESS_KEY: 'AK', S3_SECRET_KEY: 'SK',
|
|
S3_BUCKET_AUDIO_BACKUPS: 'audio', S3_BUCKET_GENERATED_IMAGES: 'images', S3_BUCKET: 'docs' };
|
|
for (const [purpose, bucket] of [['audio-backups', 'audio'], ['generated-images', 'images'], ['documents', 'docs']]) {
|
|
const resolved = storage.settingsFor(purpose, shared);
|
|
assert.equal(resolved.bucket, bucket, purpose + ' finds its bucket');
|
|
assert.equal(resolved.endpoint, 'https://s3.example.com');
|
|
assert.deepEqual(resolved.credentials, { accessKeyId: 'AK', secretAccessKey: 'SK' });
|
|
}
|
|
|
|
// A purpose that needs its own account still overrides everything.
|
|
const overridden = storage.settingsFor('audio-backups',
|
|
Object.assign({}, shared, { AUDIO_BACKUPS_S3_ENDPOINT: 'http://assets:9000', AUDIO_BACKUPS_S3_BUCKET: 'audio-backups' }));
|
|
assert.equal(overridden.endpoint, 'http://assets:9000');
|
|
assert.equal(overridden.bucket, 'audio-backups');
|
|
|
|
// Existing deployments keep working untouched, including the old key names.
|
|
const legacy = storage.settingsFor('documents',
|
|
{ S3_BUCKET: 'd', S3_REGION: 'us-west-004', S3_ENDPOINT: 'https://b2', S3_ACCESS_KEY_ID: 'A', S3_SECRET_ACCESS_KEY: 'B' });
|
|
assert.equal(legacy.region, 'us-west-004');
|
|
assert.deepEqual(legacy.credentials, { accessKeyId: 'A', secretAccessKey: 'B' });
|
|
// Documents defaulted path-style off; a Backblaze endpoint must keep working.
|
|
assert.equal(legacy.forcePathStyle, false);
|
|
assert.equal(storage.settingsFor('audio-backups', { AUDIO_BACKUPS_S3_BUCKET: 'a', AUDIO_BACKUPS_S3_ENDPOINT: 'http://assets:9000' }).forcePathStyle, true,
|
|
'but MinIO needs it, so a custom endpoint turns it on where there is no older default');
|
|
|
|
// No bucket means "not configured" — never an error, since all of this is optional.
|
|
assert.equal(storage.settingsFor('audio-backups', {}), null);
|
|
assert.equal(storage.isConfigured('audio-backups', {}), false);
|
|
|
|
// A mounted secret must not be overridden by an inherited environment value.
|
|
const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path');
|
|
const keyFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'objstore-')), 'key');
|
|
fs.writeFileSync(keyFile, 'from-file\n');
|
|
const fileWins = storage.settingsFor('audio-backups',
|
|
{ AUDIO_BACKUPS_S3_BUCKET: 'a', AUDIO_BACKUPS_S3_ACCESS_KEY: 'inline', AUDIO_BACKUPS_S3_ACCESS_KEY_FILE: keyFile, AUDIO_BACKUPS_S3_SECRET_KEY: 'S' });
|
|
assert.equal(fileWins.credentials.accessKeyId, 'from-file');
|
|
});
|
|
|
|
// .env.example listed 18 of the 67 variables the app reads, so anyone setting
|
|
// up a deployment had to find the rest by reading source.
|
|
test('.env.example documents every variable the app reads', () => {
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const root = path.join(__dirname, '..');
|
|
const read = f => fs.readFileSync(path.join(root, f), 'utf8');
|
|
|
|
const sources = ['server.js'].concat(
|
|
fs.readdirSync(path.join(root, 'src'), { recursive: true })
|
|
.filter(f => String(f).endsWith('.js')).map(f => path.join('src', String(f))));
|
|
const used = new Set();
|
|
for (const file of sources) {
|
|
for (const m of read(file).matchAll(/process\.env\.([A-Z_0-9]+)/g)) used.add(m[1]);
|
|
}
|
|
// Set by node:test in its own child processes, never by a deployment.
|
|
used.delete('NODE_TEST_CONTEXT');
|
|
|
|
const documented = new Set(
|
|
[...read('.env.example').matchAll(/^#?\s*([A-Z_0-9]+)=/gm)].map(m => m[1]));
|
|
const missing = [...used].filter(name => !documented.has(name)).sort();
|
|
assert.deepEqual(missing, [], 'undocumented variables: ' + missing.join(', '));
|
|
});
|