Files
IdeA/frontend/src/features/permissions/McpToolPermissionsPanel.test.tsx

329 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Ticket #82/#297 — the dedicated project `MCP` 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,
MockPluginGateway,
MockProfileGateway,
MockUserSettingsGateway,
} from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import type { Gateways } from "@/ports";
import { McpPanel } from "./McpPanel";
import { I18nProvider } from "@/i18n";
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,
plugin: new MockPluginGateway(),
profile: new MockProfileGateway(),
userSettings: new MockUserSettingsGateway(),
} as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<I18nProvider><McpPanel projectId={PROJECT_ID} /></I18nProvider>
</DIProvider>,
);
await waitFor(() => {
expect(screen.getByText("DevFrontend")).toBeTruthy();
});
return { agent, permission, agentId: created.id };
}
async function openMcpToolsTab() {
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: `MCP IdeA — ${name}` })).toBeTruthy();
});
}
describe("McpPanel", () => {
it("is exposed as a dedicated surface", async () => {
await renderPanel();
await openMcpToolsTab();
expect(screen.getByRole("heading", { name: "MCP" })).toBeTruthy();
expect(screen.queryByRole("tablist", { name: "Permissions" })).toBeNull();
});
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("shows multi-agent delegation in the Délégation agents MCP group", async () => {
await renderPanel();
await openMcpToolsTab();
const delegationGroup = screen.getByRole("button", {
name: /Délégation agents/,
});
expect(delegationGroup).toBeTruthy();
expect(screen.getByRole("checkbox", {
name: /Solliciter plusieurs agents, idea_ask_agents$/,
})).toBeTruthy();
});
it("surfaces native workspace tools as the Outils du projet group", async () => {
await renderPanel();
await openMcpToolsTab();
expect(screen.getByRole("button", { name: /Outils du projet, 4 sur 4 autorisés/ })).toBeTruthy();
expect(
screen.getByText("Permet à l’agent d’explorer, rechercher et lire les fichiers du projet."),
).toBeTruthy();
expect(screen.getByRole("checkbox", {
name: /Cartographier le projet, idea_workspace_map$/,
})).toBeTruthy();
expect(screen.getByRole("checkbox", {
name: /Lister les éléments du projet, idea_workspace_list$/,
})).toBeTruthy();
expect(screen.getByRole("checkbox", {
name: /Lire un élément du projet, idea_workspace_read$/,
})).toBeTruthy();
expect(screen.getByRole("checkbox", {
name: /Rechercher dans le projet, idea_workspace_search$/,
})).toBeTruthy();
});
it("shows cross-project tools in their UX group and catalogued unmapped tools in Autres", async () => {
const agent = new MockAgentGateway();
const permission = new MockPermissionGateway();
permission.setMcpToolCatalogue({
readOnlyTools: ["idea_project_list", "idea_future_read"],
writeActionTools: ["idea_project_ask_agent"],
});
const gateways = {
agent,
permission,
plugin: new MockPluginGateway(),
profile: new MockProfileGateway(),
userSettings: new MockUserSettingsGateway(),
} as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<I18nProvider><McpPanel projectId={PROJECT_ID} /></I18nProvider>
</DIProvider>,
);
await openMcpToolsTab();
expect(screen.getByRole("button", { name: /IdeA inter-projets/ })).toBeTruthy();
expect(
screen.getByRole("checkbox", {
name: /Demander à un agent d'un autre projet, idea_project_ask_agent$/,
}),
).toBeTruthy();
expect(screen.getByRole("button", { name: /Autres/ })).toBeTruthy();
const fallback = screen.getByRole("checkbox", {
name: /idea_future_read, idea_future_read$/,
}) as HTMLInputElement;
expect(fallback.disabled).toBe(false);
});
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(/20 lectures autorisées · 0 écritures autorisées/)).toBeTruthy();
const writeCheckbox = screen.getByRole("checkbox", {
name: /idea_ticket_update_carnet$/,
});
fireEvent.click(writeCheckbox);
expect(screen.getByText(/20 lectures autorisées · 1 écriture autorisée/)).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(21);
// 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: "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();
});
});
});