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(', ')); }); // registration_enabled is open-or-closed. Invite-only is the middle setting, // and it has to hold up against someone probing codes. test('an invitation is deletable only once it can no longer be used', () => { const src = read('src/utils/registrationInvites.js'); const route = read('src/routes/adminConfig.js'); // A code that could still be redeemed may be sitting in somebody's inbox. // Deleting the row takes it off the list without taking it out of their // hands: it quietly stops working and nothing is left to say who held it. // Revoke does that job and leaves the row behind, marked. assert.match(src, /var SPENT = '\(used_at IS NOT NULL OR \(revoked_at IS NULL AND expires_at <= NOW\(\)\)\)';/); assert.match(src, /DELETE FROM registration_invites WHERE id = \$1 AND ' \+ SPENT/); assert.match(src, /DELETE FROM registration_invites WHERE ' \+ SPENT/, 'and in bulk'); // Written to match the status the list shows: "used OR expired" would also // catch a revoked code past its date, which the screen still calls revoked // and offers no delete on. assert.match(src, /revoked_at IS NULL AND expires_at <= NOW\(\)/); // Refused with the reason, not as a missing row: the row is very likely there. assert.match(route, /That invitation can still be used\. Revoke it instead\./); assert.match(route, /res\.status\(409\)/); // The bulk route is declared before /invites/:id, or "spent" is read as an id. assert.ok(route.indexOf("router.delete('/invites/spent'") < route.indexOf("router.delete('/invites/:id'")); // And the button is only offered where it can work. const js = read('public/js/admin.js'); assert.match(js, /var SPENT_STATUS = \['used', 'expired'\];/); assert.match(js, /SPENT_STATUS\.indexOf\(row\.status\) !== -1\s*\n?\s*\? '