mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-11 16:53:45 +00:00
* 🛡️ fix: restrict system grants to role principals only Narrows GrantPrincipalType to PrincipalType.ROLE, rejecting GROUP and USER with 400. Removes grant cascade cleanup from group/user deletion handlers and their route wiring since only roles can hold grants. * 🛡️ fix: address review findings for grants roles-only restriction Add missing GROUP rejection test for revokeGrant (symmetric with getPrincipalGrants and assignGrant coverage), add extensibility comment to GrantPrincipalType, and document the checkRoleExists guard.
505 lines
19 KiB
TypeScript
505 lines
19 KiB
TypeScript
import { Types } from 'mongoose';
|
|
import { PrincipalType, SystemRoles } from 'librechat-data-provider';
|
|
import type { IUser, UserDeleteResult } from '@librechat/data-schemas';
|
|
import type { Response } from 'express';
|
|
import type { ServerRequest } from '~/types/http';
|
|
import type { AdminUsersDeps } from './users';
|
|
import { createAdminUsersHandlers } from './users';
|
|
|
|
jest.mock('@librechat/data-schemas', () => ({
|
|
...jest.requireActual('@librechat/data-schemas'),
|
|
logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() },
|
|
}));
|
|
|
|
const validUserId = new Types.ObjectId().toString();
|
|
|
|
function mockUser(overrides: Partial<IUser> = {}): IUser {
|
|
return {
|
|
_id: new Types.ObjectId(),
|
|
name: 'Test User',
|
|
username: 'testuser',
|
|
email: 'test@example.com',
|
|
avatar: 'https://example.com/avatar.png',
|
|
role: 'USER',
|
|
provider: 'local',
|
|
createdAt: new Date('2025-01-01'),
|
|
updatedAt: new Date('2025-06-01'),
|
|
...overrides,
|
|
} as IUser;
|
|
}
|
|
|
|
function createReqRes(
|
|
overrides: {
|
|
params?: Record<string, string>;
|
|
query?: Record<string, string | string[]>;
|
|
user?: { _id?: Types.ObjectId; id?: string; role?: string; tenantId?: string };
|
|
} = {},
|
|
) {
|
|
const req = {
|
|
params: overrides.params ?? {},
|
|
query: overrides.query ?? {},
|
|
body: {},
|
|
user: overrides.user ?? { _id: new Types.ObjectId(), role: 'admin' },
|
|
} as unknown as ServerRequest;
|
|
|
|
const json = jest.fn();
|
|
const status = jest.fn().mockReturnValue({ json });
|
|
const res = { status, json } as unknown as Response;
|
|
|
|
return { req, res, status, json };
|
|
}
|
|
|
|
function createDeps(overrides: Partial<AdminUsersDeps> = {}): AdminUsersDeps {
|
|
return {
|
|
findUsers: jest.fn().mockResolvedValue([]),
|
|
countUsers: jest.fn().mockResolvedValue(0),
|
|
deleteUserById: jest
|
|
.fn()
|
|
.mockResolvedValue({ deletedCount: 1, message: 'User was deleted successfully.' }),
|
|
deleteConfig: jest.fn().mockResolvedValue(null),
|
|
deleteAclEntries: jest.fn().mockResolvedValue(undefined),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('createAdminUsersHandlers', () => {
|
|
describe('listUsers', () => {
|
|
it('returns paginated users with total count', async () => {
|
|
const users = [
|
|
mockUser({ _id: new Types.ObjectId(validUserId) }),
|
|
mockUser({ name: 'Other' }),
|
|
];
|
|
const deps = createDeps({
|
|
findUsers: jest.fn().mockResolvedValue(users),
|
|
countUsers: jest.fn().mockResolvedValue(2),
|
|
});
|
|
const handlers = createAdminUsersHandlers(deps);
|
|
const { req, res, status, json } = createReqRes();
|
|
|
|
await handlers.listUsers(req, res);
|
|
|
|
expect(status).toHaveBeenCalledWith(200);
|
|
const response = json.mock.calls[0][0];
|
|
expect(response.users).toHaveLength(2);
|
|
expect(response.total).toBe(2);
|
|
expect(response).toHaveProperty('limit');
|
|
expect(response).toHaveProperty('offset');
|
|
expect(response.users[0]).toHaveProperty('id');
|
|
expect(response.users[0]).toHaveProperty('name');
|
|
expect(response.users[0]).toHaveProperty('email');
|
|
expect(response.users[0]).toHaveProperty('role');
|
|
});
|
|
|
|
it('passes pagination params to findUsers and unfiltered count', async () => {
|
|
const findUsers = jest.fn().mockResolvedValue([]);
|
|
const countUsers = jest.fn().mockResolvedValue(0);
|
|
const deps = createDeps({ findUsers, countUsers });
|
|
const handlers = createAdminUsersHandlers(deps);
|
|
const { req, res } = createReqRes({ query: { limit: '10', offset: '20' } });
|
|
|
|
await handlers.listUsers(req, res);
|
|
|
|
expect(findUsers).toHaveBeenCalledWith({}, expect.any(String), {
|
|
limit: 10,
|
|
offset: 20,
|
|
sort: { createdAt: -1 },
|
|
});
|
|
expect(countUsers).toHaveBeenCalledWith();
|
|
});
|
|
|
|
it('returns empty list when no users', async () => {
|
|
const deps = createDeps();
|
|
const handlers = createAdminUsersHandlers(deps);
|
|
const { req, res, status, json } = createReqRes();
|
|
|
|
await handlers.listUsers(req, res);
|
|
|
|
expect(status).toHaveBeenCalledWith(200);
|
|
expect(json.mock.calls[0][0].users).toEqual([]);
|
|
expect(json.mock.calls[0][0].total).toBe(0);
|
|
});
|
|
|
|
it('returns 500 when findUsers throws', async () => {
|
|
const deps = createDeps({ findUsers: jest.fn().mockRejectedValue(new Error('db down')) });
|
|
const handlers = createAdminUsersHandlers(deps);
|
|
const { req, res, status, json } = createReqRes();
|
|
|
|
await handlers.listUsers(req, res);
|
|
|
|
expect(status).toHaveBeenCalledWith(500);
|
|
expect(json).toHaveBeenCalledWith({ error: 'Failed to list users' });
|
|
});
|
|
|
|
it('returns 500 when countUsers throws', async () => {
|
|
const deps = createDeps({
|
|
countUsers: jest.fn().mockRejectedValue(new Error('count failed')),
|
|
});
|
|
const handlers = createAdminUsersHandlers(deps);
|
|
const { req, res, status, json } = createReqRes();
|
|
|
|
await handlers.listUsers(req, res);
|
|
|
|
expect(status).toHaveBeenCalledWith(500);
|
|
expect(json).toHaveBeenCalledWith({ error: 'Failed to list users' });
|
|
});
|
|
});
|
|
|
|
describe('searchUsers', () => {
|
|
it('returns matching users with total and capped flag', async () => {
|
|
const users = [mockUser()];
|
|
const deps = createDeps({ findUsers: jest.fn().mockResolvedValue(users) });
|
|
const handlers = createAdminUsersHandlers(deps);
|
|
const { req, res, status, json } = createReqRes({ query: { q: 'test' } });
|
|
|
|
await handlers.searchUsers(req, res);
|
|
|
|
expect(status).toHaveBeenCalledWith(200);
|
|
const response = json.mock.calls[0][0];
|
|
expect(response.users).toHaveLength(1);
|
|
expect(response.total).toBe(1);
|
|
expect(response.capped).toBe(false);
|
|
expect(response.users[0]).toHaveProperty('id');
|
|
expect(response.users[0]).toHaveProperty('name');
|
|
expect(response.users[0]).toHaveProperty('email');
|
|
expect(response.users[0]).toHaveProperty('username');
|
|
});
|
|
|
|
it('sets capped to true when results hit the limit', async () => {
|
|
const users = Array.from({ length: 20 }, () => mockUser());
|
|
const deps = createDeps({ findUsers: jest.fn().mockResolvedValue(users) });
|
|
const handlers = createAdminUsersHandlers(deps);
|
|
const { req, res, json } = createReqRes({ query: { q: 'test', limit: '20' } });
|
|
|
|
await handlers.searchUsers(req, res);
|
|
|
|
const response = json.mock.calls[0][0];
|
|
expect(response.total).toBe(20);
|
|
expect(response.capped).toBe(true);
|
|
});
|
|
|
|
it('searches name, email, and username with anchored prefix regex', async () => {
|
|
const findUsers = jest.fn().mockResolvedValue([]);
|
|
const deps = createDeps({ findUsers });
|
|
const handlers = createAdminUsersHandlers(deps);
|
|
const { req, res } = createReqRes({ query: { q: 'test' } });
|
|
|
|
await handlers.searchUsers(req, res);
|
|
|
|
const filter = findUsers.mock.calls[0][0];
|
|
expect(filter.$or).toHaveLength(3);
|
|
expect(filter.$or[0]).toHaveProperty('name');
|
|
expect(filter.$or[1]).toHaveProperty('email');
|
|
expect(filter.$or[2]).toHaveProperty('username');
|
|
expect(filter.$or[0].name.source).toBe('^test');
|
|
});
|
|
|
|
it('projects username in the field selection', async () => {
|
|
const findUsers = jest.fn().mockResolvedValue([]);
|
|
const deps = createDeps({ findUsers });
|
|
const handlers = createAdminUsersHandlers(deps);
|
|
const { req, res } = createReqRes({ query: { q: 'test' } });
|
|
|
|
await handlers.searchUsers(req, res);
|
|
|
|
const projection = findUsers.mock.calls[0][1];
|
|
expect(projection).toContain('username');
|
|
});
|
|
|
|
it('escapes regex special characters in query', async () => {
|
|
const findUsers = jest.fn().mockResolvedValue([]);
|
|
const deps = createDeps({ findUsers });
|
|
const handlers = createAdminUsersHandlers(deps);
|
|
const { req, res } = createReqRes({ query: { q: 'test.user+1' } });
|
|
|
|
await handlers.searchUsers(req, res);
|
|
|
|
const filter = findUsers.mock.calls[0][0];
|
|
expect(filter.$or[0].name).toBeInstanceOf(RegExp);
|
|
expect(filter.$or[0].name.source).toBe('^test\\.user\\+1');
|
|
});
|
|
|
|
it('returns 400 when query is missing', async () => {
|
|
const deps = createDeps();
|
|
const handlers = createAdminUsersHandlers(deps);
|
|
const { req, res, status, json } = createReqRes({ query: {} });
|
|
|
|
await handlers.searchUsers(req, res);
|
|
|
|
expect(status).toHaveBeenCalledWith(400);
|
|
expect(json).toHaveBeenCalledWith({ error: 'Query parameter "q" is required' });
|
|
});
|
|
|
|
it('returns 400 when query is empty string', async () => {
|
|
const deps = createDeps();
|
|
const handlers = createAdminUsersHandlers(deps);
|
|
const { req, res, status, json } = createReqRes({ query: { q: '' } });
|
|
|
|
await handlers.searchUsers(req, res);
|
|
|
|
expect(status).toHaveBeenCalledWith(400);
|
|
expect(json).toHaveBeenCalledWith({ error: 'Query parameter "q" is required' });
|
|
});
|
|
|
|
it('returns 400 when query is whitespace-only', async () => {
|
|
const deps = createDeps();
|
|
const handlers = createAdminUsersHandlers(deps);
|
|
const { req, res, status, json } = createReqRes({ query: { q: ' ' } });
|
|
|
|
await handlers.searchUsers(req, res);
|
|
|
|
expect(status).toHaveBeenCalledWith(400);
|
|
expect(json).toHaveBeenCalledWith({ error: 'Query parameter "q" is required' });
|
|
});
|
|
|
|
it('returns 400 when query is too short', async () => {
|
|
const deps = createDeps();
|
|
const handlers = createAdminUsersHandlers(deps);
|
|
const { req, res, status, json } = createReqRes({ query: { q: 'a' } });
|
|
|
|
await handlers.searchUsers(req, res);
|
|
|
|
expect(status).toHaveBeenCalledWith(400);
|
|
expect(json).toHaveBeenCalledWith({ error: 'Query must be at least 2 characters' });
|
|
});
|
|
|
|
it('returns 400 when query exceeds max length', async () => {
|
|
const deps = createDeps();
|
|
const handlers = createAdminUsersHandlers(deps);
|
|
const { req, res, status, json } = createReqRes({ query: { q: 'a'.repeat(201) } });
|
|
|
|
await handlers.searchUsers(req, res);
|
|
|
|
expect(status).toHaveBeenCalledWith(400);
|
|
expect(json).toHaveBeenCalledWith(
|
|
expect.objectContaining({ error: expect.stringContaining('200') }),
|
|
);
|
|
});
|
|
|
|
it('treats array query param as missing', async () => {
|
|
const deps = createDeps();
|
|
const handlers = createAdminUsersHandlers(deps);
|
|
const { req, res, status, json } = createReqRes({ query: { q: ['foo', 'bar'] } });
|
|
|
|
await handlers.searchUsers(req, res);
|
|
|
|
expect(status).toHaveBeenCalledWith(400);
|
|
expect(json).toHaveBeenCalledWith({ error: 'Query parameter "q" is required' });
|
|
});
|
|
|
|
it('passes limit to findUsers', async () => {
|
|
const findUsers = jest.fn().mockResolvedValue([mockUser()]);
|
|
const deps = createDeps({ findUsers });
|
|
const handlers = createAdminUsersHandlers(deps);
|
|
const { req, res } = createReqRes({ query: { q: 'User', limit: '3' } });
|
|
|
|
await handlers.searchUsers(req, res);
|
|
|
|
expect(findUsers).toHaveBeenCalledWith(expect.any(Object), expect.any(String), {
|
|
limit: 3,
|
|
sort: { name: 1 },
|
|
});
|
|
});
|
|
|
|
it('caps limit at 50', async () => {
|
|
const findUsers = jest.fn().mockResolvedValue([]);
|
|
const deps = createDeps({ findUsers });
|
|
const handlers = createAdminUsersHandlers(deps);
|
|
const { req, res } = createReqRes({ query: { q: 'User', limit: '100' } });
|
|
|
|
await handlers.searchUsers(req, res);
|
|
|
|
expect(findUsers).toHaveBeenCalledWith(expect.any(Object), expect.any(String), {
|
|
limit: 50,
|
|
sort: { name: 1 },
|
|
});
|
|
});
|
|
|
|
it('returns 500 on error', async () => {
|
|
const deps = createDeps({ findUsers: jest.fn().mockRejectedValue(new Error('db down')) });
|
|
const handlers = createAdminUsersHandlers(deps);
|
|
const { req, res, status, json } = createReqRes({ query: { q: 'test' } });
|
|
|
|
await handlers.searchUsers(req, res);
|
|
|
|
expect(status).toHaveBeenCalledWith(500);
|
|
expect(json).toHaveBeenCalledWith({ error: 'Failed to search users' });
|
|
});
|
|
});
|
|
|
|
describe('deleteUser', () => {
|
|
it('deletes user and returns 200', async () => {
|
|
const result: UserDeleteResult = {
|
|
deletedCount: 1,
|
|
message: 'User was deleted successfully.',
|
|
};
|
|
const deps = createDeps({ deleteUserById: jest.fn().mockResolvedValue(result) });
|
|
const handlers = createAdminUsersHandlers(deps);
|
|
const { req, res, status, json } = createReqRes({ params: { id: validUserId } });
|
|
|
|
await handlers.deleteUser(req, res);
|
|
|
|
expect(status).toHaveBeenCalledWith(200);
|
|
expect(json).toHaveBeenCalledWith({ message: 'User was deleted successfully.' });
|
|
});
|
|
|
|
it('returns fallback message when result.message is empty', async () => {
|
|
const result: UserDeleteResult = { deletedCount: 1, message: '' };
|
|
const deps = createDeps({ deleteUserById: jest.fn().mockResolvedValue(result) });
|
|
const handlers = createAdminUsersHandlers(deps);
|
|
const { req, res, status, json } = createReqRes({ params: { id: validUserId } });
|
|
|
|
await handlers.deleteUser(req, res);
|
|
|
|
expect(status).toHaveBeenCalledWith(200);
|
|
expect(json).toHaveBeenCalledWith({ message: 'User deleted successfully' });
|
|
});
|
|
|
|
it('returns 403 when deleting own account', async () => {
|
|
const userId = new Types.ObjectId();
|
|
const deps = createDeps();
|
|
const handlers = createAdminUsersHandlers(deps);
|
|
const { req, res, status, json } = createReqRes({
|
|
params: { id: userId.toString() },
|
|
user: { _id: userId, role: 'admin' },
|
|
});
|
|
|
|
await handlers.deleteUser(req, res);
|
|
|
|
expect(status).toHaveBeenCalledWith(403);
|
|
expect(json).toHaveBeenCalledWith({ error: 'Cannot delete your own account' });
|
|
expect(deps.deleteUserById).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns 400 when deleting the last admin', async () => {
|
|
const targetId = new Types.ObjectId().toString();
|
|
const deps = createDeps({
|
|
findUsers: jest.fn().mockResolvedValue([mockUser({ role: SystemRoles.ADMIN })]),
|
|
countUsers: jest.fn().mockResolvedValue(1),
|
|
});
|
|
const handlers = createAdminUsersHandlers(deps);
|
|
const { req, res, status, json } = createReqRes({ params: { id: targetId } });
|
|
|
|
await handlers.deleteUser(req, res);
|
|
|
|
expect(status).toHaveBeenCalledWith(400);
|
|
expect(json).toHaveBeenCalledWith({ error: 'Cannot delete the last admin user' });
|
|
expect(deps.deleteUserById).not.toHaveBeenCalled();
|
|
expect(deps.countUsers).toHaveBeenCalledWith({ role: SystemRoles.ADMIN });
|
|
});
|
|
|
|
it('allows deleting an admin when other admins exist', async () => {
|
|
const targetId = new Types.ObjectId().toString();
|
|
const deps = createDeps({
|
|
findUsers: jest.fn().mockResolvedValue([mockUser({ role: SystemRoles.ADMIN })]),
|
|
countUsers: jest.fn().mockResolvedValue(3),
|
|
});
|
|
const handlers = createAdminUsersHandlers(deps);
|
|
const { req, res, status } = createReqRes({ params: { id: targetId } });
|
|
|
|
await handlers.deleteUser(req, res);
|
|
|
|
expect(status).toHaveBeenCalledWith(200);
|
|
expect(deps.deleteUserById).toHaveBeenCalledWith(targetId);
|
|
});
|
|
|
|
it('does not check admin count when target is a regular user', async () => {
|
|
const targetId = new Types.ObjectId().toString();
|
|
const deps = createDeps({
|
|
findUsers: jest.fn().mockResolvedValue([mockUser({ role: 'USER' })]),
|
|
});
|
|
const handlers = createAdminUsersHandlers(deps);
|
|
const { req, res, status } = createReqRes({ params: { id: targetId } });
|
|
|
|
await handlers.deleteUser(req, res);
|
|
|
|
expect(status).toHaveBeenCalledWith(200);
|
|
expect(deps.countUsers).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('cascades cleanup of Config and AclEntries', async () => {
|
|
const result: UserDeleteResult = {
|
|
deletedCount: 1,
|
|
message: 'User was deleted successfully.',
|
|
};
|
|
const deps = createDeps({ deleteUserById: jest.fn().mockResolvedValue(result) });
|
|
const handlers = createAdminUsersHandlers(deps);
|
|
const { req, res, status } = createReqRes({ params: { id: validUserId } });
|
|
|
|
await handlers.deleteUser(req, res);
|
|
|
|
expect(status).toHaveBeenCalledWith(200);
|
|
expect(deps.deleteConfig).toHaveBeenCalledWith(PrincipalType.USER, validUserId);
|
|
expect(deps.deleteAclEntries).toHaveBeenCalledWith({
|
|
principalType: PrincipalType.USER,
|
|
principalId: expect.any(Types.ObjectId),
|
|
});
|
|
});
|
|
|
|
it('returns success even when cascade cleanup partially fails', async () => {
|
|
const result: UserDeleteResult = {
|
|
deletedCount: 1,
|
|
message: 'User was deleted successfully.',
|
|
};
|
|
const deps = createDeps({
|
|
deleteUserById: jest.fn().mockResolvedValue(result),
|
|
deleteConfig: jest.fn().mockRejectedValue(new Error('cleanup failed')),
|
|
});
|
|
const handlers = createAdminUsersHandlers(deps);
|
|
const { req, res, status, json } = createReqRes({ params: { id: validUserId } });
|
|
|
|
await handlers.deleteUser(req, res);
|
|
|
|
expect(status).toHaveBeenCalledWith(200);
|
|
expect(json).toHaveBeenCalledWith({ message: 'User was deleted successfully.' });
|
|
});
|
|
|
|
it('does not cascade when user is not found', async () => {
|
|
const result: UserDeleteResult = { deletedCount: 0, message: '' };
|
|
const deps = createDeps({ deleteUserById: jest.fn().mockResolvedValue(result) });
|
|
const handlers = createAdminUsersHandlers(deps);
|
|
const { req, res, status } = createReqRes({ params: { id: validUserId } });
|
|
|
|
await handlers.deleteUser(req, res);
|
|
|
|
expect(status).toHaveBeenCalledWith(404);
|
|
expect(deps.deleteConfig).not.toHaveBeenCalled();
|
|
expect(deps.deleteAclEntries).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns 400 for invalid ObjectId', async () => {
|
|
const deps = createDeps();
|
|
const handlers = createAdminUsersHandlers(deps);
|
|
const { req, res, status, json } = createReqRes({ params: { id: 'not-valid' } });
|
|
|
|
await handlers.deleteUser(req, res);
|
|
|
|
expect(status).toHaveBeenCalledWith(400);
|
|
expect(json).toHaveBeenCalledWith({ error: 'Invalid user ID format' });
|
|
});
|
|
|
|
it('returns 404 when user not found', async () => {
|
|
const result: UserDeleteResult = { deletedCount: 0, message: '' };
|
|
const deps = createDeps({ deleteUserById: jest.fn().mockResolvedValue(result) });
|
|
const handlers = createAdminUsersHandlers(deps);
|
|
const { req, res, status, json } = createReqRes({ params: { id: validUserId } });
|
|
|
|
await handlers.deleteUser(req, res);
|
|
|
|
expect(status).toHaveBeenCalledWith(404);
|
|
expect(json).toHaveBeenCalledWith({ error: 'User not found' });
|
|
});
|
|
|
|
it('returns 500 on error', async () => {
|
|
const deps = createDeps({
|
|
deleteUserById: jest.fn().mockRejectedValue(new Error('db crash')),
|
|
});
|
|
const handlers = createAdminUsersHandlers(deps);
|
|
const { req, res, status, json } = createReqRes({ params: { id: validUserId } });
|
|
|
|
await handlers.deleteUser(req, res);
|
|
|
|
expect(status).toHaveBeenCalledWith(500);
|
|
expect(json).toHaveBeenCalledWith({ error: 'Failed to delete user' });
|
|
});
|
|
});
|
|
});
|