Test user-secret UI helpers, warning banner, and env picker

- user-secret-presentation/my-value-state: coverage counts-only label,
  my-value state derivation, status tones.
- MissingUserSecretsBanner: warns about unset active user secrets, renders
  nothing when satisfied or disabled, and opens the set-value dialog
  (the warning path AC).
- EnvVarEditor: renders user_secret_ref rows with definition + Required,
  exposes the User secret source and Company secret rename, and preserves
  company-secret behavior.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-01 18:25:07 +00:00
1 parent 8d10c84ed2
commit 9294de6d1e
3 files changed
+391

No files matched your search

+128
View File
@@ -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<void>) {
let result: void | Promise<void> = 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<React.ComponentProps<typeof EnvVarEditor>>) {
root = createRoot(container);
return act(() => {
root.render(
<EnvVarEditor
value={props.value ?? {}}
secrets={props.secrets ?? []}
userSecretDefinitions={props.userSecretDefinitions}
onCreateSecret={props.onCreateSecret ?? (vi.fn() as never)}
onChange={props.onChange ?? vi.fn()}
/>,
);
});
}
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<string, EnvBinding> = {
GH_TOKEN: { type: "user_secret_ref", key: "PERSONAL_GH_TOKEN", required: true },
};
await render({ value, userSecretDefinitions: [definition] });
const keyInput = container.querySelector<HTMLInputElement>('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<string, EnvBinding> = {
API_KEY: { type: "secret_ref", secretId: "sec-1", version: "latest" },
};
await render({ value, secrets: [secret] });
const keyInput = container.querySelector<HTMLInputElement>('input[placeholder="KEY"]');
expect(keyInput?.value).toBe("API_KEY");
expect(container.textContent).toContain("API key");
});
});
@@ -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> = {}): 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<void>) {
let result: void | Promise<void> = 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(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<MissingUserSecretsBanner companyId="c1" />
</MemoryRouter>
</QueryClientProvider>,
);
});
}
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");
});
});
@@ -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");
});
});