// @vitest-environment jsdom import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { CompanySecret, EnvSecretRefBinding } from "@paperclipai/shared"; const mockSecretPickerRender = vi.hoisted(() => vi.fn()); // Keep this component test focused on access-row behavior while asserting that // the editor routes selection through the shared, folder-aware env picker. vi.mock("./environment-variables-editor/SecretPicker", () => ({ SecretPicker: (props: { secretId: string; secrets: readonly CompanySecret[]; onSelect: (secretId: string) => void; onCreateNew?: (query: string) => void; }) => { mockSecretPickerRender(props); return <> {props.onCreateNew ? ( ) : null} ; }, })); import { AgentSecretAccessEditor, parseAccessGrants, parseEnvSecretRefs, rowsToAccessMap, summarizeAgentBindings, } from "./AgentSecretAccessEditor"; function makeSecret(id: string, name: string): CompanySecret { return { id, companyId: "co", scope: "company", ownerUserId: null, userSecretDefinitionId: null, key: id, name, provider: "local_encrypted", status: "active", managedMode: "paperclip_managed", externalRef: null, providerConfigId: null, providerMetadata: null, latestVersion: 1, description: null, lastResolvedAt: null, lastRotatedAt: null, deletedAt: null, createdByAgentId: null, createdByUserId: null, createdAt: new Date(0), updatedAt: new Date(0), }; } describe("AgentSecretAccessEditor model", () => { const config = { env: { GH_TOKEN: { type: "secret_ref", secretId: "s1", version: 2 }, PLAIN: { type: "plain", value: "hi" }, }, "access.STRIPE": { type: "secret_ref", secretId: "s1" }, "access.BROKEN": { type: "plain", value: "nope" }, model: "claude", }; it("parses env secret refs, ignoring plain values", () => { expect(parseEnvSecretRefs(config)).toEqual([{ name: "GH_TOKEN", secretId: "s1", version: 2 }]); }); it("parses only well-formed top-level access.* secret refs", () => { expect(parseAccessGrants(config)).toEqual([{ name: "STRIPE", secretId: "s1", version: "latest" }]); }); it("summarizes bindings per secret with both delivery modes", () => { const summary = summarizeAgentBindings(parseEnvSecretRefs(config), parseAccessGrants(config)); expect(summary).toEqual([{ secretId: "s1", envKeys: ["GH_TOKEN"], apiAliases: ["STRIPE"] }]); }); it("drops incomplete, invalid-alias, and unselected rows from the emitted access map", () => { expect( rowsToAccessMap([ { id: "1", alias: "OK", secretId: "s1", version: "latest" }, { id: "2", alias: "", secretId: "s1", version: "latest" }, // no alias { id: "3", alias: "1BAD", secretId: "s1", version: "latest" }, // invalid alias { id: "4", alias: "NOSECRET", secretId: "", version: "latest" }, // no secret ]), ).toEqual({ OK: { type: "secret_ref", secretId: "s1", version: "latest" } }); }); }); describe("AgentSecretAccessEditor component", () => { let container: HTMLDivElement; let root: Root | null = null; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); }); afterEach(() => { flushSync(() => root?.unmount()); root = null; container.remove(); vi.clearAllMocks(); }); function render(node: React.ReactNode) { root = createRoot(container); flushSync(() => root!.render(node)); } const secrets = [makeSecret("s1", "STRIPE_KEY")]; it("shows the delivery-mode overview for existing bindings", () => { render( {}} />, ); expect(container.textContent).toContain("STRIPE_KEY"); expect(container.textContent).toContain("Env var"); expect(container.textContent).toContain("API access"); expect(container.textContent).toContain("env.GH_TOKEN"); expect(container.textContent).toContain("access.STRIPE"); }); it("adds an API-access grant, emitting an access. secret_ref", () => { const emitted: Array> = []; render( emitted.push(next)} />, ); // "Add API access" appends an editable row. const addButton = [...container.querySelectorAll("button")].find( (button) => button.textContent?.includes("Add API access"), )!; flushSync(() => addButton.click()); // Type an alias. const aliasInput = container.querySelector('input[aria-label="Access alias"]')!; const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!; setter.call(aliasInput, "STRIPE"); flushSync(() => aliasInput.dispatchEvent(new Event("input", { bubbles: true }))); // Bind a secret via the stubbed picker. const pick = container.querySelector('[data-testid="pick-secret"]')!; expect(mockSecretPickerRender).toHaveBeenLastCalledWith( expect.objectContaining({ secretId: "", secrets }), ); flushSync(() => pick.click()); const last = emitted.at(-1)!; expect(last).toEqual({ STRIPE: { type: "secret_ref", secretId: "s1", version: "latest" } }); }); it("keeps the create form open when focus returns to the shared picker anchor", async () => { vi.useFakeTimers(); try { render( {}} onCreateSecret={async () => secrets[0]!} />, ); const createButton = container.querySelector('[data-testid="create-secret"]')!; flushSync(() => createButton.click()); flushSync(() => vi.runAllTimers()); expect(document.body.textContent).toContain("Create secret"); const pickerButton = container.querySelector('[data-testid="pick-secret"]')!; pickerButton.focus(); flushSync(() => {}); expect(document.body.textContent).toContain("Create secret"); expect(document.querySelector('input[aria-label="Secret name"]')).toBeTruthy(); } finally { vi.useRealTimers(); } }); it("renders pending binding proposals as Proposed rows with approve/reject", () => { const approved: string[] = []; const rejected: string[] = []; const proposal = { id: "prop-1", companyId: "co", kind: "binding" as const, status: "pending" as const, justification: "Bind Stripe for the billing agent.", proposedName: null, proposedKey: null, proposedDescription: null, valueFingerprintSha256: null, valueLength: null, secretId: "s1", secretName: "STRIPE_KEY", secretProposalId: null, secretProposalName: null, targetType: "agent" as const, target: { id: "agent-1", name: "BillingBot", icon: null }, configPath: "access.STRIPE", proposedBy: { id: "agent-2", name: "ClaudeCoder", icon: null }, originIssue: null, originRunId: "run-1", expiresAt: new Date(Date.now() + 86_400_000).toISOString(), createdAt: new Date(0).toISOString(), resolvedByUserId: null, resolvedAt: null, resolutionReason: null, createdSecretId: null, appliedBindingConfigPath: null, viewerCanApprove: true, approveBlockReason: null, }; render( {}} proposals={[proposal]} onApproveProposal={(p) => approved.push(p.id)} onRejectProposal={(p) => rejected.push(p.id)} />, ); expect(container.textContent).toContain("Proposed access"); expect(container.textContent).toContain("STRIPE"); // alias + bound secret name expect(container.textContent).toContain("ClaudeCoder"); // proposer const approveButton = [...container.querySelectorAll("button")].find( (button) => button.textContent?.trim() === "Approve", )!; flushSync(() => approveButton.click()); expect(approved).toEqual(["prop-1"]); const rejectButton = [...container.querySelectorAll("button")].find( (button) => button.textContent?.trim() === "Reject", )!; flushSync(() => rejectButton.click()); expect(rejected).toEqual(["prop-1"]); }); });