mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-03 14:27:20 +02:00
* fix(data-schemas): resolve TypeScript strict type check errors in source files - Constrain ConfigSection to string keys via `string & keyof TCustomConfig` - Replace broken `z` import from data-provider with TCustomConfig derivation - Add `_id: Types.ObjectId` to IUser matching other Document interfaces - Add `federatedTokens` and `openidTokens` optional fields to IUser - Type mongoose model accessors as `Model<IRole>` and `Model<IUser>` - Widen `getPremiumRate` param to accept `number | null` - Widen `bulkWriteAclEntries` ops to untyped `AnyBulkWriteOperation[]` - Fix `getUserPrincipals` return type to use `PrincipalType` enum - Add non-null assertions for `connection.db` in migration files - Import DailyRotateFile constructor directly instead of relying on broken module augmentation across mismatched node_modules trees - Add winston-daily-rotate-file as devDependency for type resolution * fix(data-schemas): resolve TypeScript type errors in test files - Replace arbitrary test keys with valid TCustomConfig properties in config.spec - Use non-null assertions for permission objects in role.methods.spec - Replace `.SHARED_GLOBAL` access with `.not.toHaveProperty()` for legacy field - Add non-null assertions for balance, writeRate, readRate in spendTokens.spec - Update mock user _id to use ObjectId in user.test - Remove unused Schema import in tenantIndexes.spec * fix(api): resolve TypeScript strict type check errors across source and test files - Widen getUserPrincipals dep type in capabilities middleware - Fix federatedTokens type in createSafeUser return - Use proper mock req type for read-only properties in preAuthTenant.spec - Replace `as IUser` casts with ObjectId-typed mocks in openid/oidc specs - Use TokenExchangeMethodEnum values instead of string literals in MCP specs - Fix SessionStore type compatibility in sessionCache specs - Replace `catch (error: any)` with `(error as Error)` in redis specs - Remove invalid properties from test data in initialize and MCP specs - Add String.prototype.isWellFormed declaration for sanitizeTitle spec * fix(client): resolve TypeScript type errors in shared client components - Add default values for destructured bindings in OGDialogTemplate - Replace broken ExtendedFile import with inline type in FileIcon * ci: add TypeScript type-check job to backend review workflow Add a `typecheck` job that runs `tsc --noEmit` on all four TypeScript workspaces (data-provider, data-schemas, @librechat/api, @librechat/client) after the build step. Catches type errors that rollup builds may miss. * fix(data-schemas): add local type declaration for DailyRotateFile transport The `winston-daily-rotate-file` package ships a module augmentation for `winston/lib/winston/transports`, but it fails when winston and winston-daily-rotate-file resolve from different node_modules trees (which happens in this monorepo due to npm hoisting). Add a local `.d.ts` declaration that augments the same module path from within data-schemas' compilation unit, so `tsc --noEmit` passes while keeping the original runtime pattern (`new winston.transports.DailyRotateFile`). * fix: address code review findings from PR #12451 - Restore typed `AnyBulkWriteOperation<AclEntry>[]` on bulkWriteAclEntries, cast to untyped only at the tenantSafeBulkWrite call site (Finding 1) - Type `findUser` model accessor consistently with `findUsers` (Finding 2) - Replace inline `import('mongoose').ClientSession` with top-level import type - Use `toHaveLength` for spy assertions in playwright-expect spec file - Replace numbered Record casts with `.not.toHaveProperty()` in role.methods.spec for SHARED_GLOBAL assertions - Use per-test ObjectIds instead of shared testUserId in openid.spec - Replace inline `import()` type annotations with top-level SessionData import in sessionCache spec - Remove extraneous blank line in user.ts searchUsers * refactor: address remaining review findings (4–7) - Extract OIDCTokens interface in user.ts; deduplicate across IUser fields and oidc.ts FederatedTokens (Finding 4) - Move String.isWellFormed declaration from spec file to project-level src/types/es2024-string.d.ts (Finding 5) - Replace verbose `= undefined` defaults in OGDialogTemplate with null coalescing pattern (Finding 6) - Replace `Record<string, unknown>` TestConfig with named interface containing explicit test fields (Finding 7)
902 lines
31 KiB
TypeScript
902 lines
31 KiB
TypeScript
import mongoose from 'mongoose';
|
|
import { MongoMemoryServer } from 'mongodb-memory-server';
|
|
import { SystemRoles, Permissions, roleDefaults, PermissionTypes } from 'librechat-data-provider';
|
|
import type { IRole, IUser, RolePermissions } from '..';
|
|
import { createRoleMethods } from './role';
|
|
import { createModels } from '../models';
|
|
|
|
jest.mock('~/config/winston', () => ({
|
|
error: jest.fn(),
|
|
warn: jest.fn(),
|
|
info: jest.fn(),
|
|
debug: jest.fn(),
|
|
}));
|
|
|
|
const mockCache = {
|
|
get: jest.fn(),
|
|
set: jest.fn(),
|
|
del: jest.fn(),
|
|
};
|
|
|
|
const mockGetCache = jest.fn().mockReturnValue(mockCache);
|
|
|
|
let Role: mongoose.Model<IRole>;
|
|
let User: mongoose.Model<IUser>;
|
|
let getRoleByName: ReturnType<typeof createRoleMethods>['getRoleByName'];
|
|
let updateAccessPermissions: ReturnType<typeof createRoleMethods>['updateAccessPermissions'];
|
|
let initializeRoles: ReturnType<typeof createRoleMethods>['initializeRoles'];
|
|
let createRoleByName: ReturnType<typeof createRoleMethods>['createRoleByName'];
|
|
let deleteRoleByName: ReturnType<typeof createRoleMethods>['deleteRoleByName'];
|
|
let updateUsersByRole: ReturnType<typeof createRoleMethods>['updateUsersByRole'];
|
|
let listUsersByRole: ReturnType<typeof createRoleMethods>['listUsersByRole'];
|
|
let countUsersByRole: ReturnType<typeof createRoleMethods>['countUsersByRole'];
|
|
let updateRoleByName: ReturnType<typeof createRoleMethods>['updateRoleByName'];
|
|
let listRoles: ReturnType<typeof createRoleMethods>['listRoles'];
|
|
let countRoles: ReturnType<typeof createRoleMethods>['countRoles'];
|
|
let mongoServer: MongoMemoryServer;
|
|
|
|
beforeAll(async () => {
|
|
mongoServer = await MongoMemoryServer.create();
|
|
const mongoUri = mongoServer.getUri();
|
|
await mongoose.connect(mongoUri);
|
|
createModels(mongoose);
|
|
Role = mongoose.models.Role;
|
|
User = mongoose.models.User as mongoose.Model<IUser>;
|
|
const methods = createRoleMethods(mongoose, { getCache: mockGetCache });
|
|
getRoleByName = methods.getRoleByName;
|
|
updateAccessPermissions = methods.updateAccessPermissions;
|
|
initializeRoles = methods.initializeRoles;
|
|
createRoleByName = methods.createRoleByName;
|
|
deleteRoleByName = methods.deleteRoleByName;
|
|
updateRoleByName = methods.updateRoleByName;
|
|
updateUsersByRole = methods.updateUsersByRole;
|
|
listUsersByRole = methods.listUsersByRole;
|
|
countUsersByRole = methods.countUsersByRole;
|
|
listRoles = methods.listRoles;
|
|
countRoles = methods.countRoles;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await mongoose.disconnect();
|
|
await mongoServer.stop();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await Role.deleteMany({});
|
|
await User.deleteMany({});
|
|
mockGetCache.mockClear();
|
|
mockCache.get.mockClear();
|
|
mockCache.set.mockClear();
|
|
mockCache.del.mockClear();
|
|
});
|
|
|
|
describe('updateAccessPermissions', () => {
|
|
it('should update permissions when changes are needed', async () => {
|
|
await new Role({
|
|
name: SystemRoles.USER,
|
|
permissions: {
|
|
[PermissionTypes.PROMPTS]: {
|
|
CREATE: true,
|
|
USE: true,
|
|
SHARE: false,
|
|
},
|
|
},
|
|
}).save();
|
|
|
|
await updateAccessPermissions(SystemRoles.USER, {
|
|
[PermissionTypes.PROMPTS]: {
|
|
CREATE: true,
|
|
USE: true,
|
|
SHARE: true,
|
|
},
|
|
});
|
|
|
|
const updatedRole = await getRoleByName(SystemRoles.USER);
|
|
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
|
|
CREATE: true,
|
|
USE: true,
|
|
SHARE: true,
|
|
});
|
|
});
|
|
|
|
it('should not update permissions when no changes are needed', async () => {
|
|
await new Role({
|
|
name: SystemRoles.USER,
|
|
permissions: {
|
|
[PermissionTypes.PROMPTS]: {
|
|
CREATE: true,
|
|
USE: true,
|
|
SHARE: false,
|
|
},
|
|
},
|
|
}).save();
|
|
|
|
await updateAccessPermissions(SystemRoles.USER, {
|
|
[PermissionTypes.PROMPTS]: {
|
|
CREATE: true,
|
|
USE: true,
|
|
SHARE: false,
|
|
},
|
|
});
|
|
|
|
const updatedRole = await getRoleByName(SystemRoles.USER);
|
|
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
|
|
CREATE: true,
|
|
USE: true,
|
|
SHARE: false,
|
|
});
|
|
});
|
|
|
|
it('should handle non-existent roles', async () => {
|
|
await updateAccessPermissions('NON_EXISTENT_ROLE', {
|
|
[PermissionTypes.PROMPTS]: { CREATE: true },
|
|
});
|
|
const role = await Role.findOne({ name: 'NON_EXISTENT_ROLE' });
|
|
expect(role).toBeNull();
|
|
});
|
|
|
|
it('should update only specified permissions', async () => {
|
|
await new Role({
|
|
name: SystemRoles.USER,
|
|
permissions: {
|
|
[PermissionTypes.PROMPTS]: {
|
|
CREATE: true,
|
|
USE: true,
|
|
SHARE: false,
|
|
},
|
|
},
|
|
}).save();
|
|
|
|
await updateAccessPermissions(SystemRoles.USER, {
|
|
[PermissionTypes.PROMPTS]: { SHARE: true },
|
|
});
|
|
|
|
const updatedRole = await getRoleByName(SystemRoles.USER);
|
|
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
|
|
CREATE: true,
|
|
USE: true,
|
|
SHARE: true,
|
|
});
|
|
});
|
|
|
|
it('should handle partial updates', async () => {
|
|
await new Role({
|
|
name: SystemRoles.USER,
|
|
permissions: {
|
|
[PermissionTypes.PROMPTS]: {
|
|
CREATE: true,
|
|
USE: true,
|
|
SHARE: false,
|
|
},
|
|
},
|
|
}).save();
|
|
|
|
await updateAccessPermissions(SystemRoles.USER, {
|
|
[PermissionTypes.PROMPTS]: { USE: false },
|
|
});
|
|
|
|
const updatedRole = await getRoleByName(SystemRoles.USER);
|
|
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
|
|
CREATE: true,
|
|
USE: false,
|
|
SHARE: false,
|
|
});
|
|
});
|
|
|
|
it('should update multiple permission types at once', async () => {
|
|
await new Role({
|
|
name: SystemRoles.USER,
|
|
permissions: {
|
|
[PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARE: false },
|
|
[PermissionTypes.BOOKMARKS]: { USE: true },
|
|
},
|
|
}).save();
|
|
|
|
await updateAccessPermissions(SystemRoles.USER, {
|
|
[PermissionTypes.PROMPTS]: { USE: false, SHARE: true },
|
|
[PermissionTypes.BOOKMARKS]: { USE: false },
|
|
});
|
|
|
|
const updatedRole = await getRoleByName(SystemRoles.USER);
|
|
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
|
|
CREATE: true,
|
|
USE: false,
|
|
SHARE: true,
|
|
});
|
|
expect(updatedRole.permissions[PermissionTypes.BOOKMARKS]).toEqual({ USE: false });
|
|
});
|
|
|
|
it('should handle updates for a single permission type', async () => {
|
|
await new Role({
|
|
name: SystemRoles.USER,
|
|
permissions: {
|
|
[PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARE: false },
|
|
},
|
|
}).save();
|
|
|
|
await updateAccessPermissions(SystemRoles.USER, {
|
|
[PermissionTypes.PROMPTS]: { USE: false, SHARE: true },
|
|
});
|
|
|
|
const updatedRole = await getRoleByName(SystemRoles.USER);
|
|
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
|
|
CREATE: true,
|
|
USE: false,
|
|
SHARE: true,
|
|
});
|
|
});
|
|
|
|
it('should update MULTI_CONVO permissions', async () => {
|
|
await new Role({
|
|
name: SystemRoles.USER,
|
|
permissions: {
|
|
[PermissionTypes.MULTI_CONVO]: { USE: false },
|
|
},
|
|
}).save();
|
|
|
|
await updateAccessPermissions(SystemRoles.USER, {
|
|
[PermissionTypes.MULTI_CONVO]: { USE: true },
|
|
});
|
|
|
|
const updatedRole = await getRoleByName(SystemRoles.USER);
|
|
expect(updatedRole.permissions[PermissionTypes.MULTI_CONVO]).toEqual({ USE: true });
|
|
});
|
|
|
|
it('should update MULTI_CONVO permissions along with other permission types', async () => {
|
|
await new Role({
|
|
name: SystemRoles.USER,
|
|
permissions: {
|
|
[PermissionTypes.PROMPTS]: { CREATE: true, USE: true, SHARE: false },
|
|
[PermissionTypes.MULTI_CONVO]: { USE: false },
|
|
},
|
|
}).save();
|
|
|
|
await updateAccessPermissions(SystemRoles.USER, {
|
|
[PermissionTypes.PROMPTS]: { SHARE: true },
|
|
[PermissionTypes.MULTI_CONVO]: { USE: true },
|
|
});
|
|
|
|
const updatedRole = await getRoleByName(SystemRoles.USER);
|
|
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).toEqual({
|
|
CREATE: true,
|
|
USE: true,
|
|
SHARE: true,
|
|
});
|
|
expect(updatedRole.permissions[PermissionTypes.MULTI_CONVO]).toEqual({ USE: true });
|
|
});
|
|
|
|
it('should inherit SHARED_GLOBAL value into SHARE when SHARE is absent from both DB and update', async () => {
|
|
// Simulates the startup backfill path: caller sends SHARE_PUBLIC but not SHARE;
|
|
// migration should inherit SHARED_GLOBAL to preserve the deployment's sharing intent.
|
|
await Role.collection.insertOne({
|
|
name: SystemRoles.USER,
|
|
permissions: {
|
|
[PermissionTypes.PROMPTS]: { USE: true, CREATE: true, SHARED_GLOBAL: true },
|
|
[PermissionTypes.AGENTS]: { USE: true, CREATE: true, SHARED_GLOBAL: false },
|
|
},
|
|
});
|
|
|
|
await updateAccessPermissions(SystemRoles.USER, {
|
|
// No explicit SHARE — migration should inherit from SHARED_GLOBAL
|
|
[PermissionTypes.PROMPTS]: { SHARE_PUBLIC: false },
|
|
[PermissionTypes.AGENTS]: { SHARE_PUBLIC: false },
|
|
});
|
|
|
|
const updatedRole = await getRoleByName(SystemRoles.USER);
|
|
|
|
// SHARED_GLOBAL=true → SHARE=true (inherited)
|
|
expect(updatedRole.permissions[PermissionTypes.PROMPTS]!.SHARE).toBe(true);
|
|
// SHARED_GLOBAL=false → SHARE=false (inherited)
|
|
expect(updatedRole.permissions[PermissionTypes.AGENTS]!.SHARE).toBe(false);
|
|
// SHARED_GLOBAL cleaned up
|
|
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).not.toHaveProperty('SHARED_GLOBAL');
|
|
expect(updatedRole.permissions[PermissionTypes.AGENTS]).not.toHaveProperty('SHARED_GLOBAL');
|
|
});
|
|
|
|
it('should respect explicit SHARE in update payload and not override it with SHARED_GLOBAL', async () => {
|
|
// Caller explicitly passes SHARE: false even though SHARED_GLOBAL=true in DB.
|
|
// The explicit intent must win; migration must not silently overwrite it.
|
|
await Role.collection.insertOne({
|
|
name: SystemRoles.USER,
|
|
permissions: {
|
|
[PermissionTypes.PROMPTS]: { USE: true, SHARED_GLOBAL: true },
|
|
},
|
|
});
|
|
|
|
await updateAccessPermissions(SystemRoles.USER, {
|
|
[PermissionTypes.PROMPTS]: { SHARE: false }, // explicit false — should be preserved
|
|
});
|
|
|
|
const updatedRole = await getRoleByName(SystemRoles.USER);
|
|
|
|
expect(updatedRole.permissions[PermissionTypes.PROMPTS]!.SHARE).toBe(false);
|
|
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).not.toHaveProperty('SHARED_GLOBAL');
|
|
});
|
|
|
|
it('should migrate SHARED_GLOBAL to SHARE even when the permType is not in the update payload', async () => {
|
|
// Bug #2 regression: cleanup block removes SHARED_GLOBAL but migration block only
|
|
// runs when the permType is in the update payload. Without the fix, SHARE would be
|
|
// lost when any other permType (e.g. MULTI_CONVO) is the only thing being updated.
|
|
await Role.collection.insertOne({
|
|
name: SystemRoles.USER,
|
|
permissions: {
|
|
[PermissionTypes.PROMPTS]: {
|
|
USE: true,
|
|
SHARED_GLOBAL: true, // legacy — NO SHARE present
|
|
},
|
|
[PermissionTypes.MULTI_CONVO]: { USE: false },
|
|
},
|
|
});
|
|
|
|
// Only update MULTI_CONVO — PROMPTS is intentionally absent from the payload
|
|
await updateAccessPermissions(SystemRoles.USER, {
|
|
[PermissionTypes.MULTI_CONVO]: { USE: true },
|
|
});
|
|
|
|
const updatedRole = await getRoleByName(SystemRoles.USER);
|
|
|
|
// SHARE should have been inherited from SHARED_GLOBAL, not silently dropped
|
|
expect(updatedRole.permissions[PermissionTypes.PROMPTS]!.SHARE).toBe(true);
|
|
// SHARED_GLOBAL should be removed
|
|
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).not.toHaveProperty('SHARED_GLOBAL');
|
|
// Original USE should be untouched
|
|
expect(updatedRole.permissions[PermissionTypes.PROMPTS]!.USE).toBe(true);
|
|
// The actual update should have applied
|
|
expect(updatedRole.permissions[PermissionTypes.MULTI_CONVO]!.USE).toBe(true);
|
|
});
|
|
|
|
it('should remove orphaned SHARED_GLOBAL when SHARE already exists and permType is not in update', async () => {
|
|
// Safe cleanup case: SHARE already set, SHARED_GLOBAL is just orphaned noise.
|
|
// SHARE must not be changed; SHARED_GLOBAL must be removed.
|
|
await Role.collection.insertOne({
|
|
name: SystemRoles.USER,
|
|
permissions: {
|
|
[PermissionTypes.PROMPTS]: {
|
|
USE: true,
|
|
SHARE: true, // already migrated
|
|
SHARED_GLOBAL: true, // orphaned
|
|
},
|
|
[PermissionTypes.MULTI_CONVO]: { USE: false },
|
|
},
|
|
});
|
|
|
|
await updateAccessPermissions(SystemRoles.USER, {
|
|
[PermissionTypes.MULTI_CONVO]: { USE: true },
|
|
});
|
|
|
|
const updatedRole = await getRoleByName(SystemRoles.USER);
|
|
|
|
expect(updatedRole.permissions[PermissionTypes.PROMPTS]).not.toHaveProperty('SHARED_GLOBAL');
|
|
expect(updatedRole.permissions[PermissionTypes.PROMPTS]!.SHARE).toBe(true);
|
|
expect(updatedRole.permissions[PermissionTypes.MULTI_CONVO]!.USE).toBe(true);
|
|
});
|
|
|
|
it('should not update MULTI_CONVO permissions when no changes are needed', async () => {
|
|
await new Role({
|
|
name: SystemRoles.USER,
|
|
permissions: {
|
|
[PermissionTypes.MULTI_CONVO]: { USE: true },
|
|
},
|
|
}).save();
|
|
|
|
await updateAccessPermissions(SystemRoles.USER, {
|
|
[PermissionTypes.MULTI_CONVO]: { USE: true },
|
|
});
|
|
|
|
const updatedRole = await getRoleByName(SystemRoles.USER);
|
|
expect(updatedRole.permissions[PermissionTypes.MULTI_CONVO]).toEqual({ USE: true });
|
|
});
|
|
});
|
|
|
|
describe('initializeRoles', () => {
|
|
beforeEach(async () => {
|
|
await Role.deleteMany({});
|
|
});
|
|
|
|
it('should create default roles if they do not exist', async () => {
|
|
await initializeRoles();
|
|
|
|
const adminRole = await getRoleByName(SystemRoles.ADMIN);
|
|
const userRole = await getRoleByName(SystemRoles.USER);
|
|
|
|
expect(adminRole).toBeTruthy();
|
|
expect(userRole).toBeTruthy();
|
|
|
|
// Check if all permission types exist in the permissions field
|
|
Object.values(PermissionTypes).forEach((permType) => {
|
|
expect(adminRole.permissions[permType]).toBeDefined();
|
|
expect(userRole.permissions[permType]).toBeDefined();
|
|
});
|
|
|
|
// Example: Check default values for ADMIN role
|
|
expect(adminRole.permissions[PermissionTypes.PROMPTS]?.SHARE).toBe(true);
|
|
expect(adminRole.permissions[PermissionTypes.BOOKMARKS]?.USE).toBe(true);
|
|
expect(adminRole.permissions[PermissionTypes.AGENTS]?.CREATE).toBe(true);
|
|
});
|
|
|
|
it('should not modify existing permissions for existing roles', async () => {
|
|
const customUserRole = {
|
|
name: SystemRoles.USER,
|
|
permissions: {
|
|
[PermissionTypes.PROMPTS]: {
|
|
[Permissions.USE]: false,
|
|
[Permissions.CREATE]: true,
|
|
[Permissions.SHARE]: true,
|
|
},
|
|
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
|
},
|
|
};
|
|
|
|
await new Role(customUserRole).save();
|
|
await initializeRoles();
|
|
|
|
const userRole = await getRoleByName(SystemRoles.USER);
|
|
expect(userRole.permissions[PermissionTypes.PROMPTS]).toEqual(
|
|
customUserRole.permissions[PermissionTypes.PROMPTS],
|
|
);
|
|
expect(userRole.permissions[PermissionTypes.BOOKMARKS]).toEqual(
|
|
customUserRole.permissions[PermissionTypes.BOOKMARKS],
|
|
);
|
|
expect(userRole.permissions[PermissionTypes.AGENTS]).toBeDefined();
|
|
});
|
|
|
|
it('should add new permission types to existing roles', async () => {
|
|
const partialUserRole = {
|
|
name: SystemRoles.USER,
|
|
permissions: {
|
|
[PermissionTypes.PROMPTS]:
|
|
roleDefaults[SystemRoles.USER].permissions[PermissionTypes.PROMPTS],
|
|
[PermissionTypes.BOOKMARKS]:
|
|
roleDefaults[SystemRoles.USER].permissions[PermissionTypes.BOOKMARKS],
|
|
},
|
|
};
|
|
|
|
await new Role(partialUserRole).save();
|
|
await initializeRoles();
|
|
|
|
const userRole = await getRoleByName(SystemRoles.USER);
|
|
expect(userRole.permissions[PermissionTypes.AGENTS]).toBeDefined();
|
|
expect(userRole.permissions[PermissionTypes.AGENTS]?.CREATE).toBeDefined();
|
|
expect(userRole.permissions[PermissionTypes.AGENTS]?.USE).toBeDefined();
|
|
expect(userRole.permissions[PermissionTypes.AGENTS]?.SHARE).toBeDefined();
|
|
});
|
|
|
|
it('should handle multiple runs without duplicating or modifying data', async () => {
|
|
await initializeRoles();
|
|
await initializeRoles();
|
|
|
|
const adminRoles = await Role.find({ name: SystemRoles.ADMIN });
|
|
const userRoles = await Role.find({ name: SystemRoles.USER });
|
|
|
|
expect(adminRoles).toHaveLength(1);
|
|
expect(userRoles).toHaveLength(1);
|
|
|
|
const adminPerms = adminRoles[0].toObject().permissions as RolePermissions;
|
|
const userPerms = userRoles[0].toObject().permissions as RolePermissions;
|
|
Object.values(PermissionTypes).forEach((permType) => {
|
|
expect(adminPerms[permType]).toBeDefined();
|
|
expect(userPerms[permType]).toBeDefined();
|
|
});
|
|
});
|
|
|
|
it('should update roles with missing permission types from roleDefaults', async () => {
|
|
const partialAdminRole = {
|
|
name: SystemRoles.ADMIN,
|
|
permissions: {
|
|
[PermissionTypes.PROMPTS]: {
|
|
[Permissions.USE]: false,
|
|
[Permissions.CREATE]: false,
|
|
[Permissions.SHARE]: false,
|
|
},
|
|
[PermissionTypes.BOOKMARKS]:
|
|
roleDefaults[SystemRoles.ADMIN].permissions[PermissionTypes.BOOKMARKS],
|
|
},
|
|
};
|
|
|
|
await new Role(partialAdminRole).save();
|
|
await initializeRoles();
|
|
|
|
const adminRole = await getRoleByName(SystemRoles.ADMIN);
|
|
expect(adminRole.permissions[PermissionTypes.PROMPTS]).toEqual(
|
|
partialAdminRole.permissions[PermissionTypes.PROMPTS],
|
|
);
|
|
expect(adminRole.permissions[PermissionTypes.AGENTS]).toBeDefined();
|
|
expect(adminRole.permissions[PermissionTypes.AGENTS]?.CREATE).toBeDefined();
|
|
expect(adminRole.permissions[PermissionTypes.AGENTS]?.USE).toBeDefined();
|
|
expect(adminRole.permissions[PermissionTypes.AGENTS]?.SHARE).toBeDefined();
|
|
});
|
|
|
|
it('should include MULTI_CONVO permissions when creating default roles', async () => {
|
|
await initializeRoles();
|
|
|
|
const adminRole = await getRoleByName(SystemRoles.ADMIN);
|
|
const userRole = await getRoleByName(SystemRoles.USER);
|
|
|
|
expect(adminRole.permissions[PermissionTypes.MULTI_CONVO]).toBeDefined();
|
|
expect(userRole.permissions[PermissionTypes.MULTI_CONVO]).toBeDefined();
|
|
expect(adminRole.permissions[PermissionTypes.MULTI_CONVO]?.USE).toBe(
|
|
roleDefaults[SystemRoles.ADMIN].permissions[PermissionTypes.MULTI_CONVO].USE,
|
|
);
|
|
expect(userRole.permissions[PermissionTypes.MULTI_CONVO]?.USE).toBe(
|
|
roleDefaults[SystemRoles.USER].permissions[PermissionTypes.MULTI_CONVO].USE,
|
|
);
|
|
});
|
|
|
|
it('should add MULTI_CONVO permissions to existing roles without them', async () => {
|
|
const partialUserRole = {
|
|
name: SystemRoles.USER,
|
|
permissions: {
|
|
[PermissionTypes.PROMPTS]:
|
|
roleDefaults[SystemRoles.USER].permissions[PermissionTypes.PROMPTS],
|
|
[PermissionTypes.BOOKMARKS]:
|
|
roleDefaults[SystemRoles.USER].permissions[PermissionTypes.BOOKMARKS],
|
|
},
|
|
};
|
|
|
|
await new Role(partialUserRole).save();
|
|
await initializeRoles();
|
|
|
|
const userRole = await getRoleByName(SystemRoles.USER);
|
|
expect(userRole.permissions[PermissionTypes.MULTI_CONVO]).toBeDefined();
|
|
expect(userRole.permissions[PermissionTypes.MULTI_CONVO]?.USE).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('createRoleByName', () => {
|
|
it('creates a custom role and caches it', async () => {
|
|
const role = await createRoleByName({ name: 'editor', description: 'Can edit' });
|
|
|
|
expect(role.name).toBe('editor');
|
|
expect(role.description).toBe('Can edit');
|
|
expect(mockCache.set).toHaveBeenCalledWith(
|
|
'editor',
|
|
expect.objectContaining({ name: 'editor' }),
|
|
);
|
|
|
|
const persisted = await Role.findOne({ name: 'editor' }).lean();
|
|
expect(persisted).toBeTruthy();
|
|
});
|
|
|
|
it('trims whitespace from role name', async () => {
|
|
const role = await createRoleByName({ name: ' editor ' });
|
|
|
|
expect(role.name).toBe('editor');
|
|
});
|
|
|
|
it('throws when name is empty', async () => {
|
|
await expect(createRoleByName({ name: '' })).rejects.toThrow('Role name is required');
|
|
});
|
|
|
|
it('throws when name is whitespace-only', async () => {
|
|
await expect(createRoleByName({ name: ' ' })).rejects.toThrow('Role name is required');
|
|
});
|
|
|
|
it('throws when name is undefined', async () => {
|
|
await expect(createRoleByName({})).rejects.toThrow('Role name is required');
|
|
});
|
|
|
|
it('throws for reserved system role names', async () => {
|
|
await expect(createRoleByName({ name: SystemRoles.ADMIN })).rejects.toThrow(
|
|
/reserved system name/,
|
|
);
|
|
await expect(createRoleByName({ name: SystemRoles.USER })).rejects.toThrow(
|
|
/reserved system name/,
|
|
);
|
|
});
|
|
|
|
it('throws when role already exists', async () => {
|
|
await createRoleByName({ name: 'editor' });
|
|
|
|
await expect(createRoleByName({ name: 'editor' })).rejects.toThrow(/already exists/);
|
|
});
|
|
});
|
|
|
|
describe('deleteRoleByName', () => {
|
|
it('deletes a custom role and reassigns users to USER', async () => {
|
|
await createRoleByName({ name: 'editor' });
|
|
await User.create([
|
|
{ name: 'Alice', email: 'alice@test.com', role: 'editor', username: 'alice' },
|
|
{ name: 'Bob', email: 'bob@test.com', role: 'editor', username: 'bob' },
|
|
{ name: 'Carol', email: 'carol@test.com', role: SystemRoles.USER, username: 'carol' },
|
|
]);
|
|
|
|
const deleted = await deleteRoleByName('editor');
|
|
|
|
expect(deleted).toBeTruthy();
|
|
expect(deleted!.name).toBe('editor');
|
|
|
|
const alice = await User.findOne({ email: 'alice@test.com' }).lean();
|
|
const bob = await User.findOne({ email: 'bob@test.com' }).lean();
|
|
const carol = await User.findOne({ email: 'carol@test.com' }).lean();
|
|
expect(alice!.role).toBe(SystemRoles.USER);
|
|
expect(bob!.role).toBe(SystemRoles.USER);
|
|
expect(carol!.role).toBe(SystemRoles.USER);
|
|
});
|
|
|
|
it('returns null when role does not exist', async () => {
|
|
const result = await deleteRoleByName('nonexistent');
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('throws for system roles', async () => {
|
|
await expect(deleteRoleByName(SystemRoles.ADMIN)).rejects.toThrow(/Cannot delete system role/);
|
|
await expect(deleteRoleByName(SystemRoles.USER)).rejects.toThrow(/Cannot delete system role/);
|
|
});
|
|
|
|
it('sets cache entry to null after deletion', async () => {
|
|
await createRoleByName({ name: 'editor' });
|
|
mockCache.set.mockClear();
|
|
|
|
await deleteRoleByName('editor');
|
|
|
|
expect(mockCache.set).toHaveBeenCalledWith('editor', null);
|
|
});
|
|
|
|
it('returns null and invalidates cache when role does not exist', async () => {
|
|
mockCache.set.mockClear();
|
|
|
|
const result = await deleteRoleByName('nonexistent');
|
|
|
|
expect(result).toBeNull();
|
|
expect(mockCache.set).toHaveBeenCalledWith('nonexistent', null);
|
|
});
|
|
});
|
|
|
|
describe('updateRoleByName - cache on rename', () => {
|
|
it('invalidates old key and populates new key on rename', async () => {
|
|
await createRoleByName({ name: 'editor', description: 'Can edit' });
|
|
mockCache.set.mockClear();
|
|
|
|
const updated = await updateRoleByName('editor', { name: 'senior-editor' });
|
|
|
|
expect(updated.name).toBe('senior-editor');
|
|
expect(mockCache.set).toHaveBeenCalledWith('editor', null);
|
|
expect(mockCache.set).toHaveBeenCalledWith(
|
|
'senior-editor',
|
|
expect.objectContaining({ name: 'senior-editor' }),
|
|
);
|
|
});
|
|
|
|
it('writes same key when name unchanged', async () => {
|
|
await createRoleByName({ name: 'editor' });
|
|
mockCache.set.mockClear();
|
|
|
|
await updateRoleByName('editor', { description: 'Updated desc' });
|
|
|
|
expect(mockCache.set).toHaveBeenCalledWith(
|
|
'editor',
|
|
expect.objectContaining({ name: 'editor', description: 'Updated desc' }),
|
|
);
|
|
expect(mockCache.set).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('listUsersByRole', () => {
|
|
it('returns users matching the role', async () => {
|
|
await User.create([
|
|
{ name: 'Alice', email: 'alice@test.com', role: 'editor', username: 'alice' },
|
|
{ name: 'Bob', email: 'bob@test.com', role: 'editor', username: 'bob' },
|
|
{ name: 'Carol', email: 'carol@test.com', role: SystemRoles.USER, username: 'carol' },
|
|
]);
|
|
|
|
const users = await listUsersByRole('editor');
|
|
|
|
expect(users).toHaveLength(2);
|
|
const names = users.map((u) => u.name).sort();
|
|
expect(names).toEqual(['Alice', 'Bob']);
|
|
});
|
|
|
|
it('returns empty array when no users have the role', async () => {
|
|
const users = await listUsersByRole('nonexistent');
|
|
expect(users).toEqual([]);
|
|
});
|
|
|
|
it('respects limit and offset for pagination', async () => {
|
|
await User.create([
|
|
{ name: 'Alice', email: 'a@test.com', role: 'editor', username: 'a' },
|
|
{ name: 'Bob', email: 'b@test.com', role: 'editor', username: 'b' },
|
|
{ name: 'Carol', email: 'c@test.com', role: 'editor', username: 'c' },
|
|
{ name: 'Dave', email: 'd@test.com', role: 'editor', username: 'd' },
|
|
{ name: 'Eve', email: 'e@test.com', role: 'editor', username: 'e' },
|
|
]);
|
|
|
|
const page1 = await listUsersByRole('editor', { limit: 2, offset: 0 });
|
|
const page2 = await listUsersByRole('editor', { limit: 2, offset: 2 });
|
|
const page3 = await listUsersByRole('editor', { limit: 2, offset: 4 });
|
|
|
|
expect(page1).toHaveLength(2);
|
|
expect(page2).toHaveLength(2);
|
|
expect(page3).toHaveLength(1);
|
|
|
|
const allIds = [...page1, ...page2, ...page3].map((u) => u._id!.toString());
|
|
expect(new Set(allIds).size).toBe(5);
|
|
});
|
|
|
|
it('selects only expected fields', async () => {
|
|
await User.create({
|
|
name: 'Alice',
|
|
email: 'alice@test.com',
|
|
role: 'editor',
|
|
username: 'alice',
|
|
password: 'secret123',
|
|
});
|
|
|
|
const users = await listUsersByRole('editor');
|
|
|
|
expect(users).toHaveLength(1);
|
|
expect(users[0].name).toBe('Alice');
|
|
expect(users[0].email).toBe('alice@test.com');
|
|
expect(users[0]._id).toBeDefined();
|
|
expect('password' in users[0]).toBe(false);
|
|
expect('username' in users[0]).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('updateUsersByRole', () => {
|
|
it('migrates all users from one role to another', async () => {
|
|
await User.create([
|
|
{ name: 'Alice', email: 'alice@test.com', role: 'editor', username: 'alice' },
|
|
{ name: 'Bob', email: 'bob@test.com', role: 'editor', username: 'bob' },
|
|
{ name: 'Carol', email: 'carol@test.com', role: SystemRoles.USER, username: 'carol' },
|
|
]);
|
|
|
|
await updateUsersByRole('editor', 'senior-editor');
|
|
|
|
const alice = await User.findOne({ email: 'alice@test.com' }).lean();
|
|
const bob = await User.findOne({ email: 'bob@test.com' }).lean();
|
|
const carol = await User.findOne({ email: 'carol@test.com' }).lean();
|
|
expect(alice!.role).toBe('senior-editor');
|
|
expect(bob!.role).toBe('senior-editor');
|
|
expect(carol!.role).toBe(SystemRoles.USER);
|
|
});
|
|
|
|
it('is a no-op when no users have the source role', async () => {
|
|
await User.create({
|
|
name: 'Alice',
|
|
email: 'alice@test.com',
|
|
role: SystemRoles.USER,
|
|
username: 'alice',
|
|
});
|
|
|
|
await updateUsersByRole('nonexistent', 'new-role');
|
|
|
|
const alice = await User.findOne({ email: 'alice@test.com' }).lean();
|
|
expect(alice!.role).toBe(SystemRoles.USER);
|
|
});
|
|
});
|
|
|
|
describe('countUsersByRole', () => {
|
|
it('returns the count of users with the given role', async () => {
|
|
await User.create([
|
|
{ name: 'Alice', email: 'alice@test.com', role: 'editor', username: 'alice' },
|
|
{ name: 'Bob', email: 'bob@test.com', role: 'editor', username: 'bob' },
|
|
{ name: 'Carol', email: 'carol@test.com', role: SystemRoles.USER, username: 'carol' },
|
|
]);
|
|
|
|
expect(await countUsersByRole('editor')).toBe(2);
|
|
expect(await countUsersByRole(SystemRoles.USER)).toBe(1);
|
|
});
|
|
|
|
it('returns 0 when no users have the role', async () => {
|
|
expect(await countUsersByRole('nonexistent')).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('listRoles', () => {
|
|
beforeEach(async () => {
|
|
await Role.deleteMany({});
|
|
});
|
|
|
|
it('returns roles sorted alphabetically by name', async () => {
|
|
await Role.create([
|
|
{ name: 'zebra', permissions: {} },
|
|
{ name: 'alpha', permissions: {} },
|
|
{ name: 'middle', permissions: {} },
|
|
]);
|
|
|
|
const roles = await listRoles();
|
|
|
|
expect(roles.map((r) => r.name)).toEqual(['alpha', 'middle', 'zebra']);
|
|
});
|
|
|
|
it('respects limit and offset for pagination', async () => {
|
|
await Role.create([
|
|
{ name: 'a-role', permissions: {} },
|
|
{ name: 'b-role', permissions: {} },
|
|
{ name: 'c-role', permissions: {} },
|
|
{ name: 'd-role', permissions: {} },
|
|
{ name: 'e-role', permissions: {} },
|
|
]);
|
|
|
|
const page1 = await listRoles({ limit: 2, offset: 0 });
|
|
const page2 = await listRoles({ limit: 2, offset: 2 });
|
|
const page3 = await listRoles({ limit: 2, offset: 4 });
|
|
|
|
expect(page1).toHaveLength(2);
|
|
expect(page1.map((r) => r.name)).toEqual(['a-role', 'b-role']);
|
|
expect(page2).toHaveLength(2);
|
|
expect(page2.map((r) => r.name)).toEqual(['c-role', 'd-role']);
|
|
expect(page3).toHaveLength(1);
|
|
expect(page3.map((r) => r.name)).toEqual(['e-role']);
|
|
});
|
|
|
|
it('defaults to limit 50 and offset 0', async () => {
|
|
await Role.create({ name: 'only-role', permissions: {} });
|
|
|
|
const roles = await listRoles();
|
|
|
|
expect(roles).toHaveLength(1);
|
|
expect(roles[0].name).toBe('only-role');
|
|
});
|
|
|
|
it('returns only name and description fields', async () => {
|
|
await Role.create({
|
|
name: 'editor',
|
|
description: 'Can edit',
|
|
permissions: { PROMPTS: { USE: true } },
|
|
});
|
|
|
|
const roles = await listRoles();
|
|
|
|
expect(roles).toHaveLength(1);
|
|
expect(roles[0].name).toBe('editor');
|
|
expect(roles[0].description).toBe('Can edit');
|
|
expect(roles[0]._id).toBeDefined();
|
|
expect('permissions' in roles[0]).toBe(false);
|
|
});
|
|
|
|
it('returns empty array when no roles exist', async () => {
|
|
const roles = await listRoles();
|
|
expect(roles).toEqual([]);
|
|
});
|
|
|
|
it('returns undefined description for pre-existing roles without the field', async () => {
|
|
await Role.collection.insertOne({ name: 'legacy', permissions: {} });
|
|
|
|
const roles = await listRoles();
|
|
|
|
expect(roles).toHaveLength(1);
|
|
expect(roles[0].name).toBe('legacy');
|
|
expect(roles[0].description).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('countRoles', () => {
|
|
beforeEach(async () => {
|
|
await Role.deleteMany({});
|
|
});
|
|
|
|
it('returns the total number of roles', async () => {
|
|
await Role.create([
|
|
{ name: 'a', permissions: {} },
|
|
{ name: 'b', permissions: {} },
|
|
{ name: 'c', permissions: {} },
|
|
]);
|
|
|
|
expect(await countRoles()).toBe(3);
|
|
});
|
|
|
|
it('returns 0 when no roles exist', async () => {
|
|
expect(await countRoles()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('createRoleByName - duplicate key race', () => {
|
|
beforeEach(async () => {
|
|
await Role.deleteMany({});
|
|
});
|
|
|
|
it('throws RoleConflictError on concurrent insert (11000)', async () => {
|
|
await createRoleByName({ name: 'editor' });
|
|
|
|
const insertSpy = jest.spyOn(Role.prototype, 'save').mockImplementationOnce(() => {
|
|
const err = new Error('E11000 duplicate key error') as Error & { code: number };
|
|
err.code = 11000;
|
|
throw err;
|
|
});
|
|
|
|
await expect(createRoleByName({ name: 'editor2' })).rejects.toThrow(/already exists/);
|
|
|
|
insertSpy.mockRestore();
|
|
});
|
|
});
|