Skip to content
Merged
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
38 changes: 38 additions & 0 deletions client/src/api/prompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,4 +123,42 @@ describe("promptsApi", () => {
);
});
});

describe("delete", () => {
it("calls DELETE /prompts/:id with CSRF token and same-origin credentials", async () => {
mockFetch.mockResolvedValueOnce(new Response(null, { status: 204 }));

await promptsApi.delete("prompt-abc-123");

expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/prompts/prompt-abc-123"),
expect.objectContaining({
method: "DELETE",
headers: expect.objectContaining({
"X-CSRF-Token": "test-csrf-token",
}),
credentials: "same-origin", // pragma: allowlist secret
}),
);
});

it("throws synchronously for an empty ID", () => {
expect(() => promptsApi.delete("")).toThrow("Invalid prompt ID");
});

it("throws synchronously for an ID with path traversal characters", () => {
expect(() => promptsApi.delete("../etc/passwd")).toThrow("Invalid prompt ID format");
});

it("throws ApiError on 404 response", async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ detail: "Prompt not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
}),
);

await expect(promptsApi.delete("prompt-abc-123")).rejects.toThrow("HTTP 404");
});
});
});
12 changes: 12 additions & 0 deletions client/src/api/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,16 @@ export const promptsApi = {
const validId = validatePromptId(id);
return api.put<PromptRead>(`/prompts/${encodeURIComponent(validId)}`, { tags });
},

/**
* Delete a prompt by its primary-key ID.
*
* Mirrors `DELETE /prompts/{prompt_id}` (`mcpgateway/main.py`), which requires
* the `prompts.delete` permission. The ID is validated (not the name used by
* {@link promptsApi.render}) to guard against path traversal and injection.
*/
delete: (id: string): Promise<void> => {
const validId = validatePromptId(id);
return api.delete(`/prompts/${encodeURIComponent(validId)}`);
},
};
26 changes: 24 additions & 2 deletions client/src/components/ui/dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,11 +204,33 @@ describe("Dialog Components", () => {

const content = screen.getByTestId("content");
expect(content).toHaveClass("fixed");
expect(content).toHaveClass("inset-0");
expect(content).toHaveClass("m-auto");
expect(content).toHaveClass("inset-x-0");
expect(content).toHaveClass("top-1/2");
expect(content).toHaveClass("-translate-y-1/2");
expect(content).toHaveClass("mx-auto");
expect(content).toHaveClass("z-50");
});

it("bounds its height to the viewport and scrolls overflowing content", () => {
// Tall dialogs must stay within the viewport so the header, top content,
// and close button remain reachable instead of extending off-screen.
render(
<Dialog open={true}>
<DialogContent data-testid="content">
<DialogTitle>Tall dialog</DialogTitle>
<DialogDescription>Lots of content</DialogDescription>
{Array.from({ length: 100 }, (_, i) => (
<p key={i}>Row {i}</p>
))}
</DialogContent>
</Dialog>,
);

const content = screen.getByTestId("content");
expect(content).toHaveClass("max-h-[calc(100vh-2rem)]");
expect(content).toHaveClass("overflow-y-auto");
});

it("should include close button", () => {
render(
<Dialog open={true}>
Expand Down
4 changes: 2 additions & 2 deletions client/src/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed inset-0 z-50 m-auto grid h-fit w-full max-w-lg gap-4 border bg-popover p-6 shadow-lg duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:rounded-lg",
"fixed inset-x-0 top-1/2 z-50 mx-auto grid max-h-[calc(100vh-2rem)] w-full max-w-lg -translate-y-1/2 gap-5 overflow-y-auto border bg-popover p-7 shadow-lg duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:rounded-lg",
className,
)}
{...props}
Expand All @@ -52,7 +52,7 @@ const DialogContent = React.forwardRef<
DialogContent.displayName = DialogPrimitive.Content.displayName;

const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
DialogHeader.displayName = "DialogHeader";

Expand Down
7 changes: 6 additions & 1 deletion client/src/i18n/locales/en-US/prompts.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,10 @@
"prompts.add.error.teamRequired": "Team selection is required when visibility is set to team",
"prompts.add.error.argumentsInvalidJson": "Invalid JSON format",
"prompts.add.error.argumentsArrayRequired": "Arguments must be a JSON array",
"prompts.tags.addError": "Failed to add tag. Please try again."
"prompts.tags.addError": "Failed to add tag. Please try again.",
"prompts.delete.confirm.title": "Delete prompt",
"prompts.delete.confirm.description": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
"prompts.delete.confirm.button": "Delete",
"prompts.delete.success": "Prompt \"{name}\" deleted successfully",
"prompts.delete.error": "Failed to delete prompt"
}
7 changes: 6 additions & 1 deletion client/src/i18n/locales/es-ES/prompts.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,10 @@
"prompts.add.error.teamRequired": "La selección de equipo es obligatoria cuando la visibilidad está configurada como equipo",
"prompts.add.error.argumentsInvalidJson": "Formato JSON inválido",
"prompts.add.error.argumentsArrayRequired": "Los argumentos deben ser un array JSON",
"prompts.tags.addError": "No se pudo añadir la etiqueta. Inténtelo de nuevo."
"prompts.tags.addError": "No se pudo añadir la etiqueta. Inténtelo de nuevo.",
"prompts.delete.confirm.title": "Eliminar prompt",
"prompts.delete.confirm.description": "¿Está seguro de que desea eliminar \"{name}\"? Esta acción no se puede deshacer.",
"prompts.delete.confirm.button": "Eliminar",
"prompts.delete.success": "Prompt \"{name}\" eliminado correctamente",
"prompts.delete.error": "Error al eliminar el prompt"
}
7 changes: 6 additions & 1 deletion client/src/i18n/locales/pt-BR/prompts.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,10 @@
"prompts.add.error.teamRequired": "A seleção de equipe é obrigatória quando a visibilidade está definida como equipe",
"prompts.add.error.argumentsInvalidJson": "Formato JSON inválido",
"prompts.add.error.argumentsArrayRequired": "Os argumentos devem ser um array JSON",
"prompts.tags.addError": "Falha ao adicionar a tag. Tente novamente."
"prompts.tags.addError": "Falha ao adicionar a tag. Tente novamente.",
"prompts.delete.confirm.title": "Excluir prompt",
"prompts.delete.confirm.description": "Tem certeza de que deseja excluir \"{name}\"? Esta ação não pode ser desfeita.",
"prompts.delete.confirm.button": "Excluir",
"prompts.delete.success": "Prompt \"{name}\" excluído com sucesso",
"prompts.delete.error": "Falha ao excluir o prompt"
}
224 changes: 224 additions & 0 deletions client/src/pages/Prompts.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,230 @@ describe("Prompts", () => {
expect(screen.getByRole("menuitem", { name: "Delete" })).toBeInTheDocument();
});

it("deletes the sole prompt in a group and closes the drawer after confirmation", async () => {
const user = userEvent.setup();
let remaining: Prompt[] = [
createMockPrompt({
id: "prompt-1",
name: "summarize",
displayName: "Summarize document",
gatewayId: "gw-github",
gatewaySlug: "gh-repo-tasks",
}),
];

const deleteSpy = vi.fn(() => {
remaining = remaining.filter((p) => p.id !== "prompt-1");
return HttpResponse.json({ status: "success" });
});
server.use(
http.get("/prompts", () => HttpResponse.json(remaining)),
http.delete("/prompts/prompt-1", deleteSpy),
);

renderWithProviders(<Prompts />);

await waitFor(() => {
expect(screen.getByText("gh-repo-tasks")).toBeInTheDocument();
});

// Open the group's details panel and its Definition tab.
await user.click(screen.getByRole("button", { name: "More options for gh-repo-tasks" }));
await user.click(await screen.findByRole("menuitem", { name: "View details" }));
await user.click(await screen.findByRole("tab", { name: /definition/i }));

// Trigger delete from the row overflow menu.
await user.click(await screen.findByRole("button", { name: /more options for summarize/i }));
await user.click(await screen.findByRole("menuitem", { name: "Delete" }));

// Confirm dialog names the prompt and requires an explicit confirmation.
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText("Delete prompt")).toBeInTheDocument();
expect(
within(dialog).getByText(/Are you sure you want to delete "Summarize document"/),
).toBeInTheDocument();

await user.click(within(dialog).getByRole("button", { name: /^delete$/i }));

await waitFor(() => {
expect(deleteSpy).toHaveBeenCalledTimes(1);
});

// The group's only prompt is gone, so its grid card (and the card-only
// "More options" trigger) disappears.
await waitFor(() => {
expect(
screen.queryByRole("button", { name: "More options for gh-repo-tasks" }),
).not.toBeInTheDocument();
});
});

it("keeps the drawer and other rows when deleting one prompt from a multi-prompt group", async () => {
const user = userEvent.setup();
let remaining: Prompt[] = [
createMockPrompt({
id: "prompt-1",
name: "summarize",
displayName: "Summarize document",
gatewayId: "gw-github",
gatewaySlug: "gh-repo-tasks",
}),
createMockPrompt({
id: "prompt-2",
name: "translate",
displayName: "Translate document",
gatewayId: "gw-github",
gatewaySlug: "gh-repo-tasks",
}),
];

server.use(
http.get("/prompts", () => HttpResponse.json(remaining)),
http.delete("/prompts/prompt-1", () => {
remaining = remaining.filter((p) => p.id !== "prompt-1");
return HttpResponse.json({ status: "success" });
}),
);

renderWithProviders(<Prompts />);

await waitFor(() => {
expect(screen.getByText("gh-repo-tasks")).toBeInTheDocument();
});

await user.click(screen.getByRole("button", { name: "More options for gh-repo-tasks" }));
await user.click(await screen.findByRole("menuitem", { name: "View details" }));
await user.click(await screen.findByRole("tab", { name: /definition/i }));

await user.click(await screen.findByRole("button", { name: /more options for summarize/i }));
await user.click(await screen.findByRole("menuitem", { name: "Delete" }));

const dialog = await screen.findByRole("dialog");
await user.click(within(dialog).getByRole("button", { name: /^delete$/i }));

// The deleted row is gone but the drawer stays open with the sibling prompt.
await waitFor(() => {
expect(
screen.queryByRole("button", { name: /more options for summarize/i }),
).not.toBeInTheDocument();
});
expect(screen.getByRole("button", { name: /more options for translate/i })).toBeInTheDocument();
});

it("reconciles with the server and restores the row when the DELETE fails and the prompt survives", async () => {
const user = userEvent.setup();
const prompts: Prompt[] = [
createMockPrompt({
id: "prompt-1",
name: "summarize",
displayName: "Summarize document",
gatewayId: "gw-github",
gatewaySlug: "gh-repo-tasks",
}),
createMockPrompt({
id: "prompt-2",
name: "translate",
displayName: "Translate document",
gatewayId: "gw-github",
gatewaySlug: "gh-repo-tasks",
}),
];

// The DELETE fails and the prompt is NOT removed server-side (a pre-commit
// failure), so the post-failure refetch still returns it and the row
// reappears.
server.use(
http.get("/prompts", () => HttpResponse.json(prompts)),
http.delete("/prompts/prompt-1", () =>
HttpResponse.json({ detail: "Prompt is in use" }, { status: 409 }),
),
);

renderWithProviders(<Prompts />);

await waitFor(() => {
expect(screen.getByText("gh-repo-tasks")).toBeInTheDocument();
});

await user.click(screen.getByRole("button", { name: "More options for gh-repo-tasks" }));
await user.click(await screen.findByRole("menuitem", { name: "View details" }));
await user.click(await screen.findByRole("tab", { name: /definition/i }));

await user.click(await screen.findByRole("button", { name: /more options for summarize/i }));
await user.click(await screen.findByRole("menuitem", { name: "Delete" }));

const dialog = await screen.findByRole("dialog");
await user.click(within(dialog).getByRole("button", { name: /^delete$/i }));

// The optimistic removal is reconciled against server truth, so the row
// reappears because the prompt still exists.
await waitFor(() => {
expect(
screen.getByRole("button", { name: /more options for summarize/i }),
).toBeInTheDocument();
});
});

it("does not resurrect a prompt when the DELETE reports an error but the row is already gone", async () => {
const user = userEvent.setup();
// Simulates a post-commit failure: PromptService.delete_prompt() commits
// the removal, then a later step (notification/cache invalidation) fails
// and the endpoint returns an error. The server no longer has the prompt,
// so reconciling must NOT restore it.
let remaining: Prompt[] = [
createMockPrompt({
id: "prompt-1",
name: "summarize",
displayName: "Summarize document",
gatewayId: "gw-github",
gatewaySlug: "gh-repo-tasks",
}),
createMockPrompt({
id: "prompt-2",
name: "translate",
displayName: "Translate document",
gatewayId: "gw-github",
gatewaySlug: "gh-repo-tasks",
}),
];

server.use(
http.get("/prompts", () => HttpResponse.json(remaining)),
http.delete("/prompts/prompt-1", () => {
// Row is committed as deleted, but the request still errors out.
remaining = remaining.filter((p) => p.id !== "prompt-1");
return HttpResponse.json({ detail: "Post-commit hook failed" }, { status: 500 });
}),
);

renderWithProviders(<Prompts />);

await waitFor(() => {
expect(screen.getByText("gh-repo-tasks")).toBeInTheDocument();
});

await user.click(screen.getByRole("button", { name: "More options for gh-repo-tasks" }));
await user.click(await screen.findByRole("menuitem", { name: "View details" }));
await user.click(await screen.findByRole("tab", { name: /definition/i }));

await user.click(await screen.findByRole("button", { name: /more options for summarize/i }));
await user.click(await screen.findByRole("menuitem", { name: "Delete" }));

const dialog = await screen.findByRole("dialog");
await user.click(within(dialog).getByRole("button", { name: /^delete$/i }));

// The drawer stays open with the surviving sibling, and the deleted prompt
// is not brought back by the failure path.
await waitFor(() => {
expect(
screen.getByRole("button", { name: /more options for translate/i }),
).toBeInTheDocument();
});
expect(
screen.queryByRole("button", { name: /more options for summarize/i }),
).not.toBeInTheDocument();
});

it("collapses gateway-less prompts into a single REST prompts card", async () => {
const prompts: Prompt[] = [
createMockPrompt({
Expand Down
Loading
Loading