import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common'; import type { Request } from 'express'; import type { Principal } from 'shared-auth'; import { AuditWriter } from '../audit/audit.service'; import { ADMIN_ROLE, AdminRoleGuard } from './admin-role.guard'; interface SessionStub { user?: { oid: string; tid: string; username: string; displayName: string; amr: readonly string[]; roles: readonly string[]; }; principal?: Principal; } function makeRequest(opts: { session?: SessionStub; method?: string; originalUrl?: string; }): Request { return { session: opts.session, method: opts.method ?? 'GET', originalUrl: opts.originalUrl ?? '/api/admin/me', } as unknown as Request; } function makeContext(req: Request): ExecutionContext { return { switchToHttp: () => ({ getRequest: () => req as T }), } as unknown as ExecutionContext; } function makeAuditStub(): jest.Mocked> { return { adminAccessDenied: jest.fn().mockResolvedValue(undefined), }; } function principalWithPrivileges(privileges: readonly string[]): Principal { return { user: { id: 'user-oid', personId: 'user-oid', entraOid: 'user-oid', tenantId: 't', displayName: 'Jane', }, privileges: privileges as Principal['privileges'], roles: [], scopes: [{ kind: 'unrestricted' }], amr: ['pwd', 'mfa'], }; } describe('AdminRoleGuard', () => { it('throws 401 when the request has no session at all', async () => { const audit = makeAuditStub(); const guard = new AdminRoleGuard(audit as unknown as AuditWriter); const ctx = makeContext(makeRequest({ session: undefined })); await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(UnauthorizedException); expect(audit.adminAccessDenied).not.toHaveBeenCalled(); }); it('throws 401 when the session exists but no principal nor legacy user is bound', async () => { const audit = makeAuditStub(); const guard = new AdminRoleGuard(audit as unknown as AuditWriter); const ctx = makeContext(makeRequest({ session: {} })); await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(UnauthorizedException); expect(audit.adminAccessDenied).not.toHaveBeenCalled(); }); it('throws 403 + records admin.access_denied when the principal has no privileges', async () => { const audit = makeAuditStub(); const guard = new AdminRoleGuard(audit as unknown as AuditWriter); const ctx = makeContext( makeRequest({ session: { principal: principalWithPrivileges([]) }, method: 'GET', originalUrl: '/api/admin/me', }), ); await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException); expect(audit.adminAccessDenied).toHaveBeenCalledWith({ actor: { oid: 'user-oid' }, attemptedRoute: 'GET /api/admin/me', rolesHeld: [], }); }); it('throws 403 + records admin.access_denied when the principal has non-admin privileges only', async () => { const audit = makeAuditStub(); const guard = new AdminRoleGuard(audit as unknown as AuditWriter); const ctx = makeContext( makeRequest({ session: { principal: principalWithPrivileges(['Portal.Auditor']) }, method: 'POST', originalUrl: '/api/admin/cms/pages', }), ); await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException); expect(audit.adminAccessDenied).toHaveBeenCalledWith({ actor: { oid: 'user-oid' }, attemptedRoute: 'POST /api/admin/cms/pages', rolesHeld: ['Portal.Auditor'], }); }); it('returns true and does not audit when the principal carries Portal.Admin', async () => { const audit = makeAuditStub(); const guard = new AdminRoleGuard(audit as unknown as AuditWriter); const ctx = makeContext( makeRequest({ session: { principal: principalWithPrivileges([ADMIN_ROLE]) } }), ); await expect(guard.canActivate(ctx)).resolves.toBe(true); expect(audit.adminAccessDenied).not.toHaveBeenCalled(); }); it('returns true when admin is one of several privileges', async () => { const audit = makeAuditStub(); const guard = new AdminRoleGuard(audit as unknown as AuditWriter); const ctx = makeContext( makeRequest({ session: { principal: principalWithPrivileges(['Portal.Auditor', ADMIN_ROLE, 'Portal.DPO']), }, }), ); await expect(guard.canActivate(ctx)).resolves.toBe(true); }); it('propagates audit write failures (no audit ⇒ no action, per ADR-0013)', async () => { const audit = { adminAccessDenied: jest.fn().mockRejectedValue(new Error('audit_writer denied')), }; const guard = new AdminRoleGuard(audit as unknown as AuditWriter); const ctx = makeContext(makeRequest({ session: { principal: principalWithPrivileges([]) } })); // The 403 path goes through audit first; if audit throws, the // guard surfaces the underlying error rather than the // ForbiddenException — the caller sees a 500 and the audit // invariant is preserved. await expect(guard.canActivate(ctx)).rejects.toThrow('audit_writer denied'); }); describe('legacy-session bridge (sessions persisted before ADR-0025 landed)', () => { it('synthesises a principal from session.user when session.principal is absent', async () => { const audit = makeAuditStub(); const guard = new AdminRoleGuard(audit as unknown as AuditWriter); const ctx = makeContext( makeRequest({ session: { user: { oid: 'user-oid', tid: 't', username: 'jane@example', displayName: 'Jane', amr: ['pwd', 'mfa'], roles: [ADMIN_ROLE, 'Other.Role'], }, }, }), ); await expect(guard.canActivate(ctx)).resolves.toBe(true); expect(audit.adminAccessDenied).not.toHaveBeenCalled(); }); it('denies a legacy session whose roles claim carries no Portal.* value', async () => { const audit = makeAuditStub(); const guard = new AdminRoleGuard(audit as unknown as AuditWriter); const ctx = makeContext( makeRequest({ session: { user: { oid: 'user-oid', tid: 't', username: 'jane@example', displayName: 'Jane', amr: ['pwd'], roles: ['editor', 'auditor'], }, }, }), ); await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(ForbiddenException); // The legacy bridge filters the raw `roles` claim down to // `Portal.*` values for the audit row's `rolesHeld` field — // `editor` and `auditor` are not privileges, so the held // list is empty. expect(audit.adminAccessDenied).toHaveBeenCalledWith({ actor: { oid: 'user-oid' }, attemptedRoute: 'GET /api/admin/me', rolesHeld: [], }); }); }); });