Login, forced password change, admin layout with every 05 §2 page in the sidebar (unbuilt ones name their milestone), Users table/cards + drawer (General tab), one-time temporary passwords, reset password. The last active admin can't be demoted, deactivated or deleted. Audit diffs only mask secret strings. scripts/e2e has a dependency-free CDP browser check for this task's acceptance flow. Task: M1-T4
126 lines
4.9 KiB
TypeScript
126 lines
4.9 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// M1-T4 acceptance (API side): admins create/edit/delete users and every change is audited.
|
|
import { asc } from 'drizzle-orm';
|
|
import type { LightMyRequestResponse } from 'fastify';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import { auditLog } from '../src/db/schema.ts';
|
|
import { hasTestDb } from './db.ts';
|
|
import { ADMIN, H, createTestApp } from './app.ts';
|
|
|
|
describe.skipIf(!hasTestDb)('users API', () => {
|
|
let t: Awaited<ReturnType<typeof createTestApp>>;
|
|
let admin: string;
|
|
let req: (
|
|
method: string,
|
|
url: string,
|
|
payload?: unknown,
|
|
cookie?: string,
|
|
) => Promise<LightMyRequestResponse>;
|
|
beforeAll(async () => {
|
|
t = await createTestApp();
|
|
await t.clearMustChange(ADMIN.email);
|
|
admin = await t.login(ADMIN.email, ADMIN.password);
|
|
req = (method, url, payload, cookie = admin) =>
|
|
t.app.inject({
|
|
method: method as 'GET',
|
|
url,
|
|
payload: payload as object,
|
|
headers: { ...H, cookie },
|
|
});
|
|
});
|
|
afterAll(() => t?.close());
|
|
|
|
let userId: string;
|
|
|
|
it('creates a user with a one-time temporary password', async () => {
|
|
const r = await req('POST', '/api/users', { email: 'ann@example.com', displayName: 'Ann Lee' });
|
|
expect(r.statusCode).toBe(201);
|
|
const body = r.json();
|
|
expect(body.user).toMatchObject({
|
|
email: 'ann@example.com',
|
|
role: 'user',
|
|
mustChangePassword: true,
|
|
});
|
|
expect(body.user.passwordHash).toBeUndefined();
|
|
expect(body.temporaryPassword).toMatch(/^[A-Za-z0-9]{16}$/);
|
|
userId = body.user.id;
|
|
const ann = await t.login('ann@example.com', body.temporaryPassword);
|
|
const me = await t.app.inject({ url: '/api/auth/me', headers: { cookie: ann } });
|
|
expect(me.json().user.mustChangePassword).toBe(true);
|
|
});
|
|
|
|
it('rejects duplicate emails (case-insensitive) and bad input', async () => {
|
|
expect(
|
|
(await req('POST', '/api/users', { email: 'ANN@example.com', displayName: 'x' })).statusCode,
|
|
).toBe(409);
|
|
const bad = await req('POST', '/api/users', { email: 'nope', displayName: '' });
|
|
expect(bad.statusCode).toBe(400);
|
|
expect(bad.json().error.code).toBe('validation_failed');
|
|
});
|
|
|
|
it('lists and fetches users', async () => {
|
|
const list = (await req('GET', '/api/users')).json();
|
|
expect(list.nextCursor).toBeNull();
|
|
expect(list.items.map((u: { email: string }) => u.email).sort()).toEqual([
|
|
ADMIN.email,
|
|
'ann@example.com',
|
|
]);
|
|
expect((await req('GET', `/api/users/${userId}`)).json().displayName).toBe('Ann Lee');
|
|
expect((await req('GET', '/api/users/00000000-0000-4000-8000-000000000000')).statusCode).toBe(
|
|
404,
|
|
);
|
|
expect((await req('GET', '/api/users/not-a-uuid')).statusCode).toBe(400);
|
|
});
|
|
|
|
it('edits a user', async () => {
|
|
const r = await req('PATCH', `/api/users/${userId}`, {
|
|
displayName: 'Ann B. Lee',
|
|
role: 'admin',
|
|
});
|
|
expect(r.statusCode).toBe(200);
|
|
expect(r.json()).toMatchObject({ displayName: 'Ann B. Lee', role: 'admin' });
|
|
});
|
|
|
|
it('never leaves zero active admins', async () => {
|
|
await req('PATCH', `/api/users/${userId}`, { role: 'user' });
|
|
const me = (await req('GET', '/api/auth/me')).json().user.id;
|
|
expect((await req('PATCH', `/api/users/${me}`, { role: 'user' })).statusCode).toBe(409);
|
|
expect((await req('PATCH', `/api/users/${me}`, { isActive: false })).statusCode).toBe(409);
|
|
expect((await req('DELETE', `/api/users/${me}`)).statusCode).toBe(409); // self-delete
|
|
});
|
|
|
|
it('resets a password and logs the user out', async () => {
|
|
const r = await req('POST', `/api/users/${userId}/reset-password`);
|
|
expect(r.statusCode).toBe(200);
|
|
const ann = await t.login('ann@example.com', r.json().temporaryPassword);
|
|
expect((await t.app.inject({ url: '/api/auth/me', headers: { cookie: ann } })).statusCode).toBe(
|
|
200,
|
|
);
|
|
});
|
|
|
|
it('non-admins get 403', async () => {
|
|
await t.addUser('bob@example.com', 'bob-password-123');
|
|
const bob = await t.login('bob@example.com', 'bob-password-123');
|
|
expect((await req('GET', '/api/users', undefined, bob)).statusCode).toBe(403);
|
|
});
|
|
|
|
it('deletes a user', async () => {
|
|
expect((await req('DELETE', `/api/users/${userId}`)).statusCode).toBe(200);
|
|
expect((await req('GET', `/api/users/${userId}`)).statusCode).toBe(404);
|
|
});
|
|
|
|
it('audits every change, without secrets', async () => {
|
|
const rows = await t.db.select().from(auditLog).orderBy(asc(auditLog.id));
|
|
const userRows = rows.filter((r) => r.entityId === userId).map((r) => r.action);
|
|
expect(userRows).toEqual(
|
|
expect.arrayContaining(['create', 'update', 'reset_password', 'delete', 'login']),
|
|
);
|
|
const update = rows.find((r) => r.entityId === userId && r.action === 'update')!;
|
|
expect(update.diff).toEqual({
|
|
before: { displayName: 'Ann Lee', role: 'user' },
|
|
after: { displayName: 'Ann B. Lee', role: 'admin' },
|
|
});
|
|
expect(JSON.stringify(rows)).not.toMatch(/\$argon2/);
|
|
});
|
|
});
|