Files
sentryagent-idp/tests/unit/middleware/opa.wasm.test.ts
SentryAgent.ai Developer 7328a61c44 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>
2026-03-28 23:02:11 +00:00

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();
});
});