test/dashboard-credentials-route.test.js
Rama master · réplica de lectura del repositorio original.
// SEC-04 follow-up (3.10.0): PUT /settings/credentials accepted an
// all-whitespace dashboard password. The length gate ran on the raw string, so
// " " (8 spaces) passed `pw.length < 8`, was scrypt-hashed, and became a
// password no operator can reproduce: the login header path trims and rejects
// blanks (SEC-01), so the account was left with a credential that can never
// authenticate and silently replaced the env fallback.
//
// The route now trims first, rejects a non-empty input that is blank after
// trimming, and stores the trimmed value. An empty string keeps its documented
// "clear the runtime override" meaning — that is how the dashboard offers
// "fall back to .env".
import { afterEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { config } from '../src/config.js';
import { handleDashboardApi } from '../src/dashboard/api.js';
import {
__setRuntimeConfigPersistFailureForTests,
_resetRuntimeConfigForTests,
getCredentials,
getEffectiveDashboardPasswordStored,
setRuntimeApiKey,
setRuntimeDashboardPassword,
verifyPassword,
} from '../src/runtime-config.js';
const originalDashboardPassword = config.dashboardPassword;
function fakeRes() {
return {
statusCode: 0,
headers: {},
body: '',
writeHead(status, headers) { this.statusCode = status; this.headers = headers || {}; },
end(chunk) { this.body += chunk ? String(chunk) : ''; },
json() { return this.body ? JSON.parse(this.body) : null; },
};
}
function authedReq(password = 'operator-secret') {
return {
url: '/dashboard/api/settings/credentials',
headers: { 'x-dashboard-password': password },
socket: { remoteAddress: '127.0.0.1' },
};
}
afterEach(() => {
_resetRuntimeConfigForTests();
config.dashboardPassword = originalDashboardPassword;
});
describe('PUT /settings/credentials dashboardPassword validation', () => {
it('rejects an 8-space password instead of hashing it', async () => {
config.dashboardPassword = 'operator-secret';
const res = fakeRes();
await handleDashboardApi('PUT', '/settings/credentials', { dashboardPassword: ' ' }, authedReq(), res);
assert.equal(res.statusCode, 400, JSON.stringify(res.json()));
assert.match(res.json().error, /dashboardPassword/);
assert.equal(
getEffectiveDashboardPasswordStored(),
'operator-secret',
'the env fallback must remain in force after a rejected rotation'
);
});
it('rejects a password shorter than 8 characters after trimming', async () => {
config.dashboardPassword = 'operator-secret';
const res = fakeRes();
await handleDashboardApi('PUT', '/settings/credentials', { dashboardPassword: ' short ' }, authedReq(), res);
assert.equal(res.statusCode, 400, JSON.stringify(res.json()));
assert.equal(getEffectiveDashboardPasswordStored(), 'operator-secret');
});
it('accepts a normal password, stores the trimmed value, and returns 200', async () => {
config.dashboardPassword = 'operator-secret';
const res = fakeRes();
await handleDashboardApi('PUT', '/settings/credentials', { dashboardPassword: ' goodpassword ' }, authedReq(), res);
assert.equal(res.statusCode, 200, JSON.stringify(res.json()));
assert.equal(res.json().dashboardPasswordUpdated, true);
const stored = getEffectiveDashboardPasswordStored();
assert.ok(stored.startsWith('scrypt$'), `expected scrypt hash, got ${stored.slice(0, 16)}`);
assert.equal(verifyPassword('goodpassword', stored), true);
assert.equal(verifyPassword(' goodpassword ', stored), false, 'the untrimmed form must not authenticate');
});
it('still treats an empty string as "clear the runtime override"', async () => {
config.dashboardPassword = 'operator-secret';
setRuntimeDashboardPassword('runtime-password');
const res = fakeRes();
// The runtime hash now overrides the env password, so authenticate with it.
await handleDashboardApi('PUT', '/settings/credentials', { dashboardPassword: '' }, authedReq('runtime-password'), res);
assert.equal(res.statusCode, 200, JSON.stringify(res.json()));
assert.equal(getEffectiveDashboardPasswordStored(), 'operator-secret');
});
});
// V-21 (audit A22): the route validated and COMMITTED `apiKey` before it looked
// at `dashboardPassword`, so a request that ends in 400 had already rotated the
// chat key on disk. The two credentials now form one transaction: the complete
// request is validated first, then both setters commit together (or neither
// does). A rejected password must leave the chat key exactly as it was.
describe('PUT /settings/credentials commits both credentials atomically', () => {
it('a rejected dashboardPassword does not rotate the runtime chat apiKey', async () => {
config.dashboardPassword = 'operator-secret';
setRuntimeApiKey('sk-existing-key');
assert.equal(getCredentials().apiKey, 'sk-existing-key');
const res = fakeRes();
await handleDashboardApi('PUT', '/settings/credentials', {
apiKey: 'sk-replace-key',
dashboardPassword: 'short',
}, authedReq(), res);
assert.equal(res.statusCode, 400, JSON.stringify(res.json()));
assert.match(res.json().error, /dashboardPassword/);
assert.equal(
getCredentials().apiKey,
'sk-existing-key',
'a request rejected for its password must not rotate the chat apiKey',
);
const onDisk = JSON.parse(readFileSync(join(config.dataDir, 'runtime-config.json'), 'utf-8'));
assert.equal(onDisk.credentials.apiKey, 'sk-existing-key', 'the rejected key must not reach the file');
assert.equal(res.json().apiKeyUpdated, undefined, 'a rejected request must not claim a rotation');
});
it('a rejected apiKey does not rotate the dashboard password', async () => {
config.dashboardPassword = 'operator-secret';
const before = getEffectiveDashboardPasswordStored();
const res = fakeRes();
await handleDashboardApi('PUT', '/settings/credentials', {
apiKey: 'has whitespace',
dashboardPassword: 'replacement-password',
}, authedReq(), res);
assert.equal(res.statusCode, 400, JSON.stringify(res.json()));
assert.match(res.json().error, /apiKey/);
assert.equal(getEffectiveDashboardPasswordStored(), before, 'the rejected request must not rotate the password');
});
it('a fully valid request commits both credentials in one write', async () => {
config.dashboardPassword = 'operator-secret';
setRuntimeApiKey('sk-existing-key');
const res = fakeRes();
await handleDashboardApi('PUT', '/settings/credentials', {
apiKey: 'sk-both-key',
dashboardPassword: 'both-applied-password',
}, authedReq(), res);
assert.equal(res.statusCode, 200, JSON.stringify(res.json()));
assert.equal(getCredentials().apiKey, 'sk-both-key');
assert.equal(verifyPassword('both-applied-password', getEffectiveDashboardPasswordStored()), true);
// Both fields in the SAME persisted document: a crash between two setters
// could otherwise leave a rotated key next to the old password hash.
const onDisk = JSON.parse(readFileSync(join(config.dataDir, 'runtime-config.json'), 'utf-8'));
assert.equal(onDisk.credentials.apiKey, 'sk-both-key');
assert.equal(onDisk.credentials.dashboardPasswordHash, getCredentials().dashboardPasswordHash);
});
it('a failed write rolls both fields back and reports 500', async () => {
config.dashboardPassword = 'operator-secret';
setRuntimeApiKey('sk-existing-key');
const before = getCredentials();
__setRuntimeConfigPersistFailureForTests(new Error('simulated ENOSPC'));
try {
const res = fakeRes();
await handleDashboardApi('PUT', '/settings/credentials', {
apiKey: 'sk-rollback-key',
dashboardPassword: 'should-not-stick-either',
}, authedReq(), res);
assert.equal(res.statusCode, 500, JSON.stringify(res.json()));
assert.equal(res.json().error, 'ERR_CREDENTIAL_PERSIST_FAILED');
assert.deepEqual(getCredentials(), before, 'neither credential may remain applied after a failed write');
assert.equal(verifyPassword('operator-secret', getEffectiveDashboardPasswordStored()), true);
assert.equal(verifyPassword('should-not-stick-either', getEffectiveDashboardPasswordStored()), false);
} finally {
__setRuntimeConfigPersistFailureForTests(null);
}
});
});