Files
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

4617 lines
159 KiB
TypeScript

import type { Credentials } from '@rocket.chat/api-client';
import type { IAbacAttributeDefinition, IRoom, IUser } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import { before, after, describe, it } from 'mocha';
import { MongoClient } from 'mongodb';
import { api, getCredentials, request, credentials, methodCall } from '../../data/api-data';
import { sleep } from '../../data/livechat/utils';
import {
mockServerHealthy,
mockServerReset,
mockServerSet,
mockServerSetMany,
seedBulkDecisionByEntity,
seedDefaultMocks,
seedGetDecisionBulk,
seedGetEntitlements,
} from '../../data/mock-server.helper';
import { updatePermission, updateSetting } from '../../data/permissions.helper';
import { createRoom, deleteRoom } from '../../data/rooms.helper';
import { deleteTeam } from '../../data/teams.helper';
import { adminEmail, adminUsername, password } from '../../data/user';
import { createUser, deleteUser, login } from '../../data/users.helper';
import { IS_EE, URL_MONGODB } from '../../e2e/config/constants';
// NOTE: This manipulates the DB directly to add ABAC attributes to a user
// The idea is to avoid having to go through LDAP to add info to the user
let connection: MongoClient;
const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: IAbacAttributeDefinition[]) => {
await connection.db().collection('users').updateOne(
{
// @ts-expect-error - collection types for _id
_id: userId,
},
{ $set: { abacAttributes } },
);
};
(IS_EE ? describe : describe.skip)('[ABAC] (Enterprise Only)', function () {
this.retries(0);
let testRoom: IRoom;
let unauthorizedUser: IUser;
let unauthorizedCredentials: Credentials;
const initialKey = `attr_${Date.now()}`;
const updatedKey = `${initialKey}_renamed`;
const anotherKey = `${initialKey}_another`;
let attributeId: string;
let page1AttributeIds: string[] = [];
before((done) => getCredentials(done));
before(async () => {
connection = await MongoClient.connect(URL_MONGODB);
await Promise.all([
updatePermission('abac-management', ['admin']),
updatePermission('manage-abac-admin-settings', ['admin']),
updatePermission('manage-abac-admin-room-attributes', ['admin']),
updatePermission('manage-abac-admin-rooms', ['admin']),
updatePermission('view-abac-admin-audit', ['admin']),
]);
await updateSetting('ABAC_Enabled', true);
testRoom = (await createRoom({ type: 'p', name: `abac-test-${Date.now()}` })).body.group;
unauthorizedUser = await createUser();
unauthorizedCredentials = await login(unauthorizedUser.username, password);
});
after(async () => {
await deleteRoom({ type: 'p', roomId: testRoom._id });
await deleteUser(unauthorizedUser);
await updateSetting('ABAC_Enabled', false);
await connection.close();
});
const v1 = '/api/v1';
describe('Permission & Authentication', () => {
it('GET /api/v1/abac/attributes should return 401 when not authenticated', async () => {
await request.get(`${v1}/abac/attributes`).expect(401);
});
it('POST /api/v1/abac/attributes should return 403 for user without abac-management permission', async () => {
await request
.post(`${v1}/abac/attributes`)
.set(unauthorizedCredentials)
.send({ key: 'nokey', values: ['v1'] })
.expect(403)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]');
});
});
describe('Granular admin permissions (with abac-management, missing the granular)', () => {
const dummyKey = 'granular_perm_dummy_key';
const dummyId = 'granular_perm_dummy_id';
after(async () => {
await Promise.all([
updatePermission('manage-abac-admin-settings', ['admin']),
updatePermission('manage-abac-admin-room-attributes', ['admin']),
updatePermission('manage-abac-admin-rooms', ['admin']),
updatePermission('view-abac-admin-audit', ['admin']),
]);
});
describe('without manage-abac-admin-rooms', () => {
before(async () => {
await updatePermission('manage-abac-admin-rooms', []);
});
after(async () => {
await updatePermission('manage-abac-admin-rooms', ['admin']);
});
it('POST /abac/rooms/:rid/attributes should return 403', async () => {
await request.post(`${v1}/abac/rooms/${testRoom._id}/attributes`).set(credentials).send({ attributes: {} }).expect(403);
});
it('DELETE /abac/rooms/:rid/attributes should return 403', async () => {
await request.delete(`${v1}/abac/rooms/${testRoom._id}/attributes`).set(credentials).expect(403);
});
it('POST /abac/rooms/:rid/attributes/:key should return 403', async () => {
await request
.post(`${v1}/abac/rooms/${testRoom._id}/attributes/${dummyKey}`)
.set(credentials)
.send({ values: ['v1'] })
.expect(403);
});
it('PUT /abac/rooms/:rid/attributes/:key should return 403', async () => {
await request
.put(`${v1}/abac/rooms/${testRoom._id}/attributes/${dummyKey}`)
.set(credentials)
.send({ values: ['v1'] })
.expect(403);
});
it('DELETE /abac/rooms/:rid/attributes/:key should return 403', async () => {
await request.delete(`${v1}/abac/rooms/${testRoom._id}/attributes/${dummyKey}`).set(credentials).expect(403);
});
it('GET /abac/rooms should return 403', async () => {
await request.get(`${v1}/abac/rooms`).set(credentials).expect(403);
});
});
describe('without manage-abac-admin-room-attributes', () => {
before(async () => {
await updatePermission('manage-abac-admin-room-attributes', []);
});
after(async () => {
await updatePermission('manage-abac-admin-room-attributes', ['admin']);
});
it('GET /abac/attributes should return 403', async () => {
await request.get(`${v1}/abac/attributes`).set(credentials).expect(403);
});
it('POST /abac/attributes should return 403', async () => {
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: dummyKey, values: ['v1'] })
.expect(403);
});
it('PUT /abac/attributes/:_id should return 403', async () => {
await request.put(`${v1}/abac/attributes/${dummyId}`).set(credentials).send({ key: dummyKey }).expect(403);
});
it('GET /abac/attributes/:_id should return 403', async () => {
await request.get(`${v1}/abac/attributes/${dummyId}`).set(credentials).expect(403);
});
it('DELETE /abac/attributes/:_id should return 403', async () => {
await request.delete(`${v1}/abac/attributes/${dummyId}`).set(credentials).expect(403);
});
it('GET /abac/attributes/:key/is-in-use should return 403', async () => {
await request.get(`${v1}/abac/attributes/${dummyKey}/is-in-use`).set(credentials).expect(403);
});
it('POST /abac/users/sync should return 403', async () => {
await request
.post(api('abac/users/sync'))
.set(credentials)
.send({ usernames: ['x'] })
.expect(403);
});
});
describe('without manage-abac-admin-settings', () => {
before(async () => {
await updatePermission('manage-abac-admin-settings', []);
});
after(async () => {
await updatePermission('manage-abac-admin-settings', ['admin']);
});
it('GET /abac/pdp/health should return 403', async () => {
await request.get(`${v1}/abac/pdp/health`).set(credentials).expect(403);
});
});
describe('without view-abac-admin-audit', () => {
before(async () => {
await updatePermission('view-abac-admin-audit', []);
});
after(async () => {
await updatePermission('view-abac-admin-audit', ['admin']);
});
it('GET /abac/audit should return 403', async () => {
await request.get(`${v1}/abac/audit`).set(credentials).expect(403);
});
});
});
});
describe('Attribute Definition - Validations & CRUD', () => {
it('POST should fail with invalid key pattern (space)', async () => {
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: 'invalid key with space', values: ['one'] })
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
});
});
it('POST should fail with duplicate values', async () => {
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: anotherKey, values: ['dup', 'dup'] })
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
});
});
it('POST should fail with empty values array', async () => {
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: `${anotherKey}_empty`, values: [] })
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
});
});
it('POST should create a valid attribute definition', async () => {
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: initialKey, values: ['red', 'green', 'blue'] })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
});
it('POST should allow more than 10 values for an attribute definition', async () => {
const manyValues = ['v1', 'v2', 'v3', 'v4', 'v5', 'v6', 'v7', 'v8', 'v9', 'v10', 'v11', 'v12'];
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: `${anotherKey}_many_values`, values: manyValues })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
await request
.get(`${v1}/abac/attributes`)
.set(credentials)
.query({ key: `${anotherKey}_many_values` })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('attributes').that.is.an('array');
const found = res.body.attributes.find((a: any) => a.key === `${anotherKey}_many_values`);
expect(found).to.exist;
expect(found).to.have.property('values').that.deep.equals(manyValues);
});
});
it('POST should fail creating duplicate key', async () => {
await request.get(`${v1}/abac/attributes`).set(credentials).expect(200);
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: initialKey, values: ['another'] })
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
});
});
it('GET should list attributes including the created one', async () => {
await request
.get(`${v1}/abac/attributes`)
.set(credentials)
.query({ key: initialKey })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('attributes').that.is.an('array');
const found = res.body.attributes.find((a: any) => a.key === initialKey);
expect(found).to.exist;
expect(found).to.have.property('_id');
attributeId = found._id;
});
});
it('GET should paginate attributes (page 1)', async () => {
const paginationBase = `pg_${Date.now()}`;
await Promise.all(
['a', 'b', 'c'].map((suffix) =>
request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: `${paginationBase}_${suffix}`, values: ['one'] })
.expect(200),
),
);
await request
.get(`${v1}/abac/attributes`)
.set(credentials)
.query({ count: 2, offset: 0 })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('offset', 0);
expect(res.body).to.have.property('count', 2);
expect(res.body).to.have.property('total').that.is.a('number').and.to.be.at.least(4);
expect(res.body).to.have.property('attributes').that.is.an('array').with.lengthOf(2);
page1AttributeIds = res.body.attributes.map((a: any) => a._id);
});
});
it('GET should paginate attributes (page 2)', async () => {
await request
.get(`${v1}/abac/attributes`)
.set(credentials)
.query({ count: 2, offset: 2 })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('offset', 2);
expect(res.body).to.have.property('count').that.is.a('number');
expect(res.body.count).to.be.at.most(2);
expect(res.body).to.have.property('total').that.is.a('number').and.to.be.at.least(4);
expect(res.body).to.have.property('attributes').that.is.an('array');
const page2Ids = res.body.attributes.map((a: any) => a._id);
page2Ids.forEach((id: string) => {
expect(page1AttributeIds).to.not.include(id);
});
});
});
it('GET by id should retrieve attribute definition', async () => {
await request
.get(`${v1}/abac/attributes/${attributeId}`)
.set(credentials)
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('key', initialKey);
expect(res.body).to.have.property('values').that.is.an('array');
});
});
it('PUT should fail with empty body (needs key or values)', async () => {
await request
.put(`${v1}/abac/attributes/${attributeId}`)
.set(credentials)
.send({})
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
});
});
it('PUT should update values only', async () => {
await request
.put(`${v1}/abac/attributes/${attributeId}`)
.set(credentials)
.send({ values: ['cyan', 'magenta', 'yellow'] })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
await request
.get(`${v1}/abac/attributes/${attributeId}`)
.set(credentials)
.expect(200)
.expect((res) => {
expect(res.body.values).to.deep.equal(['cyan', 'magenta', 'yellow']);
});
});
it('PUT should allow updating an attribute definition to have more than 10 values', async () => {
const manyValues = ['cyan', 'magenta', 'yellow', 'w4', 'w5', 'w6', 'w7', 'w8', 'w9', 'w10', 'w11', 'w12'];
await request
.put(`${v1}/abac/attributes/${attributeId}`)
.set(credentials)
.send({ values: manyValues })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
await request
.get(`${v1}/abac/attributes/${attributeId}`)
.set(credentials)
.expect(200)
.expect((res) => {
expect(res.body.values).to.deep.equal(manyValues);
});
});
it('PUT should update key only', async () => {
await request
.put(`${v1}/abac/attributes/${attributeId}`)
.set(credentials)
.send({ key: updatedKey })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
await request
.get(`${v1}/abac/attributes/${attributeId}`)
.set(credentials)
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('key', updatedKey);
});
});
});
describe('Room Attribute Operations', () => {
it('GET is-in-use should initially be false (no room usage yet)', async () => {
await request
.get(`${v1}/abac/attributes/${updatedKey}/is-in-use`)
.set(credentials)
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('inUse', false);
});
});
it('POST room attribute should fail with duplicate values', async () => {
await request
.post(`${v1}/abac/rooms/${testRoom._id}/attributes/${updatedKey}`)
.set(credentials)
.send({ values: ['dup', 'dup'] })
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
});
});
it('POST room attribute should add values and reflect inUse=true', async () => {
await request
.post(`${v1}/abac/rooms/${testRoom._id}/attributes/${updatedKey}`)
.set(credentials)
.send({ values: ['cyan'] })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
// inUse endpoint should now be true
await request
.get(`${v1}/abac/attributes/${updatedKey}/is-in-use`)
.set(credentials)
.expect(200)
.expect((res) => {
expect(res.body.inUse).to.be.true;
});
});
it('should throw an error when trying to audit the messages of an abac managed room', async () => {
await request
.post(methodCall('auditGetMessages'))
.set(credentials)
.send({
message: JSON.stringify({
method: 'auditGetMessages',
params: [
{
type: '',
msg: 'test1234',
startDate: { $date: new Date() },
endDate: { $date: new Date() },
rid: testRoom._id,
users: [],
},
],
id: '14',
msg: 'method',
}),
})
.expect(400)
.expect((res) => {
const result = JSON.parse(res.body.message);
expect(result).to.have.property('error');
expect(result.error).to.have.property('error', `Room doesn't exist`);
});
});
describe('Audit messages by user and ABAC-managed rooms', () => {
let auditUser: IUser;
let auditUserCreds: Credentials;
let auditRoom: IRoom;
const auditAttrKey = `audit_attr_${Date.now()}`;
let startDate: Date;
let endDate: Date;
before(async () => {
startDate = new Date();
endDate = new Date(startDate.getTime() + 1000 * 60);
auditUser = await createUser();
auditUserCreds = await login(auditUser.username, password);
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: auditAttrKey, values: ['v1'] })
.expect(200);
await addAbacAttributesToUserDirectly(auditUser._id, [{ key: auditAttrKey, values: ['v1'] }]);
await addAbacAttributesToUserDirectly(credentials['X-User-Id'], [{ key: auditAttrKey, values: ['v1'] }]);
const roomRes = await createRoom({ type: 'p', name: `abac-audit-user-room-${Date.now()}`, members: [auditUser.username!] });
auditRoom = roomRes.body.group as IRoom;
await request
.post(`${v1}/abac/rooms/${auditRoom._id}/attributes/${auditAttrKey}`)
.set(credentials)
.send({ values: ['v1'] })
.expect(200);
});
after(async () => {
await deleteRoom({ type: 'p', roomId: auditRoom._id });
await deleteUser(auditUser);
});
it("should return no messages when auditing a user that's part of an ABAC-managed room", async () => {
await request
.post(`${v1}/chat.sendMessage`)
.set(auditUserCreds)
.send({ message: { rid: auditRoom._id, msg: 'audit message in abac room' } })
.expect(200);
await request
.post(methodCall('auditGetMessages'))
.set(credentials)
.send({
message: JSON.stringify({
method: 'auditGetMessages',
params: [
{
type: 'u',
msg: 'audit message in abac room',
startDate: { $date: startDate },
endDate: { $date: endDate },
rid: '',
users: [auditUser.username],
visitor: '',
agent: '',
},
],
id: 'abac-audit-1',
msg: 'method',
}),
})
.expect(200)
.expect((res) => {
const parsed = JSON.parse(res.body.message);
expect(parsed).to.have.property('result');
expect(parsed.result).to.be.an('array').that.is.empty;
});
});
it('should return no messages when auditing a user that WAS part of an ABAC-managed room', async () => {
await request
.post(`${v1}/chat.sendMessage`)
.set(auditUserCreds)
.send({ message: { rid: auditRoom._id, msg: 'audit message before removal' } })
.expect(200);
await request.post(`${v1}/groups.kick`).set(credentials).send({ roomId: auditRoom._id, username: auditUser.username }).expect(200);
await request
.post(methodCall('auditGetMessages'))
.set(credentials)
.send({
message: JSON.stringify({
method: 'auditGetMessages',
params: [
{
type: 'u',
msg: 'audit message before removal',
startDate: { $date: startDate },
endDate: { $date: endDate },
rid: '',
users: [auditUser.username],
visitor: '',
agent: '',
},
],
id: 'abac-audit-2',
msg: 'method',
}),
})
.expect(200)
.expect((res) => {
const parsed = JSON.parse(res.body.message);
expect(parsed).to.have.property('result');
expect(parsed.result).to.be.an('array').that.is.empty;
});
});
it("should return messages when auditing a user that is part of a room that's no longer ABAC-managed", async () => {
await request
.post(`${v1}/groups.invite`)
.set(credentials)
.send({ roomId: auditRoom._id, usernames: [auditUser.username] })
.expect(200);
await request.delete(`${v1}/abac/rooms/${auditRoom._id}/attributes/${auditAttrKey}`).set(credentials).expect(200);
await request
.post(`${v1}/chat.sendMessage`)
.set(auditUserCreds)
.send({ message: { rid: auditRoom._id, msg: 'audit message after room no longer abac' } })
.expect(200);
await request
.post(methodCall('auditGetMessages'))
.set(credentials)
.send({
message: JSON.stringify({
method: 'auditGetMessages',
params: [
{
type: 'u',
msg: 'audit message after room no longer abac',
startDate: { $date: startDate },
endDate: { $date: endDate },
rid: '',
users: [auditUser.username],
visitor: '',
agent: '',
},
],
id: 'abac-audit-3',
msg: 'method',
}),
})
.expect(200)
.expect((res) => {
const parsed = JSON.parse(res.body.message);
expect(parsed).to.have.property('result');
expect(parsed.result).to.be.an('array').with.lengthOf.greaterThan(0);
});
});
});
it('PUT room attribute should replace values and keep inUse=true', async () => {
await request
.put(`${v1}/abac/rooms/${testRoom._id}/attributes/${updatedKey}`)
.set(credentials)
.send({ values: ['magenta', 'yellow'] })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
// inUse should remain true
await request
.get(`${v1}/abac/attributes/${updatedKey}/is-in-use`)
.set(credentials)
.expect(200)
.expect((res) => {
expect(res.body.inUse).to.be.true;
});
});
describe('DELETE room attribute key (dedicated room)', () => {
let dedicatedRoom: IRoom;
before(async () => {
dedicatedRoom = (await createRoom({ type: 'p', name: `abac-dedicated-${Date.now()}` })).body.group;
});
after(async () => {
await deleteRoom({ type: 'p', roomId: dedicatedRoom._id });
});
it('should succeed and remove only the specified key (key-removed)', async () => {
const key1 = `${initialKey}_room1`;
const key2 = `${initialKey}_room2`;
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: key1, values: ['value1'] })
.expect(200);
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: key2, values: ['value2'] })
.expect(200);
await addAbacAttributesToUserDirectly(credentials['X-User-Id'], [
{ key: key1, values: ['value1'] },
{ key: key2, values: ['value2'] },
]);
await request
.post(`${v1}/abac/rooms/${dedicatedRoom._id}/attributes`)
.set(credentials)
.send({ attributes: { [key1]: ['value1'], [key2]: ['value2'] } })
.expect(200);
await request
.delete(`${v1}/abac/rooms/${dedicatedRoom._id}/attributes/${key1}`)
.set(credentials)
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
const roomRes = await request.get(`${v1}/rooms.info?roomId=${dedicatedRoom._id}`).set(credentials);
expect(roomRes.status).to.equal(200);
const abacAttrs = roomRes.body.room?.abacAttributes || [];
expect(abacAttrs).to.be.an('array').that.has.lengthOf(1);
expect(abacAttrs[0]).to.have.property('key', key2);
});
});
it('DELETE room attribute key should succeed and clear usage/inUse=false', async () => {
await request
.delete(`${v1}/abac/rooms/${testRoom._id}/attributes/${updatedKey}`)
.set(credentials)
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
await request
.get(`${v1}/abac/attributes/${updatedKey}/is-in-use`)
.set(credentials)
.expect(200)
.expect((res) => {
expect(res.body.inUse).to.be.false;
});
});
it('DELETE all room attributes should succeed even if ABAC disabled', async () => {
await updateSetting('ABAC_Enabled', false);
await request
.delete(`${v1}/abac/rooms/${testRoom._id}/attributes`)
.set(credentials)
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
await updateSetting('ABAC_Enabled', true);
});
});
describe('Default and Team Default Room Restrictions', () => {
let privateDefaultRoomId: string;
let teamId: string;
let teamPrivateRoomId: string;
let teamDefaultRoomId: string;
const localAbacKey = `default_team_test_${Date.now()}`;
let mainRoomIdSaveSettings: string;
const teamName = `abac-team-${Date.now()}`;
const teamNameMainRoom = `abac-team-main-save-settings-${Date.now()}`;
before('create team main room for rooms.saveRoomSettings default restriction test', async () => {
const createTeamMain = await request
.post(`${v1}/teams.create`)
.set(credentials)
.send({ name: teamNameMainRoom, type: 1 })
.expect(200);
mainRoomIdSaveSettings = createTeamMain.body.team?.roomId;
await request.post(`${v1}/rooms.saveRoomSettings`).set(credentials).send({ rid: mainRoomIdSaveSettings, default: true }).expect(200);
});
before('create local ABAC attribute definition for tests', async () => {
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: localAbacKey, values: ['red', 'green'] })
.expect(200);
});
before('create private room and try to set it as default', async () => {
const res = await createRoom({
type: 'p',
name: `abac-default-room-${Date.now()}`,
});
privateDefaultRoomId = res.body.group._id;
await request.post(`${v1}/rooms.saveRoomSettings`).set(credentials).send({ rid: privateDefaultRoomId, default: true }).expect(200);
});
before('create private team, private room inside it and set as team default', async () => {
const createTeamRes = await request.post(`${v1}/teams.create`).set(credentials).send({ name: teamName, type: 0 }).expect(200);
teamId = createTeamRes.body.team._id;
const roomRes = await createRoom({
type: 'p',
name: `abac-team-room-${Date.now()}`,
extraData: { teamId },
});
teamPrivateRoomId = roomRes.body.group._id;
const setDefaultRes = await request
.post(`${v1}/teams.updateRoom`)
.set(credentials)
// TODO: teamId is accepted but never used by the endpoint handler — callers should stop sending it
.send({ teamId, roomId: teamPrivateRoomId, isDefault: true })
.expect(200);
if (setDefaultRes.body?.room?.teamDefault) {
teamDefaultRoomId = teamPrivateRoomId;
}
});
it('should fail adding ABAC attribute to private default room', async () => {
await request
.post(`${v1}/abac/rooms/${privateDefaultRoomId}/attributes/${localAbacKey}`)
.set(credentials)
.send({ values: ['red'] })
.expect(400)
.expect((res) => {
expect(res.body.success).to.be.false;
expect(res.body.error).to.include('error-cannot-convert-default-room-to-abac');
});
});
it('should fail adding ABAC attribute to team default private room', async () => {
await request
.post(`${v1}/abac/rooms/${teamDefaultRoomId}/attributes/${localAbacKey}`)
.set(credentials)
.send({ values: ['red'] })
.expect(400)
.expect((res) => {
expect(res.body.success).to.be.false;
expect(res.body.error).to.include('error-cannot-convert-default-room-to-abac');
});
});
it('should allow adding ABAC attribute after removing default flag from private room', async () => {
await request.post(`${v1}/rooms.saveRoomSettings`).set(credentials).send({ rid: privateDefaultRoomId, default: false }).expect(200);
await request
.post(`${v1}/abac/rooms/${privateDefaultRoomId}/attributes/${localAbacKey}`)
.set(credentials)
.send({ values: ['red'] })
.expect(200)
.expect((res) => {
expect(res.body.success).to.be.true;
});
});
it('should allow adding ABAC attribute after removing team default flag', async () => {
await request
.post(`${v1}/teams.updateRoom`)
.set(credentials)
// TODO: teamId is accepted but never used by the endpoint handler — callers should stop sending it
.send({ teamId, roomId: teamDefaultRoomId, isDefault: false })
.expect(200);
await request
.post(`${v1}/abac/rooms/${teamDefaultRoomId}/attributes/${localAbacKey}`)
.set(credentials)
.send({ values: ['green'] })
.expect(200)
.expect((res) => {
expect(res.body.success).to.be.true;
});
});
it('should enforce restriction on team main room when default using rooms.saveRoomSettings', async () => {
await request
.post(`${v1}/abac/rooms/${mainRoomIdSaveSettings}/attributes/${localAbacKey}`)
.set(credentials)
.send({ values: ['red'] })
.expect(400)
.expect((res) => {
expect(res.body.success).to.be.false;
expect(res.body.error).to.include('error-cannot-convert-default-room-to-abac');
});
await request.post(`${v1}/rooms.saveRoomSettings`).set(credentials).send({ rid: mainRoomIdSaveSettings, default: false }).expect(200);
await request
.post(`${v1}/abac/rooms/${mainRoomIdSaveSettings}/attributes/${localAbacKey}`)
.set(credentials)
.send({ values: ['red'] })
.expect(200)
.expect((res) => {
expect(res.body.success).to.be.true;
});
});
it('DELETE attribute definition should remove the key (key-removed)', async () => {
const testKey = `${initialKey}_delete_test`;
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: testKey, values: ['del1', 'del2'] })
.expect(200);
const listRes = await request.get(`${v1}/abac/attributes`).query({ key: testKey }).set(credentials).expect(200);
const attr = listRes.body.attributes.find((a: any) => a.key === testKey);
expect(attr).to.exist;
const attrId = attr._id;
await request
.delete(`${v1}/abac/attributes/${attrId}`)
.set(credentials)
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
await request
.get(`${v1}/abac/attributes/${attrId}`)
.set(credentials)
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('error', 'error-attribute-not-found');
});
});
after(async () => {
await deleteRoom({ type: 'p', roomId: privateDefaultRoomId });
await deleteTeam(credentials, teamName);
await deleteTeam(credentials, teamNameMainRoom);
});
});
describe('Usage & Deletion', () => {
it('POST add room usage for attribute (re-add after clearing) and expect delete while in use to fail', async () => {
await request
.post(`${v1}/abac/rooms/${testRoom._id}/attributes/${updatedKey}`)
.set(credentials)
.send({ values: ['cyan'] })
.expect(200);
// Attempt to delete attribute while in use should fail
await request
.delete(`${v1}/abac/attributes/${attributeId}`)
.set(credentials)
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error').that.includes('error-attribute-in-use');
});
});
it('GET is-in-use should reflect usage true before clearing, then false after removal', async () => {
await request
.get(`${v1}/abac/attributes/${updatedKey}/is-in-use`)
.set(credentials)
.expect(200)
.expect((res) => {
expect(res.body.inUse).to.be.true;
});
// Remove room attribute again
await request.delete(`${v1}/abac/rooms/${testRoom._id}/attributes/${updatedKey}`).set(credentials).expect(200);
await request
.get(`${v1}/abac/attributes/${updatedKey}/is-in-use`)
.set(credentials)
.expect(200)
.expect((res) => {
expect(res.body.inUse).to.be.false;
});
});
it('DELETE attribute definition should now succeed (no room usage)', async () => {
await request
.delete(`${v1}/abac/attributes/${attributeId}`)
.set(credentials)
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
});
});
describe('ABAC Managed Room Default Conversion Restrictions', () => {
const conversionAttrKey = `conversion_test_${Date.now()}`;
const teamName = `abac-conversion-team-${Date.now()}`;
let abacRoomId: string;
let teamIdForConversion: string;
let teamRoomId: string;
before('create attribute definition and ABAC-managed private room', async () => {
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: conversionAttrKey, values: ['alpha', 'beta'] })
.expect(200);
const roomRes = await createRoom({
type: 'p',
name: `abac-conversion-room-${Date.now()}`,
});
abacRoomId = roomRes.body.group._id;
await request
.post(`${v1}/abac/rooms/${abacRoomId}/attributes/${conversionAttrKey}`)
.set(credentials)
.send({ values: ['alpha'] })
.expect(200);
});
before('create team, private room inside, add ABAC attribute', async () => {
// Public team
const teamRes = await request.post(`${v1}/teams.create`).set(credentials).send({ name: teamName, type: 0 }).expect(200);
teamIdForConversion = teamRes.body.team._id;
const teamRoomRes = await createRoom({
type: 'p',
name: `abac-team-conversion-room-${Date.now()}`,
extraData: { teamId: teamIdForConversion },
});
teamRoomId = teamRoomRes.body.group._id;
await request
.post(`${v1}/abac/rooms/${teamRoomId}/attributes/${conversionAttrKey}`)
.set(credentials)
.send({ values: ['beta'] })
.expect(200);
});
after(async () => {
await Promise.all([deleteTeam(credentials, teamName), deleteRoom({ type: 'p', roomId: abacRoomId })]);
});
it('should fail converting ABAC-managed private room into default room', async () => {
await request
.post(`${v1}/rooms.saveRoomSettings`)
.set(credentials)
.send({ rid: abacRoomId, default: true })
.expect(400)
.expect((res) => {
expect(res.body.success).to.be.false;
expect(res.body.error).to.include('Setting an ABAC managed room as default is not allowed [error-action-not-allowed]');
});
});
it('should fail converting ABAC-managed team room into team default room', async () => {
await request
.post(`${v1}/teams.updateRoom`)
.set(credentials)
// TODO: teamId is accepted but never used by the endpoint handler — callers should stop sending it
.send({ teamId: teamIdForConversion, roomId: teamRoomId, isDefault: true })
.expect(400)
.expect((res) => {
expect(res.body.success).to.be.false;
expect(res.body.error).to.include('error-room-is-abac-managed');
});
});
});
describe('Extended Validations & Edge Cases', () => {
let secondAttributeId: string;
const firstKey = `${initialKey}_first`;
const secondKey = `${initialKey}_second`;
const bulkAttrPrefix = `bulk_${Date.now()}`;
const tempKeyForPattern = `Tmp${Date.now()}`;
const invalidCharKey = `bad*key`;
const invalidCharValue = `bad*value`;
const randomId = 'nonExistingId1234567890';
it('POST should create a second attribute definition for conflict tests', async () => {
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: secondKey, values: ['alpha', 'beta', 'gamma'] })
.expect(200);
await request
.get(`${v1}/abac/attributes`)
.set(credentials)
.query({ key: secondKey })
.expect(200)
.expect((res) => {
const found = res.body.attributes.find((a: any) => a.key === secondKey);
expect(found).to.exist;
secondAttributeId = found._id;
});
});
it('POST should create an attribute definition for conflicts', async () => {
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: firstKey, values: ['alpha', 'beta', 'gamma'] })
.expect(200);
await request
.get(`${v1}/abac/attributes`)
.set(credentials)
.query({ key: firstKey })
.expect(200)
.expect((res) => {
const found = res.body.attributes.find((a: any) => a.key === firstKey);
expect(found).to.exist;
});
});
it('PUT attribute should fail when renaming to an existing key (duplicate key)', async () => {
await request
.put(`${v1}/abac/attributes/${secondAttributeId}`)
.set(credentials)
.send({ key: firstKey })
.expect(400)
.expect((res) => {
expect(res.body.success).to.be.false;
});
});
it('PUT attribute should fail when values array has duplicates', async () => {
await request
.put(`${v1}/abac/attributes/${secondAttributeId}`)
.set(credentials)
.send({ values: ['alpha', 'alpha'] })
.expect(400);
});
it('PUT attribute should fail when values array empty', async () => {
await request.put(`${v1}/abac/attributes/${secondAttributeId}`).set(credentials).send({ values: [] }).expect(400);
});
it('GET attribute by invalid/non-existing id should fail', async () => {
await request.get(`${v1}/abac/attributes/${randomId}`).set(credentials).expect(400);
});
it('DELETE attribute by invalid/non-existing id should fail', async () => {
await request.delete(`${v1}/abac/attributes/${randomId}`).set(credentials).expect(400);
});
it('PUT attribute by invalid/non-existing id should fail', async () => {
await request
.put(`${v1}/abac/attributes/${randomId}`)
.set(credentials)
.send({ key: `${tempKeyForPattern}_X` })
.expect(400);
});
it('POST attribute should fail with invalid key pattern (special char)', async () => {
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: invalidCharKey, values: ['ok'] })
.expect(400);
});
it('POST attribute should fail with invalid value pattern (special char in value)', async () => {
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: `${tempKeyForPattern}_values`, values: [invalidCharValue] })
.expect(400);
});
it('POST attribute should succeed with exactly 10 values (boundary)', async () => {
const tenValues = Array.from({ length: 10 }, (_, i) => `b${i}`);
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: `${tempKeyForPattern}_maxvals`, values: tenValues })
.expect(200);
});
it('GET attributes with count=100 should succeed (boundary)', async () => {
await request
.get(`${v1}/abac/attributes`)
.set(credentials)
.query({ count: 100 })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('count');
});
});
it('GET attributes should fail with negative offset', async () => {
await request.get(`${v1}/abac/attributes`).set(credentials).query({ offset: -1 }).expect(400);
});
it('GET attributes should fail with unexpected extra query parameter', async () => {
await request.get(`${v1}/abac/attributes`).set(credentials).query({ extraneous: 'param' }).expect(400);
});
it('GET attributes should fail with invalid value pattern in values filter', async () => {
await request
.get(`${v1}/abac/attributes`)
.set(credentials)
.query({ values: [invalidCharValue] })
.expect(400);
});
it('GET attributes should filter by values when valid (expect subset or empty)', async () => {
await request
.get(`${v1}/abac/attributes`)
.set(credentials)
.query({ values: 'magenta' })
.expect(200)
.expect((res) => {
expect(res.body.success).to.be.true;
});
});
describe('Room attribute bulk operations validations', () => {
it('POST bulk room attributes should fail when more than 10 distinct attribute keys provided', async () => {
const tooMany: Record<string, string[]> = {};
for (let i = 0; i < 11; i++) tooMany[`${bulkAttrPrefix}_${i}`] = ['v1'];
await request.post(`${v1}/abac/rooms/${testRoom._id}/attributes`).set(credentials).send({ attributes: tooMany }).expect(400);
});
it('POST bulk room attributes should fail when one attribute has >10 values', async () => {
const bigValues = Array.from({ length: 11 }, (_, i) => `v${i}`);
await request
.post(`${v1}/abac/rooms/${testRoom._id}/attributes`)
.set(credentials)
.send({ attributes: { [`${bulkAttrPrefix}_k1`]: bigValues } })
.expect(400);
});
it('POST bulk room attributes should fail when using a value not in attribute definition', async () => {
await request
.post(`${v1}/abac/rooms/${testRoom._id}/attributes`)
.set(credentials)
.send({ attributes: { [secondKey]: ['alpha', 'delta'] } })
.expect(400);
});
it('POST bulk room attributes should succeed when providing valid subset for existing definitions', async () => {
await request
.post(`${v1}/abac/rooms/${testRoom._id}/attributes`)
.set(credentials)
.send({
attributes: {
[secondKey]: ['alpha', 'beta'],
},
})
.expect(200);
});
});
describe('Room attribute key/value validation edge cases', () => {
it('POST single room attribute should fail with >10 values', async () => {
const eleven = Array.from({ length: 11 }, (_, i) => `x${i}`);
await request
.post(`${v1}/abac/rooms/${testRoom._id}/attributes/${secondKey}`)
.set(credentials)
.send({ values: eleven })
.expect(400);
});
it('POST single room attribute should fail when value not allowed by definition', async () => {
await request
.post(`${v1}/abac/rooms/${testRoom._id}/attributes/${secondKey}`)
.set(credentials)
.send({ values: ['alpha', 'zzz'] })
.expect(400);
});
it('PUT single room attribute should fail with value not in definition', async () => {
await request
.put(`${v1}/abac/rooms/${testRoom._id}/attributes/${secondKey}`)
.set(credentials)
.send({ values: ['gamma', 'invalid'] })
.expect(400);
});
});
describe('Room attribute limits (max 10 attribute keys)', () => {
it('Reset room attributes before limit test and populate with 10 keys', async () => {
await request.delete(`${v1}/abac/rooms/${testRoom._id}/attributes`).set(credentials).expect(200);
const timestamp = Date.now();
const keys = Array.from({ length: 10 }, (_, i) => `limitk_${timestamp}_${i}`);
await Promise.all(
keys.map((k) =>
request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: k, values: ['v1'] })
.expect(200)
.then(() =>
request
.post(`${v1}/abac/rooms/${testRoom._id}/attributes/${k}`)
.set(credentials)
.send({ values: ['v1'] })
.expect(200),
),
),
);
});
it('Adding an 11th attribute key to room should fail', async () => {
const extraKey = `limitk_extra_${Date.now()}`;
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: extraKey, values: ['v1'] })
.expect(200);
await request
.post(`${v1}/abac/rooms/${testRoom._id}/attributes/${extraKey}`)
.set(credentials)
.send({ values: ['v1'] })
.expect(400);
});
});
describe('Permission & Auth extended checks', () => {
it('POST /abac/rooms/:rid/attributes/:key should return 403 for unauthorized user', async () => {
await request
.post(`${v1}/abac/rooms/${testRoom._id}/attributes/${secondKey}`)
.set(unauthorizedCredentials)
.send({ values: ['alpha'] })
.expect(403);
});
it('PUT /abac/rooms/:rid/attributes/:key should return 403 for unauthorized user', async () => {
await request
.put(`${v1}/abac/rooms/${testRoom._id}/attributes/${secondKey}`)
.set(unauthorizedCredentials)
.send({ values: ['alpha'] })
.expect(403);
});
it('DELETE /abac/rooms/:rid/attributes/:key should return 403 for unauthorized user', async () => {
await request.delete(`${v1}/abac/rooms/${testRoom._id}/attributes/${secondKey}`).set(unauthorizedCredentials).expect(403);
});
it('GET /abac/attributes/:key/is-in-use should return 403 for unauthorized user', async () => {
await request.get(`${v1}/abac/attributes/${secondKey}/is-in-use`).set(unauthorizedCredentials).expect(403);
});
});
describe('ABAC Disabled behavior for protected endpoints', () => {
before(async () => {
await updateSetting('ABAC_Enabled', false);
});
it('POST /abac/attributes should fail with error-abac-not-enabled', async () => {
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: `disabled_${Date.now()}`, values: ['one'] })
.expect(400)
.expect((res) => {
expect(res.body.error).to.include('error-abac-not-enabled');
});
});
it('PUT /abac/attributes/:_id should fail while disabled', async () => {
await request
.put(`${v1}/abac/attributes/${secondAttributeId}`)
.set(credentials)
.send({ values: ['alpha'] })
.expect(400)
.expect((res) => {
expect(res.body.error).to.include('error-abac-not-enabled');
});
});
it('POST /abac/rooms/:rid/attributes/:key should fail while disabled', async () => {
await request
.post(`${v1}/abac/rooms/${testRoom._id}/attributes/${secondKey}`)
.set(credentials)
.send({ values: ['alpha'] })
.expect(400)
.expect((res) => {
expect(res.body.error).to.include('error-abac-not-enabled');
});
});
it('PUT /abac/rooms/:rid/attributes/:key should fail while disabled', async () => {
await request
.put(`${v1}/abac/rooms/${testRoom._id}/attributes/${secondKey}`)
.set(credentials)
.send({ values: ['alpha'] })
.expect(400)
.expect((res) => {
expect(res.body.error).to.include('error-abac-not-enabled');
});
});
it('POST /abac/rooms/:rid/attributes (bulk replace) should fail while disabled', async () => {
await request
.post(`${v1}/abac/rooms/${testRoom._id}/attributes`)
.set(credentials)
.send({ attributes: { [secondKey]: ['alpha'] } })
.expect(400)
.expect((res) => {
expect(res.body.error).to.include('error-abac-not-enabled');
});
});
it('DELETE /abac/rooms/:rid/attributes/:key succeeds while disabled (endpoint has no enabled-check)', async () => {
await request
.delete(`${v1}/abac/rooms/${testRoom._id}/attributes/never_added_${Date.now()}`)
.set(credentials)
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
});
it('POST /abac/users/sync should fail with error-abac-not-enabled', async () => {
await request
.post(api('abac/users/sync'))
.set(credentials)
.send({ ids: ['no-such-user-id'] })
.expect(400)
.expect((res) => {
expect(res.body.error).to.include('error-abac-not-enabled');
});
});
after(async () => {
await updateSetting('ABAC_Enabled', true);
});
});
describe('ABAC Room Type Conversion', () => {
const attrKey = `type_conversion_${Date.now()}`;
let roomNoAttr: string;
let roomWithAttr: string;
let roomWithAttrAbacDisabled: string;
before(async () => {
await updateSetting('ABAC_Enabled', true);
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: attrKey, values: ['val1', 'val2'] })
.expect(200);
roomNoAttr = (await createRoom({ type: 'p', name: `abac-type-room-no-attr-${Date.now()}` })).body.group._id;
roomWithAttr = (await createRoom({ type: 'p', name: `abac-type-room-with-attr-${Date.now()}` })).body.group._id;
await request
.post(`${v1}/abac/rooms/${roomWithAttr}/attributes/${attrKey}`)
.set(credentials)
.send({ values: ['val1'] })
.expect(200);
roomWithAttrAbacDisabled = (await createRoom({ type: 'p', name: `abac-type-room-with-attr-disabled-${Date.now()}` })).body.group
._id;
await request
.post(`${v1}/abac/rooms/${roomWithAttrAbacDisabled}/attributes/${attrKey}`)
.set(credentials)
.send({ values: ['val2'] })
.expect(200);
});
after(async () => {
await updateSetting('ABAC_Enabled', false);
await Promise.all([
deleteRoom({ type: 'c', roomId: roomNoAttr }),
deleteRoom({ type: 'p', roomId: roomWithAttr }),
deleteRoom({ type: 'c', roomId: roomWithAttrAbacDisabled }),
]);
});
it('should convert private room without ABAC attributes to public', async () => {
await request
.post(`${v1}/rooms.saveRoomSettings`)
.set(credentials)
.send({ rid: roomNoAttr, roomType: 'c' })
.expect(200)
.expect((res) => {
expect(res.body.success).to.be.true;
});
});
it('should fail converting ABAC managed private room to public', async () => {
await request
.post(`${v1}/rooms.saveRoomSettings`)
.set(credentials)
.send({ rid: roomWithAttr, roomType: 'c' })
.expect(400)
.expect((res) => {
expect(res.body.success).to.be.false;
expect(res.body.error).to.include('Changing an ABAC managed private room to public is not allowed');
});
});
it('should allow converting ABAC managed private room to public when ABAC disabled', async () => {
await updateSetting('ABAC_Enabled', false);
await request
.post(`${v1}/rooms.saveRoomSettings`)
.set(credentials)
.send({ rid: roomWithAttrAbacDisabled, roomType: 'c' })
.expect(200)
.expect((res) => {
expect(res.body.success).to.be.true;
});
});
it('should strip abacAttributes when converting private room to public while ABAC disabled', async () => {
await updateSetting('ABAC_Enabled', true);
const roomId = (await createRoom({ type: 'p', name: `abac-type-strip-${Date.now()}` })).body.group._id;
await request
.post(`${v1}/abac/rooms/${roomId}/attributes/${attrKey}`)
.set(credentials)
.send({ values: ['val1'] })
.expect(200);
await updateSetting('ABAC_Enabled', false);
await request.post(`${v1}/rooms.saveRoomSettings`).set(credentials).send({ rid: roomId, roomType: 'c' }).expect(200);
const roomRes = await request.get(`${v1}/rooms.adminRooms.getRoom`).set(credentials).query({ rid: roomId }).expect(200);
expect(roomRes.body).to.not.have.property('abacAttributes');
await deleteRoom({ type: 'c', roomId });
});
it('should keep abacAttributes when room stays private while ABAC disabled', async () => {
await updateSetting('ABAC_Enabled', true);
const roomId = (await createRoom({ type: 'p', name: `abac-type-keep-${Date.now()}` })).body.group._id;
await request
.post(`${v1}/abac/rooms/${roomId}/attributes/${attrKey}`)
.set(credentials)
.send({ values: ['val2'] })
.expect(200);
await updateSetting('ABAC_Enabled', false);
await request.post(`${v1}/rooms.saveRoomSettings`).set(credentials).send({ rid: roomId, roomTopic: 'still private' }).expect(200);
const roomRes = await request.get(`${v1}/rooms.adminRooms.getRoom`).set(credentials).query({ rid: roomId }).expect(200);
expect(roomRes.body).to.have.property('abacAttributes').that.is.an('array').with.lengthOf(1);
expect(roomRes.body.abacAttributes[0]).to.have.property('key', attrKey);
await deleteRoom({ type: 'p', roomId });
});
});
describe('ABAC Team Type Conversion', () => {
const attrKeyTeam = `team_type_conversion_${Date.now()}`;
const teamNameWithAttr = `abac-team-with-attr-${Date.now()}`;
const teamNameWithAttrAbacDisabled = `abac-team-with-attr-disabled-${Date.now()}`;
let teamNameNoAttr: string;
let mainRoomIdNoAttr: string;
let mainRoomIdWithAttr: string;
let mainRoomIdWithAttrAbacDisabled: string;
before(async () => {
await updateSetting('ABAC_Enabled', true);
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: attrKeyTeam, values: ['alpha', 'beta'] })
.expect(200);
teamNameNoAttr = `abac-team-no-attr-${Date.now()}`;
const teamNoAttrRes = await request.post(`${v1}/teams.create`).set(credentials).send({ name: teamNameNoAttr, type: 1 }).expect(200);
mainRoomIdNoAttr = teamNoAttrRes.body.team.roomId;
const teamWithAttrRes = await request
.post(`${v1}/teams.create`)
.set(credentials)
.send({ name: teamNameWithAttr, type: 1 })
.expect(200);
mainRoomIdWithAttr = teamWithAttrRes.body.team.roomId;
await request
.post(`${v1}/abac/rooms/${mainRoomIdWithAttr}/attributes/${attrKeyTeam}`)
.set(credentials)
.send({ values: ['alpha'] })
.expect(200);
const teamWithAttrDisRes = await request
.post(`${v1}/teams.create`)
.set(credentials)
.send({ name: teamNameWithAttrAbacDisabled, type: 1 })
.expect(200);
mainRoomIdWithAttrAbacDisabled = teamWithAttrDisRes.body.team.roomId;
await request
.post(`${v1}/abac/rooms/${mainRoomIdWithAttrAbacDisabled}/attributes/${attrKeyTeam}`)
.set(credentials)
.send({ values: ['beta'] })
.expect(200);
});
after(async () => {
await updateSetting('ABAC_Enabled', false);
await Promise.all([
deleteTeam(credentials, teamNameNoAttr),
deleteTeam(credentials, teamNameWithAttr),
deleteTeam(credentials, teamNameWithAttrAbacDisabled),
]);
});
it('should convert private team (main room) without ABAC attributes to public', async () => {
await request
.post(`${v1}/rooms.saveRoomSettings`)
.set(credentials)
.send({ rid: mainRoomIdNoAttr, roomType: 'c' })
.expect(200)
.expect((res) => {
expect(res.body.success).to.be.true;
});
});
it('should fail converting private team (main room) with ABAC attributes to public', async () => {
await request
.post(`${v1}/rooms.saveRoomSettings`)
.set(credentials)
.send({ rid: mainRoomIdWithAttr, roomType: 'c' })
.expect(400)
.expect((res) => {
expect(res.body.success).to.be.false;
// Ideally this should say "Changing an ABAC managed private team to public is not allowed" but the room check is done before the team check
// And it fails there
expect(res.body.error).to.include('Changing an ABAC managed private room to public is not allowed');
});
});
it('should allow converting private team (main room) with ABAC attributes to public when ABAC disabled', async () => {
await updateSetting('ABAC_Enabled', false);
await request
.post(`${v1}/rooms.saveRoomSettings`)
.set(credentials)
.send({ rid: mainRoomIdWithAttrAbacDisabled, roomType: 'c' })
.expect(200)
.expect((res) => {
expect(res.body.success).to.be.true;
});
const roomRes = await request.get(`${v1}/channels.info?roomId=${mainRoomIdWithAttrAbacDisabled}`).set(credentials).expect(200);
expect(roomRes.body.channel).to.not.have.property('abacAttributes');
});
});
describe('Invite links & ABAC management', () => {
const inviteAttrKey = `invite_attr_${Date.now()}`;
const validateAttrKey = `invite_val_attr_${Date.now()}`;
let managedRoomId: string;
let plainRoomId: string;
let plainRoomInviteToken: string;
const createdInviteIds: string[] = [];
before(async () => {
await updatePermission('create-invite-links', ['admin']);
await updateSetting('ABAC_Enabled', true);
});
it('should create an invite link for a private room without ABAC attributes when ABAC is enabled', async () => {
const plainRoom = (await createRoom({ type: 'p', name: `invite-plain-${Date.now()}` })).body.group;
plainRoomId = plainRoom._id;
await request
.post(`${v1}/findOrCreateInvite`)
.set(credentials)
.send({ rid: plainRoomId, days: 1, maxUses: 0 })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('rid', plainRoomId);
expect(res.body).to.have.property('days', 1);
expect(res.body).to.have.property('maxUses', 0);
plainRoomInviteToken = res.body._id;
createdInviteIds.push(plainRoomInviteToken);
});
});
it('validateInviteToken should return valid=true for token from non-ABAC managed room', async () => {
await request
.post(`${v1}/validateInviteToken`)
.send({ token: plainRoomInviteToken })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('valid', true);
});
});
it('validateInviteToken should return valid=false for random invalid token', async () => {
await request
.post(`${v1}/validateInviteToken`)
.send({ token: 'invalid123' })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('valid', false);
});
});
it('validateInviteToken should return valid=false after room becomes ABAC managed', async () => {
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: validateAttrKey, values: ['one'] })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
await addAbacAttributesToUserDirectly(credentials['X-User-Id'], [{ key: validateAttrKey, values: ['one'] }]);
await request
.post(`${v1}/abac/rooms/${plainRoomId}/attributes/${validateAttrKey}`)
.set(credentials)
.send({ values: ['one'] })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
await request
.post(`${v1}/validateInviteToken`)
.send({ token: plainRoomInviteToken })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('valid', false);
});
});
it('validateInviteToken should return valid=true again after disabling ABAC', async () => {
await updateSetting('ABAC_Enabled', false);
await request
.post(`${v1}/validateInviteToken`)
.send({ token: plainRoomInviteToken })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('valid', true);
});
await updateSetting('ABAC_Enabled', true);
});
it('should fail creating an invite link for an ABAC managed room while ABAC is enabled', async () => {
const managedRoom = (await createRoom({ type: 'p', name: `invite-managed-${Date.now()}` })).body.group;
managedRoomId = managedRoom._id;
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: inviteAttrKey, values: ['one'] })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
await addAbacAttributesToUserDirectly(credentials['X-User-Id'], [{ key: inviteAttrKey, values: ['one'] }]);
await request
.post(`${v1}/abac/rooms/${managedRoomId}/attributes/${inviteAttrKey}`)
.set(credentials)
.send({ values: ['one'] })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
await request
.post(`${v1}/findOrCreateInvite`)
.set(credentials)
.send({ rid: managedRoomId, days: 1, maxUses: 0 })
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('errorType', 'error-invalid-room');
expect(res.body).to.have.property('error').that.includes('Room is ABAC managed');
});
});
it('should allow creating an invite link for previously ABAC managed room after disabling ABAC', async () => {
await updateSetting('ABAC_Enabled', false);
await request
.post(`${v1}/findOrCreateInvite`)
.set(credentials)
.send({ rid: managedRoomId, days: 1, maxUses: 0 })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('rid', managedRoomId);
createdInviteIds.push(res.body._id);
});
});
after(async () => {
await Promise.all(createdInviteIds.map((id) => request.delete(`${v1}/removeInvite/${id}`).set(credentials)));
await deleteRoom({ type: 'p', roomId: plainRoomId });
await deleteRoom({ type: 'p', roomId: managedRoomId });
});
});
});
describe('POST /abac/users/sync (strategy-agnostic)', () => {
before(async () => {
await updateSetting('ABAC_Enabled', true);
});
after(async () => {
await updateSetting('ABAC_Enabled', false);
});
it('responds 200 with success:true when ABAC_Enabled=true and PDP type=local (no-match id)', async () => {
await request
.post(api('abac/users/sync'))
.set(credentials)
.send({ ids: ['no-such-user-id'] })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
});
});
describe('Room access (invite, addition)', () => {
let roomWithoutAttr: IRoom;
let roomWithAttr: IRoom;
const accessAttrKey = `access_attr_${Date.now()}`;
before(async () => {
await updateSetting('ABAC_Enabled', true);
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: accessAttrKey, values: ['v1'] })
.expect(200);
// We have to add them directly cause otherwise the abac engine would kick the user from the room after the attribute is added
await addAbacAttributesToUserDirectly(credentials['X-User-Id'], [{ key: accessAttrKey, values: ['v1'] }]);
// Create two private rooms: one will stay without attributes, the other will get the attribute
roomWithoutAttr = (await createRoom({ type: 'p', name: `abac-access-noattr-${Date.now()}` })).body.group;
roomWithAttr = (await createRoom({ type: 'p', name: `abac-access-withattr-${Date.now()}` })).body.group;
// Assign the attribute to the second room
await request
.post(`${v1}/abac/rooms/${roomWithAttr._id}/attributes/${accessAttrKey}`)
.set(credentials)
.send({ values: ['v1'] })
.expect(200);
});
after(async () => {
await deleteRoom({ type: 'p', roomId: roomWithoutAttr._id });
await deleteRoom({ type: 'p', roomId: roomWithAttr._id });
});
it('INVITE: user without attributes invited to room without attributes succeeds', async () => {
await request
.post(`${v1}/groups.invite`)
.set(credentials)
.send({ roomId: roomWithoutAttr._id, usernames: [unauthorizedUser.username] })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
});
it('INVITE: user without attributes invited to room with attributes should fail', async () => {
await request
.post(`${v1}/groups.invite`)
.set(credentials)
.send({ roomId: roomWithAttr._id, usernames: [unauthorizedUser.username] })
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('errorType', 'error-only-compliant-users-can-be-added-to-abac-rooms');
});
});
it('INVITE: after room loses attributes user without attributes can be invited', async () => {
await request.delete(`${v1}/abac/rooms/${roomWithAttr._id}/attributes/${accessAttrKey}`).set(credentials).expect(200);
await request
.post(`${v1}/groups.invite`)
.set(credentials)
.send({ roomId: roomWithAttr._id, usernames: [unauthorizedUser.username] })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
});
describe('ABAC disabled with ABAC-managed room', () => {
let enabledAccessAttrKey: string;
let enabledUser: IUser;
let managedRoom: IRoom;
before(async () => {
enabledAccessAttrKey = `${accessAttrKey}_disabled_case`;
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: enabledAccessAttrKey, values: ['v1'] })
.expect(200);
await addAbacAttributesToUserDirectly(credentials['X-User-Id'], [{ key: enabledAccessAttrKey, values: ['v1'] }]);
managedRoom = (await createRoom({ type: 'p', name: `abac-access-disabled-${Date.now()}` })).body.group;
await request
.post(`${v1}/abac/rooms/${managedRoom._id}/attributes/${enabledAccessAttrKey}`)
.set(credentials)
.send({ values: ['v1'] })
.expect(200);
const username = `abac-enabled-user-${Date.now()}`;
const createUserRes = await request
.post(`${v1}/users.create`)
.set(credentials)
.send({
email: `${username}@example.com`,
name: username,
username,
password: 'pass@123',
})
.expect(200);
enabledUser = createUserRes.body.user;
await addAbacAttributesToUserDirectly(enabledUser._id, [{ key: enabledAccessAttrKey, values: ['v1'] }]);
await updateSetting('ABAC_Enabled', false);
});
after(async () => {
await updateSetting('ABAC_Enabled', true);
await deleteRoom({ type: 'p', roomId: managedRoom._id });
await deleteUser(enabledUser);
});
it('INVITE: should fail adding user to ABAC-managed private room when ABAC is disabled', async () => {
await request
.post(`${v1}/groups.invite`)
.set(credentials)
.send({ roomId: managedRoom._id, usernames: [enabledUser.username] })
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('errorType', 'error-room-is-abac-managed');
});
});
it('INVITE: should still fail after user loses attributes when ABAC is disabled', async () => {
await addAbacAttributesToUserDirectly(enabledUser._id, [{ key: enabledAccessAttrKey, values: [] }]);
await request
.post(`${v1}/groups.invite`)
.set(credentials)
.send({ roomId: managedRoom._id, usernames: [enabledUser.username] })
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('errorType', 'error-room-is-abac-managed');
});
});
});
});
describe('Room access (after subscribed)', () => {
let cacheRoom: IRoom;
const cacheAttrKey = `access_cache_attr_${Date.now()}`;
let cacheUser: IUser;
let cacheUserCreds: Credentials;
const ttlSeconds = 5;
before(async function () {
this.timeout(10000);
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: cacheAttrKey, values: ['on'] })
.expect(200);
cacheRoom = (await createRoom({ type: 'p', name: `abac-cache-room-${Date.now()}` })).body.group;
cacheUser = await createUser();
cacheUserCreds = await login(cacheUser.username, password);
await addAbacAttributesToUserDirectly(cacheUser._id, [{ key: cacheAttrKey, values: ['on'] }]);
await addAbacAttributesToUserDirectly(credentials['X-User-Id'], [{ key: cacheAttrKey, values: ['on'] }]);
await request
.post(`${v1}/abac/rooms/${cacheRoom._id}/attributes/${cacheAttrKey}`)
.set(credentials)
.send({ values: ['on'] })
.expect(200);
await request
.post(`${v1}/groups.invite`)
.set(credentials)
.send({ roomId: cacheRoom._id, usernames: [cacheUser.username] })
.expect(200);
await updateSetting('Abac_Cache_Decision_Time_Seconds', ttlSeconds);
await request
.post(`${v1}/chat.sendMessage`)
.set(credentials)
.send({ message: { rid: cacheRoom._id, msg: 'Seed message for cache access test' } })
.expect(200);
});
after(async () => {
await deleteRoom({ type: 'p', roomId: cacheRoom._id });
await deleteUser(cacheUser);
await updateSetting('Abac_Cache_Decision_Time_Seconds', 300);
});
it('ACCESS: user can retrieve messages after subscription (initial compliant)', async () => {
await request
.get(`/api/v1/groups.history`)
.set(cacheUserCreds)
.query({ roomId: cacheRoom._id })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('messages').that.is.an('array');
});
});
it('ACCESS: user retains access within cache after losing attributes', async () => {
await addAbacAttributesToUserDirectly(cacheUser._id, []);
await request
.get(`/api/v1/groups.history`)
.set(cacheUserCreds)
.query({ roomId: cacheRoom._id })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
});
it('ACCESS: user loses access after cache expiry', async () => {
await updateSetting('Abac_Cache_Decision_Time_Seconds', 0);
await request
.get(`${v1}/groups.history`)
.set(cacheUserCreds)
.query({ roomId: cacheRoom._id })
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('errorType', 'error-room-not-found');
});
});
it('ACCESS: user is removed from the room when the access check fails', async () => {
const roomInfoRes = await request
.get(`${v1}/rooms.membersOrderedByRole`)
.set(credentials)
.query({ roomId: cacheRoom._id })
.expect(200);
const { members } = roomInfoRes.body;
expect(members.find((m: IUser) => m.username === cacheUser.username)).to.be.undefined;
});
it('ACCESS: user can be re invited to the room and access history', async () => {
await addAbacAttributesToUserDirectly(cacheUser._id, [{ key: cacheAttrKey, values: ['on'] }]);
await request
.post(`${v1}/groups.invite`)
.set(credentials)
.send({ roomId: cacheRoom._id, usernames: [cacheUser.username] })
.expect(200);
await request
.get(`/api/v1/groups.history`)
.set(cacheUserCreds)
.query({ roomId: cacheRoom._id })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
});
it('ACCESS: keeps access once room attributes are removed', async () => {
await request.delete(`${v1}/abac/rooms/${cacheRoom._id}/attributes/${cacheAttrKey}`).set(credentials).expect(200);
await request
.get(`${v1}/groups.history`)
.set(cacheUserCreds)
.query({ roomId: cacheRoom._id })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
});
});
describe('Room access via projection-path endpoints (groups.messages)', () => {
// Unlike groups.history (which re-loads the full room), groups.messages gates access
// solely through the shared `roomAccessAttributes` projection. That projection must carry
// `abacAttributes`, otherwise canAccessRoom falls back to the subscription check and a
// non-compliant but still-subscribed member can read messages.
let projRoom: IRoom;
const projAttrKey = `proj_access_attr_${Date.now()}`;
let projUser: IUser;
let projUserCreds: Credentials;
before(async function () {
this.timeout(10000);
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: projAttrKey, values: ['on'] })
.expect(200);
projRoom = (await createRoom({ type: 'p', name: `abac-proj-room-${Date.now()}` })).body.group;
projUser = await createUser();
projUserCreds = await login(projUser.username, password);
await addAbacAttributesToUserDirectly(projUser._id, [{ key: projAttrKey, values: ['on'] }]);
await addAbacAttributesToUserDirectly(credentials['X-User-Id'], [{ key: projAttrKey, values: ['on'] }]);
await request
.post(`${v1}/abac/rooms/${projRoom._id}/attributes/${projAttrKey}`)
.set(credentials)
.send({ values: ['on'] })
.expect(200);
await request
.post(`${v1}/groups.invite`)
.set(credentials)
.send({ roomId: projRoom._id, usernames: [projUser.username] })
.expect(200);
await request
.post(`${v1}/chat.sendMessage`)
.set(credentials)
.send({ message: { rid: projRoom._id, msg: 'Seed message for projection access test' } })
.expect(200);
// Evaluate every access check against the PDP, no cached decisions
await updateSetting('Abac_Cache_Decision_Time_Seconds', 0);
});
after(async () => {
await deleteRoom({ type: 'p', roomId: projRoom._id });
await deleteUser(projUser);
await updateSetting('Abac_Cache_Decision_Time_Seconds', 300);
});
it('PROJECTION: compliant member can read messages', async () => {
await request
.get(`${v1}/groups.messages`)
.set(projUserCreds)
.query({ roomId: projRoom._id })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('messages').that.is.an('array');
});
});
it('PROJECTION: member that lost its attributes is denied while still subscribed', async () => {
await addAbacAttributesToUserDirectly(projUser._id, []);
await request
.get(`${v1}/groups.messages`)
.set(projUserCreds)
.query({ roomId: projRoom._id })
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('errorType', 'error-room-not-found');
});
});
});
describe('list abac rooms', () => {
const listAttrKey1 = `list_attr_1_${Date.now()}`;
const listAttrKey2 = `list_attr_2_${Date.now()}`;
const listRoomName1 = `abac-list-room-1-${Date.now()}`;
const listRoomName2 = `abac-list-room-2-${Date.now()}`;
const listRoomNoAttrName = `abac-list-room-noattr-${Date.now()}`;
let listRoomId1: string;
let listRoomId2: string;
let listRoomNoAttrId: string;
before('ensure ABAC enabled and create attribute definitions & rooms', async () => {
await updateSetting('ABAC_Enabled', true);
// Create attribute definitions
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: listAttrKey1, values: ['alpha', 'beta'] })
.expect(200);
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: listAttrKey2, values: ['x', 'y'] })
.expect(200);
// Create private rooms
listRoomId1 = (await createRoom({ type: 'p', name: listRoomName1 })).body.group._id;
listRoomId2 = (await createRoom({ type: 'p', name: listRoomName2 })).body.group._id;
listRoomNoAttrId = (await createRoom({ type: 'p', name: listRoomNoAttrName })).body.group._id;
// Assign attributes to first two rooms
await request
.post(`${v1}/abac/rooms/${listRoomId1}/attributes/${listAttrKey1}`)
.set(credentials)
.send({ values: ['alpha'] })
.expect(200);
await request
.post(`${v1}/abac/rooms/${listRoomId2}/attributes/${listAttrKey2}`)
.set(credentials)
.send({ values: ['x'] })
.expect(200);
});
after(async () => {
await Promise.all([
deleteRoom({ type: 'p', roomId: listRoomId1 }),
deleteRoom({ type: 'p', roomId: listRoomId2 }),
deleteRoom({ type: 'p', roomId: listRoomNoAttrId }),
]);
});
it('should list only private rooms with ABAC attributes (baseline)', async () => {
await request
.get(`${v1}/abac/rooms`)
.set(credentials)
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('rooms').that.is.an('array');
const ids = res.body.rooms.map((r: any) => r._id);
expect(ids).to.include(listRoomId1);
expect(ids).to.include(listRoomId2);
expect(ids).to.not.include(listRoomNoAttrId);
});
});
it('should NOT list a newly created private room without attributes', async () => {
const tempNoAttrRoomId = (await createRoom({ type: 'p', name: `abac-list-temp-noattr-${Date.now()}` })).body.group._id;
const res = await request.get(`${v1}/abac/rooms`).set(credentials).expect(200);
const ids = res.body.rooms.map((r: any) => r._id);
expect(ids).to.not.include(tempNoAttrRoomId);
await deleteRoom({ type: 'p', roomId: tempNoAttrRoomId });
});
it('should NOT list a public room (cannot be ABAC managed)', async () => {
const publicRoomId = (await createRoom({ type: 'c', name: `abac-list-public-${Date.now()}` })).body.channel._id;
const res = await request.get(`${v1}/abac/rooms`).set(credentials).expect(200);
const ids = res.body.rooms.map((r: any) => r._id);
expect(ids).to.not.include(publicRoomId);
await deleteRoom({ type: 'c', roomId: publicRoomId });
});
it('should NOT list a team private room without attributes', async () => {
// Create a private team and a private room inside it (not main room)
const teamRes = await request
.post(`${v1}/teams.create`)
.set(credentials)
.send({ name: `abac-list-team-noattr-${Date.now()}`, type: 0 })
.expect(200);
const teamIdLocal = teamRes.body.team._id;
const teamRoomRes = await createRoom({ type: 'p', name: `abac-list-team-room-${Date.now()}`, extraData: { teamId: teamIdLocal } });
const teamPrivateRoomId = teamRoomRes.body.group._id;
const res = await request.get(`${v1}/abac/rooms`).set(credentials).expect(200);
const ids = res.body.rooms.map((r: any) => r._id);
expect(ids).to.not.include(teamPrivateRoomId);
await deleteRoom({ type: 'p', roomId: teamPrivateRoomId });
await deleteTeam(credentials, teamRes.body.team.name);
});
it('should stop listing a room after its ABAC attributes are removed', async () => {
// Ensure room 1 currently listed
const resBefore = await request.get(`${v1}/abac/rooms`).set(credentials).expect(200);
const idsBefore = resBefore.body.rooms.map((r: any) => r._id);
expect(idsBefore).to.include(listRoomId1);
// Remove its only attribute key (listAttrKey1) - value was 'alpha'
await request.delete(`${v1}/abac/rooms/${listRoomId1}/attributes/${listAttrKey1}`).set(credentials).expect(200);
const resAfter = await request.get(`${v1}/abac/rooms`).set(credentials).expect(200);
const idsAfter = resAfter.body.rooms.map((r: any) => r._id);
expect(idsAfter).to.not.include(listRoomId1);
// Re-add attribute so other tests relying on it remain stable
await request
.post(`${v1}/abac/rooms/${listRoomId1}/attributes/${listAttrKey1}`)
.set(credentials)
.send({ values: ['alpha'] })
.expect(200);
});
it('should NOT list a default private room even if attempt to add attribute fails', async () => {
const defaultRoomId = (await createRoom({ type: 'p', name: `abac-list-default-${Date.now()}` })).body.group._id;
await request.post(`${v1}/rooms.saveRoomSettings`).set(credentials).send({ rid: defaultRoomId, default: true }).expect(200);
const defKey = `list_def_attr_${Date.now()}`;
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: defKey, values: ['one'] })
.expect(200);
await request
.post(`${v1}/abac/rooms/${defaultRoomId}/attributes/${defKey}`)
.set(credentials)
.send({ values: ['one'] })
.expect(400);
const res = await request.get(`${v1}/abac/rooms`).set(credentials).expect(200);
const ids = res.body.rooms.map((r: any) => r._id);
expect(ids).to.not.include(defaultRoomId);
await request.post(`${v1}/rooms.saveRoomSettings`).set(credentials).send({ rid: defaultRoomId, default: false }).expect(200);
await deleteRoom({ type: 'p', roomId: defaultRoomId });
});
it('should filter by room name (filterType=roomName)', async () => {
await request
.get(`${v1}/abac/rooms`)
.set(credentials)
.query({ filter: 'abac-list-room-1', filterType: 'roomName' })
.expect(200)
.expect((res) => {
expect(res.body.success).to.be.true;
const ids = res.body.rooms.map((r: any) => r._id);
expect(ids).to.include(listRoomId1);
expect(ids).to.not.include(listRoomId2);
});
});
it('should filter by attribute key (filterType=attribute)', async () => {
await request
.get(`${v1}/abac/rooms`)
.set(credentials)
.query({ filter: listAttrKey2, filterType: 'attribute' })
.expect(200)
.expect((res) => {
expect(res.body.success).to.be.true;
const ids = res.body.rooms.map((r: any) => r._id);
expect(ids).to.include(listRoomId2);
expect(ids).to.not.include(listRoomId1);
});
});
it('should filter by attribute value (filterType=value)', async () => {
await request
.get(`${v1}/abac/rooms`)
.set(credentials)
.query({ filter: 'alpha', filterType: 'value' })
.expect(200)
.expect((res) => {
expect(res.body.success).to.be.true;
const ids = res.body.rooms.map((r: any) => r._id);
expect(ids).to.include(listRoomId1);
expect(ids).to.not.include(listRoomId2);
});
});
it('should match across name, key and values when filterType=all (default)', async () => {
await request
.get(`${v1}/abac/rooms`)
.set(credentials)
.query({ filter: 'x', filterType: 'all' })
.expect(200)
.expect((res) => {
expect(res.body.success).to.be.true;
const ids = res.body.rooms.map((r: any) => r._id);
expect(ids).to.include(listRoomId2);
expect(ids).to.not.include(listRoomId1);
});
});
it('should paginate results (count=1 offset=0)', async () => {
await request
.get(`${v1}/abac/rooms`)
.set(credentials)
.query({ count: 1, offset: 0 })
.expect(200)
.expect((res) => {
expect(res.body.success).to.be.true;
expect(res.body).to.have.property('offset', 0);
expect(res.body).to.have.property('count', 1);
expect(res.body.rooms).to.have.lengthOf(1);
});
});
it('should paginate results (count=1 offset=1)', async () => {
await request
.get(`${v1}/abac/rooms`)
.set(credentials)
.query({ count: 1, offset: 1 })
.expect(200)
.expect((res) => {
expect(res.body.success).to.be.true;
expect(res.body).to.have.property('offset', 1);
expect(res.body).to.have.property('count').that.is.at.most(1);
expect(res.body.rooms).to.have.length.at.most(1);
});
});
it('should return empty list when filter does not match anything', async () => {
await request
.get(`${v1}/abac/rooms`)
.set(credentials)
.query({ filter: 'nonexistent-filter-xyz', filterType: 'roomName' })
.expect(200)
.expect((res) => {
expect(res.body.success).to.be.true;
expect(res.body.rooms).to.be.an('array').with.lengthOf(0);
});
});
it('should reject unauthorized user (403)', async () => {
await request.get(`${v1}/abac/rooms`).set(unauthorizedCredentials).expect(403);
});
describe('ABAC managed private team main rooms listing', () => {
const teamListAttrKey = `team_list_attr_${Date.now()}`;
const teamListName = `abac-list-team-${Date.now()}`;
let teamListMainRoomId: string;
before('create private team (no attributes yet) and attribute definition', async () => {
const teamRes = await request.post(`${v1}/teams.create`).set(credentials).send({ name: teamListName, type: 1 }).expect(200);
teamListMainRoomId = teamRes.body.team.roomId;
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: teamListAttrKey, values: ['red', 'blue'] })
.expect(200);
});
it('baseline: team main private room without attributes should NOT appear in /abac/rooms list', async () => {
await request
.get(`${v1}/abac/rooms`)
.set(credentials)
.expect(200)
.expect((res) => {
const ids = res.body.rooms.map((r: any) => r._id);
expect(ids).to.not.include(teamListMainRoomId);
});
});
it('after adding ABAC attribute to team main room it SHOULD appear in /abac/rooms list', async () => {
await request
.post(`${v1}/abac/rooms/${teamListMainRoomId}/attributes/${teamListAttrKey}`)
.set(credentials)
.send({ values: ['red'] })
.expect(200);
await request
.get(`${v1}/abac/rooms`)
.set(credentials)
.expect(200)
.expect((res) => {
const ids = res.body.rooms.map((r: any) => r._id);
expect(ids).to.include(teamListMainRoomId);
});
});
it('filterType=attribute should return team main room when filtering by its attribute key', async () => {
await request
.get(`${v1}/abac/rooms`)
.set(credentials)
.query({ filter: teamListAttrKey, filterType: 'attribute' })
.expect(200)
.expect((res) => {
const ids = res.body.rooms.map((r: any) => r._id);
expect(ids).to.include(teamListMainRoomId);
});
});
it('filterType=value should return team main room when filtering by an assigned attribute value', async () => {
await request
.get(`${v1}/abac/rooms`)
.set(credentials)
.query({ filter: 'red', filterType: 'value' })
.expect(200)
.expect((res) => {
const ids = res.body.rooms.map((r: any) => r._id);
expect(ids).to.include(teamListMainRoomId);
});
});
it('filterType=value should NOT return team main room when filtering by a non-existent value', async () => {
await request
.get(`${v1}/abac/rooms`)
.set(credentials)
.query({ filter: 'nonexistent-team-value', filterType: 'value' })
.expect(200)
.expect((res) => {
const ids = res.body.rooms.map((r: any) => r._id);
expect(ids).to.not.include(teamListMainRoomId);
});
});
it('pagination should include team main room on a page where it falls (count=1 offset varies)', async () => {
// Get full list to determine index
const fullRes = await request.get(`${v1}/abac/rooms`).set(credentials).expect(200);
const allIds: string[] = fullRes.body.rooms.map((r: any) => r._id);
const index = allIds.indexOf(teamListMainRoomId);
expect(index).to.not.equal(-1);
// Request the page containing the team main room
const pageRes = await request.get(`${v1}/abac/rooms`).set(credentials).query({ count: 1, offset: index }).expect(200);
expect(pageRes.body.rooms.map((r: any) => r._id)).to.include(teamListMainRoomId);
});
after(async () => {
await deleteTeam(credentials, teamListName);
});
});
});
describe('LDAP integration', () => {
before(async () => {
await Promise.all([
updateSetting('LDAP_Enable', true),
updateSetting('ABAC_Enabled', true),
updateSetting('LDAP_Background_Sync', true),
updateSetting('LDAP_Background_Sync_Import_New_Users', true),
updateSetting('LDAP_Host', 'openldap'),
updateSetting('LDAP_Port', 1389),
updateSetting('LDAP_Authentication', true),
updateSetting('LDAP_Authentication_UserDN', 'cn=admin,dc=space,dc=air'),
updateSetting('LDAP_Authentication_Password', 'adminpassword'),
updateSetting('LDAP_BaseDN', 'ou=users,dc=space,dc=air'),
updateSetting('LDAP_AD_User_Search_Field', 'uid'),
updateSetting('LDAP_AD_Username_Field', 'uid'),
updateSetting('LDAP_Background_Sync_ABAC_Attributes', true),
updateSetting('LDAP_Background_Sync_ABAC_Attributes_Interval', '0 0 * * *'),
updateSetting(
'LDAP_ABAC_AttributeMap',
JSON.stringify({
departmentNumber: 'department',
telephoneNumber: 'phone',
}),
),
]);
});
before(async function () {
this.timeout(10000);
// ldap.syncNow now also syncs ABAC attributes for all users
await request.post(`${v1}/ldap.syncNow`).set(credentials);
await sleep(5000);
});
it('should sync LDAP user john.young with mapped ABAC attributes', async () => {
const res = await request.get(`${v1}/users.info`).set(credentials).query({ username: 'john.young' }).expect(200);
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('user');
const user = res.body.user as IUser;
expect(user).to.have.property('abacAttributes');
expect(user.abacAttributes).to.be.an('array');
const departmentAttr = user?.abacAttributes?.find((attr: IAbacAttributeDefinition) => attr.key === 'department');
expect(departmentAttr).to.exist;
expect(departmentAttr?.values || []).to.be.an('array').that.is.not.empty;
});
it('should sync ABAC attributes for SOME users via /abac/users/sync', async () => {
await request
.post(api('abac/users/sync'))
.set(credentials)
.send({
usernames: ['david.scott', 'gene.cernan'],
})
.expect(200);
const usersToCheck = ['david.scott', 'gene.cernan'];
const results = await Promise.all(
usersToCheck.map(async (username) => {
const res = await request.get(`${v1}/users.info`).set(credentials).query({ username }).expect(200);
return res.body.user as IUser;
}),
);
for (const user of results) {
const attrs = user.abacAttributes || [];
expect(attrs).to.be.an('array').that.is.not.empty;
const dept = attrs.find((attr: IAbacAttributeDefinition) => attr.key === 'department');
expect(dept).to.exist;
expect(dept?.values || []).to.be.an('array').that.is.not.empty;
}
});
// Hard limits for the endpoint - max 100 identifiers per type, 400 total per request
it('should fail /abac/users/sync when more than 100 usernames are provided', async () => {
const usernames = Array.from({ length: 101 }, (_, i) => `user_${i}@example.com`);
await request
.post(api('abac/users/sync'))
.set(credentials)
.send({
usernames,
})
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
});
});
it('should fail /abac/users/sync when more than 100 ids are provided', async () => {
const ids = Array.from({ length: 101 }, (_, i) => `id_${i}`);
await request
.post(api('abac/users/sync'))
.set(credentials)
.send({
ids,
})
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
});
});
it('should fail /abac/users/sync when more than 100 emails are provided', async () => {
const emails = Array.from({ length: 101 }, (_, i) => `user_${i}@example.com`);
await request
.post(api('abac/users/sync'))
.set(credentials)
.send({
emails,
})
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
});
});
it('should fail /abac/users/sync when more than 100 ldapIds are provided', async () => {
const ldapIds = Array.from({ length: 101 }, (_, i) => `ldap_${i}`);
await request
.post(api('abac/users/sync'))
.set(credentials)
.send({
ldapIds,
})
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
});
});
it('should succeed /abac/users/sync when exactly 100 usernames are provided (boundary)', async () => {
const usernames = Array.from({ length: 100 }, (_, i) => `boundary_user_${i}`);
await request
.post(api('abac/users/sync'))
.set(credentials)
.send({
usernames,
})
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
});
describe('LDAP ABAC room membership sync', () => {
let roomIdWithAbac: string;
before(async () => {
// Ensure the ABAC attribute definition for department exists
const attrsRes = await request.get(`${v1}/abac/attributes`).set(credentials).expect(200);
const attrs = attrsRes.body.attributes as IAbacAttributeDefinition[];
const existingDept = attrs.find((attr) => attr.key === 'department');
if (!existingDept) {
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({
key: 'department',
values: ['lifeSupport', 'lifeSupport2', 'navControl'],
})
.expect(200);
}
// Create a private room and add LDAP users that will later lose ABAC compliance
const roomRes = await createRoom({
type: 'p',
name: `ldapAbacRoom-${Date.now()}`,
});
roomIdWithAbac = roomRes.body.group._id;
const davidUser = await request.get(`${v1}/users.info`).set(credentials).query({ username: 'david.scott' }).expect(200);
const sergeiUser = await request.get(`${v1}/users.info`).set(credentials).query({ username: 'sergei.krikalev' }).expect(200);
await addAbacAttributesToUserDirectly(davidUser.body.user._id, [{ key: 'department', values: ['navControl'] }]);
await addAbacAttributesToUserDirectly(sergeiUser.body.user._id, [{ key: 'department', values: ['navControl'] }]);
await addAbacAttributesToUserDirectly(credentials['X-User-Id'], [{ key: 'department', values: ['navControl'] }]);
await request
.post(`${v1}/abac/rooms/${roomIdWithAbac}/attributes/department`)
.set(credentials)
.send({
values: ['navControl'],
})
.expect(200);
// Invite two LDAP users that will initially match the room attributes
await request
.post(`${v1}/groups.invite`)
.set(credentials)
.send({
roomId: roomIdWithAbac,
usernames: ['david.scott', 'sergei.krikalev'],
});
});
after(async () => {
await deleteRoom({ type: 'p', roomId: roomIdWithAbac });
});
it('should remove users from room after LDAP sync changes their ABAC attributes', async () => {
const initialDept = 'navControl';
const davidInitialAttrs = [{ key: 'department', values: [initialDept] }];
const sergeiInitialAttrs = [{ key: 'department', values: [initialDept] }];
expect(davidInitialAttrs[0].values).to.include(initialDept);
expect(sergeiInitialAttrs[0].values).to.include(initialDept);
await request
.post(api('abac/users/sync'))
.set(credentials)
.send({
usernames: ['david.scott', 'sergei.krikalev'],
})
.expect(200);
const afterMembersRes = await request.get(`${v1}/groups.members`).set(credentials).query({ roomId: roomIdWithAbac }).expect(200);
const afterMembers = afterMembersRes.body.members as IUser[];
const afterUsernames = afterMembers.map((u) => u.username);
expect(afterUsernames).to.not.include('david.scott');
expect(afterUsernames).to.not.include('sergei.krikalev');
const userInfoDavidAfter = await request.get(`${v1}/users.info`).set(credentials).query({ username: 'david.scott' }).expect(200);
const userInfoSergeiAfter = await request
.get(`${v1}/users.info`)
.set(credentials)
.query({ username: 'sergei.krikalev' })
.expect(200);
const davidAfter = userInfoDavidAfter.body.user as IUser;
const sergeiAfter = userInfoSergeiAfter.body.user as IUser;
const davidDeptAfter = (davidAfter.abacAttributes || []).find((attr: IAbacAttributeDefinition) => attr.key === 'department');
const sergeiDeptAfter = (sergeiAfter.abacAttributes || []).find((attr: IAbacAttributeDefinition) => attr.key === 'department');
expect(davidDeptAfter?.values || []).to.not.deep.equal(davidInitialAttrs[0].values);
expect(sergeiDeptAfter?.values || []).to.not.deep.equal(sergeiInitialAttrs[0].values);
});
});
after(async () => {
await Promise.all([
updateSetting('LDAP_Enable', false),
updateSetting('ABAC_Enabled', false),
updateSetting('LDAP_Background_Sync', false),
updateSetting('LDAP_Background_Sync_Import_New_Users', false),
updateSetting('LDAP_Host', ''),
updateSetting('LDAP_Authentication', false),
updateSetting('LDAP_Authentication_UserDN', ''),
updateSetting('LDAP_Authentication_Password', ''),
updateSetting('LDAP_BaseDN', ''),
updateSetting('LDAP_AD_User_Search_Field', ''),
updateSetting('LDAP_AD_Username_Field', ''),
updateSetting('LDAP_Background_Sync_ABAC_Attributes', false),
updateSetting('LDAP_Background_Sync_ABAC_Attributes_Interval', ''),
updateSetting('LDAP_ABAC_AttributeMap', ''),
]);
});
});
describe('[Admin Rooms - Sensitive Fields] (ABAC managed rooms)', () => {
const sensitiveAttrKey = `attr_sensitive_${Date.now()}`;
const createdRids: string[] = [];
let sensitiveAttrId: string;
let owner: IUser;
let ownerCredentials: Credentials;
const sensitiveFields = [
{ name: 'announcement', settingKey: 'roomAnnouncement', roomKey: 'announcement' },
{ name: 'topic', settingKey: 'roomTopic', roomKey: 'topic' },
{ name: 'description', settingKey: 'roomDescription', roomKey: 'description' },
] as const;
const createPrivateRoomWithField = async (
settingKey: (typeof sensitiveFields)[number]['settingKey'],
value: string | undefined,
): Promise<{ rid: string; name: string }> => {
const name = `abac-sensitive-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const created = (await createRoom({ type: 'p', name })).body.group as IRoom;
if (value !== undefined) {
await request
.post(`${v1}/rooms.saveRoomSettings`)
.set(credentials)
.send({ rid: created._id, [settingKey]: value })
.expect(200);
}
createdRids.push(created._id);
return { rid: created._id, name };
};
before(async () => {
await updateSetting('ABAC_Enabled', true);
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: sensitiveAttrKey, values: ['v1'] })
.expect(200);
const listRes = await request.get(`${v1}/abac/attributes`).query({ key: sensitiveAttrKey }).set(credentials).expect(200);
const attr = listRes.body.attributes.find((a: { _id: string; key: string }) => a.key === sensitiveAttrKey);
expect(attr, 'attribute should have been created').to.exist;
sensitiveAttrId = attr._id;
owner = await createUser();
ownerCredentials = await login(owner.username, password);
});
after(async () => {
await Promise.all(createdRids.map((rid) => request.delete(`${v1}/abac/rooms/${rid}/attributes`).set(credentials).expect(200)));
await Promise.all(createdRids.map((rid) => deleteRoom({ type: 'p', roomId: rid })));
await request.delete(`${v1}/abac/attributes/${sensitiveAttrId}`).set(credentials).expect(200);
await deleteUser(owner);
await updateSetting('ABAC_Enabled', false);
});
it('should strip announcement, topic and description from rooms.adminRooms.getRoom on ABAC managed room', async () => {
const { rid } = await createPrivateRoomWithField('roomAnnouncement', 'SECRET_ANN');
await request
.post(`${v1}/rooms.saveRoomSettings`)
.set(credentials)
.send({ rid, roomTopic: 'SECRET_TOPIC', roomDescription: 'SECRET_DESC' })
.expect(200);
await request
.post(`${v1}/abac/rooms/${rid}/attributes/${sensitiveAttrKey}`)
.set(credentials)
.send({ values: ['v1'] })
.expect(200);
const res = await request.get(`${v1}/rooms.adminRooms.getRoom`).set(credentials).query({ rid }).expect(200);
expect(res.body).to.not.have.property('announcement');
expect(res.body).to.not.have.property('topic');
expect(res.body).to.not.have.property('description');
});
it('should return announcement, topic and description from rooms.adminRooms.getRoom on non-ABAC private room (control)', async () => {
const { rid } = await createPrivateRoomWithField('roomAnnouncement', 'VISIBLE_ANN');
await request
.post(`${v1}/rooms.saveRoomSettings`)
.set(credentials)
.send({ rid, roomTopic: 'VISIBLE_TOPIC', roomDescription: 'VISIBLE_DESC' })
.expect(200);
const res = await request.get(`${v1}/rooms.adminRooms.getRoom`).set(credentials).query({ rid }).expect(200);
expect(res.body).to.have.property('announcement', 'VISIBLE_ANN');
expect(res.body).to.have.property('topic', 'VISIBLE_TOPIC');
expect(res.body).to.have.property('description', 'VISIBLE_DESC');
});
it('should strip announcement, topic and description from rooms.adminRooms list on ABAC managed rows', async () => {
const { rid, name } = await createPrivateRoomWithField('roomAnnouncement', 'LIST_SECRET_ANN');
await request
.post(`${v1}/rooms.saveRoomSettings`)
.set(credentials)
.send({ rid, roomTopic: 'LIST_SECRET_TOPIC', roomDescription: 'LIST_SECRET_DESC' })
.expect(200);
await request
.post(`${v1}/abac/rooms/${rid}/attributes/${sensitiveAttrKey}`)
.set(credentials)
.send({ values: ['v1'] })
.expect(200);
const res = await request.get(`${v1}/rooms.adminRooms`).set(credentials).query({ 'filter': name, 'types[]': 'p' }).expect(200);
const found = (res.body.rooms as Array<IRoom>).find((r) => r._id === rid);
expect(found, 'created room should appear in admin list').to.exist;
expect(found).to.not.have.property('announcement');
expect(found).to.not.have.property('topic');
expect(found).to.not.have.property('description');
});
it('should strip announcement, topic and description from rooms.adminRooms.privateRooms list on ABAC managed rows', async () => {
const { rid, name } = await createPrivateRoomWithField('roomAnnouncement', 'PRIVATE_SECRET_ANN');
await request
.post(`${v1}/rooms.saveRoomSettings`)
.set(credentials)
.send({ rid, roomTopic: 'PRIVATE_SECRET_TOPIC', roomDescription: 'PRIVATE_SECRET_DESC' })
.expect(200);
await request
.post(`${v1}/abac/rooms/${rid}/attributes/${sensitiveAttrKey}`)
.set(credentials)
.send({ values: ['v1'] })
.expect(200);
const res = await request.get(`${v1}/rooms.adminRooms.privateRooms`).set(credentials).query({ filter: name }).expect(200);
const found = (res.body.rooms as Array<IRoom>).find((r) => r._id === rid);
expect(found, 'created room should appear in admin private rooms list').to.exist;
expect(found).to.not.have.property('announcement');
expect(found).to.not.have.property('topic');
expect(found).to.not.have.property('description');
});
sensitiveFields.forEach(({ name, settingKey, roomKey }) => {
describe(`${name}`, () => {
it(`should reject ${settingKey} save on ABAC managed room (admin)`, async () => {
const { rid } = await createPrivateRoomWithField(settingKey, 'original');
await request
.post(`${v1}/abac/rooms/${rid}/attributes/${sensitiveAttrKey}`)
.set(credentials)
.send({ values: ['v1'] })
.expect(200);
const res = await request
.post(`${v1}/rooms.saveRoomSettings`)
.set(credentials)
.send({ rid, [settingKey]: 'changed' })
.expect(400);
expect(res.body).to.have.property('errorType', 'error-action-not-allowed');
});
it(`should accept idempotent ${settingKey} re-submit on ABAC managed room`, async () => {
const { rid } = await createPrivateRoomWithField(settingKey, 'keep-me');
await request
.post(`${v1}/abac/rooms/${rid}/attributes/${sensitiveAttrKey}`)
.set(credentials)
.send({ values: ['v1'] })
.expect(200);
await request
.post(`${v1}/rooms.saveRoomSettings`)
.set(credentials)
.send({ rid, [settingKey]: 'keep-me' })
.expect(200);
});
it(`should accept ${settingKey} save on non-ABAC private room (control)`, async () => {
const { rid } = await createPrivateRoomWithField(settingKey, 'original');
await request
.post(`${v1}/rooms.saveRoomSettings`)
.set(credentials)
.send({ rid, [settingKey]: 'changed' })
.expect(200);
});
it(`should reject empty-string unset on ABAC managed room with ${roomKey} set`, async () => {
const { rid } = await createPrivateRoomWithField(settingKey, 'X');
await request
.post(`${v1}/abac/rooms/${rid}/attributes/${sensitiveAttrKey}`)
.set(credentials)
.send({ values: ['v1'] })
.expect(200);
const res = await request
.post(`${v1}/rooms.saveRoomSettings`)
.set(credentials)
.send({ rid, [settingKey]: '' })
.expect(400);
expect(res.body).to.have.property('errorType', 'error-action-not-allowed');
});
it(`should accept idempotent empty submit on ABAC room with no ${roomKey} set (direct API contract)`, async () => {
const { rid } = await createPrivateRoomWithField(settingKey, undefined);
await request
.post(`${v1}/abac/rooms/${rid}/attributes/${sensitiveAttrKey}`)
.set(credentials)
.send({ values: ['v1'] })
.expect(200);
await request
.post(`${v1}/rooms.saveRoomSettings`)
.set(credentials)
.send({ rid, [settingKey]: '' })
.expect(200);
});
it(`should accept ${settingKey} save after the last ABAC attribute is removed (transition)`, async () => {
const { rid } = await createPrivateRoomWithField(settingKey, 'initial');
await request
.post(`${v1}/abac/rooms/${rid}/attributes/${sensitiveAttrKey}`)
.set(credentials)
.send({ values: ['v1'] })
.expect(200);
await request
.post(`${v1}/rooms.saveRoomSettings`)
.set(credentials)
.send({ rid, [settingKey]: 'changed' })
.expect(400);
await request.delete(`${v1}/abac/rooms/${rid}/attributes`).set(credentials).expect(200);
await request
.post(`${v1}/rooms.saveRoomSettings`)
.set(credentials)
.send({ rid, [settingKey]: 'changed' })
.expect(200);
});
});
});
it('should reject roomAnnouncement save on ABAC managed room (non-admin owner)', async () => {
const { rid } = await createPrivateRoomWithField('roomAnnouncement', 'original');
await request.post(`${v1}/groups.invite`).set(credentials).send({ roomId: rid, userId: owner._id }).expect(200);
await request.post(`${v1}/groups.addOwner`).set(credentials).send({ roomId: rid, userId: owner._id }).expect(200);
await request
.post(`${v1}/abac/rooms/${rid}/attributes/${sensitiveAttrKey}`)
.set(credentials)
.send({ values: ['v1'] })
.expect(200);
const res = await request
.post(`${v1}/rooms.saveRoomSettings`)
.set(ownerCredentials)
.send({ rid, roomAnnouncement: 'owner-changed' })
.expect(400);
expect(res.body).to.have.property('errorType', 'error-action-not-allowed');
});
});
});
(IS_EE ? describe : describe.skip)('[ABAC] External PDP (mock-server)', function () {
this.retries(0);
const attrKey = `ext_pdp_attr_${Date.now()}`;
before((done) => {
getCredentials(done);
});
before(async function () {
this.timeout(15000);
connection = await MongoClient.connect(URL_MONGODB);
const healthy = await mockServerHealthy();
expect(healthy, 'mock-server is not reachable — ensure it is running').to.be.true;
await Promise.all([
updatePermission('abac-management', ['admin']),
updatePermission('manage-abac-admin-settings', ['admin']),
updatePermission('manage-abac-admin-room-attributes', ['admin']),
updatePermission('manage-abac-admin-rooms', ['admin']),
updatePermission('view-abac-admin-audit', ['admin']),
]);
await updateSetting('ABAC_Enabled', true);
await updateSetting('ABAC_PDP_Type', 'virtru');
await Promise.all([
updateSetting('ABAC_Virtru_Base_URL', 'http://mock-server:8080'),
updateSetting('ABAC_Virtru_OIDC_Endpoint', 'http://mock-server:8080/auth/realms/mock'),
updateSetting('ABAC_Virtru_Client_ID', 'mock-client'),
updateSetting('ABAC_Virtru_Client_Secret', 'mock-secret'),
updateSetting('ABAC_Virtru_Default_Entity_Key', 'emailAddress'),
updateSetting('ABAC_Virtru_Attribute_Namespace', 'example.com'),
updateSetting('Abac_Cache_Decision_Time_Seconds', 0),
]);
await request
.post('/api/v1/abac/attributes')
.set(credentials)
.send({ key: attrKey, values: ['alpha', 'beta', 'gamma'] })
.expect(200);
});
after(async function () {
this.timeout(10000);
await mockServerReset();
await updateSetting('ABAC_PDP_Type', 'local');
await updateSetting('ABAC_Enabled', false);
await connection.close();
});
describe('PERMIT all: users remain when PDP permits everyone', () => {
let room: IRoom;
let user: IUser;
let userCreds: Credentials;
before(async function () {
this.timeout(15000);
user = await createUser({ verified: true });
userCreds = await login(user.username, password);
room = (await createRoom({ type: 'p', name: `extpdp-permit-${Date.now()}` })).body.group;
await request
.post('/api/v1/groups.invite')
.set(credentials)
.send({ roomId: room._id, usernames: [user.username] })
.expect(200);
await mockServerReset();
await seedDefaultMocks();
await seedBulkDecisionByEntity([], 'DECISION_PERMIT');
await request
.post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`)
.set(credentials)
.send({ values: ['alpha'] })
.expect(200);
});
after(async () => {
await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(user)]);
});
it('room creator remains in the room', async () => {
const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200);
const memberIds = res.body.members.map((m: IUser) => m._id);
expect(memberIds).to.include(credentials['X-User-Id']);
});
it('user remains in the room', async () => {
const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200);
const usernames = res.body.members.map((m: IUser) => m.username);
expect(usernames).to.include(user.username);
});
it('user can access room history when PDP returns PERMIT', async () => {
await mockServerReset();
await seedDefaultMocks();
await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }]);
await request
.get('/api/v1/groups.history')
.set(userCreds)
.query({ roomId: room._id })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('messages').that.is.an('array');
});
});
});
describe('Access check: PDP DENY removes user on room access', () => {
let room: IRoom;
let user: IUser;
let userCreds: Credentials;
before(async function () {
this.timeout(15000);
user = await createUser({ verified: true });
userCreds = await login(user.username, password);
room = (await createRoom({ type: 'p', name: `extpdp-access-${Date.now()}` })).body.group;
await request
.post('/api/v1/groups.invite')
.set(credentials)
.send({ roomId: room._id, usernames: [user.username] })
.expect(200);
await mockServerReset();
await seedDefaultMocks();
await seedGetDecisionBulk([
{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] },
{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] },
]);
await request
.post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`)
.set(credentials)
.send({ values: ['alpha'] })
.expect(200);
});
after(async () => {
await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(user)]);
});
it('user loses access when PDP flips to DENY', async () => {
await mockServerReset();
await seedDefaultMocks();
await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_DENY', ephemeralResourceId: room._id }] }]);
await request
.get('/api/v1/groups.history')
.set(userCreds)
.query({ roomId: room._id })
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('errorType', 'error-room-not-found');
});
});
it('user is removed from room after access DENY', async () => {
await mockServerReset();
await seedDefaultMocks();
await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }]);
const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200);
const usernames = res.body.members.map((m: IUser) => m.username);
expect(usernames).to.not.include(user.username);
});
});
describe('Invite to ABAC room: PDP decides who can join', () => {
let room: IRoom;
let permitUser: IUser;
let denyUser: IUser;
before(async function () {
this.timeout(10000);
permitUser = await createUser({ verified: true });
denyUser = await createUser({ verified: true });
room = (await createRoom({ type: 'p', name: `extpdp-invite-${Date.now()}` })).body.group;
await mockServerReset();
await seedDefaultMocks();
await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }]);
await request
.post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`)
.set(credentials)
.send({ values: ['alpha'] })
.expect(200);
});
after(async () => {
await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(permitUser), deleteUser(denyUser)]);
});
it('should allow invite when PDP returns PERMIT', async () => {
await mockServerReset();
await seedDefaultMocks();
await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }]);
await request
.post('/api/v1/groups.invite')
.set(credentials)
.send({ roomId: room._id, usernames: [permitUser.username] })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
});
it('invited user is a member of the room after PERMIT', async () => {
const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200);
const usernames = res.body.members.map((m: IUser) => m.username);
expect(usernames).to.include(permitUser.username);
});
it('should reject invite when PDP returns DENY', async () => {
await mockServerReset();
await seedDefaultMocks();
await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_DENY', ephemeralResourceId: room._id }] }]);
await request
.post('/api/v1/groups.invite')
.set(credentials)
.send({ roomId: room._id, usernames: [denyUser.username] })
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('errorType', 'error-only-compliant-users-can-be-added-to-abac-rooms');
});
});
it('denied user is not a member of the room', async () => {
await mockServerReset();
await seedDefaultMocks();
await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }]);
const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200);
const usernames = res.body.members.map((m: IUser) => m.username);
expect(usernames).to.not.include(denyUser.username);
});
it('room creator remains after invite operations', async () => {
const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200);
const memberIds = res.body.members.map((m: IUser) => m._id);
expect(memberIds).to.include(credentials['X-User-Id']);
});
});
describe('PDP unavailability: fail-closed behavior', () => {
let room: IRoom;
let user: IUser;
let userCredentials: Credentials;
before(async function () {
this.timeout(10000);
user = await createUser({ verified: true });
userCredentials = await login(user.username, password);
room = (await createRoom({ type: 'p', name: `extpdp-failclose-${Date.now()}` })).body.group;
await request
.post('/api/v1/groups.invite')
.set(credentials)
.send({ roomId: room._id, usernames: [user.username] })
.expect(200);
await mockServerReset();
await seedDefaultMocks();
await seedGetDecisionBulk([
{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] },
{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] },
]);
await request
.post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`)
.set(credentials)
.send({ values: ['alpha'] })
.expect(200);
});
after(async () => {
await mockServerReset();
await seedDefaultMocks();
await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(user)]);
});
it('should deny access when PDP health check returns NOT_SERVING', async () => {
await mockServerReset();
await mockServerSet('GET', '/healthz', { status: 'NOT_SERVING' });
await request
.get('/api/v1/groups.history')
.set(userCredentials)
.query({ roomId: room._id })
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('errorType', 'error-room-not-found');
});
});
it('should deny invite when PDP is unavailable', async () => {
await mockServerReset();
await mockServerSet('GET', '/healthz', { status: 'NOT_SERVING' });
const newUser = await createUser({ verified: true });
await request
.post('/api/v1/groups.invite')
.set(credentials)
.send({ roomId: room._id, usernames: [newUser.username!] })
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
});
await deleteUser(newUser);
});
it('should deny room attribute changes when PDP is unavailable', async () => {
await mockServerReset();
await mockServerSet('GET', '/healthz', { status: 'NOT_SERVING' });
await request
.post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`)
.set(credentials)
.send({ values: ['beta'] })
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error', 'error-pdp-unavailable');
});
});
});
describe('Selective DENY: only non-permitted users are removed', () => {
let room: IRoom;
let user: IUser;
before(async function () {
this.timeout(15000);
user = await createUser({ verified: true });
room = (await createRoom({ type: 'p', name: `extpdp-selective-${Date.now()}` })).body.group;
await request
.post('/api/v1/groups.invite')
.set(credentials)
.send({ roomId: room._id, usernames: [user.username] })
.expect(200);
await mockServerReset();
await seedDefaultMocks();
await seedBulkDecisionByEntity([adminEmail], 'DECISION_DENY');
await request
.post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`)
.set(credentials)
.send({ values: ['alpha'] })
.expect(200);
});
after(async () => {
await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(user)]);
});
it('admin (permitted) remains in the room', async () => {
const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200);
const memberIds = res.body.members.map((m: IUser) => m._id);
expect(memberIds).to.include(credentials['X-User-Id']);
});
it('user (denied) was removed from the room', async () => {
const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200);
const usernames = res.body.members.map((m: IUser) => m.username);
expect(usernames).to.not.include(user.username);
});
});
describe('Tightening attributes: selective DENY removes only denied users', () => {
let room: IRoom;
let user: IUser;
before(async function () {
this.timeout(15000);
user = await createUser({ verified: true });
room = (await createRoom({ type: 'p', name: `extpdp-tighten-${Date.now()}` })).body.group;
await request
.post('/api/v1/groups.invite')
.set(credentials)
.send({ roomId: room._id, usernames: [user.username] })
.expect(200);
await mockServerReset();
await seedDefaultMocks();
await seedGetDecisionBulk([
{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] },
{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] },
]);
await request
.post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`)
.set(credentials)
.send({ values: ['alpha'] })
.expect(200);
});
after(async () => {
await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(user)]);
});
it('user is removed when attributes are tightened and PDP denies them', async function () {
this.timeout(10000);
await mockServerReset();
await seedDefaultMocks();
await seedBulkDecisionByEntity([adminEmail], 'DECISION_DENY');
await request
.put(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`)
.set(credentials)
.send({ values: ['alpha', 'beta'] })
.expect(200);
const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200);
const usernames = res.body.members.map((m: IUser) => m.username);
expect(usernames).to.not.include(user.username);
});
it('admin remains in the room after tightening', async () => {
const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200);
const memberIds = res.body.members.map((m: IUser) => m._id);
expect(memberIds).to.include(credentials['X-User-Id']);
});
});
describe('Re-evaluation via POST /abac/users/sync', () => {
describe('PDP DENY removes the synced user', () => {
let room: IRoom;
let user: IUser;
const username = `abac-sync-deny-${Date.now()}`;
const email = `${username}@rocket.chat`;
before(async function () {
this.timeout(15000);
user = await createUser({ username, email, verified: true });
room = (await createRoom({ type: 'p', name: `extpdp-sync-deny-${Date.now()}` })).body.group;
await request
.post(api('groups.invite'))
.set(credentials)
.send({ roomId: room._id, usernames: [user.username] })
.expect(200);
await mockServerReset();
await seedDefaultMocks();
await seedBulkDecisionByEntity([adminEmail, email], 'DECISION_DENY');
await request
.post(api(`abac/rooms/${room._id}/attributes/${attrKey}`))
.set(credentials)
.send({ values: ['alpha'] })
.expect(200);
});
after(async () => {
await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(user)]);
});
it('keeps the user before re-evaluation', async () => {
const res = await request.get(api('groups.members')).set(credentials).query({ roomId: room._id }).expect(200);
const usernames = res.body.members.map((m: IUser) => m.username);
expect(usernames).to.include(user.username);
});
it('removes the user when the Virtru PDP returns DENY', async () => {
await mockServerReset();
await seedDefaultMocks();
await seedBulkDecisionByEntity([adminEmail], 'DECISION_DENY');
await request
.post(api('abac/users/sync'))
.set(credentials)
.send({ usernames: [user.username] })
.expect(200);
const res = await request.get(api('groups.members')).set(credentials).query({ roomId: room._id }).expect(200);
const usernames = res.body.members.map((m: IUser) => m.username);
expect(usernames).to.not.include(user.username);
});
it('keeps the room creator (permitted) after re-evaluation', async () => {
const res = await request.get(api('groups.members')).set(credentials).query({ roomId: room._id }).expect(200);
const memberIds = res.body.members.map((m: IUser) => m._id);
expect(memberIds).to.include(credentials['X-User-Id']);
});
});
describe('PDP PERMIT keeps the synced user', () => {
let room: IRoom;
let user: IUser;
const username = `abac-sync-permit-${Date.now()}`;
const email = `${username}@rocket.chat`;
before(async function () {
this.timeout(15000);
user = await createUser({ username, email, verified: true });
room = (await createRoom({ type: 'p', name: `extpdp-sync-permit-${Date.now()}` })).body.group;
await request
.post(api('groups.invite'))
.set(credentials)
.send({ roomId: room._id, usernames: [user.username] })
.expect(200);
await mockServerReset();
await seedDefaultMocks();
await seedBulkDecisionByEntity([adminEmail, email], 'DECISION_DENY');
await request
.post(api(`abac/rooms/${room._id}/attributes/${attrKey}`))
.set(credentials)
.send({ values: ['alpha'] })
.expect(200);
});
after(async () => {
await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(user)]);
});
it('keeps the user when the Virtru PDP returns PERMIT', async () => {
await mockServerReset();
await seedDefaultMocks();
await seedBulkDecisionByEntity([adminEmail, email], 'DECISION_DENY');
await request
.post(api('abac/users/sync'))
.set(credentials)
.send({ usernames: [user.username] })
.expect(200);
const res = await request.get(api('groups.members')).set(credentials).query({ roomId: room._id }).expect(200);
const usernames = res.body.members.map((m: IUser) => m.username);
expect(usernames).to.include(user.username);
});
});
});
describe('[GET] /abac/pdp/health', () => {
beforeEach(async () => {
await mockServerReset();
});
after(async () => {
await mockServerReset();
});
it('returns ABAC_PDP_Health_Platform_Failed when platform /healthz is unavailable', async () => {
await mockServerSetMany([
{ method: 'GET', path: '/healthz', body: { status: 'NOT_SERVING' }, statusCode: 503 },
{
method: 'POST',
path: '/auth/realms/mock/protocol/openid-connect/token',
body: { access_token: 'mock-pdp-token', token_type: 'Bearer', expires_in: 3600 },
},
]);
const res = await request.get('/api/v1/abac/pdp/health').set(credentials).expect(400);
expect(res.body).to.deep.include({
success: false,
available: false,
message: 'ABAC_PDP_Health_Platform_Failed',
});
});
it('returns ABAC_PDP_Health_IdP_Failed when platform is OK but the OIDC token endpoint fails', async () => {
await mockServerSetMany([
{ method: 'GET', path: '/healthz', body: { status: 'SERVING' } },
{
method: 'POST',
path: '/auth/realms/mock/protocol/openid-connect/token',
body: { error: 'invalid_client' },
statusCode: 401,
},
]);
const res = await request.get('/api/v1/abac/pdp/health').set(credentials).expect(400);
expect(res.body).to.deep.include({
success: false,
available: false,
message: 'ABAC_PDP_Health_IdP_Failed',
});
});
it('returns 200 ABAC_PDP_Health_OK when platform, IdP, and authorization all succeed', async () => {
await seedDefaultMocks();
await seedGetEntitlements({});
const res = await request.get('/api/v1/abac/pdp/health').set(credentials).expect(200);
expect(res.body).to.deep.include({
success: true,
available: true,
message: 'ABAC_PDP_Health_OK',
});
});
});
describe('users.info abacAttributes visibility', () => {
let userWithAttrs: IUser;
before(async () => {
userWithAttrs = await createUser({ verified: true });
await addAbacAttributesToUserDirectly(userWithAttrs._id, [{ key: attrKey, values: ['alpha'] }]);
});
after(async () => {
await deleteUser(userWithAttrs);
});
it('omits abacAttributes from /v1/users.info when PDP type is virtru', async () => {
const res = await request.get('/api/v1/users.info').set(credentials).query({ userId: userWithAttrs._id }).expect(200);
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('user');
expect(res.body.user).to.not.have.property('abacAttributes');
});
});
(IS_EE ? describe : describe.skip)('[ABAC] virtru-attribute-store (mock-server)', function () {
this.retries(0);
const v1 = '/api/v1';
const NS = 'example.com';
const fqn = (key: string, value: string) => `https://${NS}/attr/${key}/value/${value}`;
let storeConnection: MongoClient;
const makeAdmin = async (slug: string) => {
const u = await createUser({
roles: ['admin'],
username: `vstore-${slug}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
verified: true,
});
const creds = await login(u.username, password);
return { user: u, creds };
};
before((done) => getCredentials(done));
before(async function () {
this.timeout(20000);
const healthy = await mockServerHealthy();
expect(healthy, 'mock-server is not reachable — ensure it is running').to.be.true;
storeConnection = await MongoClient.connect(URL_MONGODB);
await Promise.all([
updatePermission('abac-management', ['admin']),
updatePermission('manage-abac-admin-settings', ['admin']),
updatePermission('manage-abac-admin-room-attributes', ['admin']),
updatePermission('manage-abac-admin-rooms', ['admin']),
updatePermission('view-abac-admin-audit', ['admin']),
]);
await Promise.all([
updateSetting('ABAC_Virtru_Base_URL', 'http://mock-server:8080'),
updateSetting('ABAC_Virtru_OIDC_Endpoint', 'http://mock-server:8080/auth/realms/mock'),
updateSetting('ABAC_Virtru_Client_ID', 'mock-client'),
updateSetting('ABAC_Virtru_Client_Secret', 'mock-secret'),
updateSetting('ABAC_Virtru_Default_Entity_Key', 'emailAddress'),
updateSetting('ABAC_Virtru_Attribute_Namespace', NS),
updateSetting('Abac_Cache_Decision_Time_Seconds', 0),
]);
await updateSetting('ABAC_Enabled', true);
await updateSetting('ABAC_PDP_Type', 'virtru');
await updateSetting('ABAC_Attribute_Store', 'virtru');
});
after(async function () {
this.timeout(15000);
await mockServerReset();
await updateSetting('ABAC_Attribute_Store', 'local');
await updateSetting('ABAC_PDP_Type', 'local');
await updateSetting('ABAC_Enabled', false);
await storeConnection.close();
});
describe('redactor / single-room (spec §5.0/§5.0a)', () => {
let room: IRoom;
let adminA: { user: IUser; creds: Credentials };
let adminB: { user: IUser; creds: Credentials };
before(async function () {
this.timeout(20000);
adminA = await makeAdmin('redA');
adminB = await makeAdmin('redB');
await mockServerReset();
await seedDefaultMocks();
await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} });
await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: '__seed__' }] }]);
room = (await createRoom({ type: 'p', name: `vstore-red-${Date.now()}` })).body.group;
await storeConnection
.db()
.collection('rocketchat_room')
.updateOne({ _id: room._id as any }, { $set: { abacAttributes: [{ key: 'clearance', values: ['secret'] }] } });
});
after(async () => {
await mockServerReset();
await deleteRoom({ type: 'p', roomId: room._id });
await deleteUser(adminA.user);
await deleteUser(adminB.user);
});
it('admin A (PERMIT): /abac/rooms returns room with abacAttributes populated, no redacted flag', async () => {
await mockServerReset();
await seedDefaultMocks();
await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} });
await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }]);
const res = await request.get(`${v1}/abac/rooms`).set(adminA.creds).expect(200);
const found = (res.body.rooms as IRoom[]).find((r) => r._id === room._id);
expect(found, 'room must be in list for permit admin').to.exist;
expect(found?.abacAttributes).to.deep.equal([{ key: 'clearance', values: ['secret'] }]);
expect(found).to.not.have.property('abacAttributesRedacted');
});
it('admin A (PERMIT): rooms.adminRooms.getRoom returns room with abacAttributes populated, no flag', async () => {
await mockServerReset();
await seedDefaultMocks();
await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} });
await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }]);
const res = await request.get(`${v1}/rooms.adminRooms.getRoom`).set(adminA.creds).query({ rid: room._id }).expect(200);
expect(res.body.abacAttributes).to.deep.equal([{ key: 'clearance', values: ['secret'] }]);
expect(res.body).to.not.have.property('abacAttributesRedacted');
});
it('admin A can POST a value they possess and gets 200', async () => {
const postRoom = (await createRoom({ type: 'p', name: `vstore-red-post-${Date.now()}` })).body.group;
await mockServerReset();
await seedDefaultMocks();
await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} });
await seedGetDecisionBulk([
{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: postRoom._id }] },
{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: postRoom._id }] },
]);
await request
.post(`${v1}/abac/rooms/${postRoom._id}/attributes/clearance`)
.set(adminA.creds)
.send({ values: ['secret'] })
.expect(200);
});
it('admin B (DENY): list contains the room redacted + flagged, same pagination shape as A', async () => {
await mockServerReset();
await seedDefaultMocks();
await seedGetEntitlements({ [fqn('other', 'value')]: {} });
await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_DENY', ephemeralResourceId: room._id }] }]);
const resB = await request.get(`${v1}/abac/rooms`).set(adminB.creds).expect(200);
await mockServerReset();
await seedDefaultMocks();
await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} });
await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }]);
const resA = await request.get(`${v1}/abac/rooms`).set(adminA.creds).expect(200);
expect(resB.body.total).to.equal(resA.body.total);
expect(resB.body.offset).to.equal(resA.body.offset);
const denied = (resB.body.rooms as IRoom[]).find((r) => r._id === room._id);
expect(denied, 'room must still appear in deny admin list').to.exist;
expect(denied?.abacAttributes).to.deep.equal([]);
expect(denied).to.have.property('abacAttributesRedacted', true);
for (const r of resB.body.rooms as Array<IRoom & { restricted?: unknown }>) {
expect(r).to.not.have.property('restricted');
}
expect(resB.body).to.not.have.property('restricted');
});
it('admin B (DENY): rooms.adminRooms.getRoom returns room redacted + flagged', async () => {
await mockServerReset();
await seedDefaultMocks();
await seedGetEntitlements({ [fqn('other', 'value')]: {} });
await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_DENY', ephemeralResourceId: room._id }] }]);
const res = await request.get(`${v1}/rooms.adminRooms.getRoom`).set(adminB.creds).query({ rid: room._id }).expect(200);
expect(res.body.abacAttributes).to.deep.equal([]);
expect(res.body).to.have.property('abacAttributesRedacted', true);
});
it('admin B (DENY): non-empty POST → 400 error-abac-not-authorized-to-modify-room', async () => {
await mockServerReset();
await seedDefaultMocks();
await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} });
await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_DENY', ephemeralResourceId: room._id }] }]);
const res = await request
.post(`${v1}/abac/rooms/${room._id}/attributes/clearance`)
.set(adminB.creds)
.send({ values: ['secret'] })
.expect(400);
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error', 'error-abac-not-authorized-to-modify-room');
});
it('Virtru unreachable on list → 200 with rooms attribute-redacted+flagged (not 4xx/5xx)', async () => {
await mockServerReset();
await mockServerSet('GET', '/healthz', { status: 'NOT_SERVING' }, 503);
const res = await request.get(`${v1}/abac/rooms`).set(adminA.creds).expect(200);
const found = (res.body.rooms as IRoom[]).find((r) => r._id === room._id);
expect(found, 'room remains in list when PDP is unreachable').to.exist;
expect(found?.abacAttributes).to.deep.equal([]);
expect(found).to.have.property('abacAttributesRedacted', true);
});
});
describe('write guard / validateAssignable (spec §4.1)', () => {
let room: IRoom;
let adminWG: { user: IUser; creds: Credentials };
before(async function () {
this.timeout(15000);
adminWG = await makeAdmin('wg');
room = (await createRoom({ type: 'p', name: `vstore-wg-${Date.now()}` })).body.group;
});
after(async () => {
await mockServerReset();
await deleteRoom({ type: 'p', roomId: room._id });
await deleteUser(adminWG.user);
});
it('assigning a key the admin lacks → 400 error-invalid-attribute-values', async () => {
await mockServerReset();
await seedDefaultMocks();
await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} });
await seedGetDecisionBulk([
{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] },
{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] },
]);
const res = await request
.post(`${v1}/abac/rooms/${room._id}/attributes/team`)
.set(adminWG.creds)
.send({ values: ['blue'] })
.expect(400);
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error', 'error-invalid-attribute-values');
});
it('assigning a value of a key they have but not that specific value → 400 error-invalid-attribute-values', async () => {
await mockServerReset();
await seedDefaultMocks();
await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} });
await seedGetDecisionBulk([
{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] },
{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] },
]);
const res = await request
.post(`${v1}/abac/rooms/${room._id}/attributes/clearance`)
.set(adminWG.creds)
.send({ values: ['topsecret'] })
.expect(400);
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error', 'error-invalid-attribute-values');
});
});
describe('bypass-abac-store-validation permission (spec §4.2)', () => {
let adminBypass: { user: IUser; creds: Credentials };
before(async function () {
this.timeout(15000);
adminBypass = await makeAdmin('bypass');
await updatePermission('bypass-abac-store-validation', ['admin']);
});
after(async () => {
await mockServerReset();
await updatePermission('bypass-abac-store-validation', []);
await deleteUser(adminBypass.user);
});
it('skips validateAssignable: assigns a value the admin is not entitled to → 200', async () => {
const room = (await createRoom({ type: 'p', name: `vstore-bypass-wg-${Date.now()}` })).body.group;
await mockServerReset();
await seedDefaultMocks();
await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} });
await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }]);
await request
.post(`${v1}/abac/rooms/${room._id}/attributes/team`)
.set(adminBypass.creds)
.send({ values: ['blue'] })
.expect(200);
await deleteRoom({ type: 'p', roomId: room._id });
});
it('skips assertCanModifyRoom: edits an attribute on a room the PDP denies → 200', async () => {
await mockServerReset();
await seedDefaultMocks();
await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} });
await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: '__seed__' }] }]);
const room = (await createRoom({ type: 'p', name: `vstore-bypass-mod-${Date.now()}` })).body.group;
await storeConnection
.db()
.collection('rocketchat_room')
.updateOne({ _id: room._id as any }, { $set: { abacAttributes: [{ key: 'clearance', values: ['secret'] }] } });
await mockServerReset();
await seedDefaultMocks();
await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} });
await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_DENY', ephemeralResourceId: room._id }] }]);
await request
.post(`${v1}/abac/rooms/${room._id}/attributes/team`)
.set(adminBypass.creds)
.send({ values: ['blue'] })
.expect(200);
await deleteRoom({ type: 'p', roomId: room._id });
});
});
describe('hierarchy expansion (spec §3.3)', () => {
let room: IRoom;
let adminH: { user: IUser; creds: Credentials };
before(async function () {
this.timeout(15000);
adminH = await makeAdmin('hier');
room = (await createRoom({ type: 'p', name: `vstore-hier-${Date.now()}` })).body.group;
await storeConnection
.db()
.collection('rocketchat_room')
.updateOne({ _id: room._id as any }, { $set: { abacAttributes: [{ key: 'clearance', values: ['secret'] }] } });
});
after(async () => {
await mockServerReset();
await deleteRoom({ type: 'p', roomId: room._id });
await deleteUser(adminH.user);
});
it('picker returns hierarchy-expanded set (secret + topsecret) when entitled to topsecret', async () => {
await mockServerReset();
await seedDefaultMocks();
await seedGetEntitlements({
[fqn('clearance', 'secret')]: {},
[fqn('clearance', 'topsecret')]: {},
});
const res = await request.get(`${v1}/abac/attributes`).set(adminH.creds).expect(200);
const clearance = (res.body.attributes as Array<{ key: string; values: string[] }>).find((a) => a.key === 'clearance');
expect(clearance, 'clearance attribute should be present').to.exist;
expect(clearance?.values).to.include.members(['secret', 'topsecret']);
});
it('a room using a lower-rank value (secret) is in-scope for an actor entitled to the higher rank', async () => {
await mockServerReset();
await seedDefaultMocks();
await seedGetEntitlements({
[fqn('clearance', 'secret')]: {},
[fqn('clearance', 'topsecret')]: {},
});
await seedGetDecisionBulk([
{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] },
{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] },
]);
const res = await request.get(`${v1}/abac/rooms`).set(adminH.creds).expect(200);
const found = (res.body.rooms as IRoom[]).find((r) => r._id === room._id);
expect(found, 'lower-rank room must be in scope when entitled to higher rank').to.exist;
expect(found?.abacAttributes).to.deep.equal([{ key: 'clearance', values: ['secret'] }]);
expect(found).to.not.have.property('abacAttributesRedacted');
await request
.put(`${v1}/abac/rooms/${room._id}/attributes/clearance`)
.set(adminH.creds)
.send({ values: ['secret'] })
.expect(200);
});
});
describe('catalog-400 in virtru store (spec §5.1, corrected to 400)', () => {
let adminC: { user: IUser; creds: Credentials };
before(async function () {
this.timeout(15000);
adminC = await makeAdmin('cat');
await mockServerReset();
await seedDefaultMocks();
await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} });
});
after(async () => {
await mockServerReset();
await deleteUser(adminC.user);
});
it('POST /abac/attributes → 400 error-abac-attribute-store-external', async () => {
const res = await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: `vstore_cat_${Date.now()}`, values: ['v1'] })
.expect(400);
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error', 'error-abac-attribute-store-external');
});
it('PUT /abac/attributes/:_id → 400 error-abac-attribute-store-external', async () => {
const res = await request
.put(`${v1}/abac/attributes/__nonexistent__`)
.set(credentials)
.send({ values: ['v1'] })
.expect(400);
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error', 'error-abac-attribute-store-external');
});
it('DELETE /abac/attributes/:_id → 400 error-abac-attribute-store-external', async () => {
const res = await request.delete(`${v1}/abac/attributes/__nonexistent__`).set(credentials).expect(400);
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error', 'error-abac-attribute-store-external');
});
it('GET /abac/attributes/:_id → 400 error-abac-attribute-store-external', async () => {
const res = await request.get(`${v1}/abac/attributes/__nonexistent__`).set(credentials).expect(400);
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error', 'error-abac-attribute-store-external');
});
it('GET /abac/attributes/:key/is-in-use → 400 error-abac-attribute-store-external', async () => {
const res = await request.get(`${v1}/abac/attributes/clearance/is-in-use`).set(credentials).expect(400);
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error', 'error-abac-attribute-store-external');
});
it('GET /abac/attributes (picker) → 200 served from store.list(actor)', async () => {
await mockServerReset();
await seedDefaultMocks();
await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} });
const res = await request.get(`${v1}/abac/attributes`).set(adminC.creds).expect(200);
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('attributes').that.is.an('array');
const clearance = (res.body.attributes as Array<{ key: string; values: string[] }>).find((a) => a.key === 'clearance');
expect(clearance?.values).to.deep.equal(['secret']);
});
it('POST /abac/users/sync is NOT blocked by external attribute store (no error-abac-attribute-store-external)', async () => {
const res = await request
.post(api('abac/users/sync'))
.set(credentials)
.send({ usernames: ['no-such-user-vstore'] });
expect(res.body?.error).to.not.equal('error-abac-attribute-store-external');
});
});
describe('picker / validateAssignable unreachable', () => {
let room: IRoom;
let adminU: { user: IUser; creds: Credentials };
before(async function () {
this.timeout(15000);
adminU = await makeAdmin('unr');
room = (await createRoom({ type: 'p', name: `vstore-unr-${Date.now()}` })).body.group;
await storeConnection
.db()
.collection('rocketchat_room')
.updateOne({ _id: room._id as any }, { $set: { abacAttributes: [{ key: 'clearance', values: ['secret'] }] } });
});
after(async () => {
await mockServerReset();
await deleteRoom({ type: 'p', roomId: room._id });
await deleteUser(adminU.user);
});
it('Virtru unreachable: picker GET /abac/attributes → 400 error-pdp-unavailable', async () => {
await mockServerReset();
await mockServerSet('GET', '/healthz', { status: 'NOT_SERVING' }, 503);
const res = await request.get(`${v1}/abac/attributes`).set(adminU.creds).expect(400);
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error', 'error-pdp-unavailable');
});
it('Virtru unreachable: write POST /abac/rooms/:rid/attributes/:key → 400 error-pdp-unavailable', async () => {
await mockServerReset();
await mockServerSet('GET', '/healthz', { status: 'NOT_SERVING' }, 503);
const res = await request
.post(`${v1}/abac/rooms/${room._id}/attributes/clearance`)
.set(adminU.creds)
.send({ values: ['secret'] })
.expect(400);
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error', 'error-pdp-unavailable');
});
it('decision unreachable on rooms.adminRooms.getRoom → 200 with abacAttributes redacted+flagged', async () => {
await mockServerReset();
await mockServerSet('GET', '/healthz', { status: 'NOT_SERVING' }, 503);
const res = await request.get(`${v1}/rooms.adminRooms.getRoom`).set(adminU.creds).query({ rid: room._id }).expect(200);
expect(res.body.abacAttributes).to.deep.equal([]);
expect(res.body).to.have.property('abacAttributesRedacted', true);
});
it('write assertCanModifyRoom unreachable → 400 error-pdp-unavailable', async () => {
await mockServerReset();
await seedDefaultMocks();
await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} });
await mockServerSet('POST', '/authorization.v2.AuthorizationService/GetDecisionBulk', { error: 'pdp-down' }, 503);
const res = await request
.put(`${v1}/abac/rooms/${room._id}/attributes/clearance`)
.set(adminU.creds)
.send({ values: ['secret'] })
.expect(400);
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error', 'error-pdp-unavailable');
});
});
describe('wipe on transition local → virtru (spec §0.2)', () => {
let wipeRoom1: IRoom;
let wipeRoom2: IRoom;
let memberUser: IUser;
before(async function () {
this.timeout(20000);
await updateSetting('ABAC_Attribute_Store', 'local');
const wipeAttrKey = `vstore_wipe_attr_${Date.now()}`;
await mockServerReset();
await seedDefaultMocks();
await seedGetEntitlements({ [fqn(wipeAttrKey, 'v1')]: {} });
wipeRoom1 = (await createRoom({ type: 'p', name: `vstore-wipe-1-${Date.now()}` })).body.group;
wipeRoom2 = (await createRoom({ type: 'p', name: `vstore-wipe-2-${Date.now()}` })).body.group;
memberUser = await createUser({ verified: true });
await request
.post(`${v1}/groups.invite`)
.set(credentials)
.send({ roomId: wipeRoom1._id, usernames: [memberUser.username] })
.expect(200);
await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: wipeAttrKey, values: ['v1'] })
.expect(200);
await request
.post(`${v1}/abac/rooms/${wipeRoom1._id}/attributes/${wipeAttrKey}`)
.set(credentials)
.send({ values: ['v1'] })
.expect(200);
await request
.post(`${v1}/abac/rooms/${wipeRoom2._id}/attributes/${wipeAttrKey}`)
.set(credentials)
.send({ values: ['v1'] })
.expect(200);
});
after(async () => {
await updateSetting('ABAC_Attribute_Store', 'virtru');
await deleteRoom({ type: 'p', roomId: wipeRoom1._id });
await deleteRoom({ type: 'p', roomId: wipeRoom2._id });
await deleteUser(memberUser);
});
it('switching ABAC_Attribute_Store local → virtru wipes abacAttributes from existing rooms', async function () {
this.timeout(20000);
await updateSetting('ABAC_Attribute_Store', 'virtru');
const rooms = storeConnection.db().collection('rocketchat_room');
let r1: any = null;
let r2: any = null;
for (let i = 0; i < 30; i++) {
r1 = await rooms.findOne({ _id: wipeRoom1._id as any });
r2 = await rooms.findOne({ _id: wipeRoom2._id as any });
if (!r1?.abacAttributes && !r2?.abacAttributes) {
break;
}
await sleep(200);
}
expect(r1?.abacAttributes, 'room1 abacAttributes must be unset after transition').to.be.undefined;
expect(r2?.abacAttributes, 'room2 abacAttributes must be unset after transition').to.be.undefined;
});
it('emits at-least-one abac.attribute.store.switched audit event with from=local, to=virtru, roomsAffected>=1', async function () {
this.timeout(15000);
let switched: Array<{ data: Array<{ key: string; value: unknown }> }> = [];
for (let i = 0; i < 30; i++) {
const res = await request.get(`${v1}/abac/audit`).set(credentials).query({ count: 100 }).expect(200);
switched = (res.body.events as Array<{ t: string; data: Array<{ key: string; value: unknown }> }>).filter(
(e) => e.t === 'abac.attribute.store.switched',
);
if (switched.length) {
break;
}
await sleep(200);
}
expect(switched.length, 'at least one switched audit event').to.be.at.least(1);
const ev = switched[0];
const pick = (k: string) => ev.data.find((d) => d.key === k)?.value;
expect(pick('from')).to.equal('local');
expect(pick('to')).to.equal('virtru');
expect(pick('roomsAffected')).to.be.a('number').and.to.be.at.least(1);
});
it('members of pre-existing ABAC rooms are NOT evicted by the wipe', async () => {
const res = await request.get(`${v1}/groups.members`).set(credentials).query({ roomId: wipeRoom1._id }).expect(200);
const usernames = res.body.members.map((m: IUser) => m.username);
expect(usernames).to.include(memberUser.username);
});
});
describe('audit / logs (spec §5.2 — settled, NOT redacted)', () => {
let auditRoom: IRoom;
const auditAttrKey = `vstore_audit_${Date.now()}`;
before(async function () {
this.timeout(15000);
await mockServerReset();
await seedDefaultMocks();
await seedGetEntitlements({ [fqn(auditAttrKey, 'v1')]: {} });
await seedGetDecisionBulk([
{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: '__seed__' }] },
{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: '__seed__' }] },
]);
auditRoom = (await createRoom({ type: 'p', name: `vstore-audit-${Date.now()}` })).body.group;
});
after(async () => {
await mockServerReset();
await deleteRoom({ type: 'p', roomId: auditRoom._id });
});
it('GET /abac/audit returns events with attribute key/value payloads visible (not redacted) and includes abac.attribute.store.switched', async () => {
const res = await request.get(`${v1}/abac/audit`).set(credentials).query({ count: 200 }).expect(200);
const events = res.body.events as Array<{ t: string; data: Array<{ key: string; value: unknown }> }>;
expect(events).to.be.an('array').that.is.not.empty;
const types = new Set(events.map((e) => e.t));
expect(types.has('abac.attribute.store.switched'), 'attribute_store.switched event included by endpoint').to.be.true;
const switched = events.find((e) => e.t === 'abac.attribute.store.switched');
expect(switched, 'switched event must exist').to.exist;
expect(switched?.data.find((d) => d.key === 'from')?.value, 'from value visible').to.be.a('string');
expect(switched?.data.find((d) => d.key === 'to')?.value, 'to value visible').to.be.a('string');
expect(switched?.data.find((d) => d.key === 'roomsAffected')?.value, 'roomsAffected visible').to.be.a('number');
});
});
describe('GET /abac/audit query filters', () => {
it('scopes events to the actor passed in the query', async () => {
const mine = await request
.get(`${v1}/abac/audit`)
.set(credentials)
.query({ count: 100, actor: { username: adminUsername } })
.expect(200);
expect(mine.body.events).to.be.an('array').that.is.not.empty;
(mine.body.events as Array<{ actor?: { username?: string } }>).forEach((e) => {
expect(e.actor?.username).to.equal(adminUsername);
});
const other = await request
.get(`${v1}/abac/audit`)
.set(credentials)
.query({ count: 100, actor: { _id: 'no-such-actor-xyz' } })
.expect(200);
expect(other.body.events).to.be.an('array').that.is.empty;
expect(other.body.total).to.equal(0);
});
it('returns no events when start is in the future', async () => {
const future = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
const res = await request.get(`${v1}/abac/audit`).set(credentials).query({ count: 100, start: future }).expect(200);
expect(res.body.events).to.be.an('array').that.is.empty;
expect(res.body.total).to.equal(0);
});
it('returns no events when end precedes all events', async () => {
const res = await request
.get(`${v1}/abac/audit`)
.set(credentials)
.query({ count: 100, end: new Date(1).toISOString() })
.expect(200);
expect(res.body.events).to.be.an('array').that.is.empty;
expect(res.body.total).to.equal(0);
});
});
describe('local-mode regression (ABAC_Attribute_Store=local)', () => {
const localKey = `vstore_local_${Date.now()}`;
let localRoom: IRoom;
let localAttrId: string;
before(async function () {
this.timeout(20000);
await updateSetting('ABAC_Attribute_Store', 'local');
await mockServerReset();
await seedDefaultMocks();
const createRes = await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: localKey, values: ['v1', 'v2'] })
.expect(200);
expect(createRes.body).to.have.property('success', true);
const listRes = await request.get(`${v1}/abac/attributes`).query({ key: localKey }).set(credentials).expect(200);
const attr = (listRes.body.attributes as Array<{ _id: string; key: string }>).find((a) => a.key === localKey);
expect(attr, 'created local attribute should be retrievable via picker').to.exist;
if (!attr) {
throw new Error('local attribute not found');
}
localAttrId = attr._id;
localRoom = (await createRoom({ type: 'p', name: `vstore-local-${Date.now()}` })).body.group;
await request
.post(`${v1}/abac/rooms/${localRoom._id}/attributes/${localKey}`)
.set(credentials)
.send({ values: ['v1'] })
.expect(200);
});
after(async () => {
await request.delete(`${v1}/abac/rooms/${localRoom._id}/attributes`).set(credentials).expect(200);
await deleteRoom({ type: 'p', roomId: localRoom._id });
await request.delete(`${v1}/abac/attributes/${localAttrId}`).set(credentials).expect(200);
await updateSetting('ABAC_Attribute_Store', 'virtru');
});
it('catalog CRUD works (PUT 200)', async () => {
await request
.put(`${v1}/abac/attributes/${localAttrId}`)
.set(credentials)
.send({ values: ['v1', 'v2', 'v3'] })
.expect(200);
});
it('GET /abac/attributes/:_id returns 200 (no virtru block)', async () => {
const res = await request.get(`${v1}/abac/attributes/${localAttrId}`).set(credentials).expect(200);
expect(res.body).to.have.property('success', true);
});
it('GET /abac/attributes/:key/is-in-use returns 200 in local mode', async () => {
const res = await request.get(`${v1}/abac/attributes/${localKey}/is-in-use`).set(credentials).expect(200);
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('inUse').that.is.a('boolean');
});
it('list /abac/rooms returns rooms with full attributes, no abacAttributesRedacted flags', async () => {
const res = await request.get(`${v1}/abac/rooms`).set(credentials).expect(200);
const found = (res.body.rooms as IRoom[]).find((r) => r._id === localRoom._id);
expect(found, 'local-mode room must be in list').to.exist;
expect(found?.abacAttributes).to.deep.equal([{ key: localKey, values: ['v1'] }]);
for (const r of res.body.rooms as IRoom[]) {
expect(r).to.not.have.property('abacAttributesRedacted');
}
});
it('write guard is permissive in local mode (admin without entitlements still succeeds)', async () => {
await request
.put(`${v1}/abac/rooms/${localRoom._id}/attributes/${localKey}`)
.set(credentials)
.send({ values: ['v2'] })
.expect(200);
});
it('local→local no-op setting write does NOT run the wipe (no new switched audit event)', async function () {
this.timeout(10000);
const before = await request.get(`${v1}/abac/audit`).set(credentials).query({ count: 200 }).expect(200);
const beforeCount = (before.body.events as Array<{ t: string }>).filter((e) => e.t === 'abac.attribute.store.switched').length;
await updateSetting('ABAC_Attribute_Store', 'local');
const after = await request.get(`${v1}/abac/audit`).set(credentials).query({ count: 200 }).expect(200);
const afterCount = (after.body.events as Array<{ t: string }>).filter((e) => e.t === 'abac.attribute.store.switched').length;
expect(afterCount).to.equal(beforeCount);
const roomRes = await request.get(`${v1}/abac/rooms`).set(credentials).expect(200);
const stillThere = (roomRes.body.rooms as IRoom[]).find((r) => r._id === localRoom._id);
expect(stillThere?.abacAttributes, 'no-op local→local must not wipe').to.deep.equal([{ key: localKey, values: ['v2'] }]);
});
});
});
});
(IS_EE ? describe : describe.skip)('[ABAC] Classification banners config schema validation on save', () => {
const validConfig = {
version: 1,
enabled: true,
banner: {
style: 'classic',
uppercase: true,
monospace: false,
delimiter: ' // ',
colorMode: 'highest',
fallbackText: 'NO CLASSIFICATION DATA',
fallbackColor: '#6C727A',
},
attributes: [
{
id: 'classification',
source: 'clearance.level',
label: 'Classification level',
showInBanner: true,
showLabel: false,
bannerLabel: '',
labelSeparator: '',
valueSeparator: '/',
sortAlpha: false,
groupThreshold: 0,
multipleLabel: '',
drivesColor: true,
values: [
{ source: 'TS', label: 'TOP SECRET', color: '#ff8c00' },
{ source: 'U', label: 'UNCLASSIFIED', color: '#007a33' },
],
},
],
};
const saveBannersConfig = (value: string) =>
request.post(api('settings/ABAC_Classification_Banners_Config')).set(credentials).send({ value });
before((done) => getCredentials(done));
after(() => updateSetting('ABAC_Classification_Banners_Config', ''));
it('should reject a value that is not valid JSON', async () => {
await saveBannersConfig('not json {')
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error', 'ABAC_Classification_Banners_Config_Invalid');
expect(res.body).to.have.property('errorType', 'error-setting-validation-failed');
});
});
it('should reject a JSON document violating the schema', async () => {
await saveBannersConfig(JSON.stringify({ ...validConfig, banner: { ...validConfig.banner, fallbackColor: 'red' } }))
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error', 'ABAC_Classification_Banners_Config_Invalid');
expect(res.body).to.have.property('errorType', 'error-setting-validation-failed');
});
});
it('should accept a document matching the schema', async () => {
await saveBannersConfig(JSON.stringify(validConfig))
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
});
it('should accept an empty value, leaving the setting unconfigured', async () => {
await saveBannersConfig('')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
});
});