import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import type { Request } from 'express'; import { AuditWriter } from '../audit/audit.service'; import { REQUIRE_MFA_METADATA, RequireMfaGuard } from './require-mfa.guard'; interface SessionStub { user?: { oid: string; tid: string; username: string; displayName: string; amr: readonly string[]; roles: readonly string[]; }; mfaVerifiedAt?: number; } 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 }), getHandler: () => () => undefined, getClass: () => class {}, } as unknown as ExecutionContext; } function makeAuditStub(): jest.Mocked> { return { mfaRequired: jest.fn().mockResolvedValue(undefined) }; } function makeReflectorStub(metadata?: { freshness?: number }): Reflector { return { getAllAndOverride: jest.fn().mockReturnValue(metadata), } as unknown as Reflector; } const USER = { oid: 'user-oid', tid: 't', username: 'jane@example', displayName: 'Jane', amr: ['pwd', 'mfa'], roles: ['admin'], }; describe('RequireMfaGuard', () => { const originalFreshness = process.env['MFA_FRESHNESS_SECONDS']; afterEach(() => { if (originalFreshness === undefined) { delete process.env['MFA_FRESHNESS_SECONDS']; } else { process.env['MFA_FRESHNESS_SECONDS'] = originalFreshness; } jest.useRealTimers(); }); it('throws 401 unauthenticated when there is no session', async () => { const audit = makeAuditStub(); const guard = new RequireMfaGuard(makeReflectorStub(), audit as unknown as AuditWriter); const ctx = makeContext(makeRequest({ session: undefined })); await expect(guard.canActivate(ctx)).rejects.toMatchObject({ response: { code: 'unauthenticated' }, }); expect(audit.mfaRequired).not.toHaveBeenCalled(); }); it('throws 401 unauthenticated when the session has no user', async () => { const audit = makeAuditStub(); const guard = new RequireMfaGuard(makeReflectorStub(), audit as unknown as AuditWriter); const ctx = makeContext(makeRequest({ session: {} })); await expect(guard.canActivate(ctx)).rejects.toBeInstanceOf(UnauthorizedException); expect(audit.mfaRequired).not.toHaveBeenCalled(); }); it('throws 401 mfa_required + audits when amr carries no MFA-class token', async () => { const audit = makeAuditStub(); const guard = new RequireMfaGuard(makeReflectorStub(), audit as unknown as AuditWriter); const ctx = makeContext( makeRequest({ session: { user: { ...USER, amr: ['pwd'] }, mfaVerifiedAt: Date.now() }, method: 'POST', originalUrl: '/api/admin/audit/query', }), ); await expect(guard.canActivate(ctx)).rejects.toMatchObject({ response: { code: 'mfa_required' }, }); expect(audit.mfaRequired).toHaveBeenCalledWith({ actor: { oid: 'user-oid' }, attemptedRoute: 'POST /api/admin/audit/query', reason: 'no-mfa-in-amr', freshnessSeconds: 600, }); }); it('throws 401 mfa_required + audits when mfaVerifiedAt is missing from the session', async () => { const audit = makeAuditStub(); const guard = new RequireMfaGuard(makeReflectorStub(), audit as unknown as AuditWriter); const ctx = makeContext(makeRequest({ session: { user: USER /* no mfaVerifiedAt */ } })); await expect(guard.canActivate(ctx)).rejects.toMatchObject({ response: { code: 'mfa_required' }, }); expect(audit.mfaRequired).toHaveBeenCalledWith({ actor: { oid: 'user-oid' }, attemptedRoute: 'GET /api/admin/me', reason: 'no-mfa-verified-at', freshnessSeconds: 600, }); }); it('throws 401 mfa_required + audits with mfaAgeMs when assertion is stale', async () => { const now = 10_000_000_000; jest.useFakeTimers(); jest.setSystemTime(now); const audit = makeAuditStub(); const guard = new RequireMfaGuard(makeReflectorStub(), audit as unknown as AuditWriter); const stale = now - 700_000; // 700 s old, default freshness is 600 s const ctx = makeContext(makeRequest({ session: { user: USER, mfaVerifiedAt: stale } })); await expect(guard.canActivate(ctx)).rejects.toMatchObject({ response: { code: 'mfa_required' }, }); expect(audit.mfaRequired).toHaveBeenCalledWith({ actor: { oid: 'user-oid' }, attemptedRoute: 'GET /api/admin/me', reason: 'mfa-stale', freshnessSeconds: 600, mfaAgeMs: 700_000, }); }); it('returns true when MFA assertion is within the freshness window', async () => { const now = 10_000_000_000; jest.useFakeTimers(); jest.setSystemTime(now); const audit = makeAuditStub(); const guard = new RequireMfaGuard(makeReflectorStub(), audit as unknown as AuditWriter); const ctx = makeContext(makeRequest({ session: { user: USER, mfaVerifiedAt: now - 60_000 } })); await expect(guard.canActivate(ctx)).resolves.toBe(true); expect(audit.mfaRequired).not.toHaveBeenCalled(); }); it('returns true at the exact freshness boundary (age == freshness)', async () => { const now = 10_000_000_000; jest.useFakeTimers(); jest.setSystemTime(now); const audit = makeAuditStub(); const guard = new RequireMfaGuard(makeReflectorStub(), audit as unknown as AuditWriter); const ctx = makeContext(makeRequest({ session: { user: USER, mfaVerifiedAt: now - 600_000 } })); await expect(guard.canActivate(ctx)).resolves.toBe(true); }); it('honours a per-route freshness override (tighter than env default)', async () => { const now = 10_000_000_000; jest.useFakeTimers(); jest.setSystemTime(now); const audit = makeAuditStub(); // Default env (600 s) would pass; route asks for 60 s, so a // 5-min-old assertion must be rejected. const guard = new RequireMfaGuard( makeReflectorStub({ freshness: 60 }), audit as unknown as AuditWriter, ); const ctx = makeContext( makeRequest({ session: { user: USER, mfaVerifiedAt: now - 5 * 60_000 } }), ); await expect(guard.canActivate(ctx)).rejects.toMatchObject({ response: { code: 'mfa_required' }, }); expect(audit.mfaRequired).toHaveBeenCalledWith( expect.objectContaining({ reason: 'mfa-stale', freshnessSeconds: 60 }), ); }); it('honours MFA_FRESHNESS_SECONDS when set and no per-route override', async () => { process.env['MFA_FRESHNESS_SECONDS'] = '120'; const now = 10_000_000_000; jest.useFakeTimers(); jest.setSystemTime(now); const audit = makeAuditStub(); const guard = new RequireMfaGuard(makeReflectorStub(), audit as unknown as AuditWriter); const ctx = makeContext(makeRequest({ session: { user: USER, mfaVerifiedAt: now - 180_000 } })); await expect(guard.canActivate(ctx)).rejects.toMatchObject({ response: { code: 'mfa_required' }, }); expect(audit.mfaRequired).toHaveBeenCalledWith( expect.objectContaining({ freshnessSeconds: 120 }), ); }); it('propagates audit-write failures (no audit ⇒ no action, per ADR-0013)', async () => { const audit = { mfaRequired: jest.fn().mockRejectedValue(new Error('audit_writer denied')), }; const guard = new RequireMfaGuard(makeReflectorStub(), audit as unknown as AuditWriter); const ctx = makeContext(makeRequest({ session: { user: { ...USER, amr: ['pwd'] } } })); await expect(guard.canActivate(ctx)).rejects.toThrow('audit_writer denied'); }); it('reads the per-route options via the Reflector under the documented metadata key', async () => { const audit = makeAuditStub(); const reflector = makeReflectorStub({ freshness: 60 }); const guard = new RequireMfaGuard(reflector, audit as unknown as AuditWriter); const ctx = makeContext(makeRequest({ session: { user: USER, mfaVerifiedAt: Date.now() } })); await guard.canActivate(ctx); expect(reflector.getAllAndOverride).toHaveBeenCalledWith( REQUIRE_MFA_METADATA, expect.any(Array), ); }); });