LibreChat/packages/data-schemas/src/methods/prompt.spec.ts
Danny Avila 7a62c42675
📦 refactor: Consolidate DB models, encapsulating Mongoose usage in data-schemas (#11830)
* chore: move database model methods to /packages/data-schemas

* chore: add TypeScript ESLint rule to warn on unused variables

* refactor: model imports to streamline access

- Consolidated model imports across various files to improve code organization and reduce redundancy.
- Updated imports for models such as Assistant, Message, Conversation, and others to a unified import path.
- Adjusted middleware and service files to reflect the new import structure, ensuring functionality remains intact.
- Enhanced test files to align with the new import paths, maintaining test coverage and integrity.

* chore: migrate database models to packages/data-schemas and refactor all direct Mongoose Model usage outside of data-schemas

* test: update agent model mocks in unit tests

- Added `getAgent` mock to `client.test.js` to enhance test coverage for agent-related functionality.
- Removed redundant `getAgent` and `getAgents` mocks from `openai.spec.js` and `responses.unit.spec.js` to streamline test setup and reduce duplication.
- Ensured consistency in agent mock implementations across test files.

* fix: update types in data-schemas

* refactor: enhance type definitions in transaction and spending methods

- Updated type definitions in `checkBalance.ts` to use specific request and response types.
- Refined `spendTokens.ts` to utilize a new `SpendTxData` interface for better clarity and type safety.
- Improved transaction handling in `transaction.ts` by introducing `TransactionResult` and `TxData` interfaces, ensuring consistent data structures across methods.
- Adjusted unit tests in `transaction.spec.ts` to accommodate new type definitions and enhance robustness.

* refactor: streamline model imports and enhance code organization

- Consolidated model imports across various controllers and services to a unified import path, improving code clarity and reducing redundancy.
- Updated multiple files to reflect the new import structure, ensuring all functionalities remain intact.
- Enhanced overall code organization by removing duplicate import statements and optimizing the usage of model methods.

* feat: implement loadAddedAgent and refactor agent loading logic

- Introduced `loadAddedAgent` function to handle loading agents from added conversations, supporting multi-convo parallel execution.
- Created a new `load.ts` file to encapsulate agent loading functionalities, including `loadEphemeralAgent` and `loadAgent`.
- Updated the `index.ts` file to export the new `load` module instead of the deprecated `loadAgent`.
- Enhanced type definitions and improved error handling in the agent loading process.
- Adjusted unit tests to reflect changes in the agent loading structure and ensure comprehensive coverage.

* refactor: enhance balance handling with new update interface

- Introduced `IBalanceUpdate` interface to streamline balance update operations across the codebase.
- Updated `upsertBalanceFields` method signatures in `balance.ts`, `transaction.ts`, and related tests to utilize the new interface for improved type safety.
- Adjusted type imports in `balance.spec.ts` to include `IBalanceUpdate`, ensuring consistency in balance management functionalities.
- Enhanced overall code clarity and maintainability by refining type definitions related to balance operations.

* feat: add unit tests for loadAgent functionality and enhance agent loading logic

- Introduced comprehensive unit tests for the `loadAgent` function, covering various scenarios including null and empty agent IDs, loading of ephemeral agents, and permission checks.
- Enhanced the `initializeClient` function by moving `getConvoFiles` to the correct position in the database method exports, ensuring proper functionality.
- Improved test coverage for agent loading, including handling of non-existent agents and user permissions.

* chore: reorder memory method exports for consistency

- Moved `deleteAllUserMemories` to the correct position in the exported memory methods, ensuring a consistent and logical order of method exports in `memory.ts`.
2026-02-21 20:47:16 -05:00

627 lines
19 KiB
TypeScript

import mongoose from 'mongoose';
import { ObjectId } from 'mongodb';
import { MongoMemoryServer } from 'mongodb-memory-server';
import {
SystemRoles,
ResourceType,
AccessRoleIds,
PrincipalType,
PermissionBits,
} from 'librechat-data-provider';
import type { IPromptGroup, AccessRole as TAccessRole, AclEntry as TAclEntry } from '..';
import { createAclEntryMethods } from './aclEntry';
import { logger, createModels } from '..';
import { createMethods } from './index';
// Disable console for tests
logger.silent = true;
/** Lean user object from .toObject() */
type LeanUser = {
_id: mongoose.Types.ObjectId | string;
name?: string;
email: string;
role?: string;
};
/** Lean group object from .toObject() */
type LeanGroup = {
_id: mongoose.Types.ObjectId | string;
name: string;
description?: string;
};
/** Lean access role object from .toObject() / .lean() */
type LeanAccessRole = TAccessRole & { _id: mongoose.Types.ObjectId | string };
/** Lean ACL entry from .lean() */
type LeanAclEntry = TAclEntry & { _id: mongoose.Types.ObjectId | string };
/** Lean prompt group from .toObject() */
type LeanPromptGroup = IPromptGroup & { _id: mongoose.Types.ObjectId | string };
let Prompt: mongoose.Model<unknown>;
let PromptGroup: mongoose.Model<unknown>;
let AclEntry: mongoose.Model<unknown>;
let AccessRole: mongoose.Model<unknown>;
let User: mongoose.Model<unknown>;
let Group: mongoose.Model<unknown>;
let methods: ReturnType<typeof createMethods>;
let aclMethods: ReturnType<typeof createAclEntryMethods>;
let testUsers: Record<string, LeanUser>;
let testGroups: Record<string, LeanGroup>;
let testRoles: Record<string, LeanAccessRole>;
let mongoServer: MongoMemoryServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
createModels(mongoose);
Prompt = mongoose.models.Prompt;
PromptGroup = mongoose.models.PromptGroup;
AclEntry = mongoose.models.AclEntry;
AccessRole = mongoose.models.AccessRole;
User = mongoose.models.User;
Group = mongoose.models.Group;
methods = createMethods(mongoose, {
removeAllPermissions: async ({ resourceType, resourceId }) => {
await AclEntry.deleteMany({ resourceType, resourceId });
},
});
aclMethods = createAclEntryMethods(mongoose);
await setupTestData();
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
async function setupTestData() {
testRoles = {
viewer: (
await AccessRole.create({
accessRoleId: AccessRoleIds.PROMPTGROUP_VIEWER,
name: 'Viewer',
description: 'Can view promptGroups',
resourceType: ResourceType.PROMPTGROUP,
permBits: PermissionBits.VIEW,
})
).toObject() as unknown as LeanAccessRole,
editor: (
await AccessRole.create({
accessRoleId: AccessRoleIds.PROMPTGROUP_EDITOR,
name: 'Editor',
description: 'Can view and edit promptGroups',
resourceType: ResourceType.PROMPTGROUP,
permBits: PermissionBits.VIEW | PermissionBits.EDIT,
})
).toObject() as unknown as LeanAccessRole,
owner: (
await AccessRole.create({
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
name: 'Owner',
description: 'Full control over promptGroups',
resourceType: ResourceType.PROMPTGROUP,
permBits:
PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE,
})
).toObject() as unknown as LeanAccessRole,
};
testUsers = {
owner: (
await User.create({
name: 'Prompt Owner',
email: 'owner@example.com',
role: SystemRoles.USER,
})
).toObject() as unknown as LeanUser,
editor: (
await User.create({
name: 'Prompt Editor',
email: 'editor@example.com',
role: SystemRoles.USER,
})
).toObject() as unknown as LeanUser,
viewer: (
await User.create({
name: 'Prompt Viewer',
email: 'viewer@example.com',
role: SystemRoles.USER,
})
).toObject() as unknown as LeanUser,
admin: (
await User.create({
name: 'Admin User',
email: 'admin@example.com',
role: SystemRoles.ADMIN,
})
).toObject() as unknown as LeanUser,
noAccess: (
await User.create({
name: 'No Access User',
email: 'noaccess@example.com',
role: SystemRoles.USER,
})
).toObject() as unknown as LeanUser,
};
testGroups = {
editors: (
await Group.create({
name: 'Prompt Editors',
description: 'Group with editor access',
})
).toObject() as unknown as LeanGroup,
viewers: (
await Group.create({
name: 'Prompt Viewers',
description: 'Group with viewer access',
})
).toObject() as unknown as LeanGroup,
};
}
/** Helper: grant permission via direct AclEntry.create */
async function grantPermission(params: {
principalType: string;
principalId: mongoose.Types.ObjectId | string;
resourceType: string;
resourceId: mongoose.Types.ObjectId | string;
accessRoleId: string;
grantedBy: mongoose.Types.ObjectId | string;
}) {
const role = (await AccessRole.findOne({
accessRoleId: params.accessRoleId,
}).lean()) as LeanAccessRole | null;
if (!role) {
throw new Error(`AccessRole ${params.accessRoleId} not found`);
}
return aclMethods.grantPermission(
params.principalType,
params.principalId,
params.resourceType,
params.resourceId,
role.permBits,
params.grantedBy,
undefined,
role._id,
);
}
/** Helper: check permission via getUserPrincipals + hasPermission */
async function checkPermission(params: {
userId: mongoose.Types.ObjectId | string;
resourceType: string;
resourceId: mongoose.Types.ObjectId | string;
requiredPermission: number;
includePublic?: boolean;
}) {
// getUserPrincipals already includes user, role, groups, and public
const principals = await methods.getUserPrincipals({
userId: params.userId,
});
// If not including public, filter it out
const filteredPrincipals = params.includePublic
? principals
: principals.filter((p) => p.principalType !== PrincipalType.PUBLIC);
return aclMethods.hasPermission(
filteredPrincipals,
params.resourceType,
params.resourceId,
params.requiredPermission,
);
}
describe('Prompt ACL Permissions', () => {
describe('Creating Prompts with Permissions', () => {
it('should grant owner permissions when creating a prompt', async () => {
const testGroup = (
await PromptGroup.create({
name: 'Test Group',
category: 'testing',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new mongoose.Types.ObjectId(),
})
).toObject() as unknown as LeanPromptGroup;
const promptData = {
prompt: {
prompt: 'Test prompt content',
name: 'Test Prompt',
type: 'text',
groupId: testGroup._id,
},
author: testUsers.owner._id,
};
await methods.savePrompt(promptData);
// Grant owner permission
await grantPermission({
principalType: PrincipalType.USER,
principalId: testUsers.owner._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: testGroup._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
grantedBy: testUsers.owner._id,
});
// Check ACL entry
const aclEntry = (await AclEntry.findOne({
resourceType: ResourceType.PROMPTGROUP,
resourceId: testGroup._id,
principalType: PrincipalType.USER,
principalId: testUsers.owner._id,
}).lean()) as LeanAclEntry | null;
expect(aclEntry).toBeTruthy();
expect(aclEntry!.permBits).toBe(testRoles.owner.permBits);
});
});
describe('Accessing Prompts', () => {
let testPromptGroup: LeanPromptGroup;
beforeEach(async () => {
testPromptGroup = (
await PromptGroup.create({
name: 'Test Group',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new ObjectId(),
})
).toObject() as unknown as LeanPromptGroup;
await Prompt.create({
prompt: 'Test prompt for access control',
name: 'Access Test Prompt',
author: testUsers.owner._id,
groupId: testPromptGroup._id,
type: 'text',
});
await grantPermission({
principalType: PrincipalType.USER,
principalId: testUsers.owner._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: testPromptGroup._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
grantedBy: testUsers.owner._id,
});
});
afterEach(async () => {
await Prompt.deleteMany({});
await PromptGroup.deleteMany({});
await AclEntry.deleteMany({});
});
it('owner should have full access to their prompt', async () => {
const hasAccess = await checkPermission({
userId: testUsers.owner._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: testPromptGroup._id,
requiredPermission: PermissionBits.VIEW,
});
expect(hasAccess).toBe(true);
const canEdit = await checkPermission({
userId: testUsers.owner._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: testPromptGroup._id,
requiredPermission: PermissionBits.EDIT,
});
expect(canEdit).toBe(true);
});
it('user with viewer role should only have view access', async () => {
await grantPermission({
principalType: PrincipalType.USER,
principalId: testUsers.viewer._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: testPromptGroup._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_VIEWER,
grantedBy: testUsers.owner._id,
});
const canView = await checkPermission({
userId: testUsers.viewer._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: testPromptGroup._id,
requiredPermission: PermissionBits.VIEW,
});
const canEdit = await checkPermission({
userId: testUsers.viewer._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: testPromptGroup._id,
requiredPermission: PermissionBits.EDIT,
});
expect(canView).toBe(true);
expect(canEdit).toBe(false);
});
it('user without permissions should have no access', async () => {
const hasAccess = await checkPermission({
userId: testUsers.noAccess._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: testPromptGroup._id,
requiredPermission: PermissionBits.VIEW,
});
expect(hasAccess).toBe(false);
});
it('admin should have access regardless of permissions', async () => {
// Admin users should work through normal permission system
// The middleware layer handles admin bypass, not the permission service
const hasAccess = await checkPermission({
userId: testUsers.admin._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: testPromptGroup._id,
requiredPermission: PermissionBits.VIEW,
});
// Without explicit permissions, even admin won't have access at this layer
expect(hasAccess).toBe(false);
// The actual admin bypass happens in the middleware layer
});
});
describe('Group-based Access', () => {
afterEach(async () => {
await Prompt.deleteMany({});
await AclEntry.deleteMany({});
await User.updateMany({}, { $set: { groups: [] } });
});
it('group members should inherit group permissions', async () => {
const testPromptGroup = (
await PromptGroup.create({
name: 'Group Test Group',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new ObjectId(),
})
).toObject() as unknown as LeanPromptGroup;
// Add user to group
await methods.addUserToGroup(testUsers.editor._id, testGroups.editors._id);
await methods.savePrompt({
author: testUsers.owner._id,
prompt: {
prompt: 'Group test prompt',
name: 'Group Test',
groupId: testPromptGroup._id,
type: 'text',
},
});
// Grant edit permissions to the group
await grantPermission({
principalType: PrincipalType.GROUP,
principalId: testGroups.editors._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: testPromptGroup._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_EDITOR,
grantedBy: testUsers.owner._id,
});
// Check if group member has access
const hasAccess = await checkPermission({
userId: testUsers.editor._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: testPromptGroup._id,
requiredPermission: PermissionBits.EDIT,
});
expect(hasAccess).toBe(true);
// Check that non-member doesn't have access
const nonMemberAccess = await checkPermission({
userId: testUsers.viewer._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: testPromptGroup._id,
requiredPermission: PermissionBits.EDIT,
});
expect(nonMemberAccess).toBe(false);
});
});
describe('Public Access', () => {
let publicPromptGroup: LeanPromptGroup;
let privatePromptGroup: LeanPromptGroup;
beforeEach(async () => {
publicPromptGroup = (
await PromptGroup.create({
name: 'Public Access Test Group',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new ObjectId(),
})
).toObject() as unknown as LeanPromptGroup;
privatePromptGroup = (
await PromptGroup.create({
name: 'Private Access Test Group',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new ObjectId(),
})
).toObject() as unknown as LeanPromptGroup;
await Prompt.create({
prompt: 'Public prompt',
name: 'Public',
author: testUsers.owner._id,
groupId: publicPromptGroup._id,
type: 'text',
});
await Prompt.create({
prompt: 'Private prompt',
name: 'Private',
author: testUsers.owner._id,
groupId: privatePromptGroup._id,
type: 'text',
});
// Grant public view access
await aclMethods.grantPermission(
PrincipalType.PUBLIC,
null,
ResourceType.PROMPTGROUP,
publicPromptGroup._id,
PermissionBits.VIEW,
testUsers.owner._id,
);
// Grant only owner access to private
await grantPermission({
principalType: PrincipalType.USER,
principalId: testUsers.owner._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: privatePromptGroup._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
grantedBy: testUsers.owner._id,
});
});
afterEach(async () => {
await Prompt.deleteMany({});
await PromptGroup.deleteMany({});
await AclEntry.deleteMany({});
});
it('public prompt should be accessible to any user', async () => {
const hasAccess = await checkPermission({
userId: testUsers.noAccess._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: publicPromptGroup._id,
requiredPermission: PermissionBits.VIEW,
includePublic: true,
});
expect(hasAccess).toBe(true);
});
it('private prompt should not be accessible to unauthorized users', async () => {
const hasAccess = await checkPermission({
userId: testUsers.noAccess._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: privatePromptGroup._id,
requiredPermission: PermissionBits.VIEW,
includePublic: true,
});
expect(hasAccess).toBe(false);
});
});
describe('Prompt Deletion', () => {
it('should remove ACL entries when prompt is deleted', async () => {
const testPromptGroup = (
await PromptGroup.create({
name: 'Deletion Test Group',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new ObjectId(),
})
).toObject() as unknown as LeanPromptGroup;
const result = await methods.savePrompt({
author: testUsers.owner._id,
prompt: {
prompt: 'To be deleted',
name: 'Delete Test',
groupId: testPromptGroup._id,
type: 'text',
},
});
const savedPrompt = result as { prompt?: { _id: mongoose.Types.ObjectId } } | null;
if (!savedPrompt?.prompt) {
throw new Error('Failed to save prompt');
}
const testPromptId = savedPrompt.prompt._id;
await grantPermission({
principalType: PrincipalType.USER,
principalId: testUsers.owner._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: testPromptGroup._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
grantedBy: testUsers.owner._id,
});
// Verify ACL entry exists
const beforeDelete = await AclEntry.find({
resourceType: ResourceType.PROMPTGROUP,
resourceId: testPromptGroup._id,
});
expect(beforeDelete).toHaveLength(1);
// Delete the prompt
await methods.deletePrompt({
promptId: testPromptId,
groupId: testPromptGroup._id,
author: testUsers.owner._id,
role: SystemRoles.USER,
});
// Verify ACL entries are removed
const aclEntries = await AclEntry.find({
resourceType: ResourceType.PROMPTGROUP,
resourceId: testPromptGroup._id,
});
expect(aclEntries).toHaveLength(0);
});
});
describe('Backwards Compatibility', () => {
it('should handle prompts without ACL entries gracefully', async () => {
const promptGroup = (
await PromptGroup.create({
name: 'Legacy Test Group',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new ObjectId(),
})
).toObject() as unknown as LeanPromptGroup;
const legacyPrompt = (
await Prompt.create({
prompt: 'Legacy prompt without ACL',
name: 'Legacy',
author: testUsers.owner._id,
groupId: promptGroup._id,
type: 'text',
})
).toObject() as { _id: mongoose.Types.ObjectId };
const prompt = (await methods.getPrompt({ _id: legacyPrompt._id })) as {
_id: mongoose.Types.ObjectId;
} | null;
expect(prompt).toBeTruthy();
expect(String(prompt!._id)).toBe(String(legacyPrompt._id));
});
});
});