Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
248 changes: 247 additions & 1 deletion api/server/routes/prompts.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,17 @@ jest.mock('~/server/middleware', () => ({
canAccessPromptViaGroup: jest.requireActual('~/server/middleware').canAccessPromptViaGroup,
canAccessPromptGroupResource:
jest.requireActual('~/server/middleware').canAccessPromptGroupResource,
checkBan: (_req, _res, next) => next(),
uaParser: (_req, _res, next) => next(),
canAccessResource: jest.requireActual('~/server/middleware').canAccessResource,
}));

const { configMiddleware } = require('~/server/middleware');

let app;
let mongoServer;
let promptRoutes;
let accessPermissionsRoutes;
let Prompt, PromptGroup, AclEntry, AccessRole, User, SystemGrant, Config;
let testUsers, testRoles;
let grantPermission;
Expand Down Expand Up @@ -114,6 +118,11 @@ beforeAll(async () => {
// Import routes after middleware is set up
promptRoutes = require('./prompts');
app.use('/api/prompts', promptRoutes);

// Mounted so the sharing test below can call the real access-permissions router
// (and its real `updateResourcePermissions` controller) instead of the service layer.
accessPermissionsRoutes = require('./accessPermissions');
app.use('/api/permissions', accessPermissionsRoutes);
});

afterEach(() => {
Expand Down Expand Up @@ -204,7 +213,7 @@ async function setupTestData() {
getRoleByName.mockImplementation((roleName) => {
switch (roleName) {
case SystemRoles.USER:
return { permissions: { PROMPTS: { USE: true, CREATE: true } } };
return { permissions: { PROMPTS: { USE: true, CREATE: true, SHARE: true } } };
case SystemRoles.ADMIN:
return { permissions: { PROMPTS: { USE: true, CREATE: true, SHARE: true } } };
default:
Expand Down Expand Up @@ -1628,6 +1637,243 @@ describe('GET /api/prompts/groups/:groupId/resolve', () => {
});
});

describe('Externally managed (Langfuse) prompt groups are read-only', () => {
const readOnlyBody = { code: 'read_only', error: 'Externally managed prompts are read-only' };
let group;

async function seedLangfuseGroup(owner = testUsers.owner) {
const langfuseGroup = await PromptGroup.create({
name: 'Read-only Langfuse group',
category: 'langfuse-read-only',
author: owner._id,
authorName: owner.name,
source: 'langfuse',
sourcePromptName: 'greeting',
sourceProjectId: 'ro-project',
sourceDestination: 'env',
});
await grantPermission({
principalType: PrincipalType.USER,
principalId: owner._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: langfuseGroup._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
grantedBy: owner._id,
});
return langfuseGroup;
}

beforeEach(async () => {
group = await seedLangfuseGroup();
});

afterEach(async () => {
await Prompt.deleteMany({});
await PromptGroup.deleteMany({});
await AclEntry.deleteMany({});
});

it('rejects PATCH /groups/:groupId with 403 and leaves the group unchanged', async () => {
const before = await PromptGroup.findById(group._id).lean();

const response = await request(app)
.patch(`/api/prompts/groups/${group._id}`)
.send({ name: 'Hacked name' })
.expect(403);

expect(response.body).toEqual(readOnlyBody);
const after = await PromptGroup.findById(group._id).lean();
expect(after).toEqual(before);
});

it('rejects DELETE /groups/:groupId with 403 and leaves the group in place', async () => {
const response = await request(app).delete(`/api/prompts/groups/${group._id}`).expect(403);

expect(response.body).toEqual(readOnlyBody);
await expect(PromptGroup.findById(group._id)).resolves.not.toBeNull();
const ownerEntry = await AclEntry.findOne({
resourceType: ResourceType.PROMPTGROUP,
resourceId: group._id,
principalType: PrincipalType.USER,
principalId: testUsers.owner._id,
});
expect(ownerEntry).toBeTruthy();
});

it('rejects POST /groups/:groupId/prompts (new revision) with 403 and adds no revision', async () => {
const response = await request(app)
.post(`/api/prompts/groups/${group._id}/prompts`)
.send({ prompt: { prompt: 'New text', type: 'text' } })
.expect(403);

expect(response.body).toEqual(readOnlyBody);
await expect(Prompt.countDocuments({ groupId: group._id })).resolves.toBe(0);
});

it('rejects PATCH /groups/:groupId for an admin too (MANAGE_PROMPTS ACL bypass still hits the service guard)', async () => {
setTestUser(app, testUsers.admin);
const before = await PromptGroup.findById(group._id).lean();

const response = await request(app)
.patch(`/api/prompts/groups/${group._id}`)
.send({ name: 'Admin hack' })
.expect(403);

expect(response.body).toEqual(readOnlyBody);
const after = await PromptGroup.findById(group._id).lean();
expect(after).toEqual(before);
});

it('rejects DELETE /groups/:groupId for an admin too (MANAGE_PROMPTS ACL bypass still hits the service guard)', async () => {
setTestUser(app, testUsers.admin);

const response = await request(app).delete(`/api/prompts/groups/${group._id}`).expect(403);

expect(response.body).toEqual(readOnlyBody);
await expect(PromptGroup.findById(group._id)).resolves.not.toBeNull();
});

describe('with a Prompt row inserted directly for the group', () => {
let revision;

beforeEach(async () => {
revision = await Prompt.create({
prompt: 'Synced from Langfuse',
author: testUsers.owner._id,
type: 'text',
groupId: group._id,
});
});

it('rejects PATCH /:promptId/tags/production with 403 and does not promote', async () => {
const response = await request(app)
.patch(`/api/prompts/${revision._id}/tags/production`)
.expect(403);

expect(response.body).toEqual(readOnlyBody);
const after = await PromptGroup.findById(group._id).lean();
expect(after.productionId ?? null).toBeNull();
await expect(Prompt.findById(revision._id)).resolves.not.toBeNull();
});

it('rejects DELETE /:promptId with 403 and keeps the revision', async () => {
const response = await request(app)
.delete(`/api/prompts/${revision._id}`)
.query({ groupId: group._id.toString() })
.expect(403);

expect(response.body).toEqual(readOnlyBody);
await expect(Prompt.findById(revision._id)).resolves.not.toBeNull();
await expect(PromptGroup.findById(group._id)).resolves.not.toBeNull();
});

it('rejects DELETE /:promptId for an admin too (MANAGE_PROMPTS ACL bypass still hits the service guard)', async () => {
setTestUser(app, testUsers.admin);

const response = await request(app)
.delete(`/api/prompts/${revision._id}`)
.query({ groupId: group._id.toString() })
.expect(403);

expect(response.body).toEqual(readOnlyBody);
await expect(Prompt.findById(revision._id)).resolves.not.toBeNull();
});
});

it('keeps POST /groups/:groupId/use working: it increments numberOfGenerations', async () => {
const response = await request(app).post(`/api/prompts/groups/${group._id}/use`).expect(200);

expect(response.body).toEqual({ numberOfGenerations: 1 });
const after = await PromptGroup.findById(group._id).lean();
expect(after.numberOfGenerations).toBe(1);
});

it('keeps sharing working through the access-permissions routes', async () => {
setTestUser(app, testUsers.owner);

const response = await request(app)
.put(`/api/permissions/${ResourceType.PROMPTGROUP}/${group._id}`)
.send({
updated: [
{
type: PrincipalType.USER,
id: testUsers.viewer._id.toString(),
accessRoleId: AccessRoleIds.PROMPTGROUP_VIEWER,
},
],
})
.expect(200);

expect(response.body.message).toBe('Permissions updated successfully');

const sharedEntry = await AclEntry.findOne({
resourceType: ResourceType.PROMPTGROUP,
resourceId: group._id,
principalType: PrincipalType.USER,
principalId: testUsers.viewer._id,
});
expect(sharedEntry).toBeTruthy();

// Sharing an externally managed group only grants an ACL entry; it never touches
// the read-only group document itself.
const after = await PromptGroup.findById(group._id).lean();
expect(after.name).toBe(group.name);
});
});

describe('A stored prompt group with no source (predates the field) stays editable', () => {
let group;

beforeEach(async () => {
// Bypasses the schema's `source: 'native'` default to reproduce a group
// written before the field existed.
const inserted = await PromptGroup.collection.insertOne({
name: 'Pre-source Group',
category: 'legacy-no-source',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new ObjectId(),
});
group = { _id: inserted.insertedId };

await grantPermission({
principalType: PrincipalType.USER,
principalId: testUsers.owner._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: group._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
grantedBy: testUsers.owner._id,
});
});

afterEach(async () => {
await Prompt.deleteMany({});
await PromptGroup.deleteMany({});
await AclEntry.deleteMany({});
});

it('allows PATCH /groups/:groupId to rename it, same as a native group', async () => {
const response = await request(app)
.patch(`/api/prompts/groups/${group._id}`)
.send({ name: 'Renamed legacy group' })
.expect(200);

expect(response.body.name).toBe('Renamed legacy group');
const stored = await PromptGroup.findById(group._id).lean();
expect(stored.name).toBe('Renamed legacy group');
});

it('allows POST /groups/:groupId/prompts to add a text prompt, same as a native group', async () => {
const response = await request(app)
.post(`/api/prompts/groups/${group._id}/prompts`)
.send({ prompt: { prompt: 'New legacy prompt text', type: 'text' } })
.expect(200);

expect(response.body.prompt.prompt).toBe('New legacy prompt text');
await expect(Prompt.countDocuments({ groupId: group._id })).resolves.toBe(1);
});
});

describe('Prompt Routes - response and failure compatibility', () => {
let consoleErrorSpy;
let group;
Expand Down
13 changes: 12 additions & 1 deletion client/src/components/Prompts/dialogs/DeletePrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import {
OGDialogTrigger,
TooltipAnchor,
OGDialogTemplate,
useToastContext,
} from '@librechat/client';
import { getPromptMutationErrorKey } from '~/utils';
import { useDeletePrompt } from '~/data-provider';
import { useLocalize } from '~/hooks';

Expand Down Expand Up @@ -75,7 +77,16 @@ interface DeletePromptProps {

const DeletePrompt = React.memo(
({ promptId, groupId, promptName, disabled }: DeletePromptProps) => {
const deletePromptMutation = useDeletePrompt();
const localize = useLocalize();
const { showToast } = useToastContext();
const deletePromptMutation = useDeletePrompt({
onError: (error) => {
showToast({
status: 'error',
message: localize(getPromptMutationErrorKey(error, 'com_ui_prompt_delete_error')),
});
},
});

const handleDelete = useCallback(() => {
if (!promptId) {
Expand Down
Loading
Loading