registration_enabled was a single switch: open to anyone, or closed to everyone. This adds the setting an operator actually wants in between — open to people you invited. A code is single-use, expires (7 days by default, 90 maximum), and can be revoked or deleted. It is stored hashed with only its last four characters kept, because an invite grants account creation and a database dump should not hand someone a working one. The code is readable exactly once, in the response that creates it. The claim is a single conditional UPDATE carrying every condition, so two registrations racing the same code cannot both succeed. It happens after the account exists, so a code is never spent on a failed registration — and if the race is lost, the just-created account is removed rather than left behind as a free registration. The rejection never says which of the four reasons applied; distinguishing them would tell someone probing codes which guesses were closer. Codes avoid I, L, O and U so they survive being read aloud or copied off a screen, and matching ignores case and separators. The sign-up field appears only when the server says a code is required. The admin card creates, lists, revokes and deletes, and carries the toggle. Verified against the live database: create, claim, second claim refused, unknown code refused, revoking a used code refused, delete. 684 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
194 lines
10 KiB
JavaScript
194 lines
10 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(', '));
|
|
});
|
|
|
|
// registration_enabled is open-or-closed. Invite-only is the middle setting,
|
|
// and it has to hold up against someone probing codes.
|
|
test('registration invites are single-use, expiring, and safe to store', () => {
|
|
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 invites = require('../src/utils/registrationInvites');
|
|
const migration = read('migrations/1780100000000_registration-invites.js');
|
|
const auth = read('src/routes/auth.js');
|
|
|
|
// A code must not be recoverable from a database dump.
|
|
assert.match(migration, /code_hash TEXT NOT NULL UNIQUE/);
|
|
assert.doesNotMatch(migration, /\bcode TEXT\b/, 'the code itself is never stored');
|
|
assert.equal(invites.hash('abcd-efgh'), invites.hash('ABCDEFGH'), 'hashing normalises case and separators');
|
|
assert.notEqual(invites.hash('a'), 'a');
|
|
|
|
// Generated codes avoid characters that are misread when typed from a screen.
|
|
const code = invites.generateCode();
|
|
assert.match(code, /^[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}$/);
|
|
assert.doesNotMatch(code, /[ILOU]/, 'no I, L, O or U');
|
|
assert.notEqual(invites.generateCode(), invites.generateCode());
|
|
|
|
// Expiry is bounded, and a nonsense value falls back rather than throwing.
|
|
assert.equal(invites.ttlDays(undefined), invites.DEFAULT_TTL_DAYS);
|
|
assert.equal(invites.ttlDays(0), invites.DEFAULT_TTL_DAYS);
|
|
assert.equal(invites.ttlDays('nonsense'), invites.DEFAULT_TTL_DAYS);
|
|
assert.equal(invites.ttlDays(10000), invites.MAX_TTL_DAYS);
|
|
|
|
// The claim is one conditional UPDATE, so two registrations racing the same
|
|
// code cannot both succeed.
|
|
const claim = read('src/utils/registrationInvites.js');
|
|
assert.match(claim, /UPDATE registration_invites SET used_at = NOW\(\), used_by = \$1 /);
|
|
assert.match(claim, /WHERE code_hash = \$2 AND used_at IS NULL AND revoked_at IS NULL AND expires_at > NOW\(\)/);
|
|
assert.match(claim, /RETURNING id/);
|
|
|
|
// Losing that race must not leave a free account behind.
|
|
assert.match(auth, /if \(!claimedInvite\) \{[\s\S]{0,120}DELETE FROM users WHERE id = \?/);
|
|
// And the rejection must not say which of the four reasons applied.
|
|
assert.match(auth, /may have expired, been revoked, or already been used/);
|
|
});
|