Files
TREK/server/tests/unit/db/reset-admin-sessions.test.ts
T
Maurice be016f180e test(server): seed the session, lease and rollback specs through the factories
The specs written alongside the session, transaction and lifecycle work
still seeded and read with raw statements. They go through the ORM
factories now; the few that test SQL below the ORM carry a test-sql-allow
reason. The raw SQL baseline holds 177 statements in 6 files.
2026-10-08 19:05:04 +02:00

130 lines
4.6 KiB
TypeScript

/**
* reset-admin.js and the session rows.
*
* The recovery script resets an account an operator is locked out of. Its
* password_version bump already refuses every token of that account; it also
* ends the account's rows in `user_sessions`, so the session list shows none
* of them as live. A database no server tracking sessions has booted yet has
* no such table, and the script must not trip over that.
*
* The script is plain JS run with `node`, so it is run here the way an
* operator runs it, against a throwaway file holding only the columns it
* touches.
*/
import { execFileSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import Database from 'better-sqlite3';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
const SERVER_ROOT = path.resolve(__dirname, '../../..');
let tmpDir: string;
let dbPath: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-reset-admin-'));
dbPath = path.join(tmpDir, 'travel.db');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function seed(withSessions: boolean): void {
const db = new Database(dbPath);
db.exec(`
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE,
email TEXT UNIQUE,
password_hash TEXT,
role TEXT,
must_change_password INTEGER DEFAULT 0,
password_version INTEGER NOT NULL DEFAULT 0
);
INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES
(1, 'boss', 'boss@example.com', 'x', 'admin', 2),
(2, 'member', 'member@example.com', 'x', 'user', 0);
`);
if (withSessions) {
db.exec(`
CREATE TABLE user_sessions (
id TEXT PRIMARY KEY NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
revoked_at DATETIME,
user_agent TEXT
);
INSERT INTO user_sessions (id, user_id, expires_at, revoked_at) VALUES
('boss-laptop', 1, '2099-01-01 00:00:00', NULL),
('boss-phone', 1, '2099-01-01 00:00:00', NULL),
('boss-old', 1, '2099-01-01 00:00:00', '2020-01-01 00:00:00'),
('member-laptop', 2, '2099-01-01 00:00:00', NULL);
`);
}
db.close();
}
function resetAdmin(email: string): void {
execFileSync(process.execPath, ['reset-admin.js'], {
cwd: SERVER_ROOT,
env: { ...process.env, TREK_DB_FILE: dbPath, RESET_ADMIN_EMAIL: email, RESET_ADMIN_PASSWORD: 'Recovery12345!' },
stdio: 'pipe',
});
}
function read<T>(sql: string): T[] {
const db = new Database(dbPath);
try {
// test-sql-allow: reads the throwaway file reset-admin.js ran on, a hand-built two-table database no ORM is bound to.
return db.prepare(sql).all() as T[];
} finally {
db.close();
}
}
describe('reset-admin.js and user_sessions', () => {
it('RESETADMIN-SESS-001: ends every live session of the account it resets, and nobody else\'s', () => {
seed(true);
resetAdmin('Boss@Example.com');
const sessions = read<{ id: string; revoked_at: string | null }>('SELECT id, revoked_at FROM user_sessions ORDER BY id');
const byId = Object.fromEntries(sessions.map((s) => [s.id, s.revoked_at]));
expect(byId['boss-laptop']).not.toBeNull();
expect(byId['boss-phone']).not.toBeNull();
// An already ended session keeps the time it ended.
expect(byId['boss-old']).toBe('2020-01-01 00:00:00');
expect(byId['member-laptop']).toBeNull();
expect(read('SELECT password_version, must_change_password FROM users WHERE id = 1')).toEqual([
{ password_version: 3, must_change_password: 1 },
]);
}, 60000);
it('RESETADMIN-SESS-002: a database without the sessions table is reset all the same', () => {
seed(false);
resetAdmin('boss@example.com');
expect(read("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'user_sessions'")).toEqual([]);
expect(read('SELECT role, password_version, must_change_password FROM users WHERE id = 1')).toEqual([
{ role: 'admin', password_version: 3, must_change_password: 1 },
]);
}, 60000);
it('RESETADMIN-SESS-003: creating a new admin ends no existing session', () => {
seed(true);
resetAdmin('new-admin@example.com');
expect(read('SELECT COUNT(*) AS n FROM user_sessions WHERE revoked_at IS NULL')).toEqual([{ n: 3 }]);
expect(read("SELECT role, must_change_password FROM users WHERE email = 'new-admin@example.com'")).toEqual([
{ role: 'admin', must_change_password: 1 },
]);
}, 60000);
});