Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
241 lines
8.1 KiB
TypeScript
241 lines
8.1 KiB
TypeScript
/**
|
|
* 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(<SettingsPage />);
|
|
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(<SettingsPage />);
|
|
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(<SettingsPage />);
|
|
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(<AdminMcpPage />);
|
|
|
|
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(<AdminMcpPage />);
|
|
|
|
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();
|
|
});
|
|
});
|