diff --git a/ui/src/components/EnvVarEditor.test.tsx b/ui/src/components/EnvVarEditor.test.tsx new file mode 100644 index 00000000000..b4c8160b599 --- /dev/null +++ b/ui/src/components/EnvVarEditor.test.tsx @@ -0,0 +1,128 @@ +// @vitest-environment jsdom + +import { createRoot, type Root } from "react-dom/client"; +import { flushSync } from "react-dom"; +import type { CompanySecret, EnvBinding, UserSecretDefinition } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EnvVarEditor } from "./EnvVarEditor"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const definition: UserSecretDefinition = { + id: "def-1", + companyId: "c1", + key: "PERSONAL_GH_TOKEN", + name: "Personal GitHub token", + description: null, + status: "active", + provider: "local_encrypted", + managedMode: "paperclip_managed", + providerConfigId: null, + providerMetadata: null, + usageGuidance: null, + createdByAgentId: null, + createdByUserId: null, + updatedByAgentId: null, + updatedByUserId: null, + deletedAt: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +async function act(callback: () => void | Promise) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; +} + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +function render(props: Partial>) { + root = createRoot(container); + return act(() => { + root.render( + , + ); + }); +} + +describe("EnvVarEditor user secret binding", () => { + it("renders an existing user_secret_ref as a User secret row with the definition and requirement", async () => { + const value: Record = { + GH_TOKEN: { type: "user_secret_ref", key: "PERSONAL_GH_TOKEN", required: true }, + }; + await render({ value, userSecretDefinitions: [definition] }); + + const keyInput = container.querySelector('input[placeholder="KEY"]'); + expect(keyInput?.value).toBe("GH_TOKEN"); + // Radix Select triggers render the selected label as text. + expect(container.textContent).toContain("User secret"); + expect(container.textContent).toContain("Personal GitHub token"); + expect(container.textContent).toContain("Required"); + }); + + it("offers the User secret source option and the company-secret rename", async () => { + await render({ userSecretDefinitions: [definition] }); + // The trailing empty row shows the source select defaulting to Plain. + const modeTrigger = container.querySelector('[aria-label="Binding mode"]'); + expect(modeTrigger?.textContent).toContain("Plain"); + // Helper copy documents both company and user secret sources. + expect(container.textContent).toContain("Company secret"); + expect(container.textContent).toContain("User secret"); + }); + + it("keeps working for company secrets when no user definitions are provided", async () => { + const secret: CompanySecret = { + id: "sec-1", + companyId: "c1", + scope: "company", + ownerUserId: null, + userSecretDefinitionId: null, + key: "api_key", + name: "API key", + provider: "local_encrypted", + status: "active", + managedMode: "paperclip_managed", + externalRef: null, + providerConfigId: null, + providerMetadata: null, + latestVersion: 2, + description: null, + lastResolvedAt: null, + lastRotatedAt: null, + deletedAt: null, + createdByAgentId: null, + createdByUserId: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + const value: Record = { + API_KEY: { type: "secret_ref", secretId: "sec-1", version: "latest" }, + }; + await render({ value, secrets: [secret] }); + + const keyInput = container.querySelector('input[placeholder="KEY"]'); + expect(keyInput?.value).toBe("API_KEY"); + expect(container.textContent).toContain("API key"); + }); +}); diff --git a/ui/src/pages/secrets/MissingUserSecretsBanner.test.tsx b/ui/src/pages/secrets/MissingUserSecretsBanner.test.tsx new file mode 100644 index 00000000000..ea79ce55007 --- /dev/null +++ b/ui/src/pages/secrets/MissingUserSecretsBanner.test.tsx @@ -0,0 +1,176 @@ +// @vitest-environment jsdom + +import { createRoot, type Root } from "react-dom/client"; +import { flushSync } from "react-dom"; +import { MemoryRouter } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { CompanySecret, UserSecretDefinition } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MissingUserSecretsBanner } from "./MissingUserSecretsBanner"; +import type { MyUserSecretEntry } from "../../api/secrets"; + +const mockSecretsApi = vi.hoisted(() => ({ + listMyUserSecrets: vi.fn(), + createMyUserSecret: vi.fn(), + rotateMyUserSecret: vi.fn(), +})); +const mockPushToast = vi.hoisted(() => vi.fn()); + +vi.mock("../../api/secrets", () => ({ secretsApi: mockSecretsApi })); +vi.mock("../../context/ToastContext", () => ({ + useToastActions: () => ({ pushToast: mockPushToast }), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function definition(overrides: Partial = {}): UserSecretDefinition { + return { + id: "def-1", + companyId: "c1", + key: "PERSONAL_GH_TOKEN", + name: "Personal GitHub token", + description: "Used for private repo access", + status: "active", + provider: "local_encrypted", + managedMode: "paperclip_managed", + providerConfigId: null, + providerMetadata: null, + usageGuidance: null, + createdByAgentId: null, + createdByUserId: null, + updatedByAgentId: null, + updatedByUserId: null, + deletedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function secret(): CompanySecret { + return { + id: "sec-1", + companyId: "c1", + scope: "user", + ownerUserId: "u1", + userSecretDefinitionId: "def-1", + key: "PERSONAL_GH_TOKEN", + name: "Personal GitHub token", + 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: "u1", + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +async function act(callback: () => void | Promise) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; +} + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + vi.clearAllMocks(); + container = document.createElement("div"); + document.body.appendChild(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +function render() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + root = createRoot(container); + return act(() => { + root.render( + + + + + , + ); + }); +} + +describe("MissingUserSecretsBanner", () => { + it("warns about an active user secret with no value and offers to set it", async () => { + const entries: MyUserSecretEntry[] = [{ definition: definition(), secret: null }]; + mockSecretsApi.listMyUserSecrets.mockResolvedValue(entries); + + await render(); + await flushReact(); + + expect(container.textContent).toContain("Personal GitHub token"); + expect(container.textContent).toContain("PERSONAL_GH_TOKEN"); + expect(container.textContent).toContain("Set value"); + expect(container.textContent?.toLowerCase()).toContain("will fail"); + }); + + it("renders nothing when every active secret already has a value", async () => { + const entries: MyUserSecretEntry[] = [{ definition: definition(), secret: secret() }]; + mockSecretsApi.listMyUserSecrets.mockResolvedValue(entries); + + await render(); + await flushReact(); + + expect(container.textContent).toBe(""); + }); + + it("ignores disabled definitions", async () => { + const entries: MyUserSecretEntry[] = [ + { definition: definition({ status: "disabled" }), secret: null }, + ]; + mockSecretsApi.listMyUserSecrets.mockResolvedValue(entries); + + await render(); + await flushReact(); + + expect(container.textContent).toBe(""); + }); + + it("opens the set-value dialog when Set value is clicked", async () => { + const entries: MyUserSecretEntry[] = [{ definition: definition(), secret: null }]; + mockSecretsApi.listMyUserSecrets.mockResolvedValue(entries); + + await render(); + await flushReact(); + + const setButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Set value"), + ); + expect(setButton).toBeTruthy(); + await act(() => setButton!.click()); + await flushReact(); + + // Dialog content renders in a portal on document.body. + expect(document.body.textContent).toContain("Set your value"); + }); +}); diff --git a/ui/src/pages/secrets/user-secret-presentation.test.ts b/ui/src/pages/secrets/user-secret-presentation.test.ts new file mode 100644 index 00000000000..eda88a3b1b3 --- /dev/null +++ b/ui/src/pages/secrets/user-secret-presentation.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import type { CompanySecret, UserSecretDefinition } from "@paperclipai/shared"; +import { + coverageSummaryLabel, + myValueLabel, + secretStatusTone, +} from "./user-secret-presentation"; +import { myValueState } from "./my-value-state"; + +function makeSecret(status: CompanySecret["status"]): CompanySecret { + return { + id: "sec-1", + companyId: "c1", + scope: "user", + ownerUserId: "u1", + userSecretDefinitionId: "def-1", + key: "PERSONAL_GH_TOKEN", + name: "Personal GH token", + provider: "local_encrypted", + status, + managedMode: "paperclip_managed", + externalRef: null, + providerConfigId: null, + providerMetadata: null, + latestVersion: 1, + description: null, + lastResolvedAt: null, + lastRotatedAt: null, + deletedAt: null, + createdByAgentId: null, + createdByUserId: "u1", + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +const definition = { id: "def-1", key: "PERSONAL_GH_TOKEN" } as UserSecretDefinition; + +describe("coverageSummaryLabel", () => { + it("shows counts only, never values", () => { + expect( + coverageSummaryLabel({ + definitionId: "def-1", + configuredCount: 5, + missingCount: 2, + inactiveCount: 0, + }), + ).toBe("5 of 7 set"); + }); + + it("counts inactive members in the total", () => { + expect( + coverageSummaryLabel({ + definitionId: "def-1", + configuredCount: 3, + missingCount: 1, + inactiveCount: 1, + }), + ).toBe("3 of 5 set"); + }); + + it("renders a dash when coverage is unknown", () => { + expect(coverageSummaryLabel(undefined)).toBe("—"); + }); +}); + +describe("myValueState", () => { + it("is not_set when the user has no value", () => { + expect(myValueState(definition, null)).toBe("not_set"); + expect(myValueLabel(myValueState(definition, null))).toBe("Not set"); + }); + + it("is set when an active value exists", () => { + expect(myValueState(definition, makeSecret("active"))).toBe("set"); + }); + + it("is inactive when the value is disabled", () => { + expect(myValueState(definition, makeSecret("disabled"))).toBe("inactive"); + }); +}); + +describe("secretStatusTone", () => { + it("uses emerald for active and muted for disabled", () => { + expect(secretStatusTone("active")).toContain("emerald"); + expect(secretStatusTone("disabled")).toContain("muted"); + }); +});