mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
e94525afd3
The session e2e suite drove login, logout, the session routes, a password change and renewal, but nothing showed end to end that a password reset, an MFA disable, account deletion or an admin action makes the old cookie stop working, the purge job had only a unit test with a stubbed service, and reset-admin.js had no test of its session handling at all. sessions.e2e.test.ts now also resets a password through the mailed token (both devices get 401, every row is ended, the new password signs in), turns two-factor off from one device (the other gets 401, this one stays), deletes the account (the rows go with it) and runs SessionPurgeJob.tick against the real table (ended and expired rows go, the live session keeps working). The admin paths need AdminModule and with it most of the app, so session-revocation.e2e.test.ts boots buildApp(): an admin setting a password, clearing two-factor and deleting a member each sign both of the member's devices out and leave the admin signed in, while an edit that leaves the password alone signs nobody out. reset-admin-sessions.test.ts runs the script against a database with session rows (only the reset account's live ones end) and against one without the table.
129 lines
4.5 KiB
TypeScript
129 lines
4.5 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 {
|
|
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);
|
|
});
|