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:
mjalmoudy94
2026-10-14 15:51:44 +03:30
co-authored by Claude Sonnet 5.5
parent c93b25741e
commit dee2f72c46
39 changed files with 9060 additions and 95 deletions
+240
View File
@@ -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();
});
});