LibreChat/api/server/services/PermissionService.spec.js
Marco Beretta 39f5f9d846
perf: Agent List and Model Selector at Scale (#14601)
* perf: cut serial round trips from the agent list query path

The agent list was the slowest path on first page load. Three separate
problems compounded:

- `getListAgentsHandler` chained its reads: two ACL lookups, the avatar
  refresh cache probe and the viewer skill scope all resolved serially
  ahead of the list query, and `attachOwnerContacts` added two more hops
  after it. The four independent reads now resolve together, and the
  avatar refresh runs alongside the list query instead of before it -
  refreshed paths reach the response through `urlCache`, not through
  whatever the list query happened to read. Serial hops per request drop
  from 7 to 4 on a warm cache.

- The avatar refresh loaded the user's whole accessible agent set (up to
  MAX_AVATAR_REFRESH_AGENTS) to discover which entries were S3-backed.
  Scoping the query to `avatar.source` means deployments on any other
  file strategy match nothing instead of walking the full set.

- `fetchAllAgentPages` walked cursor pages at the server's default size
  of 100, and callers consume the flattened result, so every extra page
  was a serial round trip for no benefit. It now requests the server
  maximum. Measured over a 2,860 agent account: 29 requests / 1.65s
  before, 3 requests / 0.29s after.

Also parallelizes the conversation file reads in `initializeAgent`. The
convo file refs and the execute_code thread walk share no inputs, and the
two code-file lookups depend only on `threadFileIds`, so the chain of six
serial reads on every turn collapses to two. This one is time to first
token the user waits through.

* perf: virtualize the model selector agent list

Opening the agents submenu with a large agent set froze the tab and could
kill it outright. With ~10k accessible agents the submenu blocked for over
15 seconds and took the heap from 96MB to 911MB. Four per-row costs were
being multiplied by the full list, which rendered unwindowed:

- `useIsActiveItem` allocated a MutationObserver per row (10,016 of them
  for one dropdown). Replaced with an Ariakit store subscription, which
  needs no observer at all and returns a boolean so a row only re-renders
  when its own active state flips.

- `useFavorites` ran per row, opening a jotai subscription, a query
  subscription and a mutation each time. Hoisted to one call per endpoint.

- Each row rescanned `endpoint.models` to recover `isGlobal`, a field the
  parent had already discarded from the array it was mapping. The parent
  now passes it down from a lookup map.

- The list itself is now windowed above 100 rows. Ariakit's composite only
  knows about mounted rows, so arrow-keying to the window edge previously
  found no next item and let focus escape the nested menu, closing it;
  `handleBoundaryNavigation` scrolls the next index in, waits for it to
  mount, then moves the composite onto it. Navigation inside the window is
  left to Ariakit.

Open drops from >15s to 96ms, mounted rows from 10,028 to ~18, DOM nodes
from 123,346 to ~1,000, and the heap no longer grows. Verified in browser:
arrow keys track 1:1 to index 238 and back, and click selection works.

* perf: serve the model selector from the shared VIEW agent query

The model selector asked for EDIT-scoped agents whenever the marketplace
is enabled, while `useAgentsMap` and `useMentions` asked for VIEW. Since
the cache key includes the params, that was two distinct entries, so first
page load ran the paginated walk twice and held two copies of the whole
agent list in memory. Measured against a 10k agent account: 22 list handler
invocations per page load, now 11.

Collapsing the two by asking for the same permission everywhere would have
changed what the selector shows - under the marketplace the EDIT scope is
what makes it "My Agents", with discovery handled by the marketplace entry.
So the list endpoint now marks each row with `isEditable`, resolved from an
ACL read folded into the existing parallel batch (no extra serial hop), and
the selector filters the shared VIEW response instead of refetching. A
VIEW-scoped list for a user with 2861 visible / 361 editable agents returns
exactly 360 rows flagged editable, matching what the EDIT query returned.

`AgentSelect` deliberately keeps its own EDIT query: it reads `skills` and
`skills_enabled`, which `sanitizeViewerSkillScope` strips from VIEW-scoped
responses. It also only mounts when the builder panel is open, so it is not
part of the first-load cost.

The field is set unconditionally rather than omitted when false so that a
client talking to an older server sees `undefined`, keeps every agent, and
degrades to showing too many rather than none.

* fix: address review findings on the agent list at scale

Three issues from review, all confirmed against the code before fixing.

Avatar refresh no longer runs alongside the list query. `updateAgent` writes
through `findOneAndUpdate` on a `timestamps: true` schema, so refreshing an
avatar advances `updatedAt` — the field `getListAgentsByAccess` sorts and
cursors on. A write landing after the first page's snapshot moved that agent
ahead of the returned cursor, dropping it from every later page and silently
truncating the caller's flattened list. This was a regression introduced when
the two were parallelized; serializing them costs nothing on the common path,
because a cache hit returns without issuing any query, so only the
once-per-30-minutes miss pays for the ordering. The new test asserts the write
lands before the list snapshot and fails against the parallel version.

The virtualized list no longer inserts a focusable grid into the combobox.
`List` spreads its props onto `Grid`, whose defaults are `role="grid"`,
`containerRole="row"` and `tabIndex={0}`; inside Ariakit's listbox that added a
tab stop ahead of any row and put grid/row semantics between the listbox and its
options. All three are now neutralized so focus and ARIA stay with the combobox
items.

The list also resets to the top when the filter changes. `Grid` keeps its scroll
offset across prop changes and clamps an out-of-range offset to
`totalRowsHeight - height`, the end of the shorter list. Scrolling deep and then
searching landed on the tail: measured at row 626 of 667 matches, with only
those rows mounted and reachable by keyboard. Keying the list on the search
value restores row 0.

* fix: declare option position and set size for the virtualized model list

Once the model list is windowed, only the mounted slice exists in the listbox,
so a screen reader infers position and total from ~19 elements instead of the
real set — announcing "3 of 19" partway through 10,014 agents.

Model rows now carry aria-posinset and aria-setsize. The marketplace entry and
any model specs share the same numbering, because they are options in the same
listbox: declaring the values on some options while leaving others to be
inferred from the DOM would make the set internally inconsistent. Both are
omitted entirely when the list is short enough to render unwindowed, where the
DOM holds every option and the implicit values are already correct.

Verified against a 10,014 agent account: the marketplace entry reports 1 of
10015, the first models 2 and 3, and after scrolling to row 4999 the leading
mounted model reports 5001 of 10015 with 19 options in the DOM.

* 🩹 fix: Address Follow-Ups on the Agent List at Scale

Corrects residual issues in the agent-list perf work, all inside its own scope.

- Forward `idOnTheSource` through `PermissionService.findAccessibleResources`
  so `getUserPrincipals` skips the user-document read. The list handler resolves
  three permission sets per request and each was paying its own `User.findById`;
  the auth strategies already normalize the field to a value or null.
- Gate the editable-set lookup on its own predicate instead of borrowing
  `canReturnSkillConfig`. The two answer unrelated questions and only coincide
  today, so redefining the skill flag would have marked every agent editable.
- Log mapping failures in the list response instead of swallowing them.
- Apply the walk page size after the caller's params in `fetchAllAgentPages`.
  A caller limit only changed page size, never what the flattened walk returned,
  so `defaultAgentParams`' `limit: 10` would have turned one request into 301.
- Carry `isEditable` on the agent rows the create and update mutations write
  into the list cache. Mutation responses omit the field, so those rows lost it.
- Document `isEditable` as list-only, ACL-derived, and fail-open on absence.
- Restore the truthiness guard on the thread walk in `initializeAgent`. Widening
  it to `!= null` made an empty `parentMessageId` issue a full-conversation read
  against an anchor that can never match.
- Await `getConvoFiles` directly rather than calling `.then()` on it, restoring
  tolerance for synchronous test doubles.
- Correct the avatar-refresh comment: the projection was never full documents,
  and the real reason to filter is that an unfiltered budget is self-reinforcing.

Tests: both new `initialize` tests and both new backend tests are
mutation-verified; the concurrency test fails under either serialization order.

* fix: preserve ACL isEditable when merging agent mutation responses

Mutation responses omit list-only isEditable. Inferring true from write
success promoted VIEW-only rows into the editable subset for MANAGE_AGENTS
callers who can PATCH agents their ACL marks non-editable.

* fix: sort imports in agent mutations test

ESLint import-order check failed on the isEditable cache-preservation test.

* 🧷 fix: Carry isEditable Onto Duplicated Agent List Rows

`useDuplicateAgentMutation` prepended the raw duplicate response to the cached
list, and mutation responses omit the list-only `isEditable` field. The row
survived the "My Agents" filter only by failing open on `undefined`, so it would
disappear the moment a consumer read the flag strictly.

Duplicating grants the caller ownership, so the new row is editable outright;
this is the create case rather than the merge case `mergeAgentListRow` handles.
Last cache write on this path that did not carry the field.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-08-07 21:04:55 -04:00

2312 lines
77 KiB
JavaScript

const mongoose = require('mongoose');
const { RoleBits, createModels, tenantStorage } = require('@librechat/data-schemas');
const { MongoMemoryServer } = require('mongodb-memory-server');
const {
ResourceType,
AccessRoleIds,
PrincipalType,
PrincipalModel,
} = require('librechat-data-provider');
const {
bulkUpdateResourcePermissions,
syncUserEntraGroupMemberships,
getEffectivePermissions,
findAccessibleResources,
getAvailableRoles,
grantPermission,
checkPermission,
ensurePrincipalExists,
ensureGroupPrincipalExists,
} = require('./PermissionService');
const { findRoleByIdentifier, getUserPrincipals, seedDefaultRoles } = require('~/models');
// Mock the getTransactionSupport function for testing
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
getTransactionSupport: jest.fn().mockResolvedValue(false),
createModels: jest.requireActual('@librechat/data-schemas').createModels,
}));
// Mock GraphApiService to prevent config loading issues
jest.mock('~/server/services/GraphApiService', () => ({
entraIdPrincipalFeatureEnabled: jest.fn().mockReturnValue(false),
getUserOwnedEntraGroups: jest.fn().mockResolvedValue([]),
getUserEntraGroups: jest.fn().mockResolvedValue([]),
getEntraGroupDetailsBatch: jest.fn().mockResolvedValue([]),
getGroupMembers: jest.fn().mockResolvedValue([]),
getGroupOwners: jest.fn().mockResolvedValue([]),
}));
// Mock the logger
jest.mock('~/config', () => ({
logger: {
error: jest.fn(),
},
}));
let mongoServer;
let AclEntry;
let User;
let Group;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
// Initialize all models
createModels(mongoose);
// Register models on mongoose.models so methods can access them
const dbModels = require('~/db/models');
Object.assign(mongoose.models, dbModels);
AclEntry = dbModels.AclEntry;
User = dbModels.User;
Group = dbModels.Group;
// Seed default roles
await seedDefaultRoles();
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
// Clear test data but keep seeded roles
await AclEntry.deleteMany({});
});
// Mock getUserPrincipals to avoid depending on the actual implementation
jest.mock('~/models', () => ({
...jest.requireActual('~/models'),
getUserPrincipals: jest.fn(),
}));
describe('PermissionService', () => {
// Common test data
const userId = new mongoose.Types.ObjectId();
const groupId = new mongoose.Types.ObjectId();
const resourceId = new mongoose.Types.ObjectId();
const grantedById = new mongoose.Types.ObjectId();
const roleResourceId = new mongoose.Types.ObjectId();
describe('grantPermission', () => {
test('should grant permission to a user with a role', async () => {
const entry = await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.AGENT,
resourceId,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
grantedBy: grantedById,
});
expect(entry).toBeDefined();
expect(entry.principalType).toBe(PrincipalType.USER);
expect(entry.principalId.toString()).toBe(userId.toString());
expect(entry.principalModel).toBe(PrincipalModel.USER);
expect(entry.resourceType).toBe(ResourceType.AGENT);
expect(entry.resourceId.toString()).toBe(resourceId.toString());
// Get the role to verify the permission bits are correctly set
const role = await findRoleByIdentifier(AccessRoleIds.AGENT_VIEWER);
expect(entry.permBits).toBe(role.permBits);
expect(entry.roleId.toString()).toBe(role._id.toString());
expect(entry.grantedBy.toString()).toBe(grantedById.toString());
expect(entry.grantedAt).toBeInstanceOf(Date);
});
test('should grant permission to a group with a role', async () => {
const entry = await grantPermission({
principalType: PrincipalType.GROUP,
principalId: groupId,
resourceType: ResourceType.AGENT,
resourceId,
accessRoleId: AccessRoleIds.AGENT_EDITOR,
grantedBy: grantedById,
});
expect(entry).toBeDefined();
expect(entry.principalType).toBe(PrincipalType.GROUP);
expect(entry.principalId.toString()).toBe(groupId.toString());
expect(entry.principalModel).toBe(PrincipalModel.GROUP);
// Get the role to verify the permission bits are correctly set
const role = await findRoleByIdentifier(AccessRoleIds.AGENT_EDITOR);
expect(entry.permBits).toBe(role.permBits);
expect(entry.roleId.toString()).toBe(role._id.toString());
});
test('should grant public permission with a role', async () => {
const entry = await grantPermission({
principalType: PrincipalType.PUBLIC,
principalId: null,
resourceType: ResourceType.AGENT,
resourceId,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
grantedBy: grantedById,
});
expect(entry).toBeDefined();
expect(entry.principalType).toBe(PrincipalType.PUBLIC);
expect(entry.principalId).toBeUndefined();
expect(entry.principalModel).toBeUndefined();
// Get the role to verify the permission bits are correctly set
const role = await findRoleByIdentifier(AccessRoleIds.AGENT_VIEWER);
expect(entry.permBits).toBe(role.permBits);
expect(entry.roleId.toString()).toBe(role._id.toString());
});
test('should throw error for invalid principal type', async () => {
await expect(
grantPermission({
principalType: 'invalid',
principalId: userId,
resourceType: ResourceType.AGENT,
resourceId,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
grantedBy: grantedById,
}),
).rejects.toThrow('Invalid principal type: invalid');
});
test('should throw error for missing principalId with user type', async () => {
await expect(
grantPermission({
principalType: PrincipalType.USER,
principalId: null,
resourceType: ResourceType.AGENT,
resourceId,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
grantedBy: grantedById,
}),
).rejects.toThrow('Principal ID is required for user, group, and role principals');
});
test('should throw error for non-existent role', async () => {
await expect(
grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.AGENT,
resourceId,
accessRoleId: 'non_existent_role',
grantedBy: grantedById,
}),
).rejects.toThrow('Role non_existent_role not found');
});
test('should throw error for role-resource type mismatch', async () => {
await expect(
grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.AGENT,
resourceId,
accessRoleId: AccessRoleIds.PROMPTGROUP_VIEWER, // PromptGroup role for agent resource
grantedBy: grantedById,
}),
).rejects.toThrow('Role promptGroup_viewer is for promptGroup resources, not agent');
});
test('should update existing permission when granting to same principal and resource', async () => {
// First grant with viewer role
await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.AGENT,
resourceId,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
grantedBy: grantedById,
});
// Then update to editor role
const updated = await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.AGENT,
resourceId,
accessRoleId: AccessRoleIds.AGENT_EDITOR,
grantedBy: grantedById,
});
const editorRole = await findRoleByIdentifier(AccessRoleIds.AGENT_EDITOR);
expect(updated.permBits).toBe(editorRole.permBits);
expect(updated.roleId.toString()).toBe(editorRole._id.toString());
// Verify there's only one entry
const entries = await AclEntry.find({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.AGENT,
resourceId,
});
expect(entries).toHaveLength(1);
});
});
describe('principal validation for ACL writes', () => {
beforeEach(async () => {
await User.deleteMany({ email: /acl-principal/i });
await Group.deleteMany({ name: /ACL Principal/i });
});
test('rejects a local user id outside the current request context', async () => {
const outsideUser = await User.create({
name: 'ACL Principal Outside User',
email: 'acl-principal-outside-user@example.com',
tenantId: 'tenant-b',
});
await expect(
tenantStorage.run({ tenantId: 'tenant-a' }, async () =>
ensurePrincipalExists({
type: PrincipalType.USER,
id: outsideUser._id.toString(),
name: 'Outside User',
source: 'local',
}),
),
).rejects.toThrow('User principal not found');
});
test('accepts a local user id in the current request context', async () => {
const currentUser = await User.create({
name: 'ACL Principal Current User',
email: 'acl-principal-current-user@example.com',
tenantId: 'tenant-a',
});
const principalId = await tenantStorage.run({ tenantId: 'tenant-a' }, async () =>
ensurePrincipalExists({
type: PrincipalType.USER,
id: currentUser._id.toString(),
name: 'Current User',
source: 'local',
}),
);
expect(principalId).toBe(currentUser._id.toString());
});
test('rejects a local group id outside the current request context', async () => {
const outsideGroup = await Group.create({
name: 'ACL Principal Outside Group',
tenantId: 'tenant-b',
});
await expect(
tenantStorage.run({ tenantId: 'tenant-a' }, async () =>
ensureGroupPrincipalExists({
type: PrincipalType.GROUP,
id: outsideGroup._id.toString(),
name: 'Outside Group',
source: 'local',
}),
),
).rejects.toThrow('Group principal not found');
});
});
describe('checkPermission', () => {
let otherResourceId;
beforeEach(async () => {
// Reset the mock implementation for getUserPrincipals
getUserPrincipals.mockReset();
// Setup test data
await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.AGENT,
resourceId,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
grantedBy: grantedById,
});
otherResourceId = new mongoose.Types.ObjectId();
await grantPermission({
principalType: PrincipalType.GROUP,
principalId: groupId,
resourceType: ResourceType.AGENT,
resourceId: otherResourceId,
accessRoleId: AccessRoleIds.AGENT_EDITOR,
grantedBy: grantedById,
});
});
test('should check permission for user principal', async () => {
// Mock getUserPrincipals to return just the user principal
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: userId },
]);
const hasViewPermission = await checkPermission({
userId,
resourceType: ResourceType.AGENT,
resourceId,
requiredPermission: 1, // RoleBits.VIEWER // 1 = VIEW
});
expect(hasViewPermission).toBe(true);
// Check higher permission level that user doesn't have
const hasEditPermission = await checkPermission({
userId,
resourceType: ResourceType.AGENT,
resourceId,
requiredPermission: 3, // RoleBits.EDITOR = VIEW + EDIT
});
expect(hasEditPermission).toBe(false);
});
test('should check permission for user and group principals', async () => {
// Mock getUserPrincipals to return both user and group principals
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: userId },
{ principalType: PrincipalType.GROUP, principalId: groupId },
]);
// Check original resource (user has access)
const hasViewOnOriginal = await checkPermission({
userId,
resourceType: ResourceType.AGENT,
resourceId,
requiredPermission: 1, // RoleBits.VIEWER // 1 = VIEW
});
expect(hasViewOnOriginal).toBe(true);
// Check other resource (group has access)
const hasViewOnOther = await checkPermission({
userId,
resourceType: ResourceType.AGENT,
resourceId: otherResourceId,
requiredPermission: 1, // RoleBits.VIEWER // 1 = VIEW
});
// Group has agent_editor role which includes viewer permissions
expect(hasViewOnOther).toBe(true);
});
test('should check permission for public access', async () => {
const publicResourceId = new mongoose.Types.ObjectId();
// Grant public access to a resource
await grantPermission({
principalType: PrincipalType.PUBLIC,
principalId: null,
resourceType: ResourceType.AGENT,
resourceId: publicResourceId,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
grantedBy: grantedById,
});
// Mock getUserPrincipals to return user, group, and public principals
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: userId },
{ principalType: PrincipalType.GROUP, principalId: groupId },
{ principalType: PrincipalType.PUBLIC },
]);
const hasPublicAccess = await checkPermission({
userId,
resourceType: ResourceType.AGENT,
resourceId: publicResourceId,
requiredPermission: 1, // RoleBits.VIEWER // 1 = VIEW
});
expect(hasPublicAccess).toBe(true);
});
test('should return false for invalid permission bits', async () => {
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: userId },
]);
await expect(
checkPermission({
userId,
resourceType: ResourceType.AGENT,
resourceId,
requiredPermission: 'invalid',
}),
).rejects.toThrow('requiredPermission must be a positive number');
const nonExistentResource = await checkPermission({
userId,
resourceType: ResourceType.AGENT,
resourceId: new mongoose.Types.ObjectId(),
requiredPermission: 1, // RoleBits.VIEWER
});
expect(nonExistentResource).toBe(false);
});
test('should return false if user has no principals', async () => {
getUserPrincipals.mockResolvedValue([]);
const hasPermission = await checkPermission({
userId,
resourceType: ResourceType.AGENT,
resourceId,
requiredPermission: 1, // RoleBits.VIEWER
});
expect(hasPermission).toBe(false);
});
});
describe('getEffectivePermissions', () => {
beforeEach(async () => {
// Reset the mock implementation for getUserPrincipals
getUserPrincipals.mockReset();
// Setup test data with multiple permissions from different sources
await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.AGENT,
resourceId,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
grantedBy: grantedById,
});
await grantPermission({
principalType: PrincipalType.GROUP,
principalId: groupId,
resourceType: ResourceType.AGENT,
resourceId,
accessRoleId: AccessRoleIds.AGENT_EDITOR,
grantedBy: grantedById,
});
// Create another resource with public permission
const publicResourceId = new mongoose.Types.ObjectId();
await grantPermission({
principalType: PrincipalType.PUBLIC,
principalId: null,
resourceType: ResourceType.AGENT,
resourceId: publicResourceId,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
grantedBy: grantedById,
});
// Setup a resource with inherited permission
const parentResourceId = new mongoose.Types.ObjectId();
const childResourceId = new mongoose.Types.ObjectId();
await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.PROMPTGROUP,
resourceId: parentResourceId,
accessRoleId: AccessRoleIds.PROMPTGROUP_VIEWER,
grantedBy: grantedById,
});
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: userId,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: childResourceId,
permBits: RoleBits.VIEWER,
roleId: (await findRoleByIdentifier(AccessRoleIds.AGENT_VIEWER))._id,
grantedBy: grantedById,
grantedAt: new Date(),
inheritedFrom: parentResourceId,
});
});
test('should get effective permissions from multiple sources', async () => {
// Mock getUserPrincipals to return both user and group principals
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: userId },
{ principalType: PrincipalType.GROUP, principalId: groupId },
]);
const effective = await getEffectivePermissions({
userId,
resourceType: ResourceType.AGENT,
resourceId,
});
// Should return the combined permission bits from both user (VIEWER=1) and group (EDITOR=3)
// EDITOR includes VIEWER, so result should be 3 (VIEW + EDIT)
expect(effective).toBe(RoleBits.EDITOR); // 3 = VIEW + EDIT
});
test('should get effective permissions from inherited permissions', async () => {
// Find the child resource ID
const inheritedEntry = await AclEntry.findOne({ inheritedFrom: { $exists: true } });
const childResourceId = inheritedEntry.resourceId;
// Mock getUserPrincipals to return user principal
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: userId },
]);
const effective = await getEffectivePermissions({
userId,
resourceType: ResourceType.AGENT,
resourceId: childResourceId,
});
// Should return VIEWER permission bits from inherited permission
expect(effective).toBe(RoleBits.VIEWER); // 1 = VIEW
});
test('should return 0 for non-existent permissions', async () => {
getUserPrincipals.mockResolvedValue([{ principalType: 'user', principalId: userId }]);
const nonExistentResource = new mongoose.Types.ObjectId();
const effective = await getEffectivePermissions({
userId,
resourceType: ResourceType.AGENT,
resourceId: nonExistentResource,
});
// Should return 0 for no permissions
expect(effective).toBe(0);
});
test('should return 0 if user has no principals', async () => {
getUserPrincipals.mockResolvedValue([]);
const effective = await getEffectivePermissions({
userId,
resourceType: ResourceType.AGENT,
resourceId,
});
// Should return 0 for no permissions
expect(effective).toBe(0);
});
});
describe('findAccessibleResources', () => {
beforeEach(async () => {
// Reset the mock implementation for getUserPrincipals
getUserPrincipals.mockReset();
// Setup test data with multiple resources
const resource1 = new mongoose.Types.ObjectId();
const resource2 = new mongoose.Types.ObjectId();
const resource3 = new mongoose.Types.ObjectId();
// User can view resource 1
await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.AGENT,
resourceId: resource1,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
grantedBy: grantedById,
});
// User can edit resource 2
await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.AGENT,
resourceId: resource2,
accessRoleId: AccessRoleIds.AGENT_EDITOR,
grantedBy: grantedById,
});
// Group can view resource 3
await grantPermission({
principalType: PrincipalType.GROUP,
principalId: groupId,
resourceType: ResourceType.AGENT,
resourceId: resource3,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
grantedBy: grantedById,
});
});
test('should forward idOnTheSource so principal resolution can skip the user lookup', async () => {
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: userId },
]);
await findAccessibleResources({
userId,
role: 'USER',
idOnTheSource: null,
resourceType: ResourceType.AGENT,
requiredPermissions: 1, // VIEW
});
expect(getUserPrincipals).toHaveBeenCalledWith(
expect.objectContaining({ idOnTheSource: null }),
);
});
test('should find resources user can view', async () => {
// Mock getUserPrincipals to return user principal
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: userId },
]);
const viewableResources = await findAccessibleResources({
userId,
resourceType: ResourceType.AGENT,
requiredPermissions: 1, // RoleBits.VIEWER // 1 = VIEW
});
// Should find both resources (viewer role is included in editor role)
expect(viewableResources).toHaveLength(2);
});
test('should find resources user can edit', async () => {
// Mock getUserPrincipals to return user principal
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: userId },
]);
const editableResources = await findAccessibleResources({
userId,
resourceType: ResourceType.AGENT,
requiredPermissions: 3, // RoleBits.EDITOR = VIEW + EDIT
});
// Should find only one resource (only the editor resource has EDIT permission)
expect(editableResources).toHaveLength(1);
});
test('should find resources accessible via group membership', async () => {
// Mock getUserPrincipals to return user and group principals
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: userId },
{ principalType: PrincipalType.GROUP, principalId: groupId },
]);
const viewableResources = await findAccessibleResources({
userId,
resourceType: ResourceType.AGENT,
requiredPermissions: 1, // RoleBits.VIEWER // 1 = VIEW
});
// Should find all three resources
expect(viewableResources).toHaveLength(3);
});
test('should return empty array for invalid permissions', async () => {
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: userId },
]);
await expect(
findAccessibleResources({
userId,
resourceType: ResourceType.AGENT,
requiredPermissions: 'invalid',
}),
).rejects.toThrow('requiredPermissions must be a positive number');
const nonExistentType = await findAccessibleResources({
userId,
resourceType: 'non_existent_type',
requiredPermissions: 1, // RoleBits.VIEWER
});
expect(nonExistentType).toEqual([]);
});
test('should return empty array if user has no principals', async () => {
getUserPrincipals.mockResolvedValue([]);
const resources = await findAccessibleResources({
userId,
resourceType: ResourceType.AGENT,
requiredPermissions: 1, // RoleBits.VIEWER
});
expect(resources).toEqual([]);
});
});
describe('getAvailableRoles', () => {
test('should get all roles for a resource type', async () => {
const roles = await getAvailableRoles({
resourceType: ResourceType.AGENT,
});
expect(roles).toHaveLength(3);
expect(roles.map((r) => r.accessRoleId).sort()).toEqual(
[AccessRoleIds.AGENT_EDITOR, AccessRoleIds.AGENT_OWNER, AccessRoleIds.AGENT_VIEWER].sort(),
);
});
test('should throw error for non-existent resource type', async () => {
await expect(
getAvailableRoles({
resourceType: 'non_existent_type',
}),
).rejects.toThrow('Invalid resourceType: non_existent_type. Valid types: agent, promptGroup');
});
});
describe('bulkUpdateResourcePermissions', () => {
const otherUserId = new mongoose.Types.ObjectId();
beforeEach(async () => {
// Ensure roles are properly seeded
await seedDefaultRoles();
// Setup existing permissions for testing
await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.AGENT,
resourceId,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
grantedBy: grantedById,
});
await grantPermission({
principalType: PrincipalType.GROUP,
principalId: groupId,
resourceType: ResourceType.AGENT,
resourceId,
accessRoleId: AccessRoleIds.AGENT_EDITOR,
grantedBy: grantedById,
});
await grantPermission({
principalType: PrincipalType.PUBLIC,
principalId: null,
resourceType: ResourceType.AGENT,
resourceId,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
grantedBy: grantedById,
});
});
test('should grant new permissions in bulk', async () => {
const newResourceId = new mongoose.Types.ObjectId();
const updatedPrincipals = [
{
type: PrincipalType.USER,
id: userId,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
},
{
type: PrincipalType.USER,
id: otherUserId,
accessRoleId: AccessRoleIds.AGENT_EDITOR,
},
{
type: PrincipalType.GROUP,
id: groupId,
accessRoleId: AccessRoleIds.AGENT_OWNER,
},
];
const results = await bulkUpdateResourcePermissions({
resourceType: ResourceType.AGENT,
resourceId: newResourceId,
updatedPrincipals,
grantedBy: grantedById,
});
expect(results.granted).toHaveLength(3);
expect(results.updated).toHaveLength(0);
expect(results.revoked).toHaveLength(0);
expect(results.errors).toHaveLength(0);
// Verify permissions were created
const aclEntries = await AclEntry.find({
resourceType: ResourceType.AGENT,
resourceId: newResourceId,
});
expect(aclEntries).toHaveLength(3);
});
test('should update existing permissions in bulk', async () => {
const updatedPrincipals = [
{
type: PrincipalType.USER,
id: userId,
accessRoleId: AccessRoleIds.AGENT_EDITOR, // Upgrade from viewer to editor
},
{
type: PrincipalType.GROUP,
id: groupId,
accessRoleId: AccessRoleIds.AGENT_OWNER, // Upgrade from editor to owner
},
{
type: PrincipalType.PUBLIC,
accessRoleId: AccessRoleIds.AGENT_VIEWER, // Keep same role
},
];
const results = await bulkUpdateResourcePermissions({
resourceType: ResourceType.AGENT,
resourceId,
updatedPrincipals,
grantedBy: grantedById,
});
// Function puts all updatedPrincipals in granted array since it uses upserts
expect(results.granted).toHaveLength(3);
expect(results.updated).toHaveLength(0);
expect(results.revoked).toHaveLength(0);
expect(results.errors).toHaveLength(0);
// Verify updates
const userEntry = await AclEntry.findOne({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.AGENT,
resourceId,
}).populate('roleId', 'accessRoleId');
expect(userEntry.roleId.accessRoleId).toBe(AccessRoleIds.AGENT_EDITOR);
const groupEntry = await AclEntry.findOne({
principalType: PrincipalType.GROUP,
principalId: groupId,
resourceType: ResourceType.AGENT,
resourceId,
}).populate('roleId', 'accessRoleId');
expect(groupEntry.roleId.accessRoleId).toBe(AccessRoleIds.AGENT_OWNER);
});
test('should revoke specified permissions', async () => {
const revokedPrincipals = [
{
type: PrincipalType.GROUP,
id: groupId,
},
{
type: PrincipalType.PUBLIC,
},
];
const results = await bulkUpdateResourcePermissions({
resourceType: ResourceType.AGENT,
resourceId,
revokedPrincipals,
grantedBy: grantedById,
});
expect(results.granted).toHaveLength(0);
expect(results.updated).toHaveLength(0);
expect(results.revoked).toHaveLength(2); // Group and public revoked
expect(results.errors).toHaveLength(0);
// Verify only user permission remains
const remainingEntries = await AclEntry.find({
resourceType: ResourceType.AGENT,
resourceId,
});
expect(remainingEntries).toHaveLength(1);
expect(remainingEntries[0].principalType).toBe(PrincipalType.USER);
expect(remainingEntries[0].principalId.toString()).toBe(userId.toString());
});
test('grant wins over revoke when a principal is in both lists (prevents owner lockout, #14316)', async () => {
// Simulates the share dialog sending the owner in both updatedPrincipals (grant OWNER) and
// revokedPrincipals (e.g. from a client id/idOnTheSource mismatch). Grants flush before
// deletes, so without the guard the owner would be upserted and then deleted. The owner
// must keep access.
const results = await bulkUpdateResourcePermissions({
resourceType: ResourceType.AGENT,
resourceId,
updatedPrincipals: [
{
type: PrincipalType.USER,
id: userId,
accessRoleId: AccessRoleIds.AGENT_OWNER,
},
],
revokedPrincipals: [
{
type: PrincipalType.USER,
id: userId,
},
],
grantedBy: grantedById,
});
expect(results.granted).toHaveLength(1);
// The revoke for the same principal is skipped, not applied, so it is absent from results.
expect(results.revoked).toHaveLength(0);
expect(results.errors).toHaveLength(0);
const userEntry = await AclEntry.findOne({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.AGENT,
resourceId,
}).populate('roleId', 'accessRoleId');
expect(userEntry).not.toBeNull();
expect(userEntry.roleId.accessRoleId).toBe(AccessRoleIds.AGENT_OWNER);
});
test('revoke wins for PUBLIC so an explicit public disable is honored (#14316)', async () => {
// A contradictory payload that both grants public and disables it puts the public principal
// in both lists. Unlike user/group principals, disabling public access must win so the
// resource is never left public when the caller asked to make it private.
const results = await bulkUpdateResourcePermissions({
resourceType: ResourceType.AGENT,
resourceId,
updatedPrincipals: [
{
type: PrincipalType.PUBLIC,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
},
],
revokedPrincipals: [
{
type: PrincipalType.PUBLIC,
},
],
grantedBy: grantedById,
});
expect(results.revoked).toHaveLength(1);
expect(results.errors).toHaveLength(0);
const publicEntry = await AclEntry.findOne({
principalType: PrincipalType.PUBLIC,
resourceType: ResourceType.AGENT,
resourceId,
});
expect(publicEntry).toBeNull();
});
test('records a malformed revoke entry in errors instead of throwing after grants flush (#14316)', async () => {
// A nullish/malformed entry in revokedPrincipals must not throw out of the function after
// grants have already been flushed; it is captured in results.errors and processing continues.
const results = await bulkUpdateResourcePermissions({
resourceType: ResourceType.AGENT,
resourceId,
updatedPrincipals: [
{ type: PrincipalType.USER, id: otherUserId, accessRoleId: AccessRoleIds.AGENT_VIEWER },
],
revokedPrincipals: [null],
grantedBy: grantedById,
});
expect(results.errors).toHaveLength(1);
expect(results.granted).toHaveLength(1);
// The valid grant still landed despite the malformed revoke entry.
const grantedEntry = await AclEntry.findOne({
principalType: PrincipalType.USER,
principalId: otherUserId,
resourceType: ResourceType.AGENT,
resourceId,
});
expect(grantedEntry).not.toBeNull();
});
test('should handle mixed operations (grant, update, revoke)', async () => {
const updatedPrincipals = [
{
type: PrincipalType.USER,
id: userId,
accessRoleId: AccessRoleIds.AGENT_OWNER, // Update existing
},
{
type: PrincipalType.USER,
id: otherUserId,
accessRoleId: AccessRoleIds.AGENT_VIEWER, // New permission
},
];
const revokedPrincipals = [
{
type: PrincipalType.GROUP,
id: groupId,
},
{
type: PrincipalType.PUBLIC,
},
];
const results = await bulkUpdateResourcePermissions({
resourceType: ResourceType.AGENT,
resourceId,
updatedPrincipals,
revokedPrincipals,
grantedBy: grantedById,
});
expect(results.granted).toHaveLength(2); // Both users granted (function uses upserts)
expect(results.updated).toHaveLength(0);
expect(results.revoked).toHaveLength(2); // Group and public revoked
expect(results.errors).toHaveLength(0);
// Verify final state
const finalEntries = await AclEntry.find({
resourceType: ResourceType.AGENT,
resourceId,
}).populate('roleId', 'accessRoleId');
expect(finalEntries).toHaveLength(2);
const userEntry = finalEntries.find((e) => e.principalId.toString() === userId.toString());
expect(userEntry.roleId.accessRoleId).toBe(AccessRoleIds.AGENT_OWNER);
const otherUserEntry = finalEntries.find(
(e) => e.principalId.toString() === otherUserId.toString(),
);
expect(otherUserEntry.roleId.accessRoleId).toBe(AccessRoleIds.AGENT_VIEWER);
});
test('should handle errors for invalid roles gracefully', async () => {
const updatedPrincipals = [
{
type: PrincipalType.USER,
id: userId,
accessRoleId: AccessRoleIds.AGENT_VIEWER, // Valid
},
{
type: PrincipalType.USER,
id: otherUserId,
accessRoleId: 'non_existent_role', // Invalid
},
{
type: PrincipalType.GROUP,
id: groupId,
accessRoleId: AccessRoleIds.PROMPTGROUP_VIEWER, // Wrong resource type
},
];
const results = await bulkUpdateResourcePermissions({
resourceType: ResourceType.AGENT,
resourceId,
updatedPrincipals,
grantedBy: grantedById,
});
expect(results.granted).toHaveLength(1); // Only valid user permission
expect(results.updated).toHaveLength(0);
expect(results.revoked).toHaveLength(0);
expect(results.errors).toHaveLength(2); // Two invalid permissions
// Check error details
expect(results.errors[0].error).toContain('Role non_existent_role not found');
expect(results.errors[1].error).toContain('Role promptGroup_viewer not found');
});
test('should handle empty arrays (no operations)', async () => {
const results = await bulkUpdateResourcePermissions({
resourceType: ResourceType.AGENT,
resourceId,
updatedPrincipals: [],
revokedPrincipals: [],
grantedBy: grantedById,
});
expect(results.granted).toHaveLength(0);
expect(results.updated).toHaveLength(0);
expect(results.revoked).toHaveLength(0);
expect(results.errors).toHaveLength(0);
// Verify no changes to existing permissions (since no operations were performed)
const remainingEntries = await AclEntry.find({
resourceType: ResourceType.AGENT,
resourceId,
});
expect(remainingEntries).toHaveLength(3); // Original permissions still exist
});
test('should throw error for invalid updatedPrincipals array', async () => {
await expect(
bulkUpdateResourcePermissions({
resourceType: ResourceType.AGENT,
resourceId,
updatedPrincipals: 'not an array',
grantedBy: grantedById,
}),
).rejects.toThrow('updatedPrincipals must be an array');
});
test('should throw error for invalid resource ID', async () => {
await expect(
bulkUpdateResourcePermissions({
resourceType: ResourceType.AGENT,
resourceId: 'invalid-id',
permissions: [],
grantedBy: grantedById,
}),
).rejects.toThrow('Invalid resource ID: invalid-id');
});
test('should handle public permissions correctly', async () => {
const updatedPrincipals = [
{
type: PrincipalType.PUBLIC,
accessRoleId: AccessRoleIds.AGENT_EDITOR, // Update public permission
},
{
type: PrincipalType.USER,
id: otherUserId,
accessRoleId: AccessRoleIds.AGENT_VIEWER, // New user permission
},
];
const revokedPrincipals = [
{
type: PrincipalType.USER,
id: userId,
},
{
type: PrincipalType.GROUP,
id: groupId,
},
];
const results = await bulkUpdateResourcePermissions({
resourceType: ResourceType.AGENT,
resourceId,
updatedPrincipals,
revokedPrincipals,
grantedBy: grantedById,
});
expect(results.granted).toHaveLength(2); // Public and new user
expect(results.updated).toHaveLength(0);
expect(results.revoked).toHaveLength(2); // Existing user and group revoked
expect(results.errors).toHaveLength(0);
// Verify public permission was updated
const publicEntry = await AclEntry.findOne({
principalType: PrincipalType.PUBLIC,
resourceType: ResourceType.AGENT,
resourceId,
}).populate('roleId', 'accessRoleId');
expect(publicEntry).toBeDefined();
expect(publicEntry.roleId.accessRoleId).toBe(AccessRoleIds.AGENT_EDITOR);
});
test('should grant permission to a role', async () => {
const entry = await grantPermission({
principalType: PrincipalType.ROLE,
principalId: 'admin',
resourceType: ResourceType.AGENT,
resourceId: roleResourceId,
accessRoleId: AccessRoleIds.AGENT_EDITOR,
grantedBy: grantedById,
});
expect(entry).toBeDefined();
expect(entry.principalType).toBe(PrincipalType.ROLE);
expect(entry.principalId).toBe('admin');
expect(entry.principalModel).toBe(PrincipalModel.ROLE);
expect(entry.resourceType).toBe(ResourceType.AGENT);
expect(entry.resourceId.toString()).toBe(roleResourceId.toString());
// Get the role to verify the permission bits are correctly set
const role = await findRoleByIdentifier(AccessRoleIds.AGENT_EDITOR);
expect(entry.permBits).toBe(role.permBits);
expect(entry.roleId.toString()).toBe(role._id.toString());
});
test('should check permissions for user with role', async () => {
// Grant permission to admin role
await grantPermission({
principalType: PrincipalType.ROLE,
principalId: 'admin',
resourceType: ResourceType.AGENT,
resourceId: roleResourceId,
accessRoleId: AccessRoleIds.AGENT_EDITOR,
grantedBy: grantedById,
});
// Mock getUserPrincipals to return user with admin role
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: userId },
{ principalType: PrincipalType.ROLE, principalId: 'admin' },
{ principalType: PrincipalType.PUBLIC },
]);
const hasPermission = await checkPermission({
userId,
resourceType: ResourceType.AGENT,
resourceId: roleResourceId,
requiredPermission: 1, // VIEW
});
expect(hasPermission).toBe(true);
// Check that user without admin role cannot access
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: userId },
{ principalType: PrincipalType.PUBLIC },
]);
const hasNoPermission = await checkPermission({
userId,
resourceType: ResourceType.AGENT,
resourceId: roleResourceId,
requiredPermission: 1, // VIEW
});
expect(hasNoPermission).toBe(false);
});
test('should optimize permission checks when role is provided', async () => {
const testUserId = new mongoose.Types.ObjectId();
const testResourceId = new mongoose.Types.ObjectId();
// Create a user with EDITOR role
const User = mongoose.models.User;
await User.create({
_id: testUserId,
email: 'editor@test.com',
emailVerified: true,
provider: 'local',
role: 'EDITOR',
});
// Grant permission to EDITOR role
await grantPermission({
principalType: PrincipalType.ROLE,
principalId: 'EDITOR',
resourceType: ResourceType.AGENT,
resourceId: testResourceId,
accessRoleId: AccessRoleIds.AGENT_EDITOR,
grantedBy: grantedById,
});
// Mock getUserPrincipals to return user with EDITOR role when called
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: testUserId },
{ principalType: PrincipalType.ROLE, principalId: 'EDITOR' },
{ principalType: PrincipalType.PUBLIC },
]);
// Test 1: Check permission with role provided (optimization should be used)
const hasPermissionWithRole = await checkPermission({
userId: testUserId,
role: 'EDITOR',
resourceType: ResourceType.AGENT,
resourceId: testResourceId,
requiredPermission: 1, // VIEW
});
expect(hasPermissionWithRole).toBe(true);
expect(getUserPrincipals).toHaveBeenCalledWith({ userId: testUserId, role: 'EDITOR' });
// Test 2: Check permission without role (should call getUserPrincipals)
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: testUserId },
{ principalType: PrincipalType.ROLE, principalId: 'EDITOR' },
{ principalType: PrincipalType.PUBLIC },
]);
const hasPermissionWithoutRole = await checkPermission({
userId: testUserId,
resourceType: ResourceType.AGENT,
resourceId: testResourceId,
requiredPermission: 1, // VIEW
});
expect(hasPermissionWithoutRole).toBe(true);
expect(getUserPrincipals).toHaveBeenCalledWith({ userId: testUserId, role: undefined });
// Test 3: Verify getEffectivePermissions also uses the optimization
getUserPrincipals.mockClear();
const effectiveWithRole = await getEffectivePermissions({
userId: testUserId,
role: 'EDITOR',
resourceType: ResourceType.AGENT,
resourceId: testResourceId,
});
expect(effectiveWithRole).toBe(3); // EDITOR = VIEW + EDIT
expect(getUserPrincipals).toHaveBeenCalledWith({ userId: testUserId, role: 'EDITOR' });
// Test 4: Verify findAccessibleResources also uses the optimization
getUserPrincipals.mockClear();
const accessibleWithRole = await findAccessibleResources({
userId: testUserId,
role: 'EDITOR',
resourceType: ResourceType.AGENT,
requiredPermissions: 1, // VIEW
});
expect(accessibleWithRole.map((id) => id.toString())).toContain(testResourceId.toString());
expect(getUserPrincipals).toHaveBeenCalledWith({ userId: testUserId, role: 'EDITOR' });
});
test('should handle role changes dynamically', async () => {
const testUserId = new mongoose.Types.ObjectId();
const testResourceId = new mongoose.Types.ObjectId();
// Grant permission to ADMIN role only
await grantPermission({
principalType: PrincipalType.ROLE,
principalId: 'ADMIN',
resourceType: ResourceType.AGENT,
resourceId: testResourceId,
accessRoleId: AccessRoleIds.AGENT_OWNER,
grantedBy: grantedById,
});
// Test with ADMIN role - should have access
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: testUserId },
{ principalType: PrincipalType.ROLE, principalId: 'ADMIN' },
{ principalType: PrincipalType.PUBLIC },
]);
const hasAdminAccess = await checkPermission({
userId: testUserId,
role: 'ADMIN',
resourceType: ResourceType.AGENT,
resourceId: testResourceId,
requiredPermission: 7, // Full permissions
});
expect(hasAdminAccess).toBe(true);
expect(getUserPrincipals).toHaveBeenCalledWith({ userId: testUserId, role: 'ADMIN' });
// Test with USER role - should NOT have access
getUserPrincipals.mockClear();
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: testUserId },
{ principalType: PrincipalType.ROLE, principalId: 'USER' },
{ principalType: PrincipalType.PUBLIC },
]);
const hasUserAccess = await checkPermission({
userId: testUserId,
role: 'USER',
resourceType: ResourceType.AGENT,
resourceId: testResourceId,
requiredPermission: 1, // Even VIEW
});
expect(hasUserAccess).toBe(false);
expect(getUserPrincipals).toHaveBeenCalledWith({ userId: testUserId, role: 'USER' });
// Test with EDITOR role - should NOT have access
getUserPrincipals.mockClear();
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: testUserId },
{ principalType: PrincipalType.ROLE, principalId: 'EDITOR' },
{ principalType: PrincipalType.PUBLIC },
]);
const hasEditorAccess = await checkPermission({
userId: testUserId,
role: 'EDITOR',
resourceType: ResourceType.AGENT,
resourceId: testResourceId,
requiredPermission: 1, // VIEW
});
expect(hasEditorAccess).toBe(false);
expect(getUserPrincipals).toHaveBeenCalledWith({ userId: testUserId, role: 'EDITOR' });
});
test('should work with different resource types', async () => {
// Test with promptGroup resources
const promptGroupResourceId = new mongoose.Types.ObjectId();
const updatedPrincipals = [
{
type: PrincipalType.USER,
id: userId,
accessRoleId: AccessRoleIds.PROMPTGROUP_VIEWER,
},
{
type: PrincipalType.GROUP,
id: groupId,
accessRoleId: AccessRoleIds.PROMPTGROUP_EDITOR,
},
];
const results = await bulkUpdateResourcePermissions({
resourceType: ResourceType.PROMPTGROUP,
resourceId: promptGroupResourceId,
updatedPrincipals,
grantedBy: grantedById,
});
expect(results.granted).toHaveLength(2);
expect(results.updated).toHaveLength(0);
expect(results.revoked).toHaveLength(0);
expect(results.errors).toHaveLength(0);
// Verify permissions were created with correct resource type
const promptGroupEntries = await AclEntry.find({
resourceType: ResourceType.PROMPTGROUP,
resourceId: promptGroupResourceId,
});
expect(promptGroupEntries).toHaveLength(2);
expect(promptGroupEntries.every((e) => e.resourceType === ResourceType.PROMPTGROUP)).toBe(
true,
);
});
});
describe('String vs ObjectId Edge Cases', () => {
const stringUserId = new mongoose.Types.ObjectId().toString();
const objectIdUserId = new mongoose.Types.ObjectId();
const stringGroupId = new mongoose.Types.ObjectId().toString();
const objectIdGroupId = new mongoose.Types.ObjectId();
const testResourceId = new mongoose.Types.ObjectId();
beforeEach(async () => {
// Clear any existing ACL entries
await AclEntry.deleteMany({});
getUserPrincipals.mockReset();
});
test('should handle string userId in grantPermission', async () => {
const entry = await grantPermission({
principalType: PrincipalType.USER,
principalId: stringUserId, // Pass string
resourceType: ResourceType.AGENT,
resourceId: testResourceId,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
grantedBy: grantedById,
});
expect(entry).toBeDefined();
expect(entry.principalType).toBe(PrincipalType.USER);
// Should be stored as ObjectId
expect(entry.principalId).toBeInstanceOf(mongoose.Types.ObjectId);
expect(entry.principalId.toString()).toBe(stringUserId);
});
test('should handle string groupId in grantPermission', async () => {
const entry = await grantPermission({
principalType: PrincipalType.GROUP,
principalId: stringGroupId, // Pass string
resourceType: ResourceType.AGENT,
resourceId: testResourceId,
accessRoleId: AccessRoleIds.AGENT_EDITOR,
grantedBy: grantedById,
});
expect(entry).toBeDefined();
expect(entry.principalType).toBe(PrincipalType.GROUP);
// Should be stored as ObjectId
expect(entry.principalId).toBeInstanceOf(mongoose.Types.ObjectId);
expect(entry.principalId.toString()).toBe(stringGroupId);
});
test('should handle string roleId in grantPermission for ROLE type', async () => {
const roleString = 'moderator';
const entry = await grantPermission({
principalType: PrincipalType.ROLE,
principalId: roleString,
resourceType: ResourceType.AGENT,
resourceId: testResourceId,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
grantedBy: grantedById,
});
expect(entry).toBeDefined();
expect(entry.principalType).toBe(PrincipalType.ROLE);
// Should remain as string for ROLE type
expect(typeof entry.principalId).toBe('string');
expect(entry.principalId).toBe(roleString);
expect(entry.principalModel).toBe(PrincipalModel.ROLE);
});
test('should check permissions correctly when permission granted with string userId', async () => {
// Grant permission with string userId
await grantPermission({
principalType: PrincipalType.USER,
principalId: stringUserId,
resourceType: ResourceType.AGENT,
resourceId: testResourceId,
accessRoleId: AccessRoleIds.AGENT_EDITOR,
grantedBy: grantedById,
});
// Mock getUserPrincipals to return ObjectId (as it should after our fix)
getUserPrincipals.mockResolvedValue([
{
principalType: PrincipalType.USER,
principalId: new mongoose.Types.ObjectId(stringUserId),
},
{ principalType: PrincipalType.PUBLIC },
]);
// Check permission with string userId
const hasPermission = await checkPermission({
userId: stringUserId,
resourceType: ResourceType.AGENT,
resourceId: testResourceId,
requiredPermission: 1, // VIEW
});
expect(hasPermission).toBe(true);
expect(getUserPrincipals).toHaveBeenCalledWith({ userId: stringUserId, role: undefined });
});
test('should check permissions correctly when permission granted with ObjectId', async () => {
// Grant permission with ObjectId
await grantPermission({
principalType: PrincipalType.USER,
principalId: objectIdUserId,
resourceType: ResourceType.AGENT,
resourceId: testResourceId,
accessRoleId: AccessRoleIds.AGENT_OWNER,
grantedBy: grantedById,
});
// Mock getUserPrincipals to return ObjectId
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: objectIdUserId },
{ principalType: PrincipalType.PUBLIC },
]);
// Check permission with ObjectId
const hasPermission = await checkPermission({
userId: objectIdUserId,
resourceType: ResourceType.AGENT,
resourceId: testResourceId,
requiredPermission: 7, // Full permissions
});
expect(hasPermission).toBe(true);
expect(getUserPrincipals).toHaveBeenCalledWith({ userId: objectIdUserId, role: undefined });
});
test('should handle bulkUpdateResourcePermissions with string IDs', async () => {
const updatedPrincipals = [
{
type: PrincipalType.USER,
id: stringUserId, // String ID
accessRoleId: AccessRoleIds.AGENT_VIEWER,
},
{
type: PrincipalType.GROUP,
id: stringGroupId, // String ID
accessRoleId: AccessRoleIds.AGENT_EDITOR,
},
{
type: PrincipalType.ROLE,
id: 'admin', // String ID (should remain string)
accessRoleId: AccessRoleIds.AGENT_OWNER,
},
];
const results = await bulkUpdateResourcePermissions({
resourceType: ResourceType.AGENT,
resourceId: testResourceId,
updatedPrincipals,
grantedBy: grantedById,
});
expect(results.granted).toHaveLength(3);
expect(results.errors).toHaveLength(0);
// Verify USER entry has ObjectId
const userEntry = await AclEntry.findOne({
principalType: PrincipalType.USER,
resourceType: ResourceType.AGENT,
resourceId: testResourceId,
});
expect(userEntry.principalId).toBeInstanceOf(mongoose.Types.ObjectId);
expect(userEntry.principalId.toString()).toBe(stringUserId);
// Verify GROUP entry has ObjectId
const groupEntry = await AclEntry.findOne({
principalType: PrincipalType.GROUP,
resourceType: ResourceType.AGENT,
resourceId: testResourceId,
});
expect(groupEntry.principalId).toBeInstanceOf(mongoose.Types.ObjectId);
expect(groupEntry.principalId.toString()).toBe(stringGroupId);
// Verify ROLE entry has string
const roleEntry = await AclEntry.findOne({
principalType: PrincipalType.ROLE,
resourceType: ResourceType.AGENT,
resourceId: testResourceId,
});
expect(typeof roleEntry.principalId).toBe('string');
expect(roleEntry.principalId).toBe('admin');
});
test('should handle revoking permissions with string IDs in bulkUpdateResourcePermissions', async () => {
// First grant permissions with ObjectIds
await grantPermission({
principalType: PrincipalType.USER,
principalId: objectIdUserId,
resourceType: ResourceType.AGENT,
resourceId: testResourceId,
accessRoleId: AccessRoleIds.AGENT_OWNER,
grantedBy: grantedById,
});
await grantPermission({
principalType: PrincipalType.GROUP,
principalId: objectIdGroupId,
resourceType: ResourceType.AGENT,
resourceId: testResourceId,
accessRoleId: AccessRoleIds.AGENT_EDITOR,
grantedBy: grantedById,
});
// Revoke using string IDs
const revokedPrincipals = [
{
type: PrincipalType.USER,
id: objectIdUserId.toString(), // String version of ObjectId
},
{
type: PrincipalType.GROUP,
id: objectIdGroupId.toString(), // String version of ObjectId
},
];
const results = await bulkUpdateResourcePermissions({
resourceType: ResourceType.AGENT,
resourceId: testResourceId,
revokedPrincipals,
grantedBy: grantedById,
});
expect(results.revoked).toHaveLength(2);
expect(results.errors).toHaveLength(0);
// Verify permissions were actually revoked
const remainingEntries = await AclEntry.find({
resourceType: ResourceType.AGENT,
resourceId: testResourceId,
});
expect(remainingEntries).toHaveLength(0);
});
test('should find accessible resources when permissions granted with mixed ID types', async () => {
const resource1 = new mongoose.Types.ObjectId();
const resource2 = new mongoose.Types.ObjectId();
const resource3 = new mongoose.Types.ObjectId();
// Grant with string userId
await grantPermission({
principalType: PrincipalType.USER,
principalId: stringUserId,
resourceType: ResourceType.AGENT,
resourceId: resource1,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
grantedBy: grantedById,
});
// Grant with ObjectId userId (same user)
await grantPermission({
principalType: PrincipalType.USER,
principalId: new mongoose.Types.ObjectId(stringUserId),
resourceType: ResourceType.AGENT,
resourceId: resource2,
accessRoleId: AccessRoleIds.AGENT_EDITOR,
grantedBy: grantedById,
});
// Grant to role
await grantPermission({
principalType: PrincipalType.ROLE,
principalId: 'admin',
resourceType: ResourceType.AGENT,
resourceId: resource3,
accessRoleId: AccessRoleIds.AGENT_OWNER,
grantedBy: grantedById,
});
// Mock getUserPrincipals to return user with admin role
getUserPrincipals.mockResolvedValue([
{
principalType: PrincipalType.USER,
principalId: new mongoose.Types.ObjectId(stringUserId),
},
{ principalType: PrincipalType.ROLE, principalId: 'admin' },
{ principalType: PrincipalType.PUBLIC },
]);
const accessibleResources = await findAccessibleResources({
userId: stringUserId,
role: 'admin',
resourceType: ResourceType.AGENT,
requiredPermissions: 1, // VIEW
});
// Should find all three resources
expect(accessibleResources).toHaveLength(3);
const resourceIds = accessibleResources.map((id) => id.toString());
expect(resourceIds).toContain(resource1.toString());
expect(resourceIds).toContain(resource2.toString());
expect(resourceIds).toContain(resource3.toString());
});
test('should get effective permissions with mixed ID types', async () => {
// Grant VIEW permission with string userId
await grantPermission({
principalType: PrincipalType.USER,
principalId: stringUserId,
resourceType: ResourceType.AGENT,
resourceId: testResourceId,
accessRoleId: AccessRoleIds.AGENT_VIEWER,
grantedBy: grantedById,
});
// Grant EDIT permission to a group with string groupId
await grantPermission({
principalType: PrincipalType.GROUP,
principalId: stringGroupId,
resourceType: ResourceType.AGENT,
resourceId: testResourceId,
accessRoleId: AccessRoleIds.AGENT_EDITOR,
grantedBy: grantedById,
});
// Mock getUserPrincipals to return ObjectIds (as it should after our fix)
getUserPrincipals.mockResolvedValue([
{
principalType: PrincipalType.USER,
principalId: new mongoose.Types.ObjectId(stringUserId),
},
{
principalType: PrincipalType.GROUP,
principalId: new mongoose.Types.ObjectId(stringGroupId),
},
{ principalType: PrincipalType.PUBLIC },
]);
const effectivePermissions = await getEffectivePermissions({
userId: stringUserId,
resourceType: ResourceType.AGENT,
resourceId: testResourceId,
});
// Should combine VIEW (1) and EDIT (3) permissions
expect(effectivePermissions).toBe(3); // EDITOR includes VIEW
});
});
describe('getResourcePermissionsMap - Batch Permission Queries', () => {
const { getResourcePermissionsMap } = require('./PermissionService');
beforeEach(async () => {
await AclEntry.deleteMany({});
getUserPrincipals.mockReset();
});
test('should get permissions for multiple resources in single query', async () => {
const resource1 = new mongoose.Types.ObjectId();
const resource2 = new mongoose.Types.ObjectId();
const resource3 = new mongoose.Types.ObjectId();
// Grant different permissions to different resources
await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.MCPSERVER,
resourceId: resource1,
accessRoleId: AccessRoleIds.MCPSERVER_VIEWER,
grantedBy: grantedById,
});
await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.MCPSERVER,
resourceId: resource2,
accessRoleId: AccessRoleIds.MCPSERVER_EDITOR,
grantedBy: grantedById,
});
// resource3 has no permissions
// Mock getUserPrincipals
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: userId },
{ principalType: PrincipalType.PUBLIC },
]);
const permissionsMap = await getResourcePermissionsMap({
userId,
resourceType: ResourceType.MCPSERVER,
resourceIds: [resource1, resource2, resource3],
});
expect(permissionsMap).toBeInstanceOf(Map);
expect(permissionsMap.size).toBe(2); // Only resource1 and resource2
expect(permissionsMap.get(resource1.toString())).toBe(1); // VIEW
expect(permissionsMap.get(resource2.toString())).toBe(3); // VIEW | EDIT
expect(permissionsMap.get(resource3.toString())).toBeUndefined();
});
test('should combine permissions from multiple principals', async () => {
const resource1 = new mongoose.Types.ObjectId();
const resource2 = new mongoose.Types.ObjectId();
// User has VIEW on both resources
await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.MCPSERVER,
resourceId: resource1,
accessRoleId: AccessRoleIds.MCPSERVER_VIEWER,
grantedBy: grantedById,
});
await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.MCPSERVER,
resourceId: resource2,
accessRoleId: AccessRoleIds.MCPSERVER_VIEWER,
grantedBy: grantedById,
});
// Group has EDIT on resource1
await grantPermission({
principalType: PrincipalType.GROUP,
principalId: groupId,
resourceType: ResourceType.MCPSERVER,
resourceId: resource1,
accessRoleId: AccessRoleIds.MCPSERVER_EDITOR,
grantedBy: grantedById,
});
// Mock getUserPrincipals with user + group
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: userId },
{ principalType: PrincipalType.GROUP, principalId: groupId },
{ principalType: PrincipalType.PUBLIC },
]);
const permissionsMap = await getResourcePermissionsMap({
userId,
resourceType: ResourceType.MCPSERVER,
resourceIds: [resource1, resource2],
});
expect(permissionsMap.size).toBe(2);
// Resource1 should have VIEW (1) | EDIT (3) = 3
expect(permissionsMap.get(resource1.toString())).toBe(3);
// Resource2 should have only VIEW (1)
expect(permissionsMap.get(resource2.toString())).toBe(1);
});
test('should handle empty resource list', async () => {
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: userId },
]);
const permissionsMap = await getResourcePermissionsMap({
userId,
resourceType: ResourceType.MCPSERVER,
resourceIds: [],
});
expect(permissionsMap).toBeInstanceOf(Map);
expect(permissionsMap.size).toBe(0);
});
test('should throw on invalid resource type', async () => {
const resource1 = new mongoose.Types.ObjectId();
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: userId },
]);
// Validation errors should throw immediately
await expect(
getResourcePermissionsMap({
userId,
resourceType: 'invalid_type',
resourceIds: [resource1],
}),
).rejects.toThrow('Invalid resourceType: invalid_type');
});
test('should include public permissions in batch query', async () => {
const resource1 = new mongoose.Types.ObjectId();
const resource2 = new mongoose.Types.ObjectId();
// User has VIEW | EDIT on resource1
await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.MCPSERVER,
resourceId: resource1,
accessRoleId: AccessRoleIds.MCPSERVER_EDITOR,
grantedBy: grantedById,
});
// Public has VIEW on resource2
await grantPermission({
principalType: PrincipalType.PUBLIC,
principalId: null,
resourceType: ResourceType.MCPSERVER,
resourceId: resource2,
accessRoleId: AccessRoleIds.MCPSERVER_VIEWER,
grantedBy: grantedById,
});
// Mock getUserPrincipals with user + public
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: userId },
{ principalType: PrincipalType.PUBLIC },
]);
const permissionsMap = await getResourcePermissionsMap({
userId,
resourceType: ResourceType.MCPSERVER,
resourceIds: [resource1, resource2],
});
expect(permissionsMap.size).toBe(2);
expect(permissionsMap.get(resource1.toString())).toBe(3); // VIEW | EDIT
expect(permissionsMap.get(resource2.toString())).toBe(1); // VIEW (public)
});
test('should handle large batch efficiently', async () => {
// Create 50 resources
const resources = Array.from({ length: 50 }, () => new mongoose.Types.ObjectId());
// Grant permissions to first 30 resources
for (let i = 0; i < 30; i++) {
await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.MCPSERVER,
resourceId: resources[i],
accessRoleId: AccessRoleIds.MCPSERVER_VIEWER,
grantedBy: grantedById,
});
}
// Grant group permissions to resources 20-40 (overlap)
for (let i = 20; i < 40; i++) {
await grantPermission({
principalType: PrincipalType.GROUP,
principalId: groupId,
resourceType: ResourceType.MCPSERVER,
resourceId: resources[i],
accessRoleId: AccessRoleIds.MCPSERVER_EDITOR,
grantedBy: grantedById,
});
}
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: userId },
{ principalType: PrincipalType.GROUP, principalId: groupId },
]);
const startTime = Date.now();
const permissionsMap = await getResourcePermissionsMap({
userId,
resourceType: ResourceType.MCPSERVER,
resourceIds: resources,
});
const duration = Date.now() - startTime;
// Should complete in reasonable time (under 1 second)
expect(duration).toBeLessThan(1000);
// Verify results
expect(permissionsMap.size).toBe(40); // Resources 0-39 have permissions
// Resources 0-19: USER VIEW only
for (let i = 0; i < 20; i++) {
expect(permissionsMap.get(resources[i].toString())).toBe(1); // VIEW
}
// Resources 20-29: USER VIEW | GROUP EDIT = 3
for (let i = 20; i < 30; i++) {
expect(permissionsMap.get(resources[i].toString())).toBe(3); // VIEW | EDIT
}
// Resources 30-39: GROUP EDIT = 3
for (let i = 30; i < 40; i++) {
expect(permissionsMap.get(resources[i].toString())).toBe(3); // EDIT includes VIEW
}
// Resources 40-49: No permissions
for (let i = 40; i < 50; i++) {
expect(permissionsMap.get(resources[i].toString())).toBeUndefined();
}
});
test('should work with role parameter optimization', async () => {
const resource1 = new mongoose.Types.ObjectId();
const resource2 = new mongoose.Types.ObjectId();
// Grant permissions to ADMIN role
await grantPermission({
principalType: PrincipalType.ROLE,
principalId: 'ADMIN',
resourceType: ResourceType.MCPSERVER,
resourceId: resource1,
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
grantedBy: grantedById,
});
await grantPermission({
principalType: PrincipalType.ROLE,
principalId: 'ADMIN',
resourceType: ResourceType.MCPSERVER,
resourceId: resource2,
accessRoleId: AccessRoleIds.MCPSERVER_EDITOR,
grantedBy: grantedById,
});
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: userId },
{ principalType: PrincipalType.ROLE, principalId: 'ADMIN' },
{ principalType: PrincipalType.PUBLIC },
]);
const permissionsMap = await getResourcePermissionsMap({
userId,
role: 'ADMIN',
resourceType: ResourceType.MCPSERVER,
resourceIds: [resource1, resource2],
});
expect(permissionsMap.size).toBe(2);
expect(permissionsMap.get(resource1.toString())).toBe(15); // OWNER = all bits
expect(permissionsMap.get(resource2.toString())).toBe(3); // EDIT
expect(getUserPrincipals).toHaveBeenCalledWith({ userId, role: 'ADMIN' });
});
test('should handle mixed ObjectId and string resource IDs', async () => {
const resource1 = new mongoose.Types.ObjectId();
const resource2 = new mongoose.Types.ObjectId();
await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.MCPSERVER,
resourceId: resource1,
accessRoleId: AccessRoleIds.MCPSERVER_VIEWER,
grantedBy: grantedById,
});
await grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.MCPSERVER,
resourceId: resource2,
accessRoleId: AccessRoleIds.MCPSERVER_EDITOR,
grantedBy: grantedById,
});
getUserPrincipals.mockResolvedValue([
{ principalType: PrincipalType.USER, principalId: userId },
]);
// Pass mix of ObjectId and string
const permissionsMap = await getResourcePermissionsMap({
userId,
resourceType: ResourceType.MCPSERVER,
resourceIds: [resource1, resource2.toString()],
});
expect(permissionsMap.size).toBe(2);
expect(permissionsMap.get(resource1.toString())).toBe(1);
expect(permissionsMap.get(resource2.toString())).toBe(3);
});
});
});
describe('syncUserEntraGroupMemberships - $pullAll on Group.memberIds', () => {
const {
entraIdPrincipalFeatureEnabled,
getUserEntraGroups,
} = require('~/server/services/GraphApiService');
const { Group } = require('~/db/models');
const userEntraId = 'entra-user-001';
const user = {
openidId: 'openid-sub-001',
idOnTheSource: userEntraId,
provider: 'openid',
};
beforeEach(async () => {
await Group.deleteMany({});
entraIdPrincipalFeatureEnabled.mockReturnValue(true);
});
afterEach(() => {
entraIdPrincipalFeatureEnabled.mockReturnValue(false);
getUserEntraGroups.mockResolvedValue([]);
});
it('should add user to matching Entra groups and remove from non-matching ones', async () => {
await Group.create([
{ name: 'Group A', source: 'entra', idOnTheSource: 'entra-group-a', memberIds: [] },
{
name: 'Group B',
source: 'entra',
idOnTheSource: 'entra-group-b',
memberIds: [userEntraId],
},
{
name: 'Group C',
source: 'entra',
idOnTheSource: 'entra-group-c',
memberIds: [userEntraId],
},
]);
getUserEntraGroups.mockResolvedValue(['entra-group-a', 'entra-group-c']);
await syncUserEntraGroupMemberships(user, 'fake-access-token');
const groups = await Group.find({ source: 'entra' }).sort({ name: 1 }).lean();
expect(groups[0].memberIds).toContain(userEntraId);
expect(groups[1].memberIds).not.toContain(userEntraId);
expect(groups[2].memberIds).toContain(userEntraId);
});
it('establishes the user tenant context for the sync when tenantId is present', async () => {
const { getTenantId } = require('@librechat/data-schemas');
const observed = [];
getUserEntraGroups.mockImplementation(async () => {
observed.push(getTenantId());
return [];
});
await syncUserEntraGroupMemberships({ ...user, tenantId: 'tenant-42' }, 'fake-token');
await syncUserEntraGroupMemberships(user, 'fake-token');
expect(observed).toEqual(['tenant-42', undefined]);
});
it('should not modify groups when API returns empty list (early return)', async () => {
await Group.create([
{
name: 'Group X',
source: 'entra',
idOnTheSource: 'entra-x',
memberIds: [userEntraId, 'other-user'],
},
{ name: 'Group Y', source: 'entra', idOnTheSource: 'entra-y', memberIds: [userEntraId] },
]);
getUserEntraGroups.mockResolvedValue([]);
await syncUserEntraGroupMemberships(user, 'fake-token');
const groups = await Group.find({ source: 'entra' }).sort({ name: 1 }).lean();
expect(groups[0].memberIds).toContain(userEntraId);
expect(groups[0].memberIds).toContain('other-user');
expect(groups[1].memberIds).toContain(userEntraId);
});
it('should remove user from groups not in the API response via $pullAll', async () => {
await Group.create([
{ name: 'Keep', source: 'entra', idOnTheSource: 'entra-keep', memberIds: [userEntraId] },
{
name: 'Remove',
source: 'entra',
idOnTheSource: 'entra-remove',
memberIds: [userEntraId, 'other-user'],
},
]);
getUserEntraGroups.mockResolvedValue(['entra-keep']);
await syncUserEntraGroupMemberships(user, 'fake-token');
const keep = await Group.findOne({ idOnTheSource: 'entra-keep' }).lean();
const remove = await Group.findOne({ idOnTheSource: 'entra-remove' }).lean();
expect(keep.memberIds).toContain(userEntraId);
expect(remove.memberIds).not.toContain(userEntraId);
expect(remove.memberIds).toContain('other-user');
});
it('should not modify local groups', async () => {
await Group.create([
{ name: 'Local Group', source: 'local', memberIds: [userEntraId] },
{
name: 'Entra Group',
source: 'entra',
idOnTheSource: 'entra-only',
memberIds: [userEntraId],
},
]);
getUserEntraGroups.mockResolvedValue([]);
await syncUserEntraGroupMemberships(user, 'fake-token');
const localGroup = await Group.findOne({ source: 'local' }).lean();
expect(localGroup.memberIds).toContain(userEntraId);
});
it('should early-return when feature is disabled', async () => {
entraIdPrincipalFeatureEnabled.mockReturnValue(false);
await Group.create({
name: 'Should Not Touch',
source: 'entra',
idOnTheSource: 'entra-safe',
memberIds: [userEntraId],
});
getUserEntraGroups.mockResolvedValue([]);
await syncUserEntraGroupMemberships(user, 'fake-token');
const group = await Group.findOne({ idOnTheSource: 'entra-safe' }).lean();
expect(group.memberIds).toContain(userEntraId);
});
it('should handle mix of existing and missing groups correctly (regression test)', async () => {
// This is the critical bug fix: previously syncUserEntraGroups would remove user from existing groups
// when called with only missing groups. Now using upserts + bulkUpdate to avoid this issue.
const { getEntraGroupDetailsBatch } = require('~/server/services/GraphApiService');
// Create existing groups A and B
await Group.create([
{ name: 'Group A', source: 'entra', idOnTheSource: 'entra-group-a', memberIds: [] },
{ name: 'Group B', source: 'entra', idOnTheSource: 'entra-group-b', memberIds: [] },
]);
// User is member of A, B, and C (but C doesn't exist yet)
getUserEntraGroups.mockResolvedValue(['entra-group-a', 'entra-group-b', 'entra-group-c']);
// Mock batch fetch to return details for missing group C
getEntraGroupDetailsBatch.mockResolvedValue([
{
id: 'entra-group-c',
name: 'Group C',
email: 'groupc@example.com',
description: 'Group C Description',
},
]);
await syncUserEntraGroupMemberships(user, 'fake-token');
// Verify ALL three groups now have the user as member
const groupA = await Group.findOne({ idOnTheSource: 'entra-group-a' }).lean();
const groupB = await Group.findOne({ idOnTheSource: 'entra-group-b' }).lean();
const groupC = await Group.findOne({ idOnTheSource: 'entra-group-c' }).lean();
expect(groupA.memberIds).toContain(userEntraId);
expect(groupB.memberIds).toContain(userEntraId);
expect(groupC).toBeTruthy();
expect(groupC.memberIds).toContain(userEntraId);
expect(groupC.name).toBe('Group C');
// Reset mock
getEntraGroupDetailsBatch.mockResolvedValue([]);
});
});