Files
2026-10-17 10:55:28 -04:00

176 lines
7.6 KiB
TypeScript

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);
});
});