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
669 lines
29 KiB
TypeScript
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');
|
|
});
|
|
});
|