import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common'; import type { Reflector } from '@nestjs/core'; import type { Request } from 'express'; import type { FunctionalRole, Principal } from 'shared-auth'; import type { AuditWriter } from '../audit/audit.service'; import { RequireRoleGuard } from './require-role.guard'; function principalFor(roles: readonly FunctionalRole[]): Principal { return { user: { id: 'user-1', personId: 'user-1', entraOid: 'oid-1', tenantId: 'tenant-1', displayName: 'Alice', }, privileges: [], roles, scopes: [{ kind: 'unrestricted' }], amr: ['pwd', 'mfa'], }; } function makeContext(opts: { session?: { principal?: Principal }; method?: string; url?: string; }): { ctx: ExecutionContext; req: Request } { const req = { session: opts.session, method: opts.method ?? 'GET', originalUrl: opts.url ?? '/api/hr/payroll', } as unknown as Request; const ctx = { switchToHttp: () => ({ getRequest: () => req as T }), getHandler: () => 'handler', getClass: () => 'HrController', } as unknown as ExecutionContext; return { ctx, req }; } function makeGuard(opts: { metadata: readonly FunctionalRole[] | undefined }): { guard: RequireRoleGuard; audit: jest.Mock; } { const audit = jest.fn().mockResolvedValue(undefined); const reflector = { getAllAndOverride: jest.fn().mockReturnValue(opts.metadata), } as unknown as Reflector; const writer = { authorizationDenied: audit } as unknown as AuditWriter; return { guard: new RequireRoleGuard(reflector, writer), audit }; } describe('RequireRoleGuard', () => { it('passes when no metadata is set', async () => { const { guard } = makeGuard({ metadata: undefined }); const { ctx } = makeContext({}); await expect(guard.canActivate(ctx)).resolves.toBe(true); }); it('throws 401 when no session is present', async () => { const { guard, audit } = makeGuard({ metadata: ['rh'] }); const { ctx } = makeContext({ session: undefined }); await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(UnauthorizedException); expect(audit).not.toHaveBeenCalled(); }); it('passes when the principal carries the required role', async () => { const { guard } = makeGuard({ metadata: ['rh'] }); const { ctx } = makeContext({ session: { principal: principalFor(['rh']) } }); await expect(guard.canActivate(ctx)).resolves.toBe(true); }); it('OR-combines multiple required roles', async () => { const { guard } = makeGuard({ metadata: ['rh', 'responsable-paie', 'comptable'] }); const { ctx } = makeContext({ session: { principal: principalFor(['comptable']) }, }); await expect(guard.canActivate(ctx)).resolves.toBe(true); }); it('throws 403 + audits when the principal lacks every required role', async () => { const { guard, audit } = makeGuard({ metadata: ['rh'] }); const { ctx } = makeContext({ session: { principal: principalFor(['collaborateur']) }, method: 'GET', url: '/api/hr/payroll', }); await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException); expect(audit).toHaveBeenCalledWith({ actor: { oid: 'oid-1' }, attemptedRoute: 'GET /api/hr/payroll', kind: 'role', required: ['rh'], held: ['collaborateur'], }); }); it('returns a generic forbidden body (no role hint leaks)', async () => { const { guard } = makeGuard({ metadata: ['rh'] }); const { ctx } = makeContext({ session: { principal: principalFor([]) } }); try { await guard.canActivate(ctx); throw new Error('guard did not throw'); } catch (err) { const body = (err as ForbiddenException).getResponse() as Record; expect(body['code']).toBe('forbidden'); expect(JSON.stringify(body)).not.toContain('rh'); } }); });