import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common'; import type { Reflector } from '@nestjs/core'; import type { Request } from 'express'; import type { Principal, ScopableResource, Scope } from 'shared-auth'; import type { AuditWriter } from '../audit/audit.service'; import type { RequireScopeExtractor } from './require-scope.decorator'; import { RequireScopeGuard } from './require-scope.guard'; function principalFor(scopes: readonly Scope[]): Principal { return { user: { id: 'user-1', personId: 'person-1', entraOid: 'oid-1', tenantId: 'tenant-1', displayName: 'Alice', }, privileges: [], roles: [], scopes, 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/etablissements/0330800013', } as unknown as Request; const ctx = { switchToHttp: () => ({ getRequest: () => req as T }), getHandler: () => 'handler', getClass: () => 'EtablissementController', } as unknown as ExecutionContext; return { ctx, req }; } function makeGuard(opts: { extractor: RequireScopeExtractor | undefined }): { guard: RequireScopeGuard; audit: jest.Mock; } { const audit = jest.fn().mockResolvedValue(undefined); const reflector = { getAllAndOverride: jest.fn().mockReturnValue(opts.extractor), } as unknown as Reflector; const writer = { authorizationDenied: audit } as unknown as AuditWriter; return { guard: new RequireScopeGuard(reflector, writer), audit }; } describe('RequireScopeGuard', () => { it('passes when no extractor metadata is set', async () => { const { guard } = makeGuard({ extractor: 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({ extractor: () => ({ etablissementFiness: '0330800013' }), }); const { ctx } = makeContext({ session: undefined }); await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(UnauthorizedException); expect(audit).not.toHaveBeenCalled(); }); it('passes when the principal scope covers the resource', async () => { const { guard } = makeGuard({ extractor: () => ({ etablissementFiness: '0330800013' }), }); const { ctx } = makeContext({ session: { principal: principalFor([{ kind: 'etablissement', value: '0330800013' }]), }, }); await expect(guard.canActivate(ctx)).resolves.toBe(true); }); it('honours async extractors (Prisma lookup pattern)', async () => { const { guard } = makeGuard({ extractor: async () => Promise.resolve({ delegationCode: '33' }), }); const { ctx } = makeContext({ session: { principal: principalFor([{ kind: 'delegation', value: '33' }]) }, }); await expect(guard.canActivate(ctx)).resolves.toBe(true); }); it('throws 403 + audits when the resource is outside the principal scopes', async () => { const { guard, audit } = makeGuard({ extractor: () => ({ etablissementFiness: '0330800021' }), }); const { ctx } = makeContext({ session: { principal: principalFor([{ kind: 'etablissement', value: '0330800013' }]), }, }); await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException); expect(audit).toHaveBeenCalledWith({ actor: { oid: 'oid-1' }, attemptedRoute: 'GET /api/etablissements/0330800013', kind: 'scope', required: ['etablissement:0330800021'], held: ['etablissement:0330800013'], }); }); it('throws 403 + audits with empty required when the extractor returns null', async () => { const { guard, audit } = makeGuard({ extractor: () => null }); const { ctx } = makeContext({ session: { principal: principalFor([{ kind: 'unrestricted' }]) }, }); await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException); expect(audit).toHaveBeenCalledWith(expect.objectContaining({ kind: 'scope', required: [] })); }); it('describes the resource correctly when it carries the full parentage chain', async () => { const resource: ScopableResource = { etablissementFiness: '0330800021', delegationCode: '33', regionCode: '75', }; const { guard, audit } = makeGuard({ extractor: () => resource }); const { ctx } = makeContext({ session: { principal: principalFor([{ kind: 'region', value: '11' }]) }, }); await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException); expect(audit).toHaveBeenCalledWith( expect.objectContaining({ required: ['etablissement:0330800021', 'delegation:33', 'region:75'], held: ['region:11'], }), ); }); it('returns a generic forbidden body (no resource fingerprint leaks)', async () => { const { guard } = makeGuard({ extractor: () => ({ etablissementFiness: '0330800021' }), }); 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('0330800021'); } }); });