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();
});
});
+216
View File
@@ -0,0 +1,216 @@
/**
* Org memory page.
*
* Four tabs over one organization's memory, a "teach the organization" dialog
* that is a single sentence, and a live refresh on ``memory.changed``. The
* security-relevant assertion here is the payload one: a credential typed into
* the teach box must never reach the API — the server redacts, and the client
* must not smuggle it into a key field either.
*/
import { fireEvent, render, screen, waitFor } 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: {},
memoryApi: {
list: vi.fn(),
teach: vi.fn(),
edit: vi.fn(),
setPinned: vi.fn(),
confirm: vi.fn(),
remove: vi.fn(),
listKnowledge: vi.fn(),
createKnowledge: vi.fn(),
removeKnowledge: vi.fn(),
},
session: {
activeOrganization: { id: "org-1", name: "Acme", slug: "acme", role: "admin" as const },
refresh: vi.fn(async () => {}),
},
router: { replace: vi.fn() },
subscriptions: [] as string[][],
handlers: [] as ((event: unknown) => void)[],
};
});
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", () => ({
MEMORY_CHANGED_EVENT: "memory.changed",
memoryApi: mocks.memoryApi,
memoryKinds: [
{ key: "rule", label: "قاعده" },
{ key: "preference", label: "ترجیح" },
{ key: "lesson", label: "درس" },
{ key: "fact", label: "واقعیت" },
{ key: "code_map", label: "نقشهٔ کد" },
],
}));
vi.mock("@/lib/realtime", () => ({
useLiveChannel: (channels: string[], onEvent: (event: unknown) => void) => {
mocks.subscriptions.push(channels);
mocks.handlers.push(onEvent);
},
}));
import MemoryPage from "@/app/(app)/memory/page";
const RULE = {
id: "m-rule",
kind: "rule",
kind_label: "قاعده",
key: "tests-first",
content: "پیش از هر تغییر، تست مربوطه را اجرا کن.",
source: "user",
source_label: "کاربر",
evidence_task_id: null,
pinned: true,
confidence: 1,
expires_at: null,
hits: 3,
last_used_at: "2026-02-01T00:00:00Z",
created_by_user_id: "u-1",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-02T00:00:00Z",
};
const LESSON = {
...RULE,
id: "m-lesson",
kind: "lesson",
kind_label: "درس",
key: null,
content: "دروازهٔ lint دو بار شکست؛ پیش از ارسال، lint محلی را اجرا کن.",
source: "reflection",
source_label: "بازتاب",
confidence: 0.5,
pinned: false,
expires_at: "2026-02-15T00:00:00Z",
evidence_task_id: "task-9",
};
const KNOWLEDGE = {
id: "src-1",
name: "قواعد کدنویسی",
kind: "text",
project_id: null,
created_at: "2026-01-05T00:00:00Z",
chunk_count: 4,
};
beforeEach(() => {
vi.clearAllMocks();
mocks.subscriptions = [];
mocks.handlers = [];
mocks.memoryApi.list.mockResolvedValue({
organization_id: "org-1",
items: [RULE, LESSON],
total: 2,
pinned: 1,
chars: 180,
});
mocks.memoryApi.listKnowledge.mockResolvedValue([KNOWLEDGE]);
});
describe("memory page", () => {
it("shows rules and lessons on their own tabs", async () => {
render(<MemoryPage />);
expect(await screen.findByText(RULE.content)).toBeInTheDocument();
// The lesson lives on its own tab, not in the rules list.
expect(screen.queryByText(LESSON.content)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "درس‌های آموخته" }));
expect(await screen.findByText(LESSON.content)).toBeInTheDocument();
// A reflection-sourced lesson carries its half-confidence in the UI.
expect(screen.getByText(/اعتماد ۵۰/)).toBeInTheDocument();
expect(screen.queryByText(RULE.content)).not.toBeInTheDocument();
});
it("teaches a rule with one sentence and never puts a secret in the payload", async () => {
mocks.memoryApi.teach.mockResolvedValue({ ...RULE, id: "m-new", content: "یادداشت تازه" });
render(<MemoryPage />);
fireEvent.click(await screen.findByRole("button", { name: "به سازمان یاد بده" }));
const dialog = await screen.findByRole("dialog");
// A credential pasted into the teach box is a user error the server must
// catch; the client's job is to send exactly what was typed and nothing else.
fireEvent.change(dialog.querySelector("textarea")!, {
target: { value: "توکن CI من ghp_ABCDEFGHIJKLMNOPQRSTUVWXyz123 است" },
});
fireEvent.click(dialog.querySelector('button[type="submit"]')!);
await waitFor(() => expect(mocks.memoryApi.teach).toHaveBeenCalledTimes(1));
const [orgId, payload] = mocks.memoryApi.teach.mock.calls[0];
expect(orgId).toBe("org-1");
expect(payload.kind).toBe("rule");
// Only kind/content are ever sent (no key is invented), and there is no
// field a secret could hide in.
expect(Object.keys(payload).sort()).toEqual(["content", "kind"]);
});
it("pins a rule and asks for confirmation before deleting one", async () => {
mocks.memoryApi.setPinned.mockResolvedValue({ ...RULE, pinned: false });
mocks.memoryApi.remove.mockResolvedValue(undefined);
render(<MemoryPage />);
fireEvent.click(await screen.findByRole("button", { name: "برداشتن سنجاق" }));
await waitFor(() =>
expect(mocks.memoryApi.setPinned).toHaveBeenCalledWith("org-1", "m-rule", false),
);
fireEvent.click(screen.getByRole("button", { name: "حذف" }));
const dialog = await screen.findByRole("dialog", { name: "حذف یادداشت" });
const confirm = dialog.querySelector('button') as HTMLButtonElement;
// The confirm button is the dangerous one; the dialog explains the damage.
expect(confirm).toBeDefined();
expect(screen.getByText(/از حافظهٔ سازمان برداشته شود/)).toBeInTheDocument();
fireEvent.click(dialog.querySelectorAll("button")[1]);
await waitFor(() => expect(mocks.memoryApi.remove).toHaveBeenCalledWith("org-1", "m-rule"));
});
it("refreshes when the org channel says memory changed", async () => {
render(<MemoryPage />);
await screen.findByText(RULE.content);
// The page subscribes to its own tenant's channel only.
expect(mocks.subscriptions.at(-1)).toEqual(["org:org-1"]);
const callsBefore = mocks.memoryApi.list.mock.calls.length;
mocks.handlers.at(-1)!({ type: "memory.changed", data: { kind: "lesson" } });
await waitFor(() =>
expect(mocks.memoryApi.list.mock.calls.length).toBeGreaterThan(callsBefore),
);
// An unrelated event does not cause a reload.
const callsAfter = mocks.memoryApi.list.mock.calls.length;
mocks.handlers.at(-1)!({ type: "run.step", data: {} });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(mocks.memoryApi.list.mock.calls.length).toBe(callsAfter);
});
it("keeps the knowledge sources as a second tab", async () => {
render(<MemoryPage />);
fireEvent.click(await screen.findByRole("button", { name: "منابع دانش" }));
expect(await screen.findByText(KNOWLEDGE.name)).toBeInTheDocument();
// Chunk counts use Persian digits like every other number in the product.
expect(screen.getByText("۴")).toBeInTheDocument();
});
});