LibreChat/api/server/routes/__tests__/roles.spec.js
Dustin Healy 261941c05f
🔨 fix: Custom Role Permissions (#12528)
* fix: Resolve custom role permissions not loading in frontend

Users assigned to custom roles (non-USER/ADMIN) had all permission
checks fail because AuthContext only fetched system role permissions.
The roles map keyed by USER/ADMIN never contained the custom role name,
so useHasAccess returned false for every feature gate.

- Fetch the user's custom role in AuthContext and include it in the
  roles map so useHasAccess can resolve permissions correctly
- Use encodeURIComponent instead of toLowerCase for role name URLs
  to preserve custom role casing through the API roundtrip
- Only uppercase system role names on the backend GET route; pass
  custom role names through as-is for exact DB lookup
- Allow users to fetch their own assigned role without READ_ROLES
  capability

* refactor: Normalize all role names to uppercase

Custom role names were stored in original casing, causing case-sensitivity
bugs across the stack — URL lowercasing, route uppercasing, and
case-sensitive DB lookups all conflicted for mixed-case custom roles.

Enforce uppercase normalization at every boundary:
- createRoleByName trims and uppercases the name before storage
- createRoleHandler uppercases before passing to createRoleByName
- All admin route handlers (get, update, delete, members, permissions)
  uppercase the :name URL param before DB lookups
- addRoleMemberHandler uppercases before setting user.role
- Startup migration (normalizeRoleNames) finds non-uppercase custom
  roles, renames them, and updates affected user.role values with
  collision detection

Legacy GET /api/roles/:roleName retains always-uppercase behavior.
Tests updated to expect uppercase role names throughout.

* fix: Use case-preserved role names with strict equality

Remove uppercase normalization — custom role names are stored and
compared exactly as the user sets them, with only trimming applied.
USER and ADMIN remain reserved case-insensitively via isSystemRoleName.

- Remove toUpperCase from createRoleByName, createRoleHandler, and
  all admin route handlers (get, update, delete, members, permissions)
- Remove toUpperCase from legacy GET and PUT routes in roles.js;
  the frontend now sends exact casing via encodeURIComponent
- Remove normalizeRoleNames startup migration
- Revert test expectations to original casing

* fix: Format useMemo dependency array for Prettier

* feat: Add custom role support to admin settings + review fixes

- Add backend tests for isOwnRole authorization gate on GET /api/roles/:roleName
- Add frontend tests for custom role detection and fetching in AuthContext
- Fix transient null permission flash by only spreading custom role once loaded
- Add isSystemRoleName helper to data-provider for case-insensitive system role detection
- Use sentinel value in useGetRole to avoid ghost cache entry from empty string
- Add useListRoles hook and listRoles data service for fetching all roles
- Update AdminSettingsDialog and PeoplePickerAdminSettings to dynamically
  list custom roles in the role dropdown, with proper fallback defaults

* fix: Address review findings for custom role permissions

- Add assertions to AuthContext test verifying custom role in roles map
- Fix empty array bypassing nullish coalescing fallback in role dropdowns
- Add null/undefined guard to isSystemRoleName helper
- Memoize role dropdown items to avoid unnecessary re-renders
- Apply sentinel pattern to useGetRole in admin settings for consistency
- Mark ListRolesResponse description as required to match schema

* fix: Prevent prototype pollution in role authorization gate

- Replace roleDefaults[roleName] with Object.hasOwn to prevent
  prototype chain bypass for names like constructor or __proto__
- Add dedicated rolesList query key to avoid cache collision when
  a custom role is named 'list'
- Add regression test for prototype property name authorization

* fix: Resolve Prettier formatting and unused variable lint errors

* fix: Address review findings for custom role permissions

- Add ADMIN self-read test documenting isOwnRole bypass behavior
- Guard save button while custom role data loads to prevent data loss
- Extract useRoleSelector hook eliminating ~55 lines of duplication
- Unify defaultValues/useEffect permission resolution (fixes inconsistency)
- Make ListRolesResponse.description and _id optional to match schema
- Fix vacuous test assertions to verify sentinel calls exist
- Only fetch userRole when user.role === USER (avoid unnecessary requests)
- Remove redundant empty string guard in custom role detection

* fix: Revert USER role fetch restriction to preserve admin settings

Admins need the USER role loaded in AuthContext.roles so the admin
settings dialog shows persisted USER permissions instead of defaults.

* fix: Remove unused useEffect import from useRoleSelector

* fix: Clean up useRoleSelector hook

- Use existing isCustom variable instead of re-calling isSystemRoleName
- Remove unused roles and availableRoleNames from return object

* fix: Address review findings for custom role permissions

- Use Set-based isSystemRoleName to auto-expand with future SystemRoles
- Add isCustomRoleError handling: guard useEffect reset and disable Save
- Remove resolvePermissions from hook return; use defaultValues in useEffect
  to eliminate redundant computation and stale-closure reset race
- Rename customRoleName to userRoleName in AuthContext for clarity

* fix: Request server-max roles for admin dropdown

listRoles now passes limit=200 (the server's MAX_PAGE_LIMIT) so the
admin role selector shows all roles instead of silently truncating
at the default page size of 50.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-04-03 13:24:11 -04:00

155 lines
4.8 KiB
JavaScript

const express = require('express');
const request = require('supertest');
const { SystemRoles, roleDefaults } = require('librechat-data-provider');
const mockGetRoleByName = jest.fn();
const mockHasCapability = jest.fn();
jest.mock('~/server/middleware', () => ({
requireJwtAuth: (_req, _res, next) => next(),
}));
jest.mock('~/server/middleware/roles/capabilities', () => ({
hasCapability: (...args) => mockHasCapability(...args),
requireCapability: () => (_req, _res, next) => next(),
}));
jest.mock('~/models', () => ({
getRoleByName: (...args) => mockGetRoleByName(...args),
updateRoleByName: jest.fn(),
}));
const rolesRouter = require('../roles');
function createApp(user) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.user = user;
next();
});
app.use('/api/roles', rolesRouter);
return app;
}
const staffRole = {
name: 'STAFF',
permissions: {
PROMPTS: { USE: true, CREATE: false },
},
};
const userRole = roleDefaults[SystemRoles.USER];
const adminRole = roleDefaults[SystemRoles.ADMIN];
beforeEach(() => {
jest.clearAllMocks();
mockHasCapability.mockResolvedValue(false);
mockGetRoleByName.mockResolvedValue(null);
});
describe('GET /api/roles/:roleName — isOwnRole authorization', () => {
it('allows a custom role user to fetch their own role', async () => {
mockGetRoleByName.mockResolvedValue(staffRole);
const app = createApp({ id: 'u1', role: 'STAFF' });
const res = await request(app).get('/api/roles/STAFF');
expect(res.status).toBe(200);
expect(res.body.name).toBe('STAFF');
expect(mockGetRoleByName).toHaveBeenCalledWith('STAFF', '-_id -__v');
});
it('returns 403 when a custom role user requests a different custom role', async () => {
const app = createApp({ id: 'u1', role: 'STAFF' });
const res = await request(app).get('/api/roles/MANAGER');
expect(res.status).toBe(403);
expect(mockGetRoleByName).not.toHaveBeenCalled();
});
it('returns 403 when a custom role user requests ADMIN', async () => {
const app = createApp({ id: 'u1', role: 'STAFF' });
const res = await request(app).get('/api/roles/ADMIN');
expect(res.status).toBe(403);
expect(mockGetRoleByName).not.toHaveBeenCalled();
});
it('allows USER to fetch the USER role (roleDefaults key)', async () => {
mockGetRoleByName.mockResolvedValue(userRole);
const app = createApp({ id: 'u1', role: SystemRoles.USER });
const res = await request(app).get(`/api/roles/${SystemRoles.USER}`);
expect(res.status).toBe(200);
});
it('returns 403 when USER requests the ADMIN role', async () => {
const app = createApp({ id: 'u1', role: SystemRoles.USER });
const res = await request(app).get(`/api/roles/${SystemRoles.ADMIN}`);
expect(res.status).toBe(403);
});
it('allows ADMIN user to fetch their own ADMIN role via isOwnRole', async () => {
mockHasCapability.mockResolvedValue(false);
mockGetRoleByName.mockResolvedValue(adminRole);
const app = createApp({ id: 'u1', role: SystemRoles.ADMIN });
const res = await request(app).get(`/api/roles/${SystemRoles.ADMIN}`);
expect(res.status).toBe(200);
});
it('allows any user with READ_ROLES capability to fetch any role', async () => {
mockHasCapability.mockResolvedValue(true);
mockGetRoleByName.mockResolvedValue(staffRole);
const app = createApp({ id: 'u1', role: SystemRoles.USER });
const res = await request(app).get('/api/roles/STAFF');
expect(res.status).toBe(200);
expect(res.body.name).toBe('STAFF');
});
it('returns 404 when the requested role does not exist', async () => {
mockGetRoleByName.mockResolvedValue(null);
const app = createApp({ id: 'u1', role: 'GHOST' });
const res = await request(app).get('/api/roles/GHOST');
expect(res.status).toBe(404);
});
it('returns 500 when getRoleByName throws', async () => {
mockGetRoleByName.mockRejectedValue(new Error('db error'));
const app = createApp({ id: 'u1', role: SystemRoles.USER });
const res = await request(app).get(`/api/roles/${SystemRoles.USER}`);
expect(res.status).toBe(500);
});
it('returns 403 for prototype property names like constructor (no prototype pollution)', async () => {
const app = createApp({ id: 'u1', role: 'STAFF' });
const res = await request(app).get('/api/roles/constructor');
expect(res.status).toBe(403);
expect(mockGetRoleByName).not.toHaveBeenCalled();
});
it('treats hasCapability failure as no capability (does not 500)', async () => {
mockHasCapability.mockRejectedValue(new Error('capability check failed'));
const app = createApp({ id: 'u1', role: 'STAFF' });
mockGetRoleByName.mockResolvedValue(staffRole);
const res = await request(app).get('/api/roles/STAFF');
expect(res.status).toBe(200);
});
});