import Database from 'better-sqlite3'; import { createHash } from 'node:crypto'; import { spawnSync } from 'node:child_process'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { recoverAdministrator } from '../../scripts/reset-admin.mjs'; import { verifyPassword } from '../../src/lib/server/auth/passwords.js'; import { migrateAuthSchema } from '../../src/lib/server/auth/schema.js'; vi.setConfig({ testTimeout: 20_000 }); let database: Database.Database; const password = 'replacement administrator passphrase'; const digest = (value: string) => createHash('sha256').update(value).digest('hex'); const pairKey = (kind: string, account: string, ip: string) => `${kind}:account:${digest(account.toLowerCase())}:ip:${digest(ip)}`; beforeEach(() => { database = new Database(':memory:'); database.pragma('foreign_keys = ON'); migrateAuthSchema(database); database.exec(` INSERT INTO auth_users VALUES ('admin-id','Admin_One','Admin','original-hash','admin',0,1,10,20,NULL), ('other-id','Other_Admin','Other','other-hash','admin',0,0,10,20,NULL), ('viewer-id','Viewer','Viewer','viewer-hash','viewer',0,0,10,20,NULL), ('disabled-id','Disabled_Admin','Disabled','disabled-hash','admin',1,0,10,20,NULL); INSERT INTO auth_sessions VALUES ('admin-session','admin-id',10,1000,100), ('other-session','other-id',10,1000,100); `); }); afterEach(() => database.close()); function rateKeys() { return database.prepare('SELECT key FROM auth_rate_limits ORDER BY key').all(); } function addRates(keys: string[]) { const insert = database.prepare('INSERT INTO auth_rate_limits VALUES (?,100,30)'); for (const key of keys) insert.run(key); } function addDevices() { database.exec(` CREATE TABLE auth_login_devices ( token_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE, expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL ); INSERT INTO auth_login_devices VALUES ('admin-device','admin-id',1000,10), ('other-device','other-id',1000,10); `); } describe('local administrator recovery', () => { it('resets only the target administrator and clears their sessions, devices and account limits', async () => { addDevices(); const cleared = [ pairKey('login', 'admin_one', '192.0.2.1'), pairKey('login', 'admin_one', '192.0.2.2'), digest('login:account:admin_one'), digest('password:account:admin-id') ]; const preserved = [ pairKey('login', 'other_admin', '192.0.2.1'), pairKey('setup', 'admin_one', '192.0.2.1'), digest('login:account:other_admin'), digest('password:account:other-id'), digest('login:ip:192.0.2.1'), digest('password:ip:192.0.2.1'), digest('setup:ip:192.0.2.1') ]; addRates([...cleared, ...preserved]); const others = database.prepare("SELECT * FROM auth_users WHERE id!='admin-id'").all(); await recoverAdministrator(database, 'ADMIN_ONE', password); const user = database .prepare( "SELECT password_hash,must_change_password,updated_at FROM auth_users WHERE id='admin-id'" ) .get() as { password_hash: string; must_change_password: number; updated_at: number }; expect(await verifyPassword(password, user.password_hash)).toBe(true); expect(user.must_change_password).toBe(0); expect(user.updated_at).toBeGreaterThan(20); expect(database.prepare("SELECT * FROM auth_users WHERE id!='admin-id'").all()).toEqual( others ); expect(database.prepare('SELECT token_hash,user_id FROM auth_sessions').all()).toEqual([ { token_hash: 'other-session', user_id: 'other-id' } ]); expect(database.prepare('SELECT token_hash,user_id FROM auth_login_devices').all()).toEqual( [{ token_hash: 'other-device', user_id: 'other-id' }] ); expect(rateKeys()).toEqual(preserved.sort().map((key) => ({ key }))); expect( database.prepare('SELECT actor_id,action,target_id,details FROM auth_audit').all() ).toEqual([ { actor_id: null, action: 'password.recovered', target_id: 'admin-id', details: '{"source":"local-cli"}' } ]); }); it('supports older databases without trusted-device tables and clears their legacy account limits', async () => { addRates([digest('login:account:admin_one'), digest('password:account:admin-id')]); await recoverAdministrator(database, 'admin_one', password); expect(rateKeys()).toEqual([]); expect( database.prepare("SELECT * FROM auth_sessions WHERE user_id='admin-id'").all() ).toEqual([]); expect( database.prepare("SELECT name FROM sqlite_master WHERE name='auth_login_devices'").get() ).toBeUndefined(); }); it('rejects disabled, non-administrator and missing accounts without making changes', async () => { const users = database.prepare('SELECT * FROM auth_users').all(); const sessions = database.prepare('SELECT * FROM auth_sessions').all(); for (const username of ['disabled_admin', 'viewer', 'missing']) { await expect(recoverAdministrator(database, username, password)).rejects.toThrow( 'An enabled administrator with that username was not found.' ); } expect(database.prepare('SELECT * FROM auth_users').all()).toEqual(users); expect(database.prepare('SELECT * FROM auth_sessions').all()).toEqual(sessions); expect(database.prepare('SELECT * FROM auth_audit').all()).toEqual([]); }); it('rolls back the password, session, device and rate changes when recovery cannot be audited', async () => { addDevices(); addRates([pairKey('login', 'admin_one', '192.0.2.1')]); database.exec(` CREATE TRIGGER reject_recovery BEFORE INSERT ON auth_audit BEGIN SELECT RAISE(ABORT, 'audit unavailable'); END; `); const tables = ['auth_users', 'auth_sessions', 'auth_login_devices', 'auth_rate_limits']; const before = tables.map((table) => database.prepare(`SELECT * FROM ${table}`).all()); await expect(recoverAdministrator(database, 'admin_one', password)).rejects.toThrow( 'audit unavailable' ); expect(tables.map((table) => database.prepare(`SELECT * FROM ${table}`).all())).toEqual( before ); expect(database.prepare('SELECT * FROM auth_audit').all()).toEqual([]); }); it('keeps the command restricted to interactive password entry', () => { const command = spawnSync(process.execPath, ['scripts/reset-admin.mjs', 'admin_one'], { encoding: 'utf8', input: password }); expect(command.status).toBe(1); expect(command.stderr).toContain('Run this command in an interactive terminal.'); expect(command.stdout).not.toContain(password); const argument = spawnSync( process.execPath, ['scripts/reset-admin.mjs', 'admin_one', password], { encoding: 'utf8' } ); expect(argument.status).toBe(1); expect(argument.stderr).toContain('Usage: node scripts/reset-admin.mjs'); expect(argument.stderr).not.toContain(password); }); });