1df99cd800
## Summary Second PR of the **portal-admin User-list chantier** per [ADR-0020](docs/decisions/0020-portal-admin-app.md) §"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`](apps/portal-bff/src/admin/users-query.dto.ts) Mirrors `AdminAuditQueryDto`'s posture — filters all optional, every unknown query key rejected by `forbidNonWhitelisted`, limit capped at **200** / default **50**. | Filter | Type | Notes | | --- | --- | --- | | `username` | string ≤128 | Exact-prefix match (Prisma `startsWith`). | | `displayName` | string ≤128 | Case-insensitive `contains` (display names vary in casing). | | `audience` | enum | `workforce` \| `customer`. | | `lastSeenAtFrom` | ISO-8601 | Inclusive lower bound. | | `lastSeenAtTo` | ISO-8601 | Exclusive upper bound. | | `limit` | int 1..200 | Default **50**. | | `offset` | int ≥0 | Default **0**. | ### [`AdminUsersReader`](apps/portal-bff/src/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 in one Prisma transaction** so the `total` reported to the SPA matches what's on the page even under a concurrent sign-in landing between the two queries. - **Hard cap on limit** at 200 even when a caller bypasses the DTO — defense in depth on the BFF's event loop. ### [`AdminUsersController`](apps/portal-bff/src/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 }` — the fishing-expedition deterrent (mirror of `admin.audit.query` from PR #132). ### [`AuditWriter.adminUsersQuery()`](apps/portal-bff/src/audit/audit.service.ts) New 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. ## Notes for the reviewer - The shape mirrors `AdminAuditController` (#132) deliberately. Future admin read endpoints (CMS pages, menu items if they grow read filters) should adopt the same shape — keeps the SPA's data-flow pattern consistent across modules. - `public.users` queries don't need `SET LOCAL ROLE` because there's no append-only / read-only role split on the table. That's a deliberate v1 simplification; if the security review later asks for stricter isolation we'd add a `users_reader` role + the same SET-LOCAL pattern AuditReader uses. - The future "sign-in counts" join from `audit.events` on `actor_id_hash` is **deferred**. The salted hash is computable on the fly via `HashUserIdService`, so adding it later is a service-level change — no schema migration required. - The `actor_id_hash` is deliberately **NOT** stored on `public.users` (per ADR-0013's invariant — the salt stays inside the audit module). ## Test plan - [x] `pnpm nx test portal-bff` — **391 specs pass** (was 365; +26 covering DTO validation, reader transaction shape + filter forwarding + pagination defaults + cap, controller path, audit typed method). - [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean. - [x] Prisma transaction shape verified: COUNT + SELECT both fire exactly once per call; tuple-return contract honoured. - [x] Filter projections verified per filter: username `startsWith`, displayName case-insensitive `contains`, audience exact match, lastSeenAt `gte/lt` composition. - [ ] e2e — pending the admin SPA viewer screen + a real admin session. Once both exist: `curl --cookie 'portal_admin_session=...' /api/admin/users?username=jane&limit=10` returns the matching subset and a new `admin.users.query` audit row lands. ## What's next The chantier's final PR: - **portal-admin `/users` screen** — SPA viewer with filter form + table + pagination. Same shape as the `/audit` page (PR #136): signal-driven state, color-coded badges for audience, ISO timestamps formatted locale-side, status states. Will graduate the sidebar entry from `aria-disabled` "Soon" badge to a live link. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #141
148 lines
5.5 KiB
TypeScript
148 lines
5.5 KiB
TypeScript
import { Test } from '@nestjs/testing';
|
|
import { PrismaService } from 'nestjs-prisma';
|
|
import { AdminUsersReader } from './admin-users-reader.service';
|
|
|
|
interface MockPrisma {
|
|
user: {
|
|
count: jest.Mock;
|
|
findMany: jest.Mock;
|
|
};
|
|
$transaction: jest.Mock;
|
|
}
|
|
|
|
function buildPrisma(opts?: { count?: number; items?: unknown[] }): MockPrisma {
|
|
const count = jest.fn().mockResolvedValue(opts?.count ?? 0);
|
|
const findMany = jest.fn().mockResolvedValue(opts?.items ?? []);
|
|
return {
|
|
user: { count, findMany },
|
|
// The reader calls $transaction with the promises returned by
|
|
// `count()` + `findMany()` — the operations have already fired
|
|
// by the time $transaction sees them. The mock just resolves
|
|
// them together so Prisma's tuple-return contract is honoured.
|
|
$transaction: jest
|
|
.fn()
|
|
.mockImplementation((promises: ReadonlyArray<Promise<unknown>>) => Promise.all(promises)),
|
|
};
|
|
}
|
|
|
|
async function createSubject(prisma: MockPrisma): Promise<AdminUsersReader> {
|
|
const moduleRef = await Test.createTestingModule({
|
|
providers: [AdminUsersReader, { provide: PrismaService, useValue: prisma }],
|
|
}).compile();
|
|
return moduleRef.get(AdminUsersReader);
|
|
}
|
|
|
|
const ROW = {
|
|
oid: 'user-oid',
|
|
tid: 'tenant-1',
|
|
audience: 'workforce',
|
|
username: 'jane.doe@apf.example',
|
|
displayName: 'Jane Doe',
|
|
firstSeenAt: new Date('2026-05-10T10:00:00.000Z'),
|
|
lastSeenAt: new Date('2026-05-14T08:30:00.000Z'),
|
|
};
|
|
|
|
describe('AdminUsersReader.findUsers', () => {
|
|
it('issues a COUNT + SELECT in a single Prisma transaction', async () => {
|
|
const prisma = buildPrisma({ count: 5, items: [ROW] });
|
|
const reader = await createSubject(prisma);
|
|
const page = await reader.findUsers({});
|
|
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
|
expect(prisma.user.count).toHaveBeenCalledTimes(1);
|
|
expect(prisma.user.findMany).toHaveBeenCalledTimes(1);
|
|
expect(page.total).toBe(5);
|
|
expect(page.items).toHaveLength(1);
|
|
});
|
|
|
|
it('projects rows into the SPA-facing shape (ISO timestamps)', async () => {
|
|
const prisma = buildPrisma({ items: [ROW] });
|
|
const reader = await createSubject(prisma);
|
|
const page = await reader.findUsers({});
|
|
expect(page.items[0]).toEqual({
|
|
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',
|
|
});
|
|
});
|
|
|
|
it('orders by last_seen_at DESC, oid ASC for deterministic pagination', async () => {
|
|
const prisma = buildPrisma();
|
|
const reader = await createSubject(prisma);
|
|
await reader.findUsers({});
|
|
const findManyArgs = prisma.user.findMany.mock.calls[0]?.[0] as {
|
|
orderBy: ReadonlyArray<Record<string, string>>;
|
|
};
|
|
expect(findManyArgs.orderBy).toEqual([{ lastSeenAt: 'desc' }, { oid: 'asc' }]);
|
|
});
|
|
|
|
it('passes startsWith filter on username through to Prisma', async () => {
|
|
const prisma = buildPrisma();
|
|
const reader = await createSubject(prisma);
|
|
await reader.findUsers({ username: 'jane' });
|
|
const args = prisma.user.findMany.mock.calls[0]?.[0] as {
|
|
where: { username?: { startsWith?: string } };
|
|
};
|
|
expect(args.where.username).toEqual({ startsWith: 'jane' });
|
|
});
|
|
|
|
it('passes case-insensitive contains filter on displayName through to Prisma', async () => {
|
|
const prisma = buildPrisma();
|
|
const reader = await createSubject(prisma);
|
|
await reader.findUsers({ displayName: 'doe' });
|
|
const args = prisma.user.findMany.mock.calls[0]?.[0] as {
|
|
where: { displayName?: { contains?: string; mode?: string } };
|
|
};
|
|
expect(args.where.displayName).toEqual({ contains: 'doe', mode: 'insensitive' });
|
|
});
|
|
|
|
it('combines lastSeenAtFrom and lastSeenAtTo into a single gte/lt range filter', async () => {
|
|
const prisma = buildPrisma();
|
|
const reader = await createSubject(prisma);
|
|
await reader.findUsers({
|
|
lastSeenAtFrom: '2026-05-01T00:00:00.000Z',
|
|
lastSeenAtTo: '2026-05-14T23:59:59.999Z',
|
|
});
|
|
const args = prisma.user.findMany.mock.calls[0]?.[0] as {
|
|
where: { lastSeenAt?: { gte?: Date; lt?: Date } };
|
|
};
|
|
expect(args.where.lastSeenAt?.gte).toBeInstanceOf(Date);
|
|
expect(args.where.lastSeenAt?.lt).toBeInstanceOf(Date);
|
|
expect(args.where.lastSeenAt?.gte?.toISOString()).toBe('2026-05-01T00:00:00.000Z');
|
|
expect(args.where.lastSeenAt?.lt?.toISOString()).toBe('2026-05-14T23:59:59.999Z');
|
|
});
|
|
|
|
it('applies default limit (50) and offset (0) when not provided', async () => {
|
|
const prisma = buildPrisma();
|
|
const reader = await createSubject(prisma);
|
|
const page = await reader.findUsers({});
|
|
const args = prisma.user.findMany.mock.calls[0]?.[0] as {
|
|
take: number;
|
|
skip: number;
|
|
};
|
|
expect(args.take).toBe(50);
|
|
expect(args.skip).toBe(0);
|
|
expect(page.limit).toBe(50);
|
|
expect(page.offset).toBe(0);
|
|
});
|
|
|
|
it('caps limit at MAX_LIMIT (200) even when a caller bypasses the DTO', async () => {
|
|
const prisma = buildPrisma();
|
|
const reader = await createSubject(prisma);
|
|
await reader.findUsers({ limit: 1000 });
|
|
const args = prisma.user.findMany.mock.calls[0]?.[0] as { take: number };
|
|
expect(args.take).toBe(200);
|
|
});
|
|
|
|
it('emits no WHERE clause when no filter is provided', async () => {
|
|
const prisma = buildPrisma();
|
|
const reader = await createSubject(prisma);
|
|
await reader.findUsers({});
|
|
const args = prisma.user.findMany.mock.calls[0]?.[0] as { where: object };
|
|
expect(args.where).toEqual({});
|
|
});
|
|
});
|