feat(portal-bff): admin user-directory read endpoint
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.
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user