Files
upstream-rocketchat/ee/packages/abac/src/pdp/VirtruPDP.spec.ts
upstream-mirror 30810eecd7
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:21:02 +05:00

669 lines
29 KiB
TypeScript

import { OnlyCompliantCanBeAddedToRoomError, PdpHealthCheckError } from '../errors';
import { getDeniedSubjects, VirtruPDP } from './VirtruPDP';
import type { Decision } from './types';
const serverFetchMock = jest.fn();
jest.mock('@rocket.chat/server-fetch', () => ({ serverFetch: (...a: unknown[]) => serverFetchMock(...a) }));
const usersFindOneById = jest.fn();
const usersFindByUsernames = jest.fn();
const usersFindActiveByRoomIds = jest.fn();
const roomsFindPrivateRoomsByIdsWithAbacAttributes = jest.fn();
jest.mock('@rocket.chat/models', () => ({
Users: {
findOneById: (...a: unknown[]) => usersFindOneById(...a),
findByUsernames: (...a: unknown[]) => usersFindByUsernames(...a),
findActiveByRoomIds: (...a: unknown[]) => usersFindActiveByRoomIds(...a),
},
Rooms: {
findPrivateRoomsByIdsWithAbacAttributes: (...a: unknown[]) => roomsFindPrivateRoomsByIdsWithAbacAttributes(...a),
},
}));
const cfg = {
baseUrl: 'http://pdp',
clientId: 'cid',
clientSecret: 'sec',
oidcEndpoint: 'http://oidc',
defaultEntityKey: 'emailAddress' as const,
attributeNamespace: 'example.com',
};
const okJson = (body: unknown) => ({ ok: true, status: 200, json: async () => body, text: async () => '' });
const mkClient = (over: Partial<Record<'isAvailable' | 'apiCall' | 'getConfig' | 'getClientTokenForHealthCheck', jest.Mock>> = {}) =>
({
isAvailable: over.isAvailable ?? jest.fn().mockResolvedValue(true),
apiCall: over.apiCall ?? jest.fn(),
getConfig: over.getConfig ?? jest.fn().mockReturnValue(cfg),
getClientTokenForHealthCheck: over.getClientTokenForHealthCheck ?? jest.fn().mockResolvedValue('hc-tok'),
}) as any;
const cursor = <T>(items: T[]) => ({ toArray: () => Promise.resolve(items) });
const asyncIterable = <T>(items: T[]) => ({
[Symbol.asyncIterator]: () => {
let i = 0;
return {
next: () => Promise.resolve(i < items.length ? { value: items[i++], done: false } : { value: undefined, done: true }),
};
},
});
const user = (
over: Partial<{ _id: string; username: string; emails: { address: string; verified?: boolean }[]; __rooms: string[] }> = {},
) => ({
_id: 'u1',
username: 'bob',
emails: [{ address: 'bob@x.com', verified: true }],
...over,
});
beforeEach(() => {
serverFetchMock.mockReset();
usersFindOneById.mockReset();
usersFindByUsernames.mockReset();
usersFindActiveByRoomIds.mockReset();
roomsFindPrivateRoomsByIdsWithAbacAttributes.mockReset();
});
const permitFor = (ids: string[]) => ({
decisionResponses: [
{
resourceDecisions: ids.map((id) => ({ ephemeralResourceId: id, decision: 'DECISION_PERMIT' })),
},
],
});
const denyFor = (ids: string[]) => ({
decisionResponses: [
{
resourceDecisions: ids.map((id) => ({ ephemeralResourceId: id, decision: 'DECISION_DENY' })),
},
],
});
describe('VirtruPDP.isAvailable', () => {
it('delegates to client.isAvailable', async () => {
const isAvailable = jest.fn().mockResolvedValue(true);
const pdp = new VirtruPDP(mkClient({ isAvailable }));
expect(await pdp.isAvailable()).toBe(true);
expect(isAvailable).toHaveBeenCalled();
});
});
describe('getDeniedSubjects', () => {
const pair = (userId: string) => ({ user: { _id: userId }, room: { _id: 'r1' } });
const [a, b, c] = [pair('a'), pair('b'), pair('c')];
const resp = (...decisions: Decision[]) => ({
resourceDecisions: decisions.map((decision) => ({ ephemeralResourceId: 'r1', decision })),
});
it('collects subjects with an explicit DENY decision', () => {
const responses = [resp('DECISION_DENY'), resp('DECISION_PERMIT'), resp('DECISION_DENY')];
expect(getDeniedSubjects(responses, [a, b, c])).toEqual([a, c]);
});
it('skips subjects when every decision is PERMIT', () => {
expect(getDeniedSubjects([resp('DECISION_PERMIT')], [a])).toEqual([]);
});
it('skips inconclusive subjects (UNSPECIFIED, empty decisions, missing response)', () => {
expect(getDeniedSubjects([resp('DECISION_UNSPECIFIED'), resp(), undefined], [a, b, c])).toEqual([]);
});
it('treats a DENY among PERMITs in one response as denied', () => {
expect(getDeniedSubjects([resp('DECISION_PERMIT', 'DECISION_DENY')], [a])).toEqual([a]);
});
it('ignores responses without a matching subject', () => {
expect(getDeniedSubjects([resp('DECISION_DENY'), resp('DECISION_DENY')], [a])).toEqual([a]);
});
});
describe('VirtruPDP.canAccessObject', () => {
const room = { _id: 'r1', abacAttributes: [{ key: 'clearance', values: ['secret'] }] };
it('grants when room has no abac attributes (no DB or API call)', async () => {
const apiCall = jest.fn();
const pdp = new VirtruPDP(mkClient({ apiCall }));
const r = await pdp.canAccessObject({ _id: 'r0', abacAttributes: [] } as any, { _id: 'u1' });
expect(r).toEqual({ granted: true });
expect(usersFindOneById).not.toHaveBeenCalled();
expect(apiCall).not.toHaveBeenCalled();
});
it('denies when DB has no such user', async () => {
usersFindOneById.mockResolvedValue(null);
const apiCall = jest.fn();
const pdp = new VirtruPDP(mkClient({ apiCall }));
const r = await pdp.canAccessObject(room as any, { _id: 'u1' });
expect(r).toEqual({ granted: false });
expect(apiCall).not.toHaveBeenCalled();
});
it('denies when user has no entity key (no API call)', async () => {
usersFindOneById.mockResolvedValue(user({ emails: [] }));
const apiCall = jest.fn();
const pdp = new VirtruPDP(mkClient({ apiCall }));
const r = await pdp.canAccessObject(room as any, { _id: 'u1' });
expect(r).toEqual({ granted: false });
expect(apiCall).not.toHaveBeenCalled();
});
it('grants on DECISION_PERMIT and forwards entity + fqn to client', async () => {
usersFindOneById.mockResolvedValue(user());
const apiCall = jest.fn().mockResolvedValue(permitFor(['r1']));
const pdp = new VirtruPDP(mkClient({ apiCall }));
const r = await pdp.canAccessObject(room as any, { _id: 'u1' });
expect(r).toEqual({ granted: true });
const [endpoint, body] = apiCall.mock.calls[0];
expect(endpoint).toBe('/authorization.v2.AuthorizationService/GetDecisionBulk');
expect(body.decisionRequests[0].entityIdentifier.entityChain.entities[0]).toEqual({ emailAddress: 'bob@x.com' });
expect(body.decisionRequests[0].resources[0]).toEqual({
ephemeralId: 'r1',
attributeValues: { fqns: ['https://example.com/attr/clearance/value/secret'] },
});
});
it('denies with userToRemove on DECISION_DENY', async () => {
const fullUser = user();
usersFindOneById.mockResolvedValue(fullUser);
const apiCall = jest.fn().mockResolvedValue(denyFor(['r1']));
const pdp = new VirtruPDP(mkClient({ apiCall }));
const r = await pdp.canAccessObject(room as any, { _id: 'u1' });
expect(r.granted).toBe(false);
expect(r.userToRemove).toBe(fullUser);
});
it('denies WITHOUT userToRemove on inconclusive decision', async () => {
usersFindOneById.mockResolvedValue(user());
const apiCall = jest.fn().mockResolvedValue({ decisionResponses: [{ resourceDecisions: [{ decision: 'DECISION_UNSPECIFIED' }] }] });
const pdp = new VirtruPDP(mkClient({ apiCall }));
const r = await pdp.canAccessObject(room as any, { _id: 'u1' });
expect(r).toEqual({ granted: false });
});
it('denies WITHOUT userToRemove when response has no resourceDecisions', async () => {
usersFindOneById.mockResolvedValue(user());
const apiCall = jest.fn().mockResolvedValue({ decisionResponses: [{}] });
const pdp = new VirtruPDP(mkClient({ apiCall }));
const r = await pdp.canAccessObject(room as any, { _id: 'u1' });
expect(r).toEqual({ granted: false });
});
it('uses oidcIdentifier entity shape when defaultEntityKey is oidcIdentifier', async () => {
usersFindOneById.mockResolvedValue(user());
const apiCall = jest.fn().mockResolvedValue(permitFor(['r1']));
const oidcCfg = { ...cfg, defaultEntityKey: 'oidcIdentifier' as const };
const pdp = new VirtruPDP(mkClient({ apiCall, getConfig: jest.fn().mockReturnValue(oidcCfg) }));
await pdp.canAccessObject(room as any, { _id: 'u1' });
const [, body] = apiCall.mock.calls[0];
expect(body.decisionRequests[0].entityIdentifier.entityChain.entities[0]).toEqual({ id: 'bob' });
});
});
describe('VirtruPDP.checkUsernamesMatchAttributes', () => {
const room = { _id: 'r1' } as any;
const attrs = [{ key: 'clearance', values: ['secret'] }];
it('returns without API when usernames is empty', async () => {
const apiCall = jest.fn();
const pdp = new VirtruPDP(mkClient({ apiCall }));
await pdp.checkUsernamesMatchAttributes([], attrs, room);
expect(apiCall).not.toHaveBeenCalled();
expect(usersFindByUsernames).not.toHaveBeenCalled();
});
it('returns without API when attributes is empty', async () => {
const apiCall = jest.fn();
const pdp = new VirtruPDP(mkClient({ apiCall }));
await pdp.checkUsernamesMatchAttributes(['bob'], [], room);
expect(apiCall).not.toHaveBeenCalled();
});
it('throws when any user has no entity key', async () => {
usersFindByUsernames.mockReturnValue(cursor([user({ emails: [] })]));
const apiCall = jest.fn();
const pdp = new VirtruPDP(mkClient({ apiCall }));
await expect(pdp.checkUsernamesMatchAttributes(['bob'], attrs, room)).rejects.toBeInstanceOf(OnlyCompliantCanBeAddedToRoomError);
expect(apiCall).not.toHaveBeenCalled();
});
it('throws when zero decision requests can be built (all users filtered)', async () => {
usersFindByUsernames.mockReturnValue(cursor([]));
const apiCall = jest.fn();
const pdp = new VirtruPDP(mkClient({ apiCall }));
await expect(pdp.checkUsernamesMatchAttributes(['ghost'], attrs, room)).rejects.toBeInstanceOf(OnlyCompliantCanBeAddedToRoomError);
expect(apiCall).not.toHaveBeenCalled();
});
it('resolves when all users get DECISION_PERMIT', async () => {
usersFindByUsernames.mockReturnValue(
cursor([user(), user({ _id: 'u2', username: 'alice', emails: [{ address: 'a@x.com', verified: true }] })]),
);
const apiCall = jest.fn().mockResolvedValue({
decisionResponses: [
{ resourceDecisions: [{ ephemeralResourceId: 'r1', decision: 'DECISION_PERMIT' }] },
{ resourceDecisions: [{ ephemeralResourceId: 'r1', decision: 'DECISION_PERMIT' }] },
],
});
const pdp = new VirtruPDP(mkClient({ apiCall }));
await expect(pdp.checkUsernamesMatchAttributes(['bob', 'alice'], attrs, room)).resolves.toBeUndefined();
});
it('throws when any user is DENIED', async () => {
usersFindByUsernames.mockReturnValue(
cursor([user(), user({ _id: 'u2', username: 'alice', emails: [{ address: 'a@x.com', verified: true }] })]),
);
const apiCall = jest.fn().mockResolvedValue({
decisionResponses: [
{ resourceDecisions: [{ ephemeralResourceId: 'r1', decision: 'DECISION_PERMIT' }] },
{ resourceDecisions: [{ ephemeralResourceId: 'r1', decision: 'DECISION_DENY' }] },
],
});
const pdp = new VirtruPDP(mkClient({ apiCall }));
await expect(pdp.checkUsernamesMatchAttributes(['bob', 'alice'], attrs, room)).rejects.toBeInstanceOf(
OnlyCompliantCanBeAddedToRoomError,
);
});
it('throws when a decision response is empty (no resourceDecisions)', async () => {
usersFindByUsernames.mockReturnValue(cursor([user()]));
const apiCall = jest.fn().mockResolvedValue({ decisionResponses: [{}] });
const pdp = new VirtruPDP(mkClient({ apiCall }));
await expect(pdp.checkUsernamesMatchAttributes(['bob'], attrs, room)).rejects.toBeInstanceOf(OnlyCompliantCanBeAddedToRoomError);
});
});
describe('VirtruPDP.onRoomAttributesChanged', () => {
const room = { _id: 'r1', t: 'p', teamMain: false, abacAttributes: [] } as any;
const newAttrs = [{ key: 'clearance', values: ['secret'] }];
it('returns [] when no new attributes (no DB scan)', async () => {
const apiCall = jest.fn();
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.onRoomAttributesChanged(room, []);
expect(result).toEqual([]);
expect(usersFindActiveByRoomIds).not.toHaveBeenCalled();
});
it('returns user as non-compliant when entity key missing (no API request for that user)', async () => {
const u = user({ emails: [] });
usersFindActiveByRoomIds.mockReturnValue(asyncIterable([u]));
const apiCall = jest.fn();
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.onRoomAttributesChanged(room, newAttrs);
expect(result).toEqual([u]);
expect(apiCall).not.toHaveBeenCalled();
});
it('returns [] when all users are DECISION_PERMIT', async () => {
usersFindActiveByRoomIds.mockReturnValue(asyncIterable([user()]));
const apiCall = jest.fn().mockResolvedValue(permitFor(['r1']));
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.onRoomAttributesChanged(room, newAttrs);
expect(result).toEqual([]);
});
it('returns users that are DENIED', async () => {
const u1 = user();
const u2 = user({ _id: 'u2', username: 'alice', emails: [{ address: 'a@x.com', verified: true }] });
usersFindActiveByRoomIds.mockReturnValue(asyncIterable([u1, u2]));
const apiCall = jest.fn().mockResolvedValue({
decisionResponses: [
{ resourceDecisions: [{ ephemeralResourceId: 'r1', decision: 'DECISION_PERMIT' }] },
{ resourceDecisions: [{ ephemeralResourceId: 'r1', decision: 'DECISION_DENY' }] },
],
});
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.onRoomAttributesChanged(room, newAttrs);
expect(result).toEqual([u2]);
});
it('does not evict on empty resourceDecisions (inconclusive)', async () => {
const u = user();
usersFindActiveByRoomIds.mockReturnValue(asyncIterable([u]));
const apiCall = jest.fn().mockResolvedValue({ decisionResponses: [{ resourceDecisions: [] }] });
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.onRoomAttributesChanged(room, newAttrs);
expect(result).toEqual([]);
});
it('does not evict on DECISION_UNSPECIFIED (inconclusive)', async () => {
usersFindActiveByRoomIds.mockReturnValue(asyncIterable([user()]));
const apiCall = jest.fn().mockResolvedValue({
decisionResponses: [{ resourceDecisions: [{ ephemeralResourceId: 'r1', decision: 'DECISION_UNSPECIFIED' }] }],
});
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.onRoomAttributesChanged(room, newAttrs);
expect(result).toEqual([]);
});
it('still evicts entity-keyless users alongside DENY-only filtering of decided users', async () => {
const keyless = user({ _id: 'u0', emails: [] });
const denied = user({ _id: 'u2', username: 'alice', emails: [{ address: 'a@x.com', verified: true }] });
const unspecified = user({ _id: 'u3', username: 'carol', emails: [{ address: 'c@x.com', verified: true }] });
usersFindActiveByRoomIds.mockReturnValue(asyncIterable([keyless, denied, unspecified]));
const apiCall = jest.fn().mockResolvedValue({
decisionResponses: [
{ resourceDecisions: [{ ephemeralResourceId: 'r1', decision: 'DECISION_DENY' }] },
{ resourceDecisions: [{ ephemeralResourceId: 'r1', decision: 'DECISION_UNSPECIFIED' }] },
],
});
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.onRoomAttributesChanged(room, newAttrs);
expect(result).toEqual([keyless, denied]);
});
it('returns only entity-keyless users when ALL users lack entity keys (no API call)', async () => {
const u = user({ emails: [] });
usersFindActiveByRoomIds.mockReturnValue(asyncIterable([u]));
const apiCall = jest.fn();
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.onRoomAttributesChanged(room, newAttrs);
expect(result).toEqual([u]);
expect(apiCall).not.toHaveBeenCalled();
});
});
describe('VirtruPDP.onSubjectAttributesChanged', () => {
it('returns [] when user has no __rooms', async () => {
const pdp = new VirtruPDP(mkClient());
const result = await pdp.onSubjectAttributesChanged(user() as any, []);
expect(result).toEqual([]);
expect(roomsFindPrivateRoomsByIdsWithAbacAttributes).not.toHaveBeenCalled();
});
it('returns [] when no ABAC rooms among __rooms', async () => {
roomsFindPrivateRoomsByIdsWithAbacAttributes.mockReturnValue(cursor([]));
const pdp = new VirtruPDP(mkClient());
const result = await pdp.onSubjectAttributesChanged(user({ __rooms: ['r1'] }) as any, []);
expect(result).toEqual([]);
});
it('returns ALL abac rooms when user has no entity key (no API call)', async () => {
const rooms = [{ _id: 'r1', abacAttributes: [{ key: 'k', values: ['v'] }] }];
roomsFindPrivateRoomsByIdsWithAbacAttributes.mockReturnValue(cursor(rooms));
const apiCall = jest.fn();
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.onSubjectAttributesChanged(user({ __rooms: ['r1'], emails: [] }) as any, []);
expect(result).toEqual([{ _id: 'r1' }]);
expect(apiCall).not.toHaveBeenCalled();
});
it('returns only DENIED rooms when mixed decisions come back', async () => {
const rooms = [
{ _id: 'rP', abacAttributes: [{ key: 'k', values: ['v'] }] },
{ _id: 'rD', abacAttributes: [{ key: 'k', values: ['v'] }] },
];
roomsFindPrivateRoomsByIdsWithAbacAttributes.mockReturnValue(cursor(rooms));
const apiCall = jest.fn().mockResolvedValue({
decisionResponses: [
{ resourceDecisions: [{ ephemeralResourceId: 'rP', decision: 'DECISION_PERMIT' }] },
{ resourceDecisions: [{ ephemeralResourceId: 'rD', decision: 'DECISION_DENY' }] },
],
});
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.onSubjectAttributesChanged(user({ __rooms: ['rP', 'rD'] }) as any, []);
expect(result).toEqual([{ _id: 'rD' }]);
});
it('still treats DECISION_UNSPECIFIED as non-compliant (LDAP-driven path cannot yield inconclusive decisions)', async () => {
const rooms = [{ _id: 'r1', abacAttributes: [{ key: 'k', values: ['v'] }] }];
roomsFindPrivateRoomsByIdsWithAbacAttributes.mockReturnValue(cursor(rooms));
const apiCall = jest.fn().mockResolvedValue({
decisionResponses: [{ resourceDecisions: [{ ephemeralResourceId: 'r1', decision: 'DECISION_UNSPECIFIED' }] }],
});
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.onSubjectAttributesChanged(user({ __rooms: ['r1'] }) as any, []);
expect(result).toEqual([{ _id: 'r1' }]);
});
it('splits >200 rooms into multiple decision batches', async () => {
const total = 401;
const rooms = Array.from({ length: total }, (_, i) => ({ _id: `r${i}`, abacAttributes: [{ key: 'k', values: ['v'] }] }));
roomsFindPrivateRoomsByIdsWithAbacAttributes.mockReturnValue(cursor(rooms));
const apiCall = jest.fn().mockImplementation(async (_endpoint, body: any) => ({
decisionResponses: body.decisionRequests.map((r: any) => ({
resourceDecisions: [{ ephemeralResourceId: r.resources[0].ephemeralId, decision: 'DECISION_PERMIT' }],
})),
}));
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.onSubjectAttributesChanged(user({ __rooms: rooms.map((r) => r._id) }) as any, []);
expect(result).toEqual([]);
expect(apiCall).toHaveBeenCalledTimes(3);
const sizes = apiCall.mock.calls.map(([, body]: any[]) => body.decisionRequests.length);
expect(sizes.sort((a, b) => a - b)).toEqual([1, 200, 200]);
});
});
describe('VirtruPDP.evaluateUserRooms', () => {
it('returns [] when entries is empty', async () => {
const apiCall = jest.fn();
const pdp = new VirtruPDP(mkClient({ apiCall }));
expect(await pdp.evaluateUserRooms([])).toEqual([]);
expect(apiCall).not.toHaveBeenCalled();
});
it('flags every room of a user lacking entity key, with no API call', async () => {
const u = user({ emails: [] });
const apiCall = jest.fn();
const pdp = new VirtruPDP(mkClient({ apiCall }));
const rooms = [
{ _id: 'r1', abacAttributes: [{ key: 'k', values: ['v'] }] },
{ _id: 'r2', abacAttributes: [{ key: 'k', values: ['v'] }] },
];
const result = await pdp.evaluateUserRooms([{ user: u, rooms }]);
expect(result).toEqual([
{ user: u, room: rooms[0] },
{ user: u, room: rooms[1] },
]);
expect(apiCall).not.toHaveBeenCalled();
});
it('returns [] when every entry+room PERMITs', async () => {
const u = user();
const rooms = [{ _id: 'r1', abacAttributes: [{ key: 'k', values: ['v'] }] }];
const apiCall = jest.fn().mockResolvedValue(permitFor(['r1']));
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.evaluateUserRooms([{ user: u, rooms }]);
expect(result).toEqual([]);
});
it('returns non-compliant (user,room) pairs when a decision DENYs', async () => {
const u = user();
const rooms = [
{ _id: 'rP', abacAttributes: [{ key: 'k', values: ['v'] }] },
{ _id: 'rD', abacAttributes: [{ key: 'k', values: ['v'] }] },
];
const apiCall = jest.fn().mockResolvedValue({
decisionResponses: [
{ resourceDecisions: [{ ephemeralResourceId: 'rP', decision: 'DECISION_PERMIT' }] },
{ resourceDecisions: [{ ephemeralResourceId: 'rD', decision: 'DECISION_DENY' }] },
],
});
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.evaluateUserRooms([{ user: u, rooms }]);
expect(result).toEqual([{ user: u, room: rooms[1] }]);
});
it('does not evict on empty resourceDecisions (inconclusive)', async () => {
const u = user();
const rooms = [{ _id: 'r1', abacAttributes: [{ key: 'k', values: ['v'] }] }];
const apiCall = jest.fn().mockResolvedValue({ decisionResponses: [{ resourceDecisions: [] }] });
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.evaluateUserRooms([{ user: u, rooms }]);
expect(result).toEqual([]);
});
it('does not evict on DECISION_UNSPECIFIED (inconclusive)', async () => {
const u = user();
const rooms = [{ _id: 'r1', abacAttributes: [{ key: 'k', values: ['v'] }] }];
const apiCall = jest.fn().mockResolvedValue({
decisionResponses: [{ resourceDecisions: [{ ephemeralResourceId: 'r1', decision: 'DECISION_UNSPECIFIED' }] }],
});
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.evaluateUserRooms([{ user: u, rooms }]);
expect(result).toEqual([]);
});
it('evicts only explicit DENY among mixed PERMIT/UNSPECIFIED/DENY decisions', async () => {
const u = user();
const rooms = [
{ _id: 'rP', abacAttributes: [{ key: 'k', values: ['v'] }] },
{ _id: 'rU', abacAttributes: [{ key: 'k', values: ['v'] }] },
{ _id: 'rD', abacAttributes: [{ key: 'k', values: ['v'] }] },
];
const apiCall = jest.fn().mockResolvedValue({
decisionResponses: [
{ resourceDecisions: [{ ephemeralResourceId: 'rP', decision: 'DECISION_PERMIT' }] },
{ resourceDecisions: [{ ephemeralResourceId: 'rU', decision: 'DECISION_UNSPECIFIED' }] },
{ resourceDecisions: [{ ephemeralResourceId: 'rD', decision: 'DECISION_DENY' }] },
],
});
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.evaluateUserRooms([{ user: u, rooms }]);
expect(result).toEqual([{ user: u, room: rooms[2] }]);
});
it('rejects when the decision call fails (no evictions, sweep retried next run)', async () => {
const apiCall = jest.fn().mockRejectedValue(new Error('pdp down'));
const pdp = new VirtruPDP(mkClient({ apiCall }));
const rooms = [{ _id: 'r1', abacAttributes: [{ key: 'k', values: ['v'] }] }];
await expect(pdp.evaluateUserRooms([{ user: user(), rooms }])).rejects.toThrow('pdp down');
expect(apiCall).toHaveBeenCalled();
});
});
describe('VirtruPDP — PDP unreachable (decision call rejects)', () => {
const pdpDown = () => jest.fn().mockRejectedValue(new Error('pdp down'));
const room = { _id: 'r1', abacAttributes: [{ key: 'clearance', values: ['secret'] }] } as any;
const attrs = [{ key: 'clearance', values: ['secret'] }];
it('canAccessObject rejects (never silently grants) when the decision call fails', async () => {
usersFindOneById.mockResolvedValue(user());
const apiCall = pdpDown();
const pdp = new VirtruPDP(mkClient({ apiCall }));
await expect(pdp.canAccessObject(room, { _id: 'u1' })).rejects.toThrow('pdp down');
expect(apiCall).toHaveBeenCalled();
});
it('checkUsernamesMatchAttributes rejects (never silently admits) when the decision call fails', async () => {
usersFindByUsernames.mockReturnValue(cursor([user()]));
const apiCall = pdpDown();
const pdp = new VirtruPDP(mkClient({ apiCall }));
await expect(pdp.checkUsernamesMatchAttributes(['bob'], attrs, room)).rejects.toThrow('pdp down');
expect(apiCall).toHaveBeenCalled();
});
it('onRoomAttributesChanged rejects (never silently keeps everyone) when the decision call fails', async () => {
usersFindActiveByRoomIds.mockReturnValue(asyncIterable([user()]));
const apiCall = pdpDown();
const pdp = new VirtruPDP(mkClient({ apiCall }));
await expect(pdp.onRoomAttributesChanged(room, attrs)).rejects.toThrow('pdp down');
expect(apiCall).toHaveBeenCalled();
});
it('onSubjectAttributesChanged rejects when the decision call fails', async () => {
roomsFindPrivateRoomsByIdsWithAbacAttributes.mockReturnValue(cursor([room]));
const apiCall = pdpDown();
const pdp = new VirtruPDP(mkClient({ apiCall }));
await expect(pdp.onSubjectAttributesChanged(user({ __rooms: ['r1'] }) as any, [])).rejects.toThrow('pdp down');
expect(apiCall).toHaveBeenCalled();
});
});
describe('reevaluateUsers', () => {
const room = { _id: 'r1', abacAttributes: [{ key: 'clearance', values: ['secret'] }] };
it('returns no pairs when users have no ABAC rooms', async () => {
const pdp = new VirtruPDP(mkClient());
const result = await pdp.reevaluateUsers([user({ _id: 'u1', __rooms: [] })]);
expect(result).toEqual([]);
expect(roomsFindPrivateRoomsByIdsWithAbacAttributes).not.toHaveBeenCalled();
});
it('returns non-compliant {user, room} pairs for denied users', async () => {
roomsFindPrivateRoomsByIdsWithAbacAttributes.mockReturnValue(asyncIterable([room]));
const apiCall = jest.fn().mockResolvedValue(denyFor(['r1']));
const pdp = new VirtruPDP(mkClient({ apiCall }));
const u = user({ _id: 'u1', __rooms: ['r1'] });
const result = await pdp.reevaluateUsers([u]);
expect(result).toEqual([{ user: u, room }]);
});
it('returns no pairs when the user is permitted', async () => {
roomsFindPrivateRoomsByIdsWithAbacAttributes.mockReturnValue(asyncIterable([room]));
const apiCall = jest.fn().mockResolvedValue(permitFor(['r1']));
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.reevaluateUsers([user({ _id: 'u1', __rooms: ['r1'] })]);
expect(result).toEqual([]);
});
});
describe('VirtruPDP.getHealthStatus', () => {
const platformOk = () => okJson({ status: 'SERVING' });
const authOk = () => okJson({});
it('passes when platform SERVING, IdP token resolves, authorization OK', async () => {
serverFetchMock.mockResolvedValueOnce(platformOk()).mockResolvedValueOnce(authOk());
const pdp = new VirtruPDP(mkClient());
await expect(pdp.getHealthStatus()).resolves.toBeUndefined();
});
it('throws Platform_Failed when platform healthz returns non-ok HTTP', async () => {
serverFetchMock.mockResolvedValueOnce({ ok: false, status: 503, json: async () => ({}) });
const pdp = new VirtruPDP(mkClient());
await expect(pdp.getHealthStatus()).rejects.toMatchObject({ message: 'ABAC_PDP_Health_Platform_Failed' });
});
it('throws Platform_Failed when platform healthz status is not SERVING', async () => {
serverFetchMock.mockResolvedValueOnce(okJson({ status: 'NOT_SERVING' }));
const pdp = new VirtruPDP(mkClient());
await expect(pdp.getHealthStatus()).rejects.toBeInstanceOf(PdpHealthCheckError);
});
it('throws Platform_Failed when platform healthz status is missing', async () => {
serverFetchMock.mockResolvedValueOnce(okJson({}));
const pdp = new VirtruPDP(mkClient());
await expect(pdp.getHealthStatus()).rejects.toMatchObject({ message: 'ABAC_PDP_Health_Platform_Failed' });
});
it('throws Platform_Failed when serverFetch itself rejects', async () => {
serverFetchMock.mockRejectedValueOnce(new Error('network down'));
const pdp = new VirtruPDP(mkClient());
await expect(pdp.getHealthStatus()).rejects.toMatchObject({ message: 'ABAC_PDP_Health_Platform_Failed' });
});
it('throws IdP_Failed when token fetch errors', async () => {
serverFetchMock.mockResolvedValueOnce(platformOk());
const getClientTokenForHealthCheck = jest.fn().mockRejectedValue(new Error('idp down'));
const pdp = new VirtruPDP(mkClient({ getClientTokenForHealthCheck }));
await expect(pdp.getHealthStatus()).rejects.toMatchObject({ message: 'ABAC_PDP_Health_IdP_Failed' });
});
it('throws Authorization_Failed when authorization endpoint returns non-ok HTTP', async () => {
serverFetchMock.mockResolvedValueOnce(platformOk()).mockResolvedValueOnce({ ok: false, status: 403, json: async () => ({}) });
const pdp = new VirtruPDP(mkClient());
await expect(pdp.getHealthStatus()).rejects.toMatchObject({ message: 'ABAC_PDP_Health_Authorization_Failed' });
});
it('throws Authorization_Failed when authorization fetch rejects', async () => {
serverFetchMock.mockResolvedValueOnce(platformOk()).mockRejectedValueOnce(new Error('auth down'));
const pdp = new VirtruPDP(mkClient());
await expect(pdp.getHealthStatus()).rejects.toMatchObject({ message: 'ABAC_PDP_Health_Authorization_Failed' });
});
it('sends Bearer token to authorization endpoint', async () => {
serverFetchMock.mockResolvedValueOnce(platformOk()).mockResolvedValueOnce(authOk());
const pdp = new VirtruPDP(mkClient());
await pdp.getHealthStatus();
const authCall = serverFetchMock.mock.calls.find(([url]) => String(url).includes('GetEntitlements'));
expect((authCall?.[1] as any).headers.Authorization).toBe('Bearer hc-tok');
});
});