mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 01:43:55 +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.
84 lines
3.7 KiB
TypeScript
84 lines
3.7 KiB
TypeScript
/**
|
|
* Unit tests for DiagnosticsService.collectDiagnostics: the shape of the
|
|
* report, secret redaction (allowlist), Docker probe handling, and core-table
|
|
* detection. Shared by the /api/diagnostics route and the diagnostics/validate
|
|
* CLI commands, so these guarantees protect all three.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
|
|
|
let tmpDir: string;
|
|
let collectDiagnostics: typeof import('../services/DiagnosticsService').collectDiagnostics;
|
|
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ collectDiagnostics } = await import('../services/DiagnosticsService'));
|
|
({ DatabaseService } = await import('../services/DatabaseService'));
|
|
});
|
|
|
|
afterAll(() => {
|
|
cleanupTestDb(tmpDir);
|
|
});
|
|
|
|
describe('collectDiagnostics', () => {
|
|
it('reports a healthy baseline database with at least one admin', async () => {
|
|
const report = await collectDiagnostics();
|
|
expect(report.database.ok).toBe(true);
|
|
expect(report.database.integrity).toBe('ok');
|
|
expect(report.database.missingTables).toEqual([]);
|
|
expect(report.auth.adminCount).toBeGreaterThanOrEqual(1);
|
|
expect(report.encryptionKey).toEqual({ present: true, valid: true });
|
|
});
|
|
|
|
it('reports docker unreachable when no probe is supplied', async () => {
|
|
const report = await collectDiagnostics();
|
|
expect(report.docker.reachable).toBe(false);
|
|
});
|
|
|
|
it('reflects a passed docker probe', async () => {
|
|
const reachable = await collectDiagnostics({ checkDocker: async () => true });
|
|
expect(reachable.docker.reachable).toBe(true);
|
|
const down = await collectDiagnostics({
|
|
checkDocker: async () => { throw new Error('socket closed'); },
|
|
});
|
|
expect(down.docker.reachable).toBe(false);
|
|
expect(down.docker.error).toBe('socket closed');
|
|
});
|
|
|
|
it('never surfaces secret settings in the config block', async () => {
|
|
const db = DatabaseService.getInstance();
|
|
db.updateGlobalSetting('auth_jwt_secret', 'top-secret-signing-key');
|
|
db.updateGlobalSetting('cloud_backup_secret_key', 'enc:deadbeef');
|
|
db.updateGlobalSetting('host_cpu_limit', '80');
|
|
|
|
const report = await collectDiagnostics();
|
|
|
|
expect(report.config.auth_jwt_secret).toBeUndefined();
|
|
expect(report.config.cloud_backup_secret_key).toBeUndefined();
|
|
// The allowlisted, non-secret value is present.
|
|
expect(report.config.host_cpu_limit).toBe('80');
|
|
// No emitted value is one of the seeded secrets, regardless of key name.
|
|
expect(Object.values(report.config)).not.toContain('top-secret-signing-key');
|
|
expect(Object.values(report.config)).not.toContain('enc:deadbeef');
|
|
});
|
|
|
|
// Destructive: these table drops must run last in this file.
|
|
it('degrades instead of throwing when a read table is missing', async () => {
|
|
// sso_config is read by collectDiagnostics; dropping it must not throw,
|
|
// it must flag the table and fall back to an empty provider list.
|
|
DatabaseService.getInstance().getDb().exec('DROP TABLE sso_config');
|
|
const report = await collectDiagnostics();
|
|
expect(report.database.missingTables).toContain('sso_config');
|
|
expect(report.database.ok).toBe(false);
|
|
expect(report.auth.ssoProviders).toEqual([]);
|
|
});
|
|
|
|
it('flags a missing core table', async () => {
|
|
DatabaseService.getInstance().getDb().exec('DROP TABLE audit_log');
|
|
const report = await collectDiagnostics();
|
|
expect(report.database.missingTables).toContain('audit_log');
|
|
expect(report.database.ok).toBe(false);
|
|
});
|
|
});
|