mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-23 16:39:18 +00:00
217 lines
9.3 KiB
TypeScript
217 lines
9.3 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 enableLocalLogin: typeof import('../cli/enableLocalLogin').enableLocalLogin;
|
|
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'));
|
|
({ enableLocalLogin } = await import('../cli/enableLocalLogin'));
|
|
({ 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);
|
|
});
|
|
|
|
it('rejects disabling the last provider while sso_only', () => {
|
|
const db = DatabaseService.getInstance();
|
|
db.updateGlobalSetting('authentication_mode', 'sso_only');
|
|
db.upsertSSOConfig('oidc_custom', true, '{"clientId":"abc"}');
|
|
for (const cfg of db.getEnabledSSOConfigs()) {
|
|
if (cfg.provider !== 'oidc_custom') {
|
|
db.upsertSSOConfig(cfg.provider, false, cfg.config_json);
|
|
}
|
|
}
|
|
const result = disableSso('oidc_custom');
|
|
expect(result.ok).toBe(false);
|
|
expect(result.message).toMatch(/last SSO provider/i);
|
|
expect(db.getEnabledSSOConfigs()).toHaveLength(1);
|
|
expect(db.getGlobalSettings().authentication_mode).toBe('sso_only');
|
|
});
|
|
|
|
it('preserves sso_only when disabling one of several providers', () => {
|
|
const db = DatabaseService.getInstance();
|
|
db.updateGlobalSetting('authentication_mode', 'sso_only');
|
|
for (const cfg of db.getEnabledSSOConfigs()) {
|
|
db.upsertSSOConfig(cfg.provider, false, cfg.config_json);
|
|
}
|
|
db.upsertSSOConfig('oidc_google', true, '{"clientId":"g"}');
|
|
db.upsertSSOConfig('oidc_github', true, '{"clientId":"h"}');
|
|
const result = disableSso('oidc_google');
|
|
expect(result.ok).toBe(true);
|
|
expect(result.message).toMatch(/remains SSO only/i);
|
|
expect(db.getGlobalSettings().authentication_mode).toBe('sso_only');
|
|
const remaining = db.getEnabledSSOConfigs().map(c => c.provider).sort();
|
|
expect(remaining).toEqual(['oidc_github']);
|
|
});
|
|
|
|
it('restores local_and_sso before disabling all providers under sso_only', () => {
|
|
const db = DatabaseService.getInstance();
|
|
db.updateGlobalSetting('authentication_mode', 'sso_only');
|
|
db.upsertSSOConfig('oidc_custom', true, '{"clientId":"abc"}');
|
|
const result = disableSso();
|
|
expect(result.ok).toBe(true);
|
|
expect(result.message).toMatch(/no restart required/i);
|
|
expect(db.getGlobalSettings().authentication_mode).toBe('local_and_sso');
|
|
expect(db.getEnabledSSOConfigs()).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('enableLocalLogin', () => {
|
|
it('sets authentication_mode to local_and_sso', () => {
|
|
const db = DatabaseService.getInstance();
|
|
db.updateGlobalSetting('authentication_mode', 'sso_only');
|
|
const result = enableLocalLogin();
|
|
expect(result.ok).toBe(true);
|
|
expect(result.message).toMatch(/no restart is required/i);
|
|
expect(db.getGlobalSettings().authentication_mode).toBe('local_and_sso');
|
|
});
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|