/** * Persona-matrix integration tests per ADR-0025 §"More Information" * phasing step 2: the three new guards exercised against every * persona provisioned in the `apfrd.onmicrosoft.com` test tenant on * 2026-05-20. Each test instantiates the guard with the persona's * resolved `Principal` (the shape `PrincipalBuilder` would have * produced at sign-in) and asserts the guard either passes or * denies — the matrix proves the contracts hold against the real * provisioning matrix, not just synthesised toy principals. * * Scopes are stubbed `unrestricted` for every persona in v1 per * ADR-0025 §331 (the per-persona scope values land with the * Prisma `user_scopes` table in a later PR). The scope guard is * therefore exercised here against synthesised varied principals, * not against the persona matrix. */ import { ExecutionContext, ForbiddenException } from '@nestjs/common'; import type { Reflector } from '@nestjs/core'; import type { Request } from 'express'; import type { FunctionalRole, Principal, Privilege } from 'shared-auth'; import type { AuditWriter } from '../audit/audit.service'; import { RequirePrivilegeGuard } from './require-privilege.guard'; import { RequireRoleGuard } from './require-role.guard'; interface Persona { readonly label: string; readonly privileges: readonly Privilege[]; readonly roles: readonly FunctionalRole[]; } // Verbatim copy of the persona matrix from // `principal-builder.spec.ts`. Kept here as a local constant so a // reviewer sees the same 19-row table both files assert against; // the two specs cover different layers (builder, then guards). const PERSONAS: readonly Persona[] = [ { label: 'admin', privileges: ['Portal.Admin'], roles: ['collaborateur', 'rh'] }, { label: 'directeur-bordeaux', privileges: [], roles: ['collaborateur', 'directeur-etablissement'], }, { label: 'directeur-complexe', privileges: [], roles: ['collaborateur', 'directeur-etablissement'], }, { label: 'rh-aquitaine', privileges: [], roles: ['collaborateur', 'rh', 'formation'] }, { label: 'rh-siege', privileges: [], roles: ['collaborateur', 'rh', 'responsable-paie', 'comptable'], }, { label: 'collaborateur-simple', privileges: [], roles: ['collaborateur'] }, { label: 'tresorier-bordeaux', privileges: [], roles: ['elu-cd', 'elu-cd-tresorier'] }, { label: 'dpo', privileges: ['Portal.DPO', 'Portal.Auditor'], roles: ['collaborateur', 'dpo', 'qualite'], }, { label: 'it', privileges: [], roles: ['collaborateur', 'it'] }, { label: 'benevole-aquitaine', privileges: [], roles: ['delegue', 'benevole', 'benevole-responsable'], }, { label: 'chef-equipe-bordeaux', privileges: [], roles: ['collaborateur', 'chef-equipe'] }, { label: 'chef-service-bordeaux', privileges: [], roles: ['collaborateur', 'chef-service'] }, { label: 'directeur-territorial-aquitaine', privileges: [], roles: ['collaborateur', 'directeur-territorial'], }, { label: 'juriste-siege', privileges: [], roles: ['collaborateur', 'juriste'] }, { label: 'rssi', privileges: ['Portal.SecurityOfficer'], roles: ['collaborateur', 'rssi'] }, { label: 'communication-siege', privileges: [], roles: ['collaborateur', 'communication'] }, { label: 'elu-ca-national', privileges: [], roles: ['elu-ca'] }, { label: 'president-cd-aquitaine', privileges: [], roles: ['elu-cd', 'elu-cd-president'] }, { label: 'secretaire-cd-aquitaine', privileges: [], roles: ['elu-cd', 'elu-cd-secretaire'] }, ]; function principalOf(persona: Persona): Principal { return { user: { id: `oid-${persona.label}`, personId: `oid-${persona.label}`, entraOid: `oid-${persona.label}`, tenantId: 'tenant-1', displayName: persona.label, }, privileges: persona.privileges, roles: persona.roles, scopes: [{ kind: 'unrestricted' }], amr: ['pwd', 'mfa'], }; } function makeCtxFor(principal: Principal): ExecutionContext { const req = { session: { principal }, method: 'GET', originalUrl: '/api/test', } as unknown as Request; return { switchToHttp: () => ({ getRequest: () => req as T }), getHandler: () => 'handler', getClass: () => 'TestController', } as unknown as ExecutionContext; } function privilegeGuardFor(metadata: readonly Privilege[]) { const reflector = { getAllAndOverride: jest.fn().mockReturnValue(metadata), } as unknown as Reflector; const audit = jest.fn().mockResolvedValue(undefined); const writer = { authorizationDenied: audit } as unknown as AuditWriter; return { guard: new RequirePrivilegeGuard(reflector, writer), audit }; } function roleGuardFor(metadata: readonly FunctionalRole[]) { const reflector = { getAllAndOverride: jest.fn().mockReturnValue(metadata), } as unknown as Reflector; const audit = jest.fn().mockResolvedValue(undefined); const writer = { authorizationDenied: audit } as unknown as AuditWriter; return { guard: new RequireRoleGuard(reflector, writer), audit }; } /** * Each row asserts which personas should pass a given guard. The * complement (everyone NOT in `allowed`) must be denied — the * `expect denied` block at the bottom of each describe catches a * regression that would silently let extra personas through. */ interface Matrix { readonly title: string; readonly allowed: ReadonlyArray; } const PRIVILEGE_MATRIX: ReadonlyArray<{ metadata: readonly Privilege[]; matrix: Matrix; }> = [ { metadata: ['Portal.Admin'], matrix: { title: '@RequirePrivilege(Portal.Admin)', allowed: ['admin'] }, }, { metadata: ['Portal.DPO'], matrix: { title: '@RequirePrivilege(Portal.DPO)', allowed: ['dpo'] }, }, { metadata: ['Portal.Auditor'], matrix: { title: '@RequirePrivilege(Portal.Auditor)', allowed: ['dpo'] }, }, { metadata: ['Portal.SecurityOfficer'], matrix: { title: '@RequirePrivilege(Portal.SecurityOfficer)', allowed: ['rssi'] }, }, { metadata: ['Portal.Admin', 'Portal.DPO'], matrix: { title: '@RequirePrivilege(Portal.Admin, Portal.DPO) — OR composition', allowed: ['admin', 'dpo'], }, }, ]; const ROLE_MATRIX: ReadonlyArray<{ metadata: readonly FunctionalRole[]; matrix: Matrix; }> = [ { metadata: ['rh'], matrix: { title: '@RequireRole(rh)', allowed: ['admin', 'rh-aquitaine', 'rh-siege'] }, }, { metadata: ['directeur-etablissement'], matrix: { title: '@RequireRole(directeur-etablissement)', allowed: ['directeur-bordeaux', 'directeur-complexe'], }, }, { metadata: ['rh', 'comptable', 'responsable-paie'], matrix: { title: '@RequireRole(rh, comptable, responsable-paie) — OR composition', allowed: ['admin', 'rh-aquitaine', 'rh-siege'], }, }, { metadata: ['elu-cd'], matrix: { title: '@RequireRole(elu-cd) — every CD member', allowed: ['tresorier-bordeaux', 'president-cd-aquitaine', 'secretaire-cd-aquitaine'], }, }, { metadata: ['benevole-responsable'], matrix: { title: '@RequireRole(benevole-responsable)', allowed: ['benevole-aquitaine'], }, }, { metadata: ['collaborateur'], matrix: { title: '@RequireRole(collaborateur) — every workforce persona', allowed: [ 'admin', 'directeur-bordeaux', 'directeur-complexe', 'rh-aquitaine', 'rh-siege', 'collaborateur-simple', 'dpo', 'it', 'chef-equipe-bordeaux', 'chef-service-bordeaux', 'directeur-territorial-aquitaine', 'juriste-siege', 'rssi', 'communication-siege', ], }, }, ]; describe('Privilege guard — persona matrix', () => { for (const { metadata, matrix } of PRIVILEGE_MATRIX) { describe(matrix.title, () => { for (const persona of PERSONAS) { const shouldPass = matrix.allowed.includes(persona.label); const verb = shouldPass ? 'allows' : 'denies'; it(`${verb} ${persona.label}`, async () => { const { guard } = privilegeGuardFor(metadata); const ctx = makeCtxFor(principalOf(persona)); if (shouldPass) { await expect(guard.canActivate(ctx)).resolves.toBe(true); } else { await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException); } }); } }); } }); describe('Role guard — persona matrix', () => { for (const { metadata, matrix } of ROLE_MATRIX) { describe(matrix.title, () => { for (const persona of PERSONAS) { const shouldPass = matrix.allowed.includes(persona.label); const verb = shouldPass ? 'allows' : 'denies'; it(`${verb} ${persona.label}`, async () => { const { guard } = roleGuardFor(metadata); const ctx = makeCtxFor(principalOf(persona)); if (shouldPass) { await expect(guard.canActivate(ctx)).resolves.toBe(true); } else { await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException); } }); } }); } });