'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.cts'); 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 });