329 lines
12 KiB
TypeScript
329 lines
12 KiB
TypeScript
/**
|
||
* 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();
|
||
});
|
||
});
|
||
});
|