mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-03 14:27:20 +02:00
🚫 refactor: Remove Interface Config from Override Processing (#12473)
Add INTERFACE_PERMISSION_FIELDS set defining the interface fields that seed role permissions at startup (prompts, agents, marketplace, etc.). These fields are now stripped from DB config overrides in the merge layer because updateInterfacePermissions() only runs at boot — DB overrides for these fields create a client/server permission mismatch. Pure UI fields (endpointsMenu, modelSelect, parameters, presets, sidePanel, customWelcome, etc.) continue to work in overrides as before. YAML startup path is completely unaffected.
This commit is contained in:
parent
3d1b883e9d
commit
c0ce7fee91
7 changed files with 607 additions and 20 deletions
|
|
@ -48,7 +48,7 @@ function createHandlers(overrides = {}) {
|
|||
}),
|
||||
patchConfigFields: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ _id: 'c1', overrides: { interface: { endpointsMenu: false } } }),
|
||||
.mockResolvedValue({ _id: 'c1', overrides: { registration: { enabled: false } } }),
|
||||
unsetConfigField: jest.fn().mockResolvedValue({ _id: 'c1', overrides: {} }),
|
||||
deleteConfig: jest.fn().mockResolvedValue({ _id: 'c1' }),
|
||||
toggleConfigActive: jest.fn().mockResolvedValue({ _id: 'c1', isActive: false }),
|
||||
|
|
@ -169,6 +169,93 @@ describe('createAdminConfigHandlers', () => {
|
|||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('strips permission fields from interface overrides but keeps UI fields', async () => {
|
||||
const { handlers, deps } = createHandlers({
|
||||
upsertConfig: jest.fn().mockResolvedValue({ _id: 'c1', configVersion: 1 }),
|
||||
});
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: {
|
||||
overrides: {
|
||||
interface: { endpointsMenu: false, prompts: false, agents: { use: false } },
|
||||
},
|
||||
},
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.upsertConfigOverrides(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const savedOverrides = deps.upsertConfig.mock.calls[0][3];
|
||||
expect(savedOverrides.interface).toEqual({ endpointsMenu: false });
|
||||
});
|
||||
|
||||
it('preserves UI sub-keys in composite permission fields like mcpServers', async () => {
|
||||
const { handlers, deps } = createHandlers({
|
||||
upsertConfig: jest.fn().mockResolvedValue({ _id: 'c1', configVersion: 1 }),
|
||||
});
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: {
|
||||
overrides: {
|
||||
interface: {
|
||||
mcpServers: {
|
||||
use: true,
|
||||
create: false,
|
||||
placeholder: 'Search MCP...',
|
||||
trustCheckbox: { label: 'Trust' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.upsertConfigOverrides(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const savedOverrides = deps.upsertConfig.mock.calls[0][3];
|
||||
const mcp = (savedOverrides as Record<string, unknown>).interface as Record<string, unknown>;
|
||||
expect((mcp.mcpServers as Record<string, unknown>).placeholder).toBe('Search MCP...');
|
||||
expect((mcp.mcpServers as Record<string, unknown>).trustCheckbox).toEqual({ label: 'Trust' });
|
||||
expect((mcp.mcpServers as Record<string, unknown>).use).toBeUndefined();
|
||||
expect((mcp.mcpServers as Record<string, unknown>).create).toBeUndefined();
|
||||
});
|
||||
|
||||
it('strips peoplePicker permission sub-keys in upsert', async () => {
|
||||
const { handlers, deps } = createHandlers();
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: {
|
||||
overrides: {
|
||||
interface: { peoplePicker: { users: false, groups: true, roles: true } },
|
||||
},
|
||||
},
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.upsertConfigOverrides(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body!.message).toBeDefined();
|
||||
expect(deps.upsertConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 200 with message when only permission fields in interface', async () => {
|
||||
const { handlers, deps } = createHandlers();
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: { overrides: { interface: { prompts: false, agents: false } } },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.upsertConfigOverrides(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body!.message).toBeDefined();
|
||||
expect(deps.upsertConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteConfigField', () => {
|
||||
|
|
@ -189,6 +276,87 @@ describe('createAdminConfigHandlers', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('allows deleting mcpServers UI sub-key paths', async () => {
|
||||
const { handlers, deps } = createHandlers();
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
query: { fieldPath: 'interface.mcpServers.placeholder' },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.deleteConfigField(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(deps.unsetConfigField).toHaveBeenCalledWith(
|
||||
'role',
|
||||
'admin',
|
||||
'interface.mcpServers.placeholder',
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks deleting mcpServers permission sub-key paths', async () => {
|
||||
const { handlers, deps } = createHandlers();
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
query: { fieldPath: 'interface.mcpServers.use' },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.deleteConfigField(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body!.message).toBeDefined();
|
||||
expect(deps.unsetConfigField).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks deleting peoplePicker permission sub-key paths', async () => {
|
||||
const { handlers, deps } = createHandlers();
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
query: { fieldPath: 'interface.peoplePicker.users' },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.deleteConfigField(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body!.message).toBeDefined();
|
||||
expect(deps.unsetConfigField).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 200 no-op for interface permission field path', async () => {
|
||||
const { handlers, deps } = createHandlers();
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
query: { fieldPath: 'interface.prompts' },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.deleteConfigField(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body!.message).toBeDefined();
|
||||
expect(deps.unsetConfigField).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows deleting interface UI field paths', async () => {
|
||||
const { handlers, deps } = createHandlers();
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
query: { fieldPath: 'interface.endpointsMenu' },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.deleteConfigField(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(deps.unsetConfigField).toHaveBeenCalledWith(
|
||||
'role',
|
||||
'admin',
|
||||
'interface.endpointsMenu',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 400 when fieldPath query param is missing', async () => {
|
||||
const { handlers } = createHandlers();
|
||||
const req = mockReq({
|
||||
|
|
@ -224,7 +392,110 @@ describe('createAdminConfigHandlers', () => {
|
|||
});
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: { entries: [{ fieldPath: 'interface.endpointsMenu', value: false }] },
|
||||
body: { entries: [{ fieldPath: 'registration.enabled', value: false }] },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.patchConfigField(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('strips interface permission field entries but keeps UI field entries', async () => {
|
||||
const { handlers, deps } = createHandlers();
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: {
|
||||
entries: [
|
||||
{ fieldPath: 'interface.endpointsMenu', value: false },
|
||||
{ fieldPath: 'interface.prompts', value: false },
|
||||
],
|
||||
},
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.patchConfigField(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const patchedFields = deps.patchConfigFields.mock.calls[0][3];
|
||||
expect(patchedFields['interface.endpointsMenu']).toBe(false);
|
||||
expect(patchedFields['interface.prompts']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('blocks peoplePicker permission sub-key paths', async () => {
|
||||
const { handlers, deps } = createHandlers();
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: {
|
||||
entries: [{ fieldPath: 'interface.peoplePicker.users', value: false }],
|
||||
},
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.patchConfigField(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body!.message).toBeDefined();
|
||||
expect(deps.patchConfigFields).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows mcpServers UI sub-key paths but blocks permission sub-key paths', async () => {
|
||||
const { handlers, deps } = createHandlers();
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: {
|
||||
entries: [
|
||||
{ fieldPath: 'interface.mcpServers.placeholder', value: 'Search...' },
|
||||
{ fieldPath: 'interface.mcpServers.use', value: true },
|
||||
],
|
||||
},
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.patchConfigField(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const patchedFields = deps.patchConfigFields.mock.calls[0][3];
|
||||
expect(patchedFields['interface.mcpServers.placeholder']).toBe('Search...');
|
||||
expect(patchedFields['interface.mcpServers.use']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns 200 with message when all entries are permission fields', async () => {
|
||||
const { handlers, deps } = createHandlers();
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: { entries: [{ fieldPath: 'interface.prompts', value: false }] },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.patchConfigField(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body!.message).toBeDefined();
|
||||
expect(deps.patchConfigFields).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 401 when unauthenticated even if all entries are permission fields', async () => {
|
||||
const { handlers } = createHandlers();
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: { entries: [{ fieldPath: 'interface.prompts', value: false }] },
|
||||
user: undefined,
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.patchConfigField(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 403 when unauthorized even if all entries are permission fields', async () => {
|
||||
const { handlers } = createHandlers({
|
||||
hasConfigCapability: jest.fn().mockResolvedValue(false),
|
||||
});
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: { entries: [{ fieldPath: 'interface.prompts', value: false }] },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue