mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +00:00
c6d1631afe
* 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.
161 lines
6.6 KiB
TypeScript
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');
|
|
});
|
|
});
|