LibreChat/packages/data-schemas/src/methods/role.methods.spec.ts
Danny Avila fda1bfc3cc
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
🔬 ci: Add TypeScript Type Checks to Backend Workflow and Fix All Type Errors (#12451)
* 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)
2026-03-28 21:06:39 -04:00

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();
});
});