pediatric-ai-scribe-v3/test/backend-hardening.test.js
Daniel f89dc01729 refactor: one place decides which bucket, on which S3, with which credentials
Three S3 configurations had grown separately — S3_* for documents,
GENERATED_IMAGES_S3_* for images, and AUDIO_BACKUPS_S3_* after them — with
different key names and their own client construction. That is why moving
storage meant hunting through several files.

src/utils/objectStorage.js now resolves settings for any purpose: its own
variables first, then the shared S3_* ones, with a per-purpose bucket name
(S3_BUCKET_AUDIO_BACKUPS). One endpoint plus three bucket names is enough
for the whole app, and a purpose that needs its own account still overrides
everything. Audio backups and documents use it; generated images keeps its
own tested storage module, whose variable names the resolver already
understands.

Nothing existing has to change: S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY and
the AWS_* fallbacks still resolve, and path-style addressing keeps each
purpose's previous default — off for documents, so a Backblaze endpoint
behaves as before, on where a custom endpoint implies MinIO. A _FILE
credential now always beats an inline one, so a mounted secret cannot be
shadowed by an inherited environment variable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-10 17:09:13 +02:00

128 lines
6.8 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');
});