Files
sencho/backend/src/__tests__/recovery-cli.test.ts
T
Anso c6d1631afe feat(recovery): add safe-mode recovery surface and emergency CLI (#1286)
* feat(recovery): add safe-mode recovery surface and emergency CLI

Add a read-only Recovery tab under Settings (admin-only) backed by a new
GET /api/diagnostics endpoint reporting app version, database integrity,
encryption-key status, Docker reachability, account and SSO counts, and
non-secret configuration. The endpoint loads without Docker or live metrics
so it stays available when the dashboard does not, requires a genuine admin
session, and builds its config block from a non-secret allowlist so no
credentials are ever exposed.

Expand the emergency command-line toolkit beyond the two-factor reset with
seven host-level commands: reset-password, create-emergency-admin,
clear-sessions, disable-sso, diagnostics, validate-db, and backup-data. Each
prints its result, exits with a meaningful status code, and writes an audit
entry where it changes state.

Document the toolkit in a new operator guide and link it from the recovery
and two-factor pages.

* feat(recovery): download the emergency command reference as a text file

The recovery commands are needed exactly when the dashboard is unreachable,
so reading them only in-app is a chicken-and-egg problem. Add a Download
button to the command-line section that saves the full
`docker compose exec sencho ...` reference as a text file, letting operators
keep it on hand before they need it. Reuses a shared download helper with the
existing diagnostics export.

* fix(recovery): harden diagnostics, backup, and emergency-admin against edge cases

Address findings from an independent review of the recovery toolkit:

- DiagnosticsService now degrades instead of throwing when a queried table is
  missing or corrupt: each read falls back and is folded into database.ok, so a
  broken database reports "problem detected" rather than failing the whole
  endpoint or showing a misleading healthy state with zeroed counts.
- backup-data refuses a destination that resolves to the live database, which
  would otherwise report success while producing no separate copy.
- create-emergency-admin now applies the same username rule as the user-
  management route, extracted to a shared helper so both stay in sync.

Adds tests for a missing read table, a malformed emergency-admin username, and
the backup same-target rejection.
2026-06-02 16:11:24 -04:00

161 lines
6.6 KiB
TypeScript

/**
* Unit tests for the emergency recovery CLI command functions. Each command
* exports a testable function; we exercise it against a temporary seeded
* database and assert the database side effects and audit entries.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import bcrypt from 'bcrypt';
import fs from 'fs';
import path from 'path';
import { setupTestDb, cleanupTestDb, TEST_USERNAME } from './helpers/setupTestDb';
let tmpDir: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let resetPassword: typeof import('../cli/resetPassword').resetPassword;
let createEmergencyAdmin: typeof import('../cli/createEmergencyAdmin').createEmergencyAdmin;
let clearSessions: typeof import('../cli/clearSessions').clearSessions;
let disableSso: typeof import('../cli/disableSso').disableSso;
let validateDb: typeof import('../cli/validateDb').validateDb;
let backupData: typeof import('../cli/backupData').backupData;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ resetPassword } = await import('../cli/resetPassword'));
({ createEmergencyAdmin } = await import('../cli/createEmergencyAdmin'));
({ clearSessions } = await import('../cli/clearSessions'));
({ disableSso } = await import('../cli/disableSso'));
({ validateDb } = await import('../cli/validateDb'));
({ backupData } = await import('../cli/backupData'));
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
describe('resetPassword', () => {
it('rejects a missing user', async () => {
const result = await resetPassword('nobody-here', 'newpassword123');
expect(result.ok).toBe(false);
expect(result.message).toContain('User not found');
});
it('rejects a too-short password', async () => {
const result = await resetPassword(TEST_USERNAME, 'short');
expect(result.ok).toBe(false);
expect(result.message).toContain('at least');
});
it('refuses to reset a non-local (SSO) account', async () => {
const db = DatabaseService.getInstance();
db.addUser({ username: 'sso-user', password_hash: 'unused', role: 'viewer', auth_provider: 'oidc_custom' });
const result = await resetPassword('sso-user', 'a-valid-password');
expect(result.ok).toBe(false);
expect(result.message).toContain('local accounts only');
});
it('resets the password and invalidates existing sessions', async () => {
const db = DatabaseService.getInstance();
const before = db.getUserByUsername(TEST_USERNAME)!;
const result = await resetPassword(TEST_USERNAME, 'brand-new-pass');
expect(result.ok).toBe(true);
const after = db.getUserByUsername(TEST_USERNAME)!;
expect(after.password_hash).not.toBe(before.password_hash);
expect(await bcrypt.compare('brand-new-pass', after.password_hash)).toBe(true);
expect(after.token_version).toBe(before.token_version + 1);
});
});
describe('createEmergencyAdmin', () => {
it('creates a new admin', async () => {
const db = DatabaseService.getInstance();
const result = await createEmergencyAdmin('rescue-admin', 'rescue-pass-1');
expect(result.ok).toBe(true);
const user = db.getUserByUsername('rescue-admin')!;
expect(user.role).toBe('admin');
expect(user.auth_provider).toBe('local');
});
it('refuses to overwrite an existing user', async () => {
const result = await createEmergencyAdmin(TEST_USERNAME, 'whatever-pass');
expect(result.ok).toBe(false);
expect(result.message).toContain('already exists');
});
it('rejects a malformed username', async () => {
const result = await createEmergencyAdmin('has space', 'valid-pass-1');
expect(result.ok).toBe(false);
expect(result.message).toContain('letters, numbers');
});
});
describe('clearSessions', () => {
it('bumps the token version of every user', () => {
const db = DatabaseService.getInstance();
const users = db.getUsers();
const before = users.map(u => db.getUserByUsername(u.username)!.token_version);
const result = clearSessions();
expect(result.ok).toBe(true);
const after = db.getUsers().map(u => db.getUserByUsername(u.username)!.token_version);
after.forEach((v, i) => expect(v).toBe(before[i] + 1));
});
});
describe('disableSso', () => {
it('disables a named provider and preserves its config', () => {
const db = DatabaseService.getInstance();
db.upsertSSOConfig('oidc_custom', true, '{"clientId":"abc"}');
const result = disableSso('oidc_custom');
expect(result.ok).toBe(true);
const config = db.getSSOConfig('oidc_custom')!;
expect(config.enabled).toBe(0);
expect(config.config_json).toBe('{"clientId":"abc"}');
});
it('reports cleanly when a provider is unknown', () => {
const result = disableSso('oidc_google');
expect(result.ok).toBe(false);
expect(result.message).toContain('No SSO config');
});
it('disables every enabled provider when no argument is given', () => {
const db = DatabaseService.getInstance();
db.upsertSSOConfig('ldap', true, '{"url":"ldap://x"}');
db.upsertSSOConfig('oidc_okta', true, '{"domain":"x"}');
const result = disableSso();
expect(result.ok).toBe(true);
expect(db.getEnabledSSOConfigs()).toHaveLength(0);
});
});
describe('backupData', () => {
it('refuses a destination that overwrites the live database', async () => {
const result = await backupData(process.env.DATA_DIR);
expect(result.ok).toBe(false);
expect(result.message).toContain('overwrite the live database');
});
it('writes a copy to a separate destination', async () => {
const dest = path.join(process.env.DATA_DIR as string, 'cli-backup');
const result = await backupData(dest);
expect(result.ok).toBe(true);
expect(fs.existsSync(path.join(dest, 'sencho.db'))).toBe(true);
});
});
describe('validateDb', () => {
it('passes on a healthy baseline database', async () => {
const result = await validateDb();
expect(result.ok).toBe(true);
expect(result.message).toContain('Database OK');
});
// Destructive: dropping a core table must be the last test in this file.
it('fails and names a missing core table', async () => {
DatabaseService.getInstance().getDb().exec('DROP TABLE audit_log');
const result = await validateDb();
expect(result.ok).toBe(false);
expect(result.message).toContain('audit_log');
});
});