import type { Logger } from 'nestjs-pino'; import { EntraGroupToRoleResolver, parseEntraGroupMap, type FunctionalRole, type Scope, } from 'shared-auth'; import type { AuthenticatedUser } from './auth.service'; import { PrincipalBuilder } from './principal-builder'; import type { ScopeResolver } from './scope-resolver'; /** * Mirrors the 24-entry `apf-role-*` catalogue: one synthetic GUID * per slug, in catalogue order. The exact GUIDs do not matter to * any assertion below — what matters is that the test resolver * maps them deterministically so a persona's `groups` claim can * be assembled by slug. */ const ROLE_GUID: Record = { collaborateur: '00000000-0000-0000-0000-000000000101', 'chef-equipe': '00000000-0000-0000-0000-000000000102', 'chef-service': '00000000-0000-0000-0000-000000000103', 'directeur-etablissement': '00000000-0000-0000-0000-000000000104', 'directeur-territorial': '00000000-0000-0000-0000-000000000105', rh: '00000000-0000-0000-0000-000000000106', 'responsable-paie': '00000000-0000-0000-0000-000000000107', comptable: '00000000-0000-0000-0000-000000000108', juriste: '00000000-0000-0000-0000-000000000109', dpo: '00000000-0000-0000-0000-00000000010a', rssi: '00000000-0000-0000-0000-00000000010b', it: '00000000-0000-0000-0000-00000000010c', formation: '00000000-0000-0000-0000-00000000010d', qualite: '00000000-0000-0000-0000-00000000010e', communication: '00000000-0000-0000-0000-00000000010f', 'elu-ca': '00000000-0000-0000-0000-000000000110', 'elu-cd': '00000000-0000-0000-0000-000000000111', 'elu-cd-president': '00000000-0000-0000-0000-000000000112', 'elu-cd-tresorier': '00000000-0000-0000-0000-000000000113', 'elu-cd-secretaire': '00000000-0000-0000-0000-000000000114', delegue: '00000000-0000-0000-0000-000000000115', benevole: '00000000-0000-0000-0000-000000000116', 'benevole-responsable': '00000000-0000-0000-0000-000000000117', partenaire: '00000000-0000-0000-0000-000000000118', }; /** * The 19 personas provisioned in the `apfrd.onmicrosoft.com` test * tenant on 2026-05-20, transcribed from * `notes/test-tenant-role-assignments.md` (reverse view). The * builder's contract is that these inputs round-trip to the * expected `(privileges, roles, scopes)` triple — when the spec * passes, every persona we documented in the ADR is exercised * end-to-end. * * Scopes are stubbed to `[{ kind: 'unrestricted' }]` for every * persona in v1 per ADR-0025 §"Sources of truth — apf_portal-side * `user_scopes` table". The intended per-persona scopes * (`etablissement:…`, `delegation:33`, …) land with the Prisma * `user_scopes` table — the next PR after this skeleton. */ interface Persona { readonly label: string; readonly username: string; readonly privileges: readonly string[]; // raw `roles` claim values readonly roles: readonly FunctionalRole[]; } const PERSONAS: readonly Persona[] = [ { label: 'admin', username: 'admin@apfrd.onmicrosoft.com', privileges: ['Portal.Admin'], roles: ['collaborateur', 'rh'], }, { label: 'directeur-bordeaux', username: 'directeur-bordeaux@apfrd.onmicrosoft.com', privileges: [], roles: ['collaborateur', 'directeur-etablissement'], }, { label: 'directeur-complexe', username: 'directeur-complexe@apfrd.onmicrosoft.com', privileges: [], roles: ['collaborateur', 'directeur-etablissement'], }, { label: 'rh-aquitaine', username: 'rh-aquitaine@apfrd.onmicrosoft.com', privileges: [], roles: ['collaborateur', 'rh', 'formation'], }, { label: 'rh-siege', username: 'rh-siege@apfrd.onmicrosoft.com', privileges: [], roles: ['collaborateur', 'rh', 'responsable-paie', 'comptable'], }, { label: 'collaborateur-simple', username: 'collaborateur-simple@apfrd.onmicrosoft.com', privileges: [], roles: ['collaborateur'], }, { label: 'tresorier-bordeaux', username: 'tresorier-bordeaux@apfrd.onmicrosoft.com', privileges: [], roles: ['elu-cd', 'elu-cd-tresorier'], }, { label: 'dpo', username: 'dpo@apfrd.onmicrosoft.com', privileges: ['Portal.DPO', 'Portal.Auditor'], roles: ['collaborateur', 'dpo', 'qualite'], }, { label: 'it', username: 'it@apfrd.onmicrosoft.com', privileges: [], roles: ['collaborateur', 'it'], }, { label: 'benevole-aquitaine', username: 'benevole-aquitaine@apfrd.onmicrosoft.com', privileges: [], // Catalogue order: delegue (governance) precedes benevole + // benevole-responsable (volunteer). The resolver re-sorts on // catalogue order regardless of the Entra claim's order. roles: ['delegue', 'benevole', 'benevole-responsable'], }, { label: 'chef-equipe-bordeaux', username: 'chef-equipe-bordeaux@apfrd.onmicrosoft.com', privileges: [], roles: ['collaborateur', 'chef-equipe'], }, { label: 'chef-service-bordeaux', username: 'chef-service-bordeaux@apfrd.onmicrosoft.com', privileges: [], roles: ['collaborateur', 'chef-service'], }, { label: 'directeur-territorial-aquitaine', username: 'directeur-territorial-aquitaine@apfrd.onmicrosoft.com', privileges: [], roles: ['collaborateur', 'directeur-territorial'], }, { label: 'juriste-siege', username: 'juriste-siege@apfrd.onmicrosoft.com', privileges: [], roles: ['collaborateur', 'juriste'], }, { label: 'rssi', username: 'rssi@apfrd.onmicrosoft.com', privileges: ['Portal.SecurityOfficer'], roles: ['collaborateur', 'rssi'], }, { label: 'communication-siege', username: 'communication-siege@apfrd.onmicrosoft.com', privileges: [], roles: ['collaborateur', 'communication'], }, { label: 'elu-ca-national', username: 'elu-ca-national@apfrd.onmicrosoft.com', privileges: [], roles: ['elu-ca'], }, { label: 'president-cd-aquitaine', username: 'president-cd-aquitaine@apfrd.onmicrosoft.com', privileges: [], roles: ['elu-cd', 'elu-cd-president'], }, { label: 'secretaire-cd-aquitaine', username: 'secretaire-cd-aquitaine@apfrd.onmicrosoft.com', privileges: [], roles: ['elu-cd', 'elu-cd-secretaire'], }, ]; function makeUser(p: Persona): AuthenticatedUser { return { oid: `oid-${p.label}`, tid: 'tenant-1', username: p.username, displayName: p.label, amr: ['pwd', 'mfa'], roles: [...p.privileges], groups: p.roles.map((slug) => ROLE_GUID[slug]), }; } /** * Fixed identity used by every `build()` call in the spec. The * provisioner is mocked at the `SessionEstablisher` test level; the * `PrincipalBuilder` spec receives the UUIDs verbatim — what matters * here is that they round-trip onto `Principal.user.{id, personId}`. */ const TEST_IDENTITY = { userId: '00000000-0000-0000-0000-000000000fff', personId: '00000000-0000-0000-0000-000000000eee', }; function makeBuilder(): { builder: PrincipalBuilder; scopeResolver: { resolve: jest.Mock }; logger: { log: jest.Mock; warn: jest.Mock; error: jest.Mock }; } { const rawMap: Record = {}; for (const [slug, guid] of Object.entries(ROLE_GUID) as Array<[FunctionalRole, string]>) { rawMap[guid] = slug; } const resolver = new EntraGroupToRoleResolver(parseEntraGroupMap(rawMap)); const scopeResolver = { resolve: jest.fn().mockResolvedValue([{ kind: 'unrestricted' } as Scope]), }; const logger = { log: jest.fn(), warn: jest.fn(), error: jest.fn() }; const builder = new PrincipalBuilder( resolver, scopeResolver as unknown as ScopeResolver, logger as unknown as Logger, ); return { builder, scopeResolver, logger }; } describe('PrincipalBuilder — 19 test-tenant personas', () => { for (const persona of PERSONAS) { it(`builds the principal for ${persona.label}`, async () => { const { builder } = makeBuilder(); const principal = await builder.build(makeUser(persona), TEST_IDENTITY); expect(principal.privileges).toEqual(persona.privileges); expect(principal.roles).toEqual(persona.roles); expect(principal.scopes).toEqual([{ kind: 'unrestricted' }]); expect(principal.user.entraOid).toBe(`oid-${persona.label}`); expect(principal.user.tenantId).toBe('tenant-1'); // Real UUIDs from the provisioner, per ADR-0026 PR 1 — no // longer the entraOid placeholder. expect(principal.user.id).toBe(TEST_IDENTITY.userId); expect(principal.user.personId).toBe(TEST_IDENTITY.personId); // amr passes through verbatim — MFA freshness checks read it // off the principal per ADR-0011. expect(principal.amr).toEqual(['pwd', 'mfa']); }); } it('asserts the 19 personas cover all 4 privileges + 23 of 24 functional roles', () => { // Coverage check baked into the spec so a regression that // drops a role from a persona is caught here, not at PR review. // The intentional gap is `partenaire` (placeholder per ADR-0025). const privilegesUsed = new Set(PERSONAS.flatMap((p) => p.privileges)); expect(privilegesUsed).toEqual( new Set(['Portal.Admin', 'Portal.Auditor', 'Portal.SecurityOfficer', 'Portal.DPO']), ); const rolesUsed = new Set(PERSONAS.flatMap((p) => p.roles)); expect(rolesUsed.size).toBe(23); expect(rolesUsed.has('partenaire')).toBe(false); }); }); describe('PrincipalBuilder — edge cases', () => { it('returns an empty privileges array when the user has no app role assignment', async () => { const { builder } = makeBuilder(); const user = makeUser({ label: 'anon', username: 'anon@example', privileges: [], roles: ['collaborateur'], }); const principal = await builder.build(user, TEST_IDENTITY); expect(principal.privileges).toEqual([]); }); it('drops + warns on a claim value that is not a known privilege', async () => { const { builder, logger } = makeBuilder(); const user = makeUser({ label: 'stale', username: 'stale@example', privileges: [], roles: [], }); // Force in a non-catalogue value the way an old assignment would. const userWithDrift: AuthenticatedUser = { ...user, roles: ['Portal.Admin', 'Portal.GhostRole'], }; const principal = await builder.build(userWithDrift, TEST_IDENTITY); expect(principal.privileges).toEqual(['Portal.Admin']); expect(logger.warn).toHaveBeenCalledWith( expect.objectContaining({ event: 'auth.unknown_privilege_claim', value: 'Portal.GhostRole', }), 'PrincipalBuilder', ); }); it('drops + warns on an unknown group GUID (tenant misconfiguration)', async () => { const { builder, logger } = makeBuilder(); const user = makeUser({ label: 'ghost-group', username: 'ghost-group@example', privileges: [], roles: ['collaborateur'], }); const userWithUnknownGroup: AuthenticatedUser = { ...user, groups: [...user.groups, 'ffffffff-ffff-ffff-ffff-ffffffffffff'], }; const principal = await builder.build(userWithUnknownGroup, TEST_IDENTITY); expect(principal.roles).toEqual(['collaborateur']); expect(logger.warn).toHaveBeenCalledWith( expect.objectContaining({ event: 'auth.unknown_group_claim', groupId: 'ffffffff-ffff-ffff-ffff-ffffffffffff', }), 'PrincipalBuilder', ); }); it('builds an empty-permissions principal when the user has no groups or roles', async () => { const { builder } = makeBuilder(); const user = makeUser({ label: 'empty', username: 'empty@example', privileges: [], roles: [], }); const principal = await builder.build(user, TEST_IDENTITY); expect(principal.privileges).toEqual([]); expect(principal.roles).toEqual([]); // Scope resolver stub returns unrestricted — the v1 behaviour // per ADR-0025 §331 until the user_scopes table lands. expect(principal.scopes).toEqual([{ kind: 'unrestricted' }]); }); it('asks the scope resolver to resolve by userId (ADR-0026 PR 1 seam change)', async () => { const { builder, scopeResolver } = makeBuilder(); await builder.build( makeUser({ label: 'sr', username: 'sr@example', privileges: [], roles: ['collaborateur'], }), TEST_IDENTITY, ); // Pre-ADR-0026 the resolver was called with { entraOid }; PR 1 // switches the seam to { userId } so PR 2's PrismaScopeResolver // can key queries on the real User.id. expect(scopeResolver.resolve).toHaveBeenCalledWith({ userId: TEST_IDENTITY.userId }); }); });