Files
IdeA/frontend/src/features/permissions/McpToolPermissionsPanel.test.tsx
Blomios c3078875f4 feat(frontend): surface MCP tool permissions per agent — Permissions panel (#82 lot UX/F)
Adds the "Tools MCP IdeA" tab to the project Permissions panel, alongside
the existing "Système" (file/command) tab. Lets the user grant/revoke MCP
tool capabilities per agent or project-wide default, grouped by domain
(Lecture projet, Lecture tickets, Délégation agents, Contexte et mémoire,
Tickets, Travail et exécution, Skills) instead of a flat 25-checkbox list,
per the UX conception in carnet #82.

- domain/ports/adapters (Tauri, HTTP, mock): wire get_mcp_tool_permissions,
  update_project_mcp_tool_permissions, update_agent_mcp_tool_permissions
  (already merged backend API, #82 lots B1-B4) onto PermissionGateway.
- useMcpToolPermissions: view-model owning the durable MCP tool policy
  document, distinct from the file/command permissions in usePermissions.
- McpToolPermissionsPanel: target selector (Défaut projet + agents with
  Hérité/Override badges) and grouped editor — inherited agents are
  read-only until "Créer un override" (prefilled with the effective
  allowlist), per-row Ajouté/Retiré diffing against the project default,
  inline confirmation before granting a write tool at project-default
  level, and unsaved-draft protection on target change.
- mcpToolGroups.ts: presentational-only domain grouping and short French
  labels — the read/write classification itself always comes from the
  backend-provided catalogue, never hardcoded here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 23:36:02 +02:00

260 lines
10 KiB
TypeScript

/**
* Ticket #82 — the "Tools MCP IdeA" tab of the project `Permissions` panel.
*
* Pins the UX contract from carnet #82 (Conception UX/F): an agent without an
* override shows it inherits the project default with non-editable controls
* until `Créer un override`; creating one prefills the effective allowlist;
* toggling a tool updates the visible summary; saving calls the backend
* command with the exact resulting allowlist; and an unsaved draft is
* protected when the user switches target.
*/
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import {
MockAgentGateway,
MockPermissionGateway,
MockProfileGateway,
} from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import type { Gateways } from "@/ports";
import { PermissionsPanel } from "./PermissionsPanel";
const PROJECT_ID = "proj-mcp-permissions-test";
async function renderPanel() {
const agent = new MockAgentGateway();
const permission = new MockPermissionGateway();
const created = await agent.createAgent(PROJECT_ID, {
name: "DevFrontend",
profileId: "p1",
});
const gateways = {
agent,
permission,
profile: new MockProfileGateway(),
} as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<PermissionsPanel projectId={PROJECT_ID} />
</DIProvider>,
);
await waitFor(() => {
expect(screen.getByText("DevFrontend")).toBeTruthy();
});
return { agent, permission, agentId: created.id };
}
/** Switches to the "Tools MCP IdeA" tab and waits past the doc load. */
async function openMcpToolsTab() {
fireEvent.click(screen.getByRole("tab", { name: "Tools MCP IdeA" }));
await screen.findByRole("navigation", { name: "cibles des permissions MCP" });
}
async function selectAgentTarget(name: string) {
const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" });
fireEvent.click(within(nav).getByRole("button", { name: new RegExp(name) }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: `Tools MCP IdeA — ${name}` })).toBeTruthy();
});
}
describe("PermissionsPanel — Tools MCP IdeA tab", () => {
it("has an accessible tablist and defaults to Système", async () => {
await renderPanel();
const tablist = screen.getByRole("tablist", { name: "Permissions" });
const tabs = within(tablist).getAllByRole("tab");
expect(tabs.map((t) => t.textContent)).toEqual(["Système", "Tools MCP IdeA"]);
expect(screen.getByRole("tab", { name: "Système" }).getAttribute("aria-selected")).toBe(
"true",
);
});
it("an agent without an override shows it inherits the project default, read-only", async () => {
await renderPanel();
await openMcpToolsTab();
const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" });
const agentButton = within(nav).getByRole("button", { name: /DevFrontend/ });
expect(within(agentButton).getByText("Hérité")).toBeTruthy();
await selectAgentTarget("DevFrontend");
expect(screen.getByText("Hérite du défaut projet")).toBeTruthy();
expect(screen.getByRole("button", { name: "Créer un override" })).toBeTruthy();
// Read groups are collapsed by default in agent editing (carnet #82) — expand it.
fireEvent.click(screen.getByRole("button", { name: /Lecture tickets/ }));
// A canonical read-only tool is shown checked but disabled (inherited display).
const readCheckbox = screen.getByRole("checkbox", {
name: /idea_ticket_read$/,
}) as HTMLInputElement;
expect(readCheckbox.checked).toBe(true);
expect(readCheckbox.disabled).toBe(true);
// A write tool is shown unchecked (canonical read-only fallback denies it) and disabled.
const writeCheckbox = screen.getByRole("checkbox", {
name: /idea_ticket_update_carnet$/,
}) as HTMLInputElement;
expect(writeCheckbox.checked).toBe(false);
expect(writeCheckbox.disabled).toBe(true);
// No Save action is meaningfully available before creating an override.
expect(screen.getByRole("button", { name: "Enregistrer" })).toHaveProperty("disabled", true);
});
it("creating an override prefills the draft with the current effective allowlist", async () => {
await renderPanel();
await openMcpToolsTab();
await selectAgentTarget("DevFrontend");
fireEvent.click(screen.getByRole("button", { name: "Créer un override" }));
expect(screen.getByText("Override personnalisé")).toBeTruthy();
// Read groups are collapsed by default in agent editing (carnet #82) — expand it.
fireEvent.click(screen.getByRole("button", { name: /Lecture tickets/ }));
// Prefilled from the effective (inherited) allowlist: canonical read-only
// tools checked and now editable.
const readCheckbox = screen.getByRole("checkbox", {
name: /idea_ticket_read$/,
}) as HTMLInputElement;
expect(readCheckbox.checked).toBe(true);
expect(readCheckbox.disabled).toBe(false);
const writeCheckbox = screen.getByRole("checkbox", {
name: /idea_ticket_update_carnet$/,
}) as HTMLInputElement;
expect(writeCheckbox.checked).toBe(false);
// No edit yet: nothing to save.
expect(screen.getByRole("button", { name: "Enregistrer" })).toHaveProperty("disabled", true);
});
it("checking a write tool updates the effective summary and unsaved-changes flag", async () => {
await renderPanel();
await openMcpToolsTab();
await selectAgentTarget("DevFrontend");
fireEvent.click(screen.getByRole("button", { name: "Créer un override" }));
expect(screen.getByText(/9 lecture autorisés · 0 écriture autorisé/)).toBeTruthy();
const writeCheckbox = screen.getByRole("checkbox", {
name: /idea_ticket_update_carnet$/,
});
fireEvent.click(writeCheckbox);
expect(screen.getByText(/9 lecture autorisés · 1 écriture autorisé/)).toBeTruthy();
expect(screen.getByText("Modifications non enregistrées")).toBeTruthy();
expect(screen.getByText("Ajouté")).toBeTruthy();
});
it("saving calls updateAgentMcpToolPermissions with the exact resulting allowlist", async () => {
const { permission, agentId } = await renderPanel();
const spy = vi.spyOn(permission, "updateAgentMcpToolPermissions");
await openMcpToolsTab();
await selectAgentTarget("DevFrontend");
fireEvent.click(screen.getByRole("button", { name: "Créer un override" }));
fireEvent.click(screen.getByRole("checkbox", { name: /idea_ticket_update_carnet$/ }));
fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));
await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
const [calledProjectId, calledAgentId, calledPolicy] = spy.mock.calls[0]!;
expect(calledProjectId).toBe(PROJECT_ID);
expect(calledAgentId).toBe(agentId);
expect(calledPolicy?.allowedTools).toEqual(
expect.arrayContaining([
"idea_list_agents",
"idea_context_read",
"idea_memory_read",
"idea_skill_read",
"idea_workstate_read",
"idea_ticket_read",
"idea_ticket_list",
"idea_ticket_read_carnet",
"idea_sprint_list",
"idea_ticket_update_carnet",
]),
);
expect(calledPolicy?.allowedTools).toHaveLength(10);
// Persisted: the left-column badge flips to Override, and Réinitialiser appears.
const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" });
const agentButton = within(nav).getByRole("button", { name: /DevFrontend/ });
expect(within(agentButton).getByText("Override")).toBeTruthy();
expect(screen.getByRole("button", { name: "Réinitialiser l'override" })).toBeTruthy();
});
describe("draft protection on target change", () => {
const originalConfirm = window.confirm;
beforeEach(() => {
window.confirm = vi.fn();
});
afterEach(() => {
window.confirm = originalConfirm;
});
it("asks for confirmation, and keeps the draft when the user cancels", async () => {
await renderPanel();
await openMcpToolsTab();
await selectAgentTarget("DevFrontend");
fireEvent.click(screen.getByRole("button", { name: "Créer un override" }));
fireEvent.click(screen.getByRole("checkbox", { name: /idea_ticket_update_carnet$/ }));
(window.confirm as ReturnType<typeof vi.fn>).mockReturnValue(false);
const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" });
fireEvent.click(within(nav).getByRole("button", { name: /^Défaut projet/ }));
expect(window.confirm).toHaveBeenCalled();
// Still on the agent editor, with the unsaved edit intact.
expect(
screen.getByRole("heading", { name: "Tools MCP IdeA — DevFrontend" }),
).toBeTruthy();
expect(
(screen.getByRole("checkbox", { name: /idea_ticket_update_carnet$/ }) as HTMLInputElement)
.checked,
).toBe(true);
});
it("discards the draft and switches target when the user confirms", async () => {
await renderPanel();
await openMcpToolsTab();
await selectAgentTarget("DevFrontend");
fireEvent.click(screen.getByRole("button", { name: "Créer un override" }));
fireEvent.click(screen.getByRole("checkbox", { name: /idea_ticket_update_carnet$/ }));
(window.confirm as ReturnType<typeof vi.fn>).mockReturnValue(true);
const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" });
fireEvent.click(within(nav).getByRole("button", { name: /^Défaut projet/ }));
expect(window.confirm).toHaveBeenCalled();
expect(
screen.getByRole("heading", { name: "Tools MCP IdeA — Défaut projet" }),
).toBeTruthy();
});
it("does not prompt when switching target with no unsaved changes", async () => {
await renderPanel();
await openMcpToolsTab();
await selectAgentTarget("DevFrontend");
const nav = screen.getByRole("navigation", { name: "cibles des permissions MCP" });
fireEvent.click(within(nav).getByRole("button", { name: /^Défaut projet/ }));
expect(window.confirm).not.toHaveBeenCalled();
expect(
screen.getByRole("heading", { name: "Tools MCP IdeA — Défaut projet" }),
).toBeTruthy();
});
});
});