mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
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:
1 parent
8d10c84ed2
commit
9294de6d1e
3 files changed
+391
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user