feat(org): model grants, org memory, MCP gateway (v2 phases A1-3..5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
c93b25741e
commit
dee2f72c46
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user