import assert from 'node:assert/strict'; import type { AddressInfo } from 'node:net'; import test from 'node:test'; import bcrypt from 'bcryptjs'; import express from 'express'; import { createAuthRouter } from './auth.js'; import { errorHandler } from '../http/errors.js'; type QueryFn = NonNullable[0]>['query']; type JsonBody = { code: number; message: string; data: any; }; interface UserRow { id: string; username: string; password_hash: string; full_name: string; email: string | null; department: string | null; must_change_password: boolean; enabled: boolean; locked: boolean; roles: string[]; } function queryResult(rows: T[]) { return { command: 'SELECT', rowCount: rows.length, oid: 0, fields: [], rows, }; } function buildApp(query: QueryFn, sessionData: Record = {}) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { req.session = { ...sessionData, destroy(callback: (error?: Error) => void) { callback(); }, } as typeof req.session; next(); }); app.use('/auth', createAuthRouter({ query })); app.use(errorHandler); return app; } async function requestJson( app: ReturnType, method: string, path: string, body?: unknown, ) { const server = app.listen(0); try { const address = server.address() as AddressInfo; const response = await fetch(`http://127.0.0.1:${address.port}${path}`, { method, headers: body === undefined ? undefined : { 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), }); return { status: response.status, body: await response.json() as JsonBody, }; } finally { await new Promise((resolve, reject) => { server.close((error) => { if (error) reject(error); else resolve(); }); }); } } function makeQuery(user: UserRow, calls: Array<{ text: string; params?: unknown[] }>): QueryFn { return (async (text, params) => { const sql = String(text); calls.push({ text: sql, params }); if (sql.includes('WHERE u.username = $1')) { return queryResult(params?.[0] === user.username ? [user] : []); } if (sql.includes('WHERE u.id = $1')) { return queryResult(Number(params?.[0]) === Number(user.id) ? [user] : []); } if (sql.includes('UPDATE users')) { return queryResult([]); } throw new Error(`Unexpected query: ${sql}`); }) as QueryFn; } test('login trims username and returns the current user shape', async () => { const user: UserRow = { id: '1', username: 'a', password_hash: await bcrypt.hash('1', 4), full_name: 'ACS Admin', email: 'admin@example.test', department: 'IT', must_change_password: false, enabled: true, locked: false, roles: ['ADMIN'], }; const calls: Array<{ text: string; params?: unknown[] }> = []; const app = buildApp(makeQuery(user, calls)); const response = await requestJson(app, 'POST', '/auth/login', { username: ' a ', password: '1', }); assert.equal(response.status, 200); assert.equal(response.body.data.username, 'a'); assert.equal(response.body.data.fullName, 'ACS Admin'); assert.deepEqual(response.body.data.roles, ['ADMIN']); assert.deepEqual(calls[0]?.params, ['a']); }); test('login rejects disabled accounts even with a valid password', async () => { const user: UserRow = { id: '2', username: 'disabled', password_hash: await bcrypt.hash('1', 4), full_name: 'Disabled User', email: null, department: null, must_change_password: false, enabled: false, locked: false, roles: ['HOST'], }; const app = buildApp(makeQuery(user, [])); const response = await requestJson(app, 'POST', '/auth/login', { username: 'disabled', password: '1', }); assert.equal(response.status, 401); assert.equal(response.body.data, null); }); test('me returns 401 without a session user', async () => { const app = buildApp((async () => { throw new Error('query should not run for unauthenticated /me'); }) as QueryFn); const response = await requestJson(app, 'GET', '/auth/me'); assert.equal(response.status, 401); assert.equal(response.body.data, null); }); test('change-password updates the password hash and clears must_change_password', async () => { const user: UserRow = { id: '3', username: 'host', password_hash: await bcrypt.hash('OldPassword123!', 4), full_name: 'Host User', email: null, department: null, must_change_password: true, enabled: true, locked: false, roles: ['HOST'], }; const calls: Array<{ text: string; params?: unknown[] }> = []; const app = buildApp(makeQuery(user, calls), { userId: 3 }); const response = await requestJson(app, 'POST', '/auth/change-password', { oldPassword: 'OldPassword123!', newPassword: 'NewPassword123!', }); assert.equal(response.status, 200); const updateCall = calls.find((call) => call.text.includes('UPDATE users')); assert.ok(updateCall); assert.equal(updateCall.params?.[1], 3); assert.equal(await bcrypt.compare('NewPassword123!', String(updateCall.params?.[0])), true); assert.notEqual(updateCall.params?.[0], user.password_hash); });