feat(phase-2): workstream 5 — OPA Policy Engine
- policies/authz.rego: Rego policy with path normalisation and scope enforcement - policies/data/scopes.json: all 13 endpoint → scope mappings - src/middleware/opa.ts: OpaMiddleware with Wasm primary path + scopes.json fallback; exports createOpaMiddleware() and reloadOpaPolicy() for SIGHUP hot-reload - All four route files: opaMiddleware wired after authMiddleware - AuditController, OAuth2Service: manual scope checks removed (now centralised in OPA) - src/server.ts: SIGHUP handler calls reloadOpaPolicy() - docs/devops/environment-variables.md: POLICY_DIR documented - 38 new tests; 302/302 passing; opa.ts coverage 98.66% statements Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
169
tests/unit/middleware/opa.wasm.test.ts
Normal file
169
tests/unit/middleware/opa.wasm.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Unit tests for src/middleware/opa.ts — Wasm mode and fail-closed edge cases.
|
||||
*
|
||||
* This file is kept separate from opa.test.ts because it needs different
|
||||
* `fs.existsSync` and `@open-policy-agent/opa-wasm` mock behaviour.
|
||||
*
|
||||
* Jest's module registry is isolated per test file, so the module-level
|
||||
* singletons (`wasmPolicy`, `scopesMap`) are fresh for each file.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { RequestHandler } from 'express';
|
||||
import { ITokenPayload } from '../../../src/types/index';
|
||||
import { AuthorizationError } from '../../../src/utils/errors';
|
||||
|
||||
// ─── 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;
|
||||
|
||||
// ─── Wasm mock — a LoadedPolicy-like object ───────────────────────────────────
|
||||
|
||||
/** Tracks calls so individual tests can assert on evaluation results. */
|
||||
const mockEvaluate = jest.fn();
|
||||
const mockSetData = jest.fn();
|
||||
|
||||
const MOCK_LOADED_POLICY = {
|
||||
evaluate: mockEvaluate,
|
||||
setData: mockSetData,
|
||||
};
|
||||
|
||||
// Mock @open-policy-agent/opa-wasm BEFORE the module is loaded
|
||||
jest.mock('@open-policy-agent/opa-wasm', () => ({
|
||||
loadPolicy: jest.fn().mockResolvedValue(MOCK_LOADED_POLICY),
|
||||
}));
|
||||
|
||||
// ─── Mock fs: existsSync returns true for .wasm AND scopes.json ──────────────
|
||||
|
||||
jest.mock('fs', () => {
|
||||
const actual = jest.requireActual<typeof import('fs')>('fs');
|
||||
return {
|
||||
...actual,
|
||||
existsSync: jest.fn((_filePath: unknown) => {
|
||||
// Both .wasm and other paths exist
|
||||
return true;
|
||||
}),
|
||||
readFileSync: jest.fn((filePath: unknown, encoding?: unknown) => {
|
||||
if (typeof filePath === 'string' && filePath.endsWith('.wasm')) {
|
||||
// Return a Buffer-like object for the Wasm bundle
|
||||
return Buffer.from('fake-wasm-bytes');
|
||||
}
|
||||
// For scopes.json, delegate to the real fs
|
||||
return actual.readFileSync(filePath as string, encoding as BufferEncoding);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Import AFTER mocks
|
||||
import { createOpaMiddleware, reloadOpaPolicy } from '../../../src/middleware/opa';
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeUser(scope: string): ITokenPayload {
|
||||
return {
|
||||
sub: 'agent-wasm-test',
|
||||
client_id: 'agent-wasm-test',
|
||||
scope,
|
||||
jti: 'jti-wasm-001',
|
||||
iat: 1000,
|
||||
exp: 9999999999,
|
||||
};
|
||||
}
|
||||
|
||||
function makeReq(
|
||||
method: string,
|
||||
baseUrl: string,
|
||||
reqPath: string,
|
||||
user?: ITokenPayload,
|
||||
): Partial<Request> {
|
||||
return { method, baseUrl, path: reqPath, user };
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('createOpaMiddleware (Wasm mode)', () => {
|
||||
let middleware: RequestHandler;
|
||||
let next: jest.MockedFunction<NextFunction>;
|
||||
|
||||
beforeAll(async () => {
|
||||
middleware = await createOpaMiddleware();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
next = jest.fn();
|
||||
mockEvaluate.mockReset();
|
||||
});
|
||||
|
||||
it('should load in Wasm mode and call setData with scopes.json', () => {
|
||||
// setData is called once during createOpaMiddleware() → loadWasmPolicy()
|
||||
expect(mockSetData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should allow request when Wasm policy evaluate returns allow: true', () => {
|
||||
mockEvaluate.mockReturnValue([{ result: { allow: true } }]);
|
||||
|
||||
const req = makeReq('GET', '/api/v1', '/agents', makeUser('agents:read')) as Request;
|
||||
middleware(req, {} as Response, next);
|
||||
|
||||
expect(mockEvaluate).toHaveBeenCalledTimes(1);
|
||||
expect(next).toHaveBeenCalledWith(/* no args */);
|
||||
});
|
||||
|
||||
it('should deny request when Wasm policy evaluate returns allow: false', () => {
|
||||
mockEvaluate.mockReturnValue([{ result: { allow: false } }]);
|
||||
|
||||
const req = makeReq('GET', '/api/v1', '/agents', makeUser('agents:read')) as Request;
|
||||
middleware(req, {} as Response, next);
|
||||
|
||||
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
|
||||
});
|
||||
|
||||
it('should deny request when Wasm evaluate returns empty result set', () => {
|
||||
mockEvaluate.mockReturnValue([]);
|
||||
|
||||
const req = makeReq('POST', '/api/v1', '/agents', makeUser('agents:write')) as Request;
|
||||
middleware(req, {} as Response, next);
|
||||
|
||||
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
|
||||
});
|
||||
|
||||
it('should deny request when Wasm evaluate returns non-array result', () => {
|
||||
mockEvaluate.mockReturnValue(null);
|
||||
|
||||
const req = makeReq('GET', '/api/v1', '/audit', makeUser('audit:read')) as Request;
|
||||
middleware(req, {} as Response, next);
|
||||
|
||||
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
|
||||
});
|
||||
|
||||
it('should propagate unexpected errors thrown by Wasm evaluate to next', () => {
|
||||
const wasmError = new Error('Wasm evaluation failure');
|
||||
mockEvaluate.mockImplementation(() => { throw wasmError; });
|
||||
|
||||
const req = makeReq('GET', '/api/v1', '/agents', makeUser('agents:read')) as Request;
|
||||
middleware(req, {} as Response, next);
|
||||
|
||||
expect(next).toHaveBeenCalledWith(wasmError);
|
||||
});
|
||||
|
||||
it('should call next(AuthorizationError) with "not authenticated" when req.user is absent in Wasm mode', () => {
|
||||
const req = makeReq('GET', '/api/v1', '/agents', undefined) as Request;
|
||||
middleware(req, {} as Response, next);
|
||||
|
||||
const err = (next as jest.Mock).mock.calls[0][0] as AuthorizationError;
|
||||
expect(err).toBeInstanceOf(AuthorizationError);
|
||||
expect(err.message).toMatch(/not authenticated/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reloadOpaPolicy (Wasm mode)', () => {
|
||||
it('should reload in Wasm mode without error', async () => {
|
||||
await expect(reloadOpaPolicy()).resolves.toBeUndefined();
|
||||
// setData should have been called again during reload
|
||||
expect(mockSetData).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user