/** * MCP integration: the org settings tab and the owner's gateway switches. * * The security-relevant assertion is the show-once token: the raw value comes * back from the create call and nowhere else, so the list table must never carry * it, and the modal that shows it is the only place a user can copy it. */ import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => { class FakeApiError extends Error { constructor( readonly status: number, readonly code: string, message: string, ) { super(message); } } return { FakeApiError, api: { listMembers: vi.fn(), listAudit: vi.fn(), updateMember: vi.fn(), removeMember: vi.fn(), }, orgApi: { listOrgInvites: vi.fn(), getAppProfile: vi.fn(), patchAppProfile: vi.fn(), transferOwnership: vi.fn(), leaveOrganization: vi.fn(), }, mcpApi: { list: vi.fn(), settings: vi.fn(), create: vi.fn(), revoke: vi.fn(), adminState: vi.fn(), setGateway: vi.fn(), setOrgEnabled: vi.fn(), }, session: { activeOrganization: { id: "org-1", name: "Acme", slug: "acme", role: "admin" as const }, refresh: vi.fn(async () => {}), }, router: { replace: vi.fn() }, clipboard: { writeText: vi.fn(async () => {}) }, channels: [] as string[][], }; }); vi.mock("next/navigation", () => ({ useRouter: () => mocks.router })); vi.mock("@/lib/session", () => ({ useSession: () => mocks.session })); vi.mock("@/lib/api", () => ({ ApiError: mocks.FakeApiError, api: mocks.api })); vi.mock("@/lib/api-org", () => ({ orgApi: mocks.orgApi, mcpApi: mocks.mcpApi, MCP_SCOPES: [ "app:read", "tasks:read", "tasks:write", "memory:read", "memory:write", "delivery:read", ], memoryApi: { list: vi.fn(), listKnowledge: vi.fn() }, MEMORY_CHANGED_EVENT: "memory.changed", memoryKinds: [], useNeedsOnboarding: () => ({ needsOnboarding: false, ready: true }), })); vi.mock("@/lib/realtime", () => ({ useLiveChannel: (channels: string[], onEvent: (event: unknown) => void) => { mocks.channels.push(channels); }, })); import AdminMcpPage from "@/app/(app)/admin/mcp/page"; import SettingsPage from "@/app/(app)/settings/page"; const TOKEN_ROW = { id: "tok-1", organization_id: "org-1", name: "agent CI", prefix: "smo_abc123", scopes: ["tasks:read"], expires_at: "2026-06-01T00:00:00Z", last_used_at: null, revoked_at: null, created_at: "2026-01-01T00:00:00Z", status: "active" as const, }; beforeEach(() => { vi.clearAllMocks(); mocks.channels = []; Object.defineProperty(window.navigator, "clipboard", { value: mocks.clipboard, configurable: true, }); mocks.session.activeOrganization = { id: "org-1", name: "Acme", slug: "acme", role: "admin" as const, }; mocks.api.listMembers.mockResolvedValue([]); mocks.api.listAudit.mockResolvedValue([]); mocks.orgApi.listOrgInvites.mockResolvedValue([]); mocks.orgApi.getAppProfile.mockRejectedValue(new mocks.FakeApiError(404, "nf", "no app")); mocks.mcpApi.list.mockResolvedValue([TOKEN_ROW]); mocks.mcpApi.settings.mockResolvedValue({ organization_id: "org-1", mcp_enabled: true, gateway_enabled: true, scopes: [ "app:read", "tasks:read", "tasks:write", "memory:read", "memory:write", "delivery:read", ], token_count: 1, }); mocks.mcpApi.adminState.mockResolvedValue({ gateway_enabled: true, organizations: [ { organization_id: "org-1", name: "Acme", slug: "acme", mcp_enabled: true, token_count: 1, }, ], total_tokens: 1, }); }); describe("settings — MCP integration", () => { it("lists tokens by prefix and never shows the raw token", async () => { render(); fireEvent.click(await screen.findByRole("button", { name: "یکپارچه‌سازی (MCP)" })); expect(await screen.findByText("agent CI")).toBeInTheDocument(); // Only the prefix is rendered — a nine-character handle, never a secret. expect(screen.getByText("smo_abc123…")).toBeInTheDocument(); expect(screen.queryByText(/smo_[A-Za-z0-9_\-]{20,}/)).not.toBeInTheDocument(); }); it("shows a minted token exactly once, with a copy button", async () => { mocks.mcpApi.create.mockResolvedValue({ ...TOKEN_ROW, id: "tok-2", token: "smo_SEKRET-VALUE-1234567890abcdef", }); render(); fireEvent.click(await screen.findByRole("button", { name: "یکپارچه‌سازی (MCP)" })); fireEvent.change(await screen.findByLabelText("نام توکن"), { target: { value: "agent جدید" }, }); fireEvent.click(screen.getByRole("button", { name: "ساخت توکن" })); const dialog = await screen.findByRole("dialog", { name: "توکن ساخته شد" }); expect(within(dialog).getByText("smo_SEKRET-VALUE-1234567890abcdef")).toBeInTheDocument(); fireEvent.click(within(dialog).getByRole("button", { name: "کپی توکن" })); await waitFor(() => expect(mocks.clipboard.writeText).toHaveBeenCalledWith("smo_SEKRET-VALUE-1234567890abcdef"), ); // After closing, the raw value is gone from the page for good. fireEvent.click(within(dialog).getByRole("button", { name: "بستن" })); await waitFor(() => expect(screen.queryByText("smo_SEKRET-VALUE-1234567890abcdef")).not.toBeInTheDocument(), ); }); it("requires a scope before minting, and confirms before revoking", async () => { render(); fireEvent.click(await screen.findByRole("button", { name: "یکپارچه‌سازی (MCP)" })); // Deselect the pre-checked scope: mint is disabled without one. fireEvent.change(await screen.findByLabelText("نام توکن"), { target: { value: "بدون محدوده" }, }); const checkbox = document.getElementById("check-tasks:read") as HTMLInputElement; expect(checkbox.checked).toBe(true); fireEvent.click(checkbox); expect(checkbox.checked).toBe(false); expect(screen.getByRole("button", { name: "ساخت توکن" })).toBeDisabled(); // …and selectable again, which is what re-enables it. fireEvent.click(checkbox); expect(screen.getByRole("button", { name: "ساخت توکن" })).toBeEnabled(); mocks.mcpApi.revoke.mockResolvedValue({ revoked: true }); fireEvent.click(screen.getByRole("button", { name: "لغو" })); const dialog = await screen.findByRole("dialog", { name: "لغو توکن" }); fireEvent.click(dialog.querySelectorAll("button")[1]); await waitFor(() => expect(mocks.mcpApi.revoke).toHaveBeenCalledWith("org-1", "tok-1")); }); }); describe("admin — MCP switches", () => { it("shows the gateway state and switches off one organization", async () => { mocks.mcpApi.setOrgEnabled.mockResolvedValue({ mcp_enabled: false }); render(); expect(await screen.findByText("Acme")).toBeInTheDocument(); expect(screen.getByText("روشن")).toBeInTheDocument(); // Turning one organization off asks first — it is a destructive switch. fireEvent.click(screen.getByRole("button", { name: "خاموش کن" })); const dialog = await screen.findByRole("dialog", { name: "خاموش‌کردن دسترسی یک سازمان" }); fireEvent.click(dialog.querySelectorAll("button")[1]); await waitFor(() => expect(mocks.mcpApi.setOrgEnabled).toHaveBeenCalledWith("org-1", false), ); }); it("turns the whole gateway off with a confirmation", async () => { mocks.mcpApi.setGateway.mockResolvedValue({ gateway_enabled: false }); render(); const button = await screen.findByRole("button", { name: "خاموش‌کردن دسترسی MCP سامانه", }); fireEvent.click(button); await waitFor(() => expect(mocks.mcpApi.setGateway).toHaveBeenCalledWith(false)); expect( await screen.findByRole("button", { name: "روشن‌کردن دسترسی MCP" }), ).toBeInTheDocument(); }); });