196 lines
5.3 KiB
TypeScript
196 lines
5.3 KiB
TypeScript
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<Parameters<typeof createAuthRouter>[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<T>(rows: T[]) {
|
|
return {
|
|
command: 'SELECT',
|
|
rowCount: rows.length,
|
|
oid: 0,
|
|
fields: [],
|
|
rows,
|
|
};
|
|
}
|
|
|
|
function buildApp(query: QueryFn, sessionData: Record<string, unknown> = {}) {
|
|
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<typeof buildApp>,
|
|
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<void>((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);
|
|
});
|