Files
TREK/server/tests/unit/db/reset-admin-sessions.test.ts
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00

132 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 Database from 'better-sqlite3';
import { execFileSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
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);
});