Ctrl K
ComunidadGuías…

test/dashboard-credentials-route.test.js

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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);
    }
  });
});