- 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>
170 lines
6.1 KiB
TypeScript
170 lines
6.1 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|
|
});
|