/** * Unit tests for src/middleware/opa.ts * * All tests run in fallback mode (scopes.json). * `fs.existsSync` is mocked to return false for the Wasm bundle path so * the Wasm loader is bypassed and `loadScopesFallback()` is always called. * * POLICY_DIR is set to the real `policies/` directory in the project root so * tests use the production `data/scopes.json` without duplicating it. */ import path from 'path'; import { Request, Response, NextFunction } from 'express'; import { RequestHandler } from 'express'; import { AuthorizationError } from '../../../src/utils/errors'; import { ITokenPayload } from '../../../src/types/index'; // ─── Point POLICY_DIR at the real policies directory ───────────────────────── const PROJECT_ROOT = path.resolve(__dirname, '../../..'); const POLICIES_DIR = path.join(PROJECT_ROOT, 'policies'); process.env['POLICY_DIR'] = POLICIES_DIR; // ─── Mock fs.existsSync so Wasm bundle is never found ──────────────────────── // We do this BEFORE importing the module under test so the module-level // WASM_PATH check in `loadWasmPolicy()` always returns false. jest.mock('fs', () => { const actual = jest.requireActual('fs'); return { ...actual, existsSync: jest.fn((filePath: unknown) => { // Deny Wasm bundle; allow all other paths (including scopes.json) if (typeof filePath === 'string' && filePath.endsWith('.wasm')) { return false; } return actual.existsSync(filePath as string); }), }; }); // ─── Import the module under test AFTER mocks are in place ─────────────────── import { createOpaMiddleware, reloadOpaPolicy } from '../../../src/middleware/opa'; // ─── Helpers ────────────────────────────────────────────────────────────────── function makeUser(scope: string): ITokenPayload { return { sub: 'agent-abc-123', client_id: 'agent-abc-123', scope, jti: 'jti-001', iat: 1000, exp: 9999999999, }; } /** * Builds a minimal mock Express Request for OPA middleware testing. * The middleware uses `req.baseUrl + req.path` for the full path. */ function makeReq( method: string, baseUrl: string, reqPath: string, user?: ITokenPayload, ): Partial { return { method, baseUrl, path: reqPath, user, }; } // ─── Test suite ─────────────────────────────────────────────────────────────── describe('createOpaMiddleware (fallback mode)', () => { let middleware: RequestHandler; let next: jest.MockedFunction; beforeAll(async () => { // Create middleware once; all tests share the same loaded scopes.json middleware = await createOpaMiddleware(); }); beforeEach(() => { next = jest.fn(); }); // ── Unauthenticated request ─────────────────────────────────────────────── it('should call next(AuthorizationError) with "not authenticated" when req.user is absent', () => { const req = makeReq('GET', '/api/v1', '/agents', undefined) as Request; middleware(req, {} as Response, next); expect(next).toHaveBeenCalledTimes(1); const err = (next as jest.Mock).mock.calls[0][0] as AuthorizationError; expect(err).toBeInstanceOf(AuthorizationError); expect(err.message).toMatch(/not authenticated/i); }); // ── agents:read endpoints ────────────────────────────────────────────────── it('should allow GET /api/v1/agents with agents:read scope', () => { const req = makeReq('GET', '/api/v1', '/agents', makeUser('agents:read')) as Request; middleware(req, {} as Response, next); expect(next).toHaveBeenCalledWith(/* no error */); }); it('should deny GET /api/v1/agents with agents:write scope only', () => { const req = makeReq('GET', '/api/v1', '/agents', makeUser('agents:write')) as Request; middleware(req, {} as Response, next); expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError)); }); it('should deny GET /api/v1/agents when scope list is empty', () => { const req = makeReq('GET', '/api/v1', '/agents', makeUser('')) as Request; middleware(req, {} as Response, next); expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError)); }); it('should allow GET /api/v1/agents/:id with agents:read scope (UUID path)', () => { const req = makeReq( 'GET', '/api/v1', '/agents/550e8400-e29b-41d4-a716-446655440000', makeUser('agents:read'), ) as Request; middleware(req, {} as Response, next); expect(next).toHaveBeenCalledWith(/* no error */); }); it('should deny GET /api/v1/agents/:id with wrong scope', () => { const req = makeReq( 'GET', '/api/v1', '/agents/550e8400-e29b-41d4-a716-446655440000', makeUser('audit:read'), ) as Request; middleware(req, {} as Response, next); expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError)); }); // ── agents:write endpoints ───────────────────────────────────────────────── it('should allow POST /api/v1/agents with agents:write scope', () => { const req = makeReq('POST', '/api/v1', '/agents', makeUser('agents:write')) as Request; middleware(req, {} as Response, next); expect(next).toHaveBeenCalledWith(/* no error */); }); it('should deny POST /api/v1/agents with agents:read scope only', () => { const req = makeReq('POST', '/api/v1', '/agents', makeUser('agents:read')) as Request; middleware(req, {} as Response, next); expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError)); }); it('should allow PATCH /api/v1/agents/:id with agents:write scope', () => { const req = makeReq( 'PATCH', '/api/v1', '/agents/550e8400-e29b-41d4-a716-446655440000', makeUser('agents:write'), ) as Request; middleware(req, {} as Response, next); expect(next).toHaveBeenCalledWith(/* no error */); }); it('should allow DELETE /api/v1/agents/:id with agents:write scope', () => { const req = makeReq( 'DELETE', '/api/v1', '/agents/550e8400-e29b-41d4-a716-446655440000', makeUser('agents:write'), ) as Request; middleware(req, {} as Response, next); expect(next).toHaveBeenCalledWith(/* no error */); }); // ── credentials sub-resource endpoints ──────────────────────────────────── it('should allow GET /api/v1/agents/:id/credentials with agents:read scope', () => { const req = makeReq( 'GET', '/api/v1', '/agents/550e8400-e29b-41d4-a716-446655440000/credentials', makeUser('agents:read'), ) as Request; middleware(req, {} as Response, next); expect(next).toHaveBeenCalledWith(/* no error */); }); it('should allow POST /api/v1/agents/:id/credentials with agents:write scope', () => { const req = makeReq( 'POST', '/api/v1', '/agents/550e8400-e29b-41d4-a716-446655440000/credentials', makeUser('agents:write'), ) as Request; middleware(req, {} as Response, next); expect(next).toHaveBeenCalledWith(/* no error */); }); it('should allow POST /api/v1/agents/:id/credentials/:credId/rotate with agents:write scope', () => { const req = makeReq( 'POST', '/api/v1', '/agents/550e8400-e29b-41d4-a716-446655440000/credentials/cred-id-001/rotate', makeUser('agents:write'), ) as Request; middleware(req, {} as Response, next); expect(next).toHaveBeenCalledWith(/* no error */); }); it('should deny POST /api/v1/agents/:id/credentials/:credId/rotate with agents:read scope', () => { const req = makeReq( 'POST', '/api/v1', '/agents/550e8400-e29b-41d4-a716-446655440000/credentials/cred-id-001/rotate', makeUser('agents:read'), ) as Request; middleware(req, {} as Response, next); expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError)); }); it('should allow DELETE /api/v1/agents/:id/credentials/:credId with agents:write scope', () => { const req = makeReq( 'DELETE', '/api/v1', '/agents/550e8400-e29b-41d4-a716-446655440000/credentials/cred-id-001', makeUser('agents:write'), ) as Request; middleware(req, {} as Response, next); expect(next).toHaveBeenCalledWith(/* no error */); }); // ── tokens:read endpoints ────────────────────────────────────────────────── it('should allow POST /api/v1/token/introspect with tokens:read scope', () => { const req = makeReq( 'POST', '/api/v1', '/token/introspect', makeUser('tokens:read'), ) as Request; middleware(req, {} as Response, next); expect(next).toHaveBeenCalledWith(/* no error */); }); it('should deny POST /api/v1/token/introspect with agents:read scope only', () => { const req = makeReq( 'POST', '/api/v1', '/token/introspect', makeUser('agents:read'), ) as Request; middleware(req, {} as Response, next); expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError)); }); it('should allow POST /api/v1/token/revoke with tokens:read scope', () => { const req = makeReq( 'POST', '/api/v1', '/token/revoke', makeUser('tokens:read'), ) as Request; middleware(req, {} as Response, next); expect(next).toHaveBeenCalledWith(/* no error */); }); it('should deny POST /api/v1/token/revoke without tokens:read scope', () => { const req = makeReq( 'POST', '/api/v1', '/token/revoke', makeUser('agents:write'), ) as Request; middleware(req, {} as Response, next); expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError)); }); // ── audit:read endpoints ─────────────────────────────────────────────────── it('should allow GET /api/v1/audit with audit:read scope', () => { const req = makeReq('GET', '/api/v1', '/audit', makeUser('audit:read')) as Request; middleware(req, {} as Response, next); expect(next).toHaveBeenCalledWith(/* no error */); }); it('should deny GET /api/v1/audit with agents:read scope only', () => { const req = makeReq('GET', '/api/v1', '/audit', makeUser('agents:read')) as Request; middleware(req, {} as Response, next); expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError)); }); it('should allow GET /api/v1/audit/:id with audit:read scope (UUID path)', () => { const req = makeReq( 'GET', '/api/v1', '/audit/550e8400-e29b-41d4-a716-446655440000', makeUser('audit:read'), ) as Request; middleware(req, {} as Response, next); expect(next).toHaveBeenCalledWith(/* no error */); }); it('should deny GET /api/v1/audit/:id without audit:read scope', () => { const req = makeReq( 'GET', '/api/v1', '/audit/550e8400-e29b-41d4-a716-446655440000', makeUser('tokens:read'), ) as Request; middleware(req, {} as Response, next); expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError)); }); // ── Path normalisation ──────────────────────────────────────────────────── it('should normalise UUID agent path correctly (longest segment first)', () => { // Full rotate path with real UUIDs — must hit the :credId/rotate rule const req = makeReq( 'POST', '/api/v1', '/agents/a1b2c3d4-e5f6-4000-8000-ef1234567890/credentials/b2c3d4e5-f6a7-4000-8000-fa2345678901/rotate', makeUser('agents:write'), ) as Request; middleware(req, {} as Response, next); expect(next).toHaveBeenCalledWith(/* no error */); }); it('should normalise path with non-UUID segment identifiers', () => { // Non-UUID IDs are still matched by the regex (any non-slash characters) const req = makeReq( 'GET', '/api/v1', '/agents/my-agent-slug', makeUser('agents:read'), ) as Request; middleware(req, {} as Response, next); expect(next).toHaveBeenCalledWith(/* no error */); }); // ── Unknown / unmapped paths ────────────────────────────────────────────── it('should deny (fail-closed) when path has no matching entry in scopes.json', () => { const req = makeReq( 'GET', '/api/v1', '/unknown/resource', makeUser('agents:read agents:write tokens:read audit:read'), ) as Request; middleware(req, {} as Response, next); expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError)); }); it('should deny (fail-closed) for a valid path with wrong HTTP method', () => { // PUT is not in scopes.json for any endpoint const req = makeReq( 'PUT', '/api/v1', '/agents', makeUser('agents:read agents:write'), ) as Request; middleware(req, {} as Response, next); expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError)); }); // ── Multi-scope token ───────────────────────────────────────────────────── it('should allow access when token has multiple scopes including the required one', () => { const req = makeReq( 'GET', '/api/v1', '/audit', makeUser('agents:read tokens:read audit:read'), ) as Request; middleware(req, {} as Response, next); expect(next).toHaveBeenCalledWith(/* no error */); }); }); // ─── reloadOpaPolicy ────────────────────────────────────────────────────────── describe('reloadOpaPolicy()', () => { it('should reload without error and continue to enforce policy correctly', async () => { await expect(reloadOpaPolicy()).resolves.toBeUndefined(); // After reload, fallback mode should still work — create a fresh middleware const mw = await createOpaMiddleware(); const next = jest.fn() as jest.MockedFunction; const req = { method: 'GET', baseUrl: '/api/v1', path: '/agents', user: { sub: 'agent-xyz', client_id: 'agent-xyz', scope: 'agents:read', jti: 'jti-reload', iat: 1000, exp: 9999999999, }, } as unknown as Request; mw(req, {} as Response, next); expect(next).toHaveBeenCalledWith(/* no error */); }); it('should still deny access after reload when scope is insufficient', async () => { await reloadOpaPolicy(); const mw = await createOpaMiddleware(); const next = jest.fn() as jest.MockedFunction; const req = { method: 'POST', baseUrl: '/api/v1', path: '/token/introspect', user: { sub: 'agent-xyz', client_id: 'agent-xyz', scope: 'agents:read', jti: 'jti-reload-deny', iat: 1000, exp: 9999999999, }, } as unknown as Request; mw(req, {} as Response, next); expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError)); }); });