BillTracker/tests/authService.test.js

106 lines
5.6 KiB
JavaScript

'use strict';
// Phase 1 — authService is the untested auth core: password verification, session
// storage, rotation, and invalidation. These pin the security invariants that a
// future refactor must not silently break: passwords round-trip through bcrypt,
// sessions are stored HASHED (never the raw token), the session lifecycle
// (create/lookup/expire/rotate/invalidate/prune) behaves, and rotation enforces
// ownership.
const test = require('node:test');
const assert = require('node:assert/strict');
const os = require('node:os');
const path = require('node:path');
const fs = require('node:fs');
const crypto = require('node:crypto');
const dbPath = path.join(os.tmpdir(), `bill-tracker-authsvc-${process.pid}.sqlite`);
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database');
const auth = require('../services/authService.cts');
const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
let db, userId, otherUserId;
test.before(async () => {
db = getDb();
const hash = await auth.hashPassword('correct-horse-battery');
userId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('alice', ?, 'user', 1)").run(hash).lastInsertRowid;
otherUserId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('bob', 'x', 'user', 1)").run().lastInsertRowid;
});
test.after(() => {
closeDb();
for (const s of ['', '-wal', '-shm']) { try { fs.rmSync(dbPath + s); } catch {} }
});
test('hashPassword + login: correct password authenticates, wrong password fails, unknown user is null', async () => {
const ok = await auth.login('alice', 'correct-horse-battery');
assert.ok(ok?.sessionId, 'correct password yields a session');
assert.equal(ok.user.id, userId);
const bad = await auth.login('alice', 'wrong');
assert.deepEqual({ error: bad?.error, userId: bad?.userId }, { error: 'bad_password', userId }, 'wrong password → bad_password + userId (for lockout logging)');
assert.equal(await auth.login('nobody', 'whatever'), null, 'unknown user → null (no enumeration)');
});
test('sessions are stored HASHED, never as the raw token', async () => {
const { sessionId } = await auth.createSession(userId);
const hashedRow = db.prepare('SELECT id FROM sessions WHERE id = ?').get(sha256(sessionId));
const rawRow = db.prepare('SELECT id FROM sessions WHERE id = ?').get(sessionId);
assert.ok(hashedRow, 'the DB stores sha256(sessionId)');
assert.equal(rawRow, undefined, 'the raw session token is never persisted');
});
test('getSessionUser: valid → user, bogus → null, expired → null', async () => {
const { sessionId } = await auth.createSession(userId);
assert.equal(auth.getSessionUser(sessionId)?.id, userId);
assert.equal(auth.getSessionUser('bogus-token'), null);
db.prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)')
.run(sha256('expired-raw'), userId, '2000-01-01 00:00:00');
assert.equal(auth.getSessionUser('expired-raw'), null, 'an expired session is not honoured');
});
test('rotateSessionId: old dies, new lives, and ownership is enforced', async () => {
const { sessionId: oldId } = await auth.createSession(userId);
const newId = auth.rotateSessionId(oldId, userId);
assert.ok(newId && newId !== oldId, 'a fresh session id is issued');
assert.equal(auth.getSessionUser(oldId), null, 'the old session is invalidated');
assert.equal(auth.getSessionUser(newId)?.id, userId, 'the new session is valid');
// Ownership: cannot rotate a session that isn't yours, and a dead session can't rotate.
const { sessionId: victimId } = await auth.createSession(userId);
assert.equal(auth.rotateSessionId(victimId, otherUserId), null, 'rotation requires the session to belong to the user');
assert.equal(auth.rotateSessionId(oldId, userId), null, 'an already-invalidated session cannot be rotated');
});
test('invalidateOtherSessions keeps the current session and kills the rest', async () => {
// Fresh user so other tests\' sessions don\'t interfere with the count.
const uid = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('carol', 'x', 'user', 1)").run().lastInsertRowid;
const a = await auth.createSession(uid);
const b = await auth.createSession(uid);
auth.invalidateOtherSessions(uid, a.sessionId);
assert.equal(auth.getSessionUser(a.sessionId)?.id, uid, 'kept session survives');
assert.equal(auth.getSessionUser(b.sessionId), null, 'other sessions are invalidated');
});
test('pruneExpiredSessions removes only expired rows', async () => {
const uid = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('dave', 'x', 'user', 1)").run().lastInsertRowid;
const { sessionId: live } = await auth.createSession(uid);
db.prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)')
.run(sha256('dead-raw'), uid, '2000-01-01 00:00:00');
auth.pruneExpiredSessions();
assert.equal(auth.getSessionUser(live)?.id, uid, 'live session survives the prune');
assert.equal(db.prepare('SELECT id FROM sessions WHERE id = ?').get(sha256('dead-raw')), undefined, 'expired session pruned');
});
test('recordFailedLogin writes a success=0 history row for the known account', () => {
const uid = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('erin', 'x', 'user', 1)").run().lastInsertRowid;
auth.recordFailedLogin(uid, '203.0.113.7', 'Mozilla/5.0 test');
const row = db.prepare('SELECT success FROM user_login_history WHERE user_id = ? ORDER BY id DESC LIMIT 1').get(uid);
assert.equal(row?.success, 0, 'failed attempt recorded with success=0');
auth.recordFailedLogin(null, '203.0.113.7', 'ua'); // no-op on missing userId, must not throw
});