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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user