0817520e49
Second PR of the portal-admin User-list chantier per ADR-0020 §"v1 scope — User list (read-only)". Ships the read side: paginated, filterable HTTP endpoint that queries the `public.users` directory populated at sign-in by PR #140. The SPA viewer screen lands in the final PR of the chantier. What lands - AdminUsersQueryDto (admin/users-query.dto.ts): mirrors AdminAuditQueryDto's posture — filters all optional, every unknown key rejected by `forbidNonWhitelisted`, limit capped at MAX_LIMIT (200) / default 50. Filters: username (exact prefix), displayName (case-insensitive contains), audience (workforce | customer enum), lastSeenAtFrom/To (ISO-8601). - AdminUsersReader (admin/admin-users-reader.service.ts): Prisma typed client against `public.users` — no `SET LOCAL ROLE` dance because `public.users` has no role-based privilege gate; the trust boundary is the controller's @RequireAdmin guard. Order: `last_seen_at DESC, oid ASC` (the second clause is a deterministic tie-breaker for pagination during sign-in bursts that share a timestamp). COUNT + SELECT run in a single Prisma transaction so the `total` reported to the SPA matches what's on the page even under a concurrent sign-in. - AdminUsersController (admin/admin-users.controller.ts): GET /api/admin/users, @RequireAdmin at the class level, forwards the validated DTO to AdminUsersReader, then emits admin.users.query with { filters, resultCount } as the fishing-expedition deterrent (mirror of admin.audit.query from PR #132). - AuditWriter.adminUsersQuery() typed method + AdminUsersQueryInput type. Same outcome=success / payload shape as adminAuditQuery — two distinct event types so a reviewer can pivot directly on eventType without parsing payload. Tests: +18 specs (DTO validation 9, reader 9 covering COUNT+SELECT ordering, filter forwarding, default/cap on limit, range filter composition; controller 6; audit typed method 2). All 391 BFF specs pass. Out of scope (next PR of the chantier): - portal-admin /users screen — the SPA viewer with filter form + table + pagination, mirroring the /audit page that the audit viewer PR shipped. - Sign-in counts joined from `audit.events` on `actor_id_hash` (computed via HashUserIdService on the fly). Deferred until the v1 list ships and the admin demand makes itself known.
92 lines
3.3 KiB
TypeScript
92 lines
3.3 KiB
TypeScript
import type { Request } from 'express';
|
|
import type { AuditWriter } from '../audit/audit.service';
|
|
import { AdminUsersController } from './admin-users.controller';
|
|
import type { AdminUsersReader, AdminUsersPage } from './admin-users-reader.service';
|
|
import type { AdminUsersQueryDto } from './users-query.dto';
|
|
|
|
const PAGE: AdminUsersPage = {
|
|
items: [
|
|
{
|
|
oid: 'user-oid',
|
|
tid: 'tenant-1',
|
|
audience: 'workforce',
|
|
username: 'jane.doe@apf.example',
|
|
displayName: 'Jane Doe',
|
|
firstSeenAt: '2026-05-10T10:00:00.000Z',
|
|
lastSeenAt: '2026-05-14T08:30:00.000Z',
|
|
},
|
|
],
|
|
total: 137,
|
|
limit: 50,
|
|
offset: 0,
|
|
};
|
|
|
|
function makeReq(user?: { oid: string }): Request {
|
|
return { session: user !== undefined ? { user } : {} } as unknown as Request;
|
|
}
|
|
|
|
function makeFixture() {
|
|
const usersReader = { findUsers: jest.fn().mockResolvedValue(PAGE) };
|
|
const auditWriter = { adminUsersQuery: jest.fn().mockResolvedValue(undefined) };
|
|
const controller = new AdminUsersController(
|
|
usersReader as unknown as AdminUsersReader,
|
|
auditWriter as unknown as AuditWriter,
|
|
);
|
|
return { controller, usersReader, auditWriter };
|
|
}
|
|
|
|
describe('AdminUsersController.list', () => {
|
|
it('returns the page from AdminUsersReader', async () => {
|
|
const { controller } = makeFixture();
|
|
const page = await controller.list(makeReq({ oid: 'admin-oid' }), {} as AdminUsersQueryDto);
|
|
expect(page).toBe(PAGE);
|
|
});
|
|
|
|
it('forwards the validated DTO to AdminUsersReader as-is', async () => {
|
|
const { controller, usersReader } = makeFixture();
|
|
const filters: AdminUsersQueryDto = {
|
|
username: 'jane',
|
|
audience: 'workforce',
|
|
lastSeenAtFrom: '2026-05-01T00:00:00.000Z',
|
|
limit: 25,
|
|
offset: 0,
|
|
};
|
|
await controller.list(makeReq({ oid: 'admin-oid' }), filters);
|
|
expect(usersReader.findUsers).toHaveBeenCalledWith(filters);
|
|
});
|
|
|
|
it('emits admin.users.query with filters + result count', async () => {
|
|
const { controller, auditWriter } = makeFixture();
|
|
const filters: AdminUsersQueryDto = { username: 'jane', limit: 50 };
|
|
await controller.list(makeReq({ oid: 'admin-oid' }), filters);
|
|
expect(auditWriter.adminUsersQuery).toHaveBeenCalledWith({
|
|
actor: { oid: 'admin-oid' },
|
|
filters: { username: 'jane', limit: 50 },
|
|
resultCount: PAGE.items.length,
|
|
});
|
|
});
|
|
|
|
it('captures an empty filter object verbatim', async () => {
|
|
const { controller, auditWriter } = makeFixture();
|
|
await controller.list(makeReq({ oid: 'admin-oid' }), {} as AdminUsersQueryDto);
|
|
expect(auditWriter.adminUsersQuery).toHaveBeenCalledWith(
|
|
expect.objectContaining({ filters: {} }),
|
|
);
|
|
});
|
|
|
|
it('still returns the page when no session.user (defensive — guard normally catches)', async () => {
|
|
const { controller, auditWriter } = makeFixture();
|
|
const page = await controller.list(makeReq(), {} as AdminUsersQueryDto);
|
|
expect(page).toBe(PAGE);
|
|
expect(auditWriter.adminUsersQuery).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('propagates audit write failures (blocking per ADR-0013)', async () => {
|
|
const { controller, auditWriter } = makeFixture();
|
|
auditWriter.adminUsersQuery.mockRejectedValueOnce(new Error('audit_writer denied'));
|
|
await expect(
|
|
controller.list(makeReq({ oid: 'admin-oid' }), {} as AdminUsersQueryDto),
|
|
).rejects.toThrow('audit_writer denied');
|
|
});
|
|
});
|