diff --git a/ee/packages/abac/src/subject-attributes-validations.spec.ts b/ee/packages/abac/src/subject-attributes-validations.spec.ts index dbc9e9b157c05..8ededa04cee40 100644 --- a/ee/packages/abac/src/subject-attributes-validations.spec.ts +++ b/ee/packages/abac/src/subject-attributes-validations.spec.ts @@ -1,10 +1,9 @@ import type { ILDAPEntry, IUser, IAbacAttributeDefinition } from '@rocket.chat/core-typings'; -import { registerServiceModels, Users } from '@rocket.chat/models'; +import { Users } from '@rocket.chat/models'; import type { Collection, Db } from 'mongodb'; -import { MongoClient } from 'mongodb'; -import { MongoMemoryServer } from 'mongodb-memory-server'; import { AbacService } from './index'; +import { acquireSharedInMemoryMongo, SHARED_ABAC_TEST_DB, type SharedMongoConnection } from './test-helpers/mongoMemoryServer'; jest.mock('@rocket.chat/core-services', () => ({ ServiceClass: class {}, @@ -16,13 +15,13 @@ jest.mock('@rocket.chat/core-services', () => ({ const makeUser = (overrides: Partial = {}): IUser => ({ - _id: `user-${Math.random().toString(36).substring(2, 15)}`, - username: `user${Math.random().toString(36).substring(2, 15)}`, + _id: `user-fixed-id-${Math.random()}`, + username: 'user-fixed-username', roles: [], type: 'user', active: true, - createdAt: new Date(), - _updatedAt: new Date(), + createdAt: new Date(0), + _updatedAt: new Date(0), ...overrides, }) as IUser; @@ -31,48 +30,172 @@ const makeLdap = (overrides: Partial = {}): ILDAPEntry => ...overrides, }) as ILDAPEntry; -describe('Subject Attributes validation', () => { - let db: Db; - let mongo: MongoMemoryServer; - let client: MongoClient; +type StaticUserDefinition = { + _id: string; + username: string; +}; + +const staticUserDefinitions: StaticUserDefinition[] = [ + { _id: 'u-no-map-attrs', username: 'user-no-map' }, + { _id: 'u-merge-memberof', username: 'merge-memberof' }, + { _id: 'u-distinct-keys', username: 'distinct-keys' }, + { _id: 'u-merge-array-string', username: 'merge-array-string' }, + { _id: 'u-unset-loss', username: 'unset-loss' }, + { _id: 'u-unset-none', username: 'unset-none' }, + { _id: 'u-loss-values', username: 'loss-values' }, + { _id: 'u-loss-key', username: 'loss-key' }, + { _id: 'u-gain-values', username: 'gain-values' }, + { _id: 'u-order-only', username: 'order-only' }, + { _id: 'u-merge-ldap-dup', username: 'merge-ldap-dup' }, + { _id: 'u-immutability', username: 'immutability' }, + { _id: 'u-loss', username: 'lossy' }, + { _id: 'u-key-loss', username: 'keyloss' }, + { _id: 'u-growth', username: 'growth' }, + { _id: 'u-extra-room-key', username: 'extrakey' }, + { _id: 'u-empty', username: 'empty' }, + { _id: 'u-lose-unrelated', username: 'unrelated' }, +]; + +const staticTestUsers: IUser[] = staticUserDefinitions.map(({ _id, username }) => ({ + _id, + username, + roles: [], + type: 'user', + active: true, + createdAt: new Date(0), + _updatedAt: new Date(0), + __rooms: [], +})) as IUser[]; + +const staticUserIds = staticTestUsers.map(({ _id }) => _id); +const staticUserBaseMap = Object.fromEntries(staticTestUsers.map((user) => [user._id, { ...user }])) as Record; + +const getStaticUser = (_id: string, overrides: Partial = {}): IUser => { + const base = staticUserBaseMap[_id]; + if (!base) { + throw new Error(`Unknown static user ${_id}`); + } + return { + ...base, + ...overrides, + _id: base._id, + username: overrides.username ?? base.username, + __rooms: overrides.__rooms ?? base.__rooms ?? [], + }; +}; + +type StaticUserUpdate = Partial & { _id: string }; + +const service = new AbacService(); + +let db: Db; +let sharedMongo: SharedMongoConnection; +let roomsCol: Collection; +let usersCol: Collection; + +const resetStaticUsers = async () => { + await usersCol.updateMany( + { _id: { $in: staticUserIds } }, + { + $set: { + __rooms: [], + _updatedAt: new Date(), + }, + $unset: { + abacAttributes: 1, + }, + }, + ); +}; + +const configureStaticUsers = async (users: StaticUserUpdate[]) => { + if (!usersCol) { + throw new Error('users collection not initialized'); + } + + const operations = users.map(({ _id, ...fields }) => { + const update: { $set: Record; $unset?: Record } = { + $set: { _updatedAt: new Date() }, + }; + + for (const [key, value] of Object.entries(fields)) { + if (key === 'abacAttributes') { + if (value === undefined) { + update.$unset = { ...(update.$unset || {}), abacAttributes: 1 }; + } else { + update.$set.abacAttributes = value; + } + continue; + } + if (key === '__rooms') { + update.$set.__rooms = value ?? []; + continue; + } + update.$set[key] = value; + } + + return { + updateOne: { + filter: { _id }, + update, + }, + }; + }); + + await usersCol.bulkWrite(operations); +}; +const makeLdapEntry = makeLdap; // preserve existing helper naming intent + +const insertRooms = async (rooms: { _id: string; abacAttributes?: IAbacAttributeDefinition[] }[]) => { + await roomsCol.insertMany( + rooms.map((room) => ({ + _id: room._id, + name: room._id, + t: 'p', + abacAttributes: room.abacAttributes, + })), + ); +}; + +describe('Subject Attributes validation', () => { beforeAll(async () => { - mongo = await MongoMemoryServer.create(); - client = await MongoClient.connect(mongo.getUri(), {}); - db = client.db('abac_global'); - registerServiceModels(db); + sharedMongo = await acquireSharedInMemoryMongo(SHARED_ABAC_TEST_DB); + db = sharedMongo.db; + + roomsCol = db.collection('rocketchat_room'); + usersCol = db.collection('users'); - // @ts-expect-error - ignore - await db.collection('abac_dummy_init').insertOne({ _id: 'init', createdAt: new Date() }); + await usersCol.deleteMany({ _id: { $in: staticUserIds } }); + await usersCol.insertMany(staticTestUsers); }, 30_000); afterAll(async () => { - await client.close(); - await mongo.stop(); + await usersCol.deleteMany({ _id: { $in: staticUserIds } }); + await sharedMongo.release(); }); - describe('AbacService.addSubjectAttributes (unit)', () => { - let service: AbacService; - - beforeEach(async () => { - service = new AbacService(); - }); + beforeEach(async () => { + await resetStaticUsers(); + await roomsCol.deleteMany({}); + }); + describe('AbacService.addSubjectAttributes (unit)', () => { describe('early returns and no-ops', () => { it('returns early when user has no _id', async () => { const user = makeUser({ _id: undefined }); await service.addSubjectAttributes(user, makeLdap(), { group: 'dept' }); - // Nothing inserted, ensure no user doc created const found = await Users.findOne({ username: user.username }); expect(found).toBeFalsy(); }); it('does nothing (no update) when map produces no attributes and user had none', async () => { - const user = makeUser({ abacAttributes: undefined }); - await Users.insertOne(user); - const ldap = makeLdap({ group: '' }); + const userId = 'u-no-map-attrs'; + await configureStaticUsers([{ _id: userId, abacAttributes: undefined }]); + const user = getStaticUser(userId); + const ldap = makeLdapEntry({ group: '' }); await service.addSubjectAttributes(user, ldap, { group: 'dept' }); - const updated = await Users.findOneById(user._id, { projection: { abacAttributes: 1 } }); + const updated = await Users.findOneById(userId, { projection: { abacAttributes: 1 } }); expect(updated).toBeTruthy(); expect(updated?.abacAttributes ?? undefined).toBeUndefined(); }); @@ -80,29 +203,31 @@ describe('Subject Attributes validation', () => { describe('building and setting attributes', () => { it('merges multiple LDAP keys mapping to the same ABAC key, deduplicating values', async () => { - const user = makeUser(); - await Users.insertOne(user); - const ldap = makeLdap({ + const userId = 'u-merge-memberof'; + await configureStaticUsers([{ _id: userId }]); + const user = getStaticUser(userId); + const ldap = makeLdapEntry({ memberOf: ['eng', 'sales', 'eng'], department: ['sales', 'support'], }); const map = { memberOf: 'dept', department: 'dept' }; await service.addSubjectAttributes(user, ldap, map); - const updated = await Users.findOneById(user._id, { projection: { abacAttributes: 1 } }); + const updated = await Users.findOneById(userId, { projection: { abacAttributes: 1 } }); expect(updated?.abacAttributes).toEqual([{ key: 'dept', values: ['eng', 'sales', 'support'] }]); }); it('creates distinct ABAC attributes for different mapped keys preserving insertion order', async () => { - const user = makeUser(); - await Users.insertOne(user); - const ldap = makeLdap({ + const userId = 'u-distinct-keys'; + await configureStaticUsers([{ _id: userId }]); + const user = getStaticUser(userId); + const ldap = makeLdapEntry({ groups: ['alpha', 'beta'], regionCodes: ['emea', 'apac'], role: 'admin', }); const map = { groups: 'team', regionCodes: 'region', role: 'role' }; await service.addSubjectAttributes(user, ldap, map); - const updated = await Users.findOneById(user._id, { projection: { abacAttributes: 1 } }); + const updated = await Users.findOneById(userId, { projection: { abacAttributes: 1 } }); expect(updated?.abacAttributes).toEqual([ { key: 'team', values: ['alpha', 'beta'] }, { key: 'region', values: ['emea', 'apac'] }, @@ -111,159 +236,149 @@ describe('Subject Attributes validation', () => { }); it('merges array and string LDAP values into one attribute', async () => { - const user = makeUser(); - await Users.insertOne(user); - const ldap = makeLdap({ deptCode: 'eng', deptName: ['engineering', 'eng'] }); + const userId = 'u-merge-array-string'; + await configureStaticUsers([{ _id: userId }]); + const user = getStaticUser(userId); + const ldap = makeLdapEntry({ deptCode: 'eng', deptName: ['engineering', 'eng'] }); const map = { deptCode: 'dept', deptName: 'dept' }; await service.addSubjectAttributes(user, ldap, map); - const updated = await Users.findOneById(user._id, { projection: { abacAttributes: 1 } }); + const updated = await Users.findOneById(userId, { projection: { abacAttributes: 1 } }); expect(updated?.abacAttributes).toEqual([{ key: 'dept', values: ['eng', 'engineering'] }]); }); }); describe('unsetting attributes when none extracted', () => { it('unsets abacAttributes when user previously had attributes but now extracts none', async () => { - const user = makeUser({ abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }); - await Users.insertOne(user); - const ldap = makeLdap({ other: ['x'] }); + const userId = 'u-unset-loss'; + await configureStaticUsers([{ _id: userId, abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }]); + const user = getStaticUser(userId, { abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }); + const ldap = makeLdapEntry({ other: ['x'] }); const map = { missing: 'dept' }; await service.addSubjectAttributes(user, ldap, map); - const updated = await Users.findOneById(user._id, { projection: { abacAttributes: 1 } }); + const updated = await Users.findOneById(userId, { projection: { abacAttributes: 1 } }); expect(updated?.abacAttributes).toBeUndefined(); }); it('does not unset when user had no prior attributes and extraction yields none', async () => { - const user = makeUser({ abacAttributes: [] }); - await Users.insertOne(user); - const ldap = makeLdap({}); + const userId = 'u-unset-none'; + await configureStaticUsers([{ _id: userId, abacAttributes: [] }]); + const user = getStaticUser(userId, { abacAttributes: [] }); + const ldap = makeLdapEntry({}); const map = { missing: 'dept' }; await service.addSubjectAttributes(user, ldap, map); - const updated = await Users.findOneById(user._id, { projection: { abacAttributes: 1 } }); + const updated = await Users.findOneById(userId, { projection: { abacAttributes: 1 } }); expect(updated?.abacAttributes).toEqual([]); }); }); describe('loss detection triggering hook (attribute changes)', () => { it('updates attributes reducing values on loss', async () => { - const user = makeUser({ abacAttributes: [{ key: 'dept', values: ['eng', 'qa'] }] }); - await Users.insertOne(user); - const ldap = makeLdap({ memberOf: ['eng'] }); + const userId = 'u-loss-values'; + await configureStaticUsers([{ _id: userId, abacAttributes: [{ key: 'dept', values: ['eng', 'qa'] }] }]); + const user = getStaticUser(userId, { abacAttributes: [{ key: 'dept', values: ['eng', 'qa'] }] }); + const ldap = makeLdapEntry({ memberOf: ['eng'] }); await service.addSubjectAttributes(user, ldap, { memberOf: 'dept' }); - const updated = await Users.findOneById(user._id, { projection: { abacAttributes: 1 } }); + const updated = await Users.findOneById(userId, { projection: { abacAttributes: 1 } }); expect(updated?.abacAttributes).toEqual([{ key: 'dept', values: ['eng'] }]); }); it('updates attributes removing an entire key', async () => { - const user = makeUser({ + const userId = 'u-loss-key'; + await configureStaticUsers([ + { + _id: userId, + abacAttributes: [ + { key: 'dept', values: ['eng'] }, + { key: 'region', values: ['emea'] }, + ], + }, + ]); + const user = getStaticUser(userId, { abacAttributes: [ { key: 'dept', values: ['eng'] }, { key: 'region', values: ['emea'] }, ], }); - await Users.insertOne(user); - const ldap = makeLdap({ department: ['eng'] }); + const ldap = makeLdapEntry({ department: ['eng'] }); await service.addSubjectAttributes(user, ldap, { department: 'dept' }); - const updated = await Users.findOneById(user._id, { projection: { abacAttributes: 1 } }); + const updated = await Users.findOneById(userId, { projection: { abacAttributes: 1 } }); expect(updated?.abacAttributes).toEqual([{ key: 'dept', values: ['eng'] }]); }); it('gains new values without triggering loss logic', async () => { - const user = makeUser({ abacAttributes: [{ key: 'dept', values: ['eng'] }] }); - await Users.insertOne(user); - const ldap = makeLdap({ memberOf: ['eng', 'qa'] }); + const userId = 'u-gain-values'; + await configureStaticUsers([{ _id: userId, abacAttributes: [{ key: 'dept', values: ['eng'] }] }]); + const user = getStaticUser(userId, { abacAttributes: [{ key: 'dept', values: ['eng'] }] }); + const ldap = makeLdapEntry({ memberOf: ['eng', 'qa'] }); await service.addSubjectAttributes(user, ldap, { memberOf: 'dept' }); - const updated = await Users.findOneById(user._id, { projection: { abacAttributes: 1 } }); + const updated = await Users.findOneById(userId, { projection: { abacAttributes: 1 } }); expect(updated?.abacAttributes).toEqual([{ key: 'dept', values: ['eng', 'qa'] }]); }); it('keeps attributes unchanged when only ordering differs', async () => { - const user = makeUser({ abacAttributes: [{ key: 'dept', values: ['eng', 'qa'] }] }); - await Users.insertOne(user); - const ldap = makeLdap({ memberOf: ['qa', 'eng'] }); + const userId = 'u-order-only'; + await configureStaticUsers([{ _id: userId, abacAttributes: [{ key: 'dept', values: ['eng', 'qa'] }] }]); + const user = getStaticUser(userId, { abacAttributes: [{ key: 'dept', values: ['eng', 'qa'] }] }); + const ldap = makeLdapEntry({ memberOf: ['qa', 'eng'] }); await service.addSubjectAttributes(user, ldap, { memberOf: 'dept' }); - const updated = await Users.findOneById(user._id, { projection: { abacAttributes: 1 } }); + const updated = await Users.findOneById(userId, { projection: { abacAttributes: 1 } }); expect(updated?.abacAttributes?.[0].key).toBe('dept'); expect(new Set(updated?.abacAttributes?.[0].values)).toEqual(new Set(['eng', 'qa'])); }); it('merges duplicate LDAP mapping keys retaining union of values', async () => { - const user = makeUser({ abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }); - await Users.insertOne(user); - const ldap = makeLdap({ deptA: ['eng', 'sales'], deptB: ['eng'] }); + const userId = 'u-merge-ldap-dup'; + await configureStaticUsers([{ _id: userId, abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }]); + const user = getStaticUser(userId, { abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }); + const ldap = makeLdapEntry({ deptA: ['eng', 'sales'], deptB: ['eng'] }); await service.addSubjectAttributes(user, ldap, { deptA: 'dept', deptB: 'dept' }); - const updated = await Users.findOneById(user._id, { projection: { abacAttributes: 1 } }); + const updated = await Users.findOneById(userId, { projection: { abacAttributes: 1 } }); expect(updated?.abacAttributes).toEqual([{ key: 'dept', values: ['eng', 'sales'] }]); }); }); describe('input immutability', () => { + let sharedUser: IUser; + let original: IAbacAttributeDefinition[]; + let clone: IAbacAttributeDefinition[]; + + beforeAll(() => { + original = [{ key: 'dept', values: ['eng', 'sales'] }] as IAbacAttributeDefinition[]; + clone = JSON.parse(JSON.stringify(original)); + }); + + beforeEach(async () => { + await configureStaticUsers([{ _id: 'u-immutability', abacAttributes: original }]); + sharedUser = getStaticUser('u-immutability', { abacAttributes: original }); + }); + it('does not mutate original user.abacAttributes array reference contents', async () => { - const original = [{ key: 'dept', values: ['eng', 'sales'] }] as IAbacAttributeDefinition[]; - const user = makeUser({ abacAttributes: original }); - await Users.insertOne(user); - const clone = JSON.parse(JSON.stringify(original)); - const ldap = makeLdap({ memberOf: ['eng', 'sales', 'support'] }); - await service.addSubjectAttributes(user, ldap, { memberOf: 'dept' }); + const ldap = makeLdapEntry({ memberOf: ['eng', 'sales', 'support'] }); + await service.addSubjectAttributes(sharedUser, ldap, { memberOf: 'dept' }); expect(original).toEqual(clone); }); }); }); describe('AbacService.addSubjectAttributes (room removals)', () => { - let service: AbacService; - let roomsCol: Collection; - let usersCol: Collection; - const originalCoreServices = jest.requireMock('@rocket.chat/core-services'); originalCoreServices.Room.removeUserFromRoom = async (rid: string, user: IUser) => { - // @ts-expect-error - test await usersCol.updateOne({ _id: user._id }, { $pull: { __rooms: rid } }); }; - const insertRoom = async (room: { _id: string; abacAttributes?: IAbacAttributeDefinition[] }) => - roomsCol.insertOne({ - _id: room._id, - name: room._id, - t: 'p', - abacAttributes: room.abacAttributes, - }); - - const insertUser = async (user: IUser & { __rooms?: string[] }) => - usersCol.insertOne({ - ...user, - __rooms: user.__rooms || [], - }); - - beforeEach(async () => { - service = new AbacService(); - roomsCol = db.collection('rocketchat_room'); - usersCol = db.collection('users'); - await Promise.all([roomsCol.deleteMany({}), usersCol.deleteMany({})]); - }); - it('removes user from rooms whose attributes become non-compliant after losing a value', async () => { - const user: IUser = { - _id: 'u-loss', - username: 'lossy', - roles: [], - type: 'user', - active: true, - createdAt: new Date(), - _updatedAt: new Date(), + const userId = 'u-loss'; + const user = getStaticUser(userId, { abacAttributes: [{ key: 'dept', values: ['eng', 'qa'] }], __rooms: ['rKeep', 'rRemove'], - }; + }); + await configureStaticUsers([{ _id: userId, abacAttributes: user.abacAttributes, __rooms: user.__rooms }]); - // Rooms: - // rKeep requires only 'eng' (will remain compliant) - // rRemove requires both 'eng' and 'qa' (will become non-compliant after loss) - await Promise.all([ - insertRoom({ _id: 'rKeep', abacAttributes: [{ key: 'dept', values: ['eng'] }] }), - insertRoom({ _id: 'rRemove', abacAttributes: [{ key: 'dept', values: ['eng', 'qa'] }] }), + await insertRooms([ + { _id: 'rKeep', abacAttributes: [{ key: 'dept', values: ['eng'] }] }, + { _id: 'rRemove', abacAttributes: [{ key: 'dept', values: ['eng', 'qa'] }] }, ]); - await insertUser({ ...user, __rooms: ['rKeep', 'rRemove'] }); - const ldap: ILDAPEntry = { memberOf: ['eng'], _raw: {}, @@ -271,46 +386,35 @@ describe('Subject Attributes validation', () => { await service.addSubjectAttributes(user, ldap, { memberOf: 'dept' }); - const updatedUser = await usersCol.findOne({ _id: user._id }, { projection: { abacAttributes: 1, __rooms: 1 } }); + const updatedUser = await usersCol.findOne({ _id: userId }, { projection: { abacAttributes: 1, __rooms: 1 } }); expect(updatedUser?.abacAttributes).toEqual([{ key: 'dept', values: ['eng'] }]); expect(updatedUser?.abacAttributes).not.toEqual(user.abacAttributes); - expect(updatedUser?.__rooms.sort()).toEqual(['rKeep']); + expect(updatedUser?.__rooms?.sort()).toEqual(['rKeep']); }); it('removes user from rooms containing attribute keys they no longer possess (key loss)', async () => { - const user: IUser = { - _id: 'u-key-loss', - username: 'keyloss', - roles: [], - type: 'user', - active: true, - createdAt: new Date(), - _updatedAt: new Date(), + const userId = 'u-key-loss'; + const user = getStaticUser(userId, { abacAttributes: [ { key: 'dept', values: ['eng'] }, { key: 'region', values: ['emea'] }, ], __rooms: ['rDeptOnly', 'rRegionOnly', 'rBoth'], - }; + }); + await configureStaticUsers([{ _id: userId, abacAttributes: user.abacAttributes, __rooms: user.__rooms }]); - // Rooms: - // rDeptOnly -> only dept (will stay) - // rRegionOnly -> region only (will be removed after region key loss) - // rBoth -> both dept & region (will be removed) - await Promise.all([ - insertRoom({ _id: 'rDeptOnly', abacAttributes: [{ key: 'dept', values: ['eng'] }] }), - insertRoom({ _id: 'rRegionOnly', abacAttributes: [{ key: 'region', values: ['emea'] }] }), - insertRoom({ + await insertRooms([ + { _id: 'rDeptOnly', abacAttributes: [{ key: 'dept', values: ['eng'] }] }, + { _id: 'rRegionOnly', abacAttributes: [{ key: 'region', values: ['emea'] }] }, + { _id: 'rBoth', abacAttributes: [ { key: 'dept', values: ['eng'] }, { key: 'region', values: ['emea'] }, ], - }), + }, ]); - await insertUser({ ...user, __rooms: ['rDeptOnly', 'rRegionOnly', 'rBoth'] }); - const ldap: ILDAPEntry = { department: ['eng'], _raw: {}, @@ -318,32 +422,25 @@ describe('Subject Attributes validation', () => { await service.addSubjectAttributes(user, ldap, { department: 'dept' }); - const updatedUser = await usersCol.findOne({ _id: user._id }, { projection: { abacAttributes: 1, __rooms: 1 } }); + const updatedUser = await usersCol.findOne({ _id: userId }, { projection: { abacAttributes: 1, __rooms: 1 } }); expect(updatedUser?.abacAttributes).toEqual([{ key: 'dept', values: ['eng'] }]); expect(updatedUser?.abacAttributes).not.toEqual(user.abacAttributes); expect(updatedUser?.__rooms).toEqual(['rDeptOnly']); }); it('does not remove user from any room when attribute values only grow (gain without loss)', async () => { - const user: IUser = { - _id: 'u-growth', - username: 'growth', - roles: [], - type: 'user', - active: true, - createdAt: new Date(), - _updatedAt: new Date(), + const userId = 'u-growth'; + const user = getStaticUser(userId, { abacAttributes: [{ key: 'dept', values: ['eng'] }], __rooms: ['rGrowthA', 'rGrowthB'], - }; + }); + await configureStaticUsers([{ _id: userId, abacAttributes: user.abacAttributes, __rooms: user.__rooms }]); - await Promise.all([ - insertRoom({ _id: 'rGrowthA', abacAttributes: [{ key: 'dept', values: ['eng'] }] }), - insertRoom({ _id: 'rGrowthB', abacAttributes: [{ key: 'dept', values: ['eng', 'qa'] }] }), // superset; still compliant after growth + await insertRooms([ + { _id: 'rGrowthA', abacAttributes: [{ key: 'dept', values: ['eng'] }] }, + { _id: 'rGrowthB', abacAttributes: [{ key: 'dept', values: ['eng', 'qa'] }] }, ]); - await insertUser({ ...user, __rooms: ['rGrowthA', 'rGrowthB'] }); - const ldap: ILDAPEntry = { memberOf: ['eng', 'qa'], _raw: {}, @@ -351,43 +448,36 @@ describe('Subject Attributes validation', () => { await service.addSubjectAttributes(user, ldap, { memberOf: 'dept' }); - const updatedUser = await usersCol.findOne({ _id: user._id }, { projection: { abacAttributes: 1, __rooms: 1 } }); + const updatedUser = await usersCol.findOne({ _id: userId }, { projection: { abacAttributes: 1, __rooms: 1 } }); expect(updatedUser?.abacAttributes).toEqual([{ key: 'dept', values: ['eng', 'qa'] }]); - expect(updatedUser?.__rooms.sort()).toEqual(['rGrowthA', 'rGrowthB']); + expect(updatedUser?.__rooms?.sort()).toEqual(['rGrowthA', 'rGrowthB']); }); it('removes user from rooms having attribute keys not present in new attribute set (extra keys in room)', async () => { - const user: IUser = { - _id: 'u-extra-room-key', - username: 'extrakey', - roles: [], - type: 'user', - active: true, - createdAt: new Date(), - _updatedAt: new Date(), + const userId = 'u-extra-room-key'; + const user = getStaticUser(userId, { abacAttributes: [ { key: 'dept', values: ['eng', 'sales'] }, { key: 'otherKey', values: ['value'] }, ], __rooms: ['rExtraKeyRoom', 'rBaseline'], - }; + }); + await configureStaticUsers([{ _id: userId, abacAttributes: user.abacAttributes, __rooms: user.__rooms }]); - await Promise.all([ - insertRoom({ + await insertRooms([ + { _id: 'rExtraKeyRoom', abacAttributes: [ { key: 'dept', values: ['eng', 'sales'] }, { key: 'project', values: ['X'] }, ], - }), - insertRoom({ + }, + { _id: 'rBaseline', abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }], - }), + }, ]); - await insertUser({ ...user, __rooms: ['rExtraKeyRoom', 'rBaseline'] }); - const ldap: ILDAPEntry = { deptCodes: ['eng', 'sales'], _raw: {}, @@ -395,31 +485,24 @@ describe('Subject Attributes validation', () => { await service.addSubjectAttributes(user, ldap, { deptCodes: 'dept' }); - const updatedUser = await usersCol.findOne({ _id: user._id }, { projection: { abacAttributes: 1, __rooms: 1 } }); + const updatedUser = await usersCol.findOne({ _id: userId }, { projection: { abacAttributes: 1, __rooms: 1 } }); expect(updatedUser?.abacAttributes).toEqual([{ key: 'dept', values: ['eng', 'sales'] }]); - expect(updatedUser?.__rooms.sort()).toEqual(['rBaseline']); + expect(updatedUser?.__rooms?.sort()).toEqual(['rBaseline']); }); it('unsets attributes and removes user from all ABAC rooms when no LDAP values extracted', async () => { - const user: IUser = { - _id: 'u-empty', - username: 'empty', - roles: [], - type: 'user', - active: true, - createdAt: new Date(), - _updatedAt: new Date(), + const userId = 'u-empty'; + const user = getStaticUser(userId, { abacAttributes: [{ key: 'dept', values: ['eng'] }], __rooms: ['rAny1', 'rAny2'], - }; + }); + await configureStaticUsers([{ _id: userId, abacAttributes: user.abacAttributes, __rooms: user.__rooms }]); - await Promise.all([ - insertRoom({ _id: 'rAny1', abacAttributes: [{ key: 'dept', values: ['eng'] }] }), - insertRoom({ _id: 'rAny2', abacAttributes: [{ key: 'dept', values: ['eng', 'qa'] }] }), + await insertRooms([ + { _id: 'rAny1', abacAttributes: [{ key: 'dept', values: ['eng'] }] }, + { _id: 'rAny2', abacAttributes: [{ key: 'dept', values: ['eng', 'qa'] }] }, ]); - await insertUser({ ...user, __rooms: ['rAny1', 'rAny2'] }); - const ldap: ILDAPEntry = { unrelated: ['x'], _raw: {}, @@ -427,38 +510,33 @@ describe('Subject Attributes validation', () => { await service.addSubjectAttributes(user, ldap, { missing: 'dept' }); - const updatedUser = await usersCol.findOne({ _id: user._id }, { projection: { abacAttributes: 1, __rooms: 1 } }); + const updatedUser = await usersCol.findOne({ _id: userId }, { projection: { abacAttributes: 1, __rooms: 1 } }); expect(updatedUser?.abacAttributes).toBeUndefined(); expect(updatedUser?.abacAttributes).not.toEqual(user.abacAttributes); expect(updatedUser?.__rooms).toEqual([]); }); it('does not remove user from room when losing attribute not used by room (hook runs but no change)', async () => { - const user: IUser = { - _id: 'u-lose-unrelated', - username: 'unrelated', - roles: [], - type: 'user', - active: true, - createdAt: new Date(), - _updatedAt: new Date(), + const userId = 'u-lose-unrelated'; + const user = getStaticUser(userId, { abacAttributes: [ { key: 'dept', values: ['eng'] }, { key: 'region', values: ['emea'] }, { key: 'project', values: ['X'] }, ], __rooms: ['rDeptRegion'], - }; - - await insertRoom({ - _id: 'rDeptRegion', - abacAttributes: [ - { key: 'dept', values: ['eng'] }, - { key: 'region', values: ['emea'] }, - ], }); + await configureStaticUsers([{ _id: userId, abacAttributes: user.abacAttributes, __rooms: user.__rooms }]); - await insertUser({ ...user, __rooms: ['rDeptRegion'] }); + await insertRooms([ + { + _id: 'rDeptRegion', + abacAttributes: [ + { key: 'dept', values: ['eng'] }, + { key: 'region', values: ['emea'] }, + ], + }, + ]); const ldap: ILDAPEntry = { department: ['eng', 'ceo'], @@ -468,7 +546,7 @@ describe('Subject Attributes validation', () => { await service.addSubjectAttributes(user, ldap, { department: 'dept', regionCodes: 'region', projectCodes: 'project' }); - const updatedUser = await usersCol.findOne({ _id: user._id }, { projection: { abacAttributes: 1, __rooms: 1 } }); + const updatedUser = await usersCol.findOne({ _id: userId }, { projection: { abacAttributes: 1, __rooms: 1 } }); expect(updatedUser?.abacAttributes).toEqual([ { key: 'dept', values: ['eng', 'ceo'] }, { key: 'region', values: ['emea', 'apac'] }, diff --git a/ee/packages/abac/src/test-helpers/mongoMemoryServer.ts b/ee/packages/abac/src/test-helpers/mongoMemoryServer.ts new file mode 100644 index 0000000000000..71c22000be3fa --- /dev/null +++ b/ee/packages/abac/src/test-helpers/mongoMemoryServer.ts @@ -0,0 +1,94 @@ +import { registerModel, UsersRaw, RoomsRaw, AbacAttributesRaw, ServerEventsRaw, SubscriptionsRaw } from '@rocket.chat/models'; +import type { Db } from 'mongodb'; +import { MongoClient } from 'mongodb'; +import { MongoMemoryServer } from 'mongodb-memory-server'; + +export const SHARED_ABAC_TEST_DB = 'abac_test'; + +type SharedState = { + mongo: MongoMemoryServer; + client: MongoClient; + refCount: number; +}; + +let sharedState: SharedState | null = null; +let initialization: Promise | null = null; + +const ensureState = async (): Promise => { + if (sharedState) { + return sharedState; + } + + if (!initialization) { + initialization = (async () => { + const mongo = await MongoMemoryServer.create(); + const client = await MongoClient.connect(mongo.getUri(), {}); + return { + mongo, + client, + refCount: 0, + }; + })(); + } + + sharedState = await initialization; + initialization = null; + return sharedState; +}; + +const dropDatabase = async (db: Db) => { + try { + await db.dropDatabase(); + } catch (err) { + if (!(err instanceof Error) || !/ns not found/i.test(err.message)) { + throw err; + } + } +}; + +export type SharedMongoConnection = { + mongo: MongoMemoryServer; + client: MongoClient; + db: Db; + cleanupDatabase: () => Promise; + release: () => Promise; +}; + +const registerAbacTestModels = (db: Db) => { + registerModel('IUsersModel', () => new UsersRaw(db)); + registerModel('IRoomsModel', () => new RoomsRaw(db)); + registerModel('IAbacAttributesModel', () => new AbacAttributesRaw(db)); + registerModel('IServerEventsModel', () => new ServerEventsRaw(db)); + registerModel('ISubscriptionsModel', () => new SubscriptionsRaw(db)); +}; + +export const acquireSharedInMemoryMongo = async (dbName: string): Promise => { + const state = await ensureState(); + state.refCount += 1; + + const connectionDb = state.client.db(dbName); + let released = false; + + registerAbacTestModels(connectionDb); + return { + mongo: state.mongo, + client: state.client, + db: connectionDb, + cleanupDatabase: async () => dropDatabase(connectionDb), + release: async () => { + if (released || !sharedState) { + return; + } + + released = true; + sharedState.refCount -= 1; + + if (sharedState.refCount === 0) { + const { client, mongo } = sharedState; + sharedState = null; + await client.close(); + await mongo.stop(); + } + }, + }; +}; diff --git a/ee/packages/abac/src/user-auto-removal.spec.ts b/ee/packages/abac/src/user-auto-removal.spec.ts index 4fae4f0c05c02..a287ca14dfa49 100644 --- a/ee/packages/abac/src/user-auto-removal.spec.ts +++ b/ee/packages/abac/src/user-auto-removal.spec.ts @@ -1,28 +1,25 @@ import type { IAbacAttributeDefinition, IRoom, IUser } from '@rocket.chat/core-typings'; -import { registerServiceModels } from '@rocket.chat/models'; +import { Subscriptions } from '@rocket.chat/models'; import type { Collection, Db } from 'mongodb'; -import { MongoClient } from 'mongodb'; -import { MongoMemoryServer } from 'mongodb-memory-server'; import { Audit } from './audit'; import { AbacService } from './index'; +import { acquireSharedInMemoryMongo, SHARED_ABAC_TEST_DB, type SharedMongoConnection } from './test-helpers/mongoMemoryServer'; jest.mock('@rocket.chat/core-services', () => ({ ServiceClass: class {}, Room: { // Mimic the DB side-effects of removing a user from a room (no apps/system messages) removeUserFromRoom: async (roomId: string, user: any) => { - const { Subscriptions } = await import('@rocket.chat/models'); await Subscriptions.removeByRoomIdAndUserId(roomId, user._id); }, }, })); describe('AbacService integration (onRoomAttributesChanged)', () => { - let mongo: MongoMemoryServer; - let client: MongoClient; + let sharedMongo: SharedMongoConnection; let db: Db; - let service: AbacService; + const service = new AbacService(); let roomsCol: Collection; let usersCol: Collection; @@ -30,10 +27,9 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { const fakeActor = { _id: 'test-user', username: 'testuser', type: 'user' }; const insertDefinitions = async (defs: { key: string; values: string[] }[]) => { - const svc = new AbacService(); await Promise.all( defs.map((def) => - svc.addAbacAttribute({ key: def.key, values: def.values }, fakeActor).catch((e: any) => { + service.addAbacAttribute({ key: def.key, values: def.values }, fakeActor).catch((e: any) => { if (e instanceof Error && e.message === 'error-duplicate-attribute-key') { return; } @@ -54,92 +50,217 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { return rid; }; - const insertUsers = async ( - users: Array<{ - _id: string; - abacAttributes?: IAbacAttributeDefinition[]; - member?: boolean; - extraRooms?: string[]; - }>, - ) => { + type TestUserSeed = { + _id: string; + abacAttributes?: IAbacAttributeDefinition[]; + member?: boolean; + extraRooms?: string[]; + }; + + const insertUsers = async (users: TestUserSeed[]) => { await usersCol.insertMany( - users.map((u) => ({ - _id: u._id, - username: u._id, - type: 'user', - roles: [], - active: true, - createdAt: new Date(), - _updatedAt: new Date(), - abacAttributes: u.abacAttributes, - __rooms: u.extraRooms || [], - })), + users.map((u) => { + const doc: Partial & { + _id: string; + username: string; + type: IUser['type']; + roles: IUser['roles']; + active: boolean; + createdAt: Date; + _updatedAt: Date; + __rooms: string[]; + } = { + _id: u._id, + username: u._id, + type: 'user', + roles: [], + active: true, + createdAt: new Date(), + _updatedAt: new Date(), + __rooms: u.extraRooms || [], + }; + if (u.abacAttributes !== undefined) { + doc.abacAttributes = u.abacAttributes; + } + return doc as IUser; + }), ); }; + const staticUserIds = [ + 'u1_newkey', + 'u2_newkey', + 'u3_newkey', + 'u4_newkey', + 'u5_newkey', + 'u1_dupvals', + 'u2_dupvals', + 'u1_newval', + 'u2_newval', + 'u3_newval', + 'u1_rmval', + 'u2_rmval', + 'u1_multi', + 'u2_multi', + 'u3_multi', + 'u4_multi', + 'u5_multi', + 'u1_idem', + 'u2_idem', + 'u1_superset', + 'u2_superset', + 'u1_misskey', + 'u2_misskey', + 'u3_misskey', + ]; + + const staticTestUsers: TestUserSeed[] = staticUserIds.map((_id) => ({ _id })); + + const resetStaticUsers = async () => { + await usersCol.updateMany( + { _id: { $in: staticUserIds } }, + { + $set: { + __rooms: [], + _updatedAt: new Date(), + }, + $unset: { + abacAttributes: 1, + }, + }, + ); + }; + + const configureStaticUsers = async (users: TestUserSeed[]) => { + const operations = users.map((user) => { + const setPayload: Partial = { + __rooms: user.extraRooms ?? [], + _updatedAt: new Date(), + }; + + if (user.abacAttributes !== undefined) { + setPayload.abacAttributes = user.abacAttributes; + } + + const update: { + $set: Partial; + $unset?: Record; + } = { + $set: setPayload, + }; + + if (user.abacAttributes === undefined) { + update.$unset = { abacAttributes: 1 }; + } + + return { + updateOne: { + filter: { _id: user._id }, + update, + }, + }; + }); + + if (!operations.length) { + return; + } + + await usersCol.bulkWrite(operations); + }; + + // It's utterly incredible i have to do this so the tests are "fast" because mongo is not warm + // I could have increased the timeout for the first test too but... + const dbWarmup = async () => { + const uniqueSuffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + const warmupAttributeKey = `warmup_seed_${uniqueSuffix}`; + const warmupUserId = `warmup-abac-user-${uniqueSuffix}`; + const warmupRid = await insertRoom([]); + const subscriptionCol = db.collection('rocketchat_subscription'); + const seedSubscriptionId = `warmup-${uniqueSuffix}`; + + await subscriptionCol.insertOne({ + _id: seedSubscriptionId, + rid: `warmup-room-${uniqueSuffix}`, + u: { _id: `warmup-user-${uniqueSuffix}` }, + } as any); + await insertDefinitions([{ key: warmupAttributeKey, values: ['a'] }]); + await insertUsers([{ _id: warmupUserId, member: true, extraRooms: [warmupRid] }]); + await subscriptionCol.insertOne({ + _id: `warmup-sub-${uniqueSuffix}`, + rid: warmupRid, + u: { _id: warmupUserId }, + } as any); + + try { + await service.setRoomAbacAttributes(warmupRid, { [warmupAttributeKey]: ['a'] }, fakeActor); + } finally { + await roomsCol.deleteOne({ _id: warmupRid }); + await usersCol.deleteOne({ _id: warmupUserId }); + await subscriptionCol.deleteMany({ + _id: { $in: [seedSubscriptionId, `warmup-sub-${uniqueSuffix}`] }, + }); + await subscriptionCol.deleteMany({ rid: warmupRid }); + await db.collection('rocketchat_abac_attributes').deleteOne({ key: warmupAttributeKey }); + } + }; + let debugSpy: jest.SpyInstance; let auditSpy: jest.SpyInstance; beforeAll(async () => { - mongo = await MongoMemoryServer.create(); - client = await MongoClient.connect(mongo.getUri(), {}); - db = client.db('abac_integration'); - - // Hack to register the models in here with a custom database without having to call every model by one - registerServiceModels(db as any); + sharedMongo = await acquireSharedInMemoryMongo(SHARED_ABAC_TEST_DB); + db = sharedMongo.db; - // @ts-expect-error - ignore - await db.collection('abac_dummy_init').insertOne({ _id: 'init', createdAt: new Date() }); - - service = new AbacService(); debugSpy = jest.spyOn((service as any).logger, 'debug').mockImplementation(() => undefined); auditSpy = jest.spyOn(Audit, 'actionPerformed').mockResolvedValue(); roomsCol = db.collection('rocketchat_room'); usersCol = db.collection('users'); + await usersCol.deleteMany({ _id: { $in: staticUserIds } }); + await insertUsers(staticTestUsers); + + await dbWarmup(); }, 30_000); afterAll(async () => { - await client.close(); - await mongo.stop(); + await usersCol.deleteMany({ _id: { $in: staticUserIds } }); + await sharedMongo.release(); }); beforeEach(async () => { debugSpy.mockClear(); auditSpy.mockClear(); + await resetStaticUsers(); }); describe('setRoomAbacAttributes - new key addition', () => { let rid1: string; let rid2: string; - beforeAll(async () => { + beforeEach(async () => { rid1 = await insertRoom([]); - await Promise.all([ - insertDefinitions([{ key: 'dept', values: ['eng', 'sales', 'hr'] }]), - insertUsers([ - { _id: 'u1_newkey', member: true, extraRooms: [rid1], abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }, // compliant - { _id: 'u2_newkey', member: true, extraRooms: [rid1], abacAttributes: [{ key: 'dept', values: ['eng'] }] }, // missing sales - { _id: 'u3_newkey', member: true, extraRooms: [rid1], abacAttributes: [{ key: 'location', values: ['emea'] }] }, // missing dept key - { _id: 'u4_newkey', member: true, extraRooms: [rid1], abacAttributes: [{ key: 'dept', values: ['eng', 'sales', 'hr'] }] }, // superset - { _id: 'u5_newkey', member: false, abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }, // not in room - ]), - ]); - rid2 = await insertRoom([]); - await Promise.all([ - insertDefinitions([{ key: 'dep2t', values: ['eng', 'sales'] }]), - insertUsers([ - { _id: 'u1_dupvals', member: true, extraRooms: [rid2], abacAttributes: [{ key: 'dep2t', values: ['eng', 'sales'] }] }, - { _id: 'u2_dupvals', member: true, extraRooms: [rid2], abacAttributes: [{ key: 'dep2t', values: ['eng'] }] }, // non compliant (missing sales) - ]), + await insertDefinitions([ + { key: 'dept', values: ['eng', 'sales', 'hr'] }, + { key: 'dep2t', values: ['eng', 'sales'] }, ]); - }, 30_000); - beforeEach(() => { - auditSpy.mockReset(); + await configureStaticUsers([ + { _id: 'u1_newkey', member: true, extraRooms: [rid1], abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }, // compliant + { _id: 'u2_newkey', member: true, extraRooms: [rid1], abacAttributes: [{ key: 'dept', values: ['eng'] }] }, // missing sales + { _id: 'u3_newkey', member: true, extraRooms: [rid1], abacAttributes: [{ key: 'location', values: ['emea'] }] }, // missing dept key + { _id: 'u4_newkey', member: true, extraRooms: [rid1], abacAttributes: [{ key: 'dept', values: ['eng', 'sales', 'hr'] }] }, // superset + { _id: 'u5_newkey', member: false, abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }, // not in room + { _id: 'u1_dupvals', member: true, extraRooms: [rid2], abacAttributes: [{ key: 'dep2t', values: ['eng', 'sales'] }] }, + { _id: 'u2_dupvals', member: true, extraRooms: [rid2], abacAttributes: [{ key: 'dep2t', values: ['eng'] }] }, // non compliant (missing sales) + ]); }); + + afterEach(async () => { + await roomsCol.deleteMany({ _id: { $in: [rid1, rid2] } }); + }); + it('logs users that do not satisfy newly added attribute key or its values and actually removes them', async () => { const changeSpy = jest.spyOn(service as any, 'onRoomAttributesChanged'); @@ -156,15 +277,6 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { expect(auditedUsers).toEqual(['u2_newkey', 'u3_newkey']); expect(auditedRooms).toEqual(new Set([rid1])); expect(auditedActions).toEqual(new Set(['room-attributes-change'])); - - const remaining = await usersCol - .find({ _id: { $in: ['u1_newkey', 'u2_newkey', 'u3_newkey', 'u4_newkey'] } }, { projection: { __rooms: 1 } }) - .toArray() - .then((docs) => Object.fromEntries(docs.map((d) => [d._id, d.__rooms || []]))); - expect(remaining.u1_newkey).toContain(rid1); - expect(remaining.u4_newkey).toContain(rid1); - expect(remaining.u2_newkey).not.toContain(rid1); - expect(remaining.u3_newkey).not.toContain(rid1); }); it('handles duplicate values in room attributes equivalently to unique set (logs non compliant and removes them)', async () => { @@ -184,115 +296,122 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { }); describe('updateRoomAbacAttributeValues - new value addition', () => { - let rid: string; + describe('when adding new values', () => { + let rid: string; - beforeAll(async () => { - rid = await insertRoom([{ key: 'dept', values: ['eng'] }]); + beforeEach(async () => { + rid = await insertRoom([{ key: 'dept', values: ['eng'] }]); - await Promise.all([ - insertDefinitions([{ key: 'dept', values: ['eng', 'sales'] }]), - insertUsers([ + await insertDefinitions([{ key: 'dept', values: ['eng', 'sales'] }]); + await configureStaticUsers([ { _id: 'u1_newval', member: true, extraRooms: [rid], abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }, // already superset { _id: 'u2_newval', member: true, extraRooms: [rid], abacAttributes: [{ key: 'dept', values: ['eng'] }] }, // missing new value { _id: 'u3_newval', member: true, extraRooms: [rid], abacAttributes: [{ key: 'dept', values: ['eng', 'sales', 'hr'] }] }, // superset - ]), - ]); - }, 30_000); + ]); + }); - it('logs users missing newly added value while retaining compliant ones and removes the missing ones', async () => { - await service.updateRoomAbacAttributeValues(rid, 'dept', ['eng', 'sales'], fakeActor); + afterEach(async () => { + await roomsCol.deleteOne({ _id: rid }); + }); - expect(auditSpy).toHaveBeenCalledTimes(1); - expect(auditSpy.mock.calls[0][0]).toMatchObject({ _id: 'u2_newval', username: 'u2_newval' }); - expect(auditSpy.mock.calls[0][1]).toMatchObject({ _id: rid }); - expect(auditSpy.mock.calls[0][2]).toBe('room-attributes-change'); + it('logs users missing newly added value while retaining compliant ones and removes the missing ones', async () => { + await service.updateRoomAbacAttributeValues(rid, 'dept', ['eng', 'sales'], fakeActor); - const users = await usersCol - .find({ _id: { $in: ['u1_newval', 'u2_newval', 'u3_newval'] } }, { projection: { __rooms: 1 } }) - .toArray() - .then((docs) => Object.fromEntries(docs.map((d) => [d._id, d.__rooms || []]))); - expect(users.u1_newval).toContain(rid); - expect(users.u3_newval).toContain(rid); - expect(users.u2_newval).not.toContain(rid); + expect(auditSpy).toHaveBeenCalledTimes(1); + expect(auditSpy.mock.calls[0][0]).toMatchObject({ _id: 'u2_newval', username: 'u2_newval' }); + expect(auditSpy.mock.calls[0][1]).toMatchObject({ _id: rid }); + expect(auditSpy.mock.calls[0][2]).toBe('room-attributes-change'); + }); }); - it('produces no evaluation log when only removing values from existing attribute', async () => { - const rid = await insertRoom([{ key: 'dept', values: ['eng', 'sales'] }]); + describe('when only removing values', () => { + let rid: string; - await Promise.all([ - insertDefinitions([{ key: 'dept', values: ['eng', 'sales'] }]), - insertUsers([ + beforeEach(async () => { + rid = await insertRoom([{ key: 'dept', values: ['eng', 'sales'] }]); + + await insertDefinitions([{ key: 'dept', values: ['eng', 'sales'] }]); + await configureStaticUsers([ { _id: 'u1_rmval', member: true, extraRooms: [rid], abacAttributes: [{ key: 'dept', values: ['eng'] }] }, { _id: 'u2_rmval', member: true, extraRooms: [rid], abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }, - ]), - ]); + ]); + }); - await service.updateRoomAbacAttributeValues(rid, 'dept', ['eng'], fakeActor); // removal only + afterEach(async () => { + await roomsCol.deleteOne({ _id: rid }); + }); - expect(auditSpy).not.toHaveBeenCalled(); + it('produces no evaluation log when only removing values from existing attribute', async () => { + await service.updateRoomAbacAttributeValues(rid, 'dept', ['eng'], fakeActor); // removal only - // nobody removed because removal only does not trigger reevaluation - const u1 = await usersCol.findOne({ _id: 'u1_rmval' }, { projection: { __rooms: 1 } }); - const u2 = await usersCol.findOne({ _id: 'u2_rmval' }, { projection: { __rooms: 1 } }); - expect(u1?.__rooms || []).toContain(rid); - expect(u2?.__rooms || []).toContain(rid); + expect(auditSpy).not.toHaveBeenCalled(); + + // nobody removed because removal only does not trigger reevaluation + const u1 = await usersCol.findOne({ _id: 'u1_rmval' }, { projection: { __rooms: 1 } }); + const u2 = await usersCol.findOne({ _id: 'u2_rmval' }, { projection: { __rooms: 1 } }); + expect(u1?.__rooms || []).toContain(rid); + expect(u2?.__rooms || []).toContain(rid); + }); }); }); describe('setRoomAbacAttributes - multi-attribute addition', () => { let rid: string; - beforeAll(async () => { + beforeEach(async () => { rid = await insertRoom([{ key: 'dept', values: ['eng'] }]); - await Promise.all([ - insertDefinitions([ - { key: 'dept', values: ['eng', 'sales', 'hr'] }, - { key: 'region', values: ['emea', 'apac'] }, - ]), - insertUsers([ - { - _id: 'u1_multi', - member: true, - extraRooms: [rid], - abacAttributes: [ - { key: 'dept', values: ['eng', 'sales'] }, - { key: 'region', values: ['emea'] }, - ], - }, // compliant after expansion - { - _id: 'u2_multi', - member: true, - extraRooms: [rid], - abacAttributes: [{ key: 'dept', values: ['eng'] }], // missing region - }, - { - _id: 'u3_multi', - member: true, - extraRooms: [rid], - abacAttributes: [{ key: 'region', values: ['emea'] }], // missing dept key - }, - { - _id: 'u4_multi', - member: true, - extraRooms: [rid], - abacAttributes: [ - { key: 'dept', values: ['eng', 'sales', 'hr'] }, - { key: 'region', values: ['emea', 'apac'] }, - ], - }, // superset across both - { - _id: 'u5_multi', - member: true, - extraRooms: [rid], - abacAttributes: [ - { key: 'dept', values: ['eng', 'sales'] }, - { key: 'region', values: ['apac'] }, - ], - }, - ]), + await insertDefinitions([ + { key: 'dept', values: ['eng', 'sales', 'hr'] }, + { key: 'region', values: ['emea', 'apac'] }, + ]); + + await configureStaticUsers([ + { + _id: 'u1_multi', + member: true, + extraRooms: [rid], + abacAttributes: [ + { key: 'dept', values: ['eng', 'sales'] }, + { key: 'region', values: ['emea'] }, + ], + }, // compliant after expansion + { + _id: 'u2_multi', + member: true, + extraRooms: [rid], + abacAttributes: [{ key: 'dept', values: ['eng'] }], // missing region + }, + { + _id: 'u3_multi', + member: true, + extraRooms: [rid], + abacAttributes: [{ key: 'region', values: ['emea'] }], // missing dept key + }, + { + _id: 'u4_multi', + member: true, + extraRooms: [rid], + abacAttributes: [ + { key: 'dept', values: ['eng', 'sales', 'hr'] }, + { key: 'region', values: ['emea', 'apac'] }, + ], + }, // superset across both + { + _id: 'u5_multi', + member: true, + extraRooms: [rid], + abacAttributes: [ + { key: 'dept', values: ['eng', 'sales'] }, + { key: 'region', values: ['apac'] }, + ], + }, ]); - }, 30_000); + }); + + afterEach(async () => { + await roomsCol.deleteOne({ _id: rid }); + }); it('enforces all attributes (AND semantics) removing users failing any', async () => { await service.setRoomAbacAttributes( @@ -311,33 +430,24 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { expect(auditedRooms).toEqual(new Set([rid])); const auditedActions = new Set(auditSpy.mock.calls.map((call) => call[2])); expect(auditedActions).toEqual(new Set(['room-attributes-change'])); - - const memberships = await usersCol - .find({ _id: { $in: ['u1_multi', 'u2_multi', 'u3_multi', 'u4_multi', 'u5_multi'] } }, { projection: { __rooms: 1 } }) - .toArray() - .then((docs) => Object.fromEntries(docs.map((d) => [d._id, d.__rooms || []]))); - expect(memberships.u1_multi).toContain(rid); - expect(memberships.u4_multi).toContain(rid); - expect(memberships.u2_multi).not.toContain(rid); - expect(memberships.u3_multi).not.toContain(rid); - expect(memberships.u5_multi).not.toContain(rid); }); }); describe('Idempotency & no-op behavior', () => { let rid: string; - beforeAll(async () => { + beforeEach(async () => { rid = await insertRoom([]); - await Promise.all([ - insertDefinitions([{ key: 'dept', values: ['eng', 'sales'] }]), - insertUsers([ - { _id: 'u1_idem', member: true, extraRooms: [rid], abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }, - { _id: 'u2_idem', member: true, extraRooms: [rid], abacAttributes: [{ key: 'dept', values: ['eng'] }] }, // will be removed on first pass - ]), + await insertDefinitions([{ key: 'dept', values: ['eng', 'sales'] }]); + await configureStaticUsers([ + { _id: 'u1_idem', member: true, extraRooms: [rid], abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }, + { _id: 'u2_idem', member: true, extraRooms: [rid], abacAttributes: [{ key: 'dept', values: ['eng'] }] }, // will be removed on first pass ]); - }, 30_000); + }); + afterEach(async () => { + await roomsCol.deleteOne({ _id: rid }); + }); it('does not remove anyone when calling with identical attribute set twice', async () => { await service.setRoomAbacAttributes(rid, { dept: ['eng', 'sales'] }, fakeActor); @@ -369,33 +479,33 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { let ridSuperset: string; let ridMissingKey: string; - beforeAll(async () => { + beforeEach(async () => { ridSuperset = await insertRoom([]); - await Promise.all([ - insertDefinitions([{ key: 'dept', values: ['eng', 'sales', 'hr'] }]), - insertUsers([ - { - _id: 'u1_superset', - member: true, - extraRooms: [ridSuperset], - abacAttributes: [{ key: 'dept', values: ['eng', 'sales', 'hr'] }], - }, - { _id: 'u2_superset', member: true, extraRooms: [ridSuperset], abacAttributes: [{ key: 'dept', values: ['eng', 'hr'] }] }, // missing sales - ]), + await insertDefinitions([{ key: 'dept', values: ['eng', 'sales', 'hr'] }]); + await configureStaticUsers([ + { + _id: 'u1_superset', + member: true, + extraRooms: [ridSuperset], + abacAttributes: [{ key: 'dept', values: ['eng', 'sales', 'hr'] }], + }, + { _id: 'u2_superset', member: true, extraRooms: [ridSuperset], abacAttributes: [{ key: 'dept', values: ['eng', 'hr'] }] }, // missing sales ]); ridMissingKey = await insertRoom([]); - await Promise.all([ - insertDefinitions([{ key: 'region', values: ['emea', 'apac'] }]), - insertUsers([ - { _id: 'u1_misskey', member: true, extraRooms: [ridMissingKey], abacAttributes: [{ key: 'region', values: ['emea'] }] }, - { _id: 'u2_misskey', member: true, extraRooms: [ridMissingKey], abacAttributes: [{ key: 'dept', values: ['eng'] }] }, // missing region - { _id: 'u3_misskey', member: true, extraRooms: [ridMissingKey] }, // no abacAttributes field - ]), + await insertDefinitions([{ key: 'region', values: ['emea', 'apac'] }]); + await configureStaticUsers([ + { _id: 'u1_misskey', member: true, extraRooms: [ridMissingKey], abacAttributes: [{ key: 'region', values: ['emea'] }] }, + { _id: 'u2_misskey', member: true, extraRooms: [ridMissingKey], abacAttributes: [{ key: 'dept', values: ['eng'] }] }, // missing region + { _id: 'u3_misskey', member: true, extraRooms: [ridMissingKey] }, // no abacAttributes field ]); - }, 30_000); + }); + + afterEach(async () => { + await roomsCol.deleteMany({ _id: { $in: [ridSuperset, ridMissingKey] } }); + }); it('keeps user with superset values and removes user missing one required value', async () => { await service.setRoomAbacAttributes(ridSuperset, { dept: ['eng', 'sales', 'hr'] }, fakeActor); @@ -421,24 +531,17 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { expect(auditedRooms).toEqual(new Set([ridMissingKey])); const auditedActions = new Set(auditSpy.mock.calls.map((call) => call[2])); expect(auditedActions).toEqual(new Set(['room-attributes-change'])); - - const memberships = await usersCol - .find({ _id: { $in: ['u1_misskey', 'u2_misskey', 'u3_misskey'] } }, { projection: { __rooms: 1 } }) - .toArray() - .then((docs) => Object.fromEntries(docs.map((d) => [d._id, d.__rooms || []]))); - expect(memberships.u1_misskey).toContain(ridMissingKey); - expect(memberships.u2_misskey).not.toContain(ridMissingKey); - expect(memberships.u3_misskey).not.toContain(ridMissingKey); }); }); describe('Large member set performance sanity (lightweight)', () => { let rid: string; + let bulkIds: string[]; - beforeAll(async () => { + beforeEach(async () => { rid = await insertRoom([]); - await Promise.all([insertDefinitions([{ key: 'dept', values: ['eng', 'sales'] }])]); + await insertDefinitions([{ key: 'dept', values: ['eng', 'sales'] }]); const bulk: Parameters[0] = []; for (let i = 0; i < 300; i++) { @@ -451,8 +554,16 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { abacAttributes: [{ key: 'dept', values }], }); } + bulkIds = bulk.map((u) => u._id); await insertUsers(bulk); - }, 30_000); + }); + + afterEach(async () => { + await usersCol.deleteMany({ + _id: { $in: bulkIds }, + }); + await roomsCol.deleteOne({ _id: rid }); + }); it('removes only expected fraction in a larger population', async () => { await service.setRoomAbacAttributes(rid, { dept: ['eng', 'sales'] }, fakeActor); @@ -469,9 +580,6 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { expect(auditedRooms).toEqual(new Set([rid])); const auditedActions = new Set(auditSpy.mock.calls.map((call) => call[2])); expect(auditedActions).toEqual(new Set(['room-attributes-change'])); - - const remainingCount = await usersCol.countDocuments({ __rooms: rid }); - expect(remainingCount).toBe(150); }); }); });