2060 lines
72 KiB
TypeScript
2060 lines
72 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { createRoot as createReactRoot, 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,
|
|
CompanySecretProviderConfig,
|
|
RemoteSecretImportPreviewResult,
|
|
SecretProviderConfigDiscoveryPreviewResult,
|
|
SecretProviderDescriptor,
|
|
UserSecretCoverageSummary,
|
|
UserSecretDefinition,
|
|
} from "@paperclipai/shared";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { ProviderVaultsTab, Secrets } from "./Secrets";
|
|
import { ApiError } from "../api/client";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
|
|
const mockSecretsApi = vi.hoisted(() => ({
|
|
list: vi.fn(),
|
|
providers: vi.fn(),
|
|
providerHealth: vi.fn(),
|
|
providerConfigs: vi.fn(),
|
|
providerConfigDiscoveryPreview: vi.fn(),
|
|
createProviderConfig: vi.fn(),
|
|
updateProviderConfig: vi.fn(),
|
|
disableProviderConfig: vi.fn(),
|
|
removeProviderConfig: vi.fn(),
|
|
setDefaultProviderConfig: vi.fn(),
|
|
checkProviderConfigHealth: vi.fn(),
|
|
remoteImportPreview: vi.fn(),
|
|
remoteImport: vi.fn(),
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
rotate: vi.fn(),
|
|
disable: vi.fn(),
|
|
enable: vi.fn(),
|
|
archive: vi.fn(),
|
|
remove: vi.fn(),
|
|
usage: vi.fn(),
|
|
accessEvents: vi.fn(),
|
|
listUserSecretDefinitions: vi.fn(),
|
|
createUserSecretDefinition: vi.fn(),
|
|
updateUserSecretDefinition: vi.fn(),
|
|
removeUserSecretDefinition: vi.fn(),
|
|
userSecretDefinitionCoverage: vi.fn(),
|
|
listMyUserSecrets: vi.fn(),
|
|
createMyUserSecret: vi.fn(),
|
|
updateMyUserSecret: vi.fn(),
|
|
rotateMyUserSecret: vi.fn(),
|
|
removeMyUserSecret: vi.fn(),
|
|
listProposals: vi.fn(),
|
|
approveProposal: vi.fn(),
|
|
rejectProposal: vi.fn(),
|
|
}));
|
|
|
|
const mockAgentsApi = vi.hoisted(() => ({
|
|
list: vi.fn(),
|
|
get: vi.fn(),
|
|
update: vi.fn(),
|
|
}));
|
|
|
|
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
|
const mockPushToast = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("../api/secrets", () => ({
|
|
secretsApi: mockSecretsApi,
|
|
}));
|
|
|
|
vi.mock("../api/agents", () => ({
|
|
agentsApi: mockAgentsApi,
|
|
}));
|
|
|
|
vi.mock("../context/CompanyContext", () => ({
|
|
useCompany: () => ({
|
|
selectedCompanyId: "company-1",
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../context/BreadcrumbContext", () => ({
|
|
useBreadcrumbs: () => ({
|
|
setBreadcrumbs: mockSetBreadcrumbs,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../context/ToastContext", () => ({
|
|
useToast: () => ({
|
|
pushToast: mockPushToast,
|
|
}),
|
|
useToastActions: () => ({
|
|
pushToast: mockPushToast,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../context/SidebarContext", () => ({
|
|
useSidebar: () => ({
|
|
isMobile: false,
|
|
}),
|
|
}));
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
const providers: SecretProviderDescriptor[] = [
|
|
{
|
|
id: "local_encrypted",
|
|
label: "Local encrypted",
|
|
requiresExternalRef: false,
|
|
supportsManagedValues: true,
|
|
supportsExternalReferences: false,
|
|
configured: true,
|
|
},
|
|
{
|
|
id: "aws_secrets_manager",
|
|
label: "AWS Secrets Manager",
|
|
requiresExternalRef: false,
|
|
supportsManagedValues: true,
|
|
supportsExternalReferences: true,
|
|
supportsExternalValueWrites: true,
|
|
configured: true,
|
|
},
|
|
{
|
|
id: "gcp_secret_manager",
|
|
label: "GCP Secret Manager",
|
|
requiresExternalRef: false,
|
|
supportsManagedValues: false,
|
|
supportsExternalReferences: true,
|
|
configured: false,
|
|
},
|
|
{
|
|
id: "vault",
|
|
label: "Vault",
|
|
requiresExternalRef: false,
|
|
supportsManagedValues: false,
|
|
supportsExternalReferences: true,
|
|
configured: false,
|
|
},
|
|
];
|
|
|
|
const providerConfigs = [
|
|
{
|
|
id: "vault-local",
|
|
provider: "local_encrypted",
|
|
displayName: "Local default",
|
|
status: "ready",
|
|
isDefault: true,
|
|
healthStatus: "ready",
|
|
healthCheckedAt: null,
|
|
healthMessage: null,
|
|
healthDetails: null,
|
|
},
|
|
{
|
|
id: "vault-aws",
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS production",
|
|
status: "ready",
|
|
isDefault: false,
|
|
healthStatus: null,
|
|
healthCheckedAt: null,
|
|
healthMessage: null,
|
|
healthDetails: null,
|
|
},
|
|
] satisfies Partial<CompanySecretProviderConfig>[];
|
|
|
|
const activeRoots = new Set<Root>();
|
|
|
|
async function act(callback: () => void | Promise<void>) {
|
|
let result: void | Promise<void>;
|
|
flushSync(() => {
|
|
result = callback();
|
|
});
|
|
await result!;
|
|
}
|
|
|
|
function createRoot(container: Element | DocumentFragment) {
|
|
const root = createReactRoot(container);
|
|
const unmount = root.unmount.bind(root);
|
|
root.unmount = () => {
|
|
if (!activeRoots.delete(root)) return;
|
|
unmount();
|
|
};
|
|
activeRoots.add(root);
|
|
return root;
|
|
}
|
|
|
|
function unmountActiveRoots() {
|
|
for (const root of [...activeRoots]) root.unmount();
|
|
}
|
|
|
|
async function flushReact() {
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
|
});
|
|
}
|
|
|
|
async function waitForReact(predicate: () => boolean, attempts = 20) {
|
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
if (predicate()) return;
|
|
await flushReact();
|
|
}
|
|
throw new Error("Timed out waiting for React state to settle");
|
|
}
|
|
|
|
function makeDiscoveryPreview(
|
|
overrides: Partial<SecretProviderConfigDiscoveryPreviewResult> = {},
|
|
): SecretProviderConfigDiscoveryPreviewResult {
|
|
return {
|
|
provider: "aws_secrets_manager",
|
|
nextToken: null,
|
|
sampledSecretCount: 2,
|
|
skippedForeignPaperclipSampleCount: 0,
|
|
warnings: [],
|
|
candidates: [
|
|
{
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS production",
|
|
config: {
|
|
region: "us-east-1",
|
|
namespace: "prod-use1",
|
|
secretNamePrefix: "paperclip",
|
|
kmsKeyId: "alias/paperclip-secrets",
|
|
ownerTag: "platform",
|
|
environmentTag: "production",
|
|
},
|
|
sampleCount: 2,
|
|
samples: [
|
|
{
|
|
name: "paperclip/prod-use1/company-1/openai",
|
|
hasKmsKey: true,
|
|
tagKeys: ["owner", "environment"],
|
|
},
|
|
],
|
|
signals: {
|
|
namespace: "prod-use1",
|
|
secretNamePrefix: "paperclip",
|
|
environmentTag: "production",
|
|
ownerTag: "platform",
|
|
kmsKeyId: "alias/paperclip-secrets",
|
|
hasKmsKey: true,
|
|
sampleCount: 2,
|
|
paperclipManagedSampleCount: 0,
|
|
skippedForeignPaperclipSampleCount: 0,
|
|
},
|
|
warnings: [],
|
|
},
|
|
],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeRemoteImportPreview(
|
|
overrides: Partial<RemoteSecretImportPreviewResult> = {},
|
|
): RemoteSecretImportPreviewResult {
|
|
return {
|
|
providerConfigId: "vault-aws",
|
|
provider: "aws_secrets_manager",
|
|
nextToken: null,
|
|
candidates: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeCompanySecret(overrides: Partial<CompanySecret> = {}): CompanySecret {
|
|
return {
|
|
id: "secret-openai",
|
|
companyId: "company-1",
|
|
scope: "company",
|
|
ownerUserId: null,
|
|
userSecretDefinitionId: null,
|
|
key: "openai_api_key",
|
|
name: "OPENAI_API_KEY",
|
|
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: "user-1",
|
|
referenceCount: 2,
|
|
createdAt: new Date("2026-05-06T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-05-06T00:00:00.000Z"),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeUserSecretDefinition(overrides: Partial<UserSecretDefinition> = {}): UserSecretDefinition {
|
|
return {
|
|
id: "def-github",
|
|
companyId: "company-1",
|
|
key: "PERSONAL_GH_TOKEN",
|
|
name: "Personal GitHub token",
|
|
description: "Used when the responsible user's own repos must be reached.",
|
|
status: "active",
|
|
provider: "local_encrypted",
|
|
managedMode: "paperclip_managed",
|
|
providerConfigId: null,
|
|
providerMetadata: null,
|
|
usageGuidance: "Create a fine-grained PAT with repo read access.",
|
|
createdByAgentId: null,
|
|
createdByUserId: "user-1",
|
|
updatedByAgentId: null,
|
|
updatedByUserId: "user-1",
|
|
deletedAt: null,
|
|
createdAt: new Date("2026-06-01T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-06-02T00:00:00.000Z"),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const userSecretCoverage: UserSecretCoverageSummary = {
|
|
definitionId: "def-github",
|
|
configuredCount: 3,
|
|
missingCount: 2,
|
|
inactiveCount: 0,
|
|
};
|
|
|
|
function setInputValue(input: HTMLInputElement, value: string) {
|
|
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
|
setter?.call(input, value);
|
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
}
|
|
|
|
function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
|
|
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set;
|
|
setter?.call(textarea, value);
|
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
}
|
|
|
|
function setSelectValue(select: HTMLSelectElement, value: string) {
|
|
const setter = Object.getOwnPropertyDescriptor(window.HTMLSelectElement.prototype, "value")?.set;
|
|
setter?.call(select, value);
|
|
select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
}
|
|
|
|
async function openAwsVaultDialog() {
|
|
const vaultTabButton = [...document.querySelectorAll("button")].find(
|
|
(button) => button.textContent?.includes("Provider vaults"),
|
|
) as HTMLButtonElement | undefined;
|
|
await act(async () => {
|
|
vaultTabButton?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
|
|
vaultTabButton?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
|
|
vaultTabButton?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
const addVaultButtons = [...document.querySelectorAll("button")].filter(
|
|
(button) => button.textContent?.includes("Add vault"),
|
|
) as HTMLButtonElement[];
|
|
await act(async () => {
|
|
addVaultButtons[1]?.click();
|
|
});
|
|
await flushReact();
|
|
}
|
|
|
|
describe("Secrets page layout", () => {
|
|
let container: HTMLDivElement;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
|
|
mockSecretsApi.list.mockResolvedValue([]);
|
|
mockSecretsApi.providers.mockResolvedValue(providers);
|
|
mockSecretsApi.providerHealth.mockResolvedValue({
|
|
providers: [
|
|
{
|
|
provider: "local_encrypted",
|
|
status: "warn",
|
|
message: "Local encrypted provider has a warning.",
|
|
warnings: ["Backup reminder"],
|
|
},
|
|
],
|
|
});
|
|
mockSecretsApi.providerConfigs.mockResolvedValue(providerConfigs);
|
|
mockSecretsApi.providerConfigDiscoveryPreview.mockResolvedValue(makeDiscoveryPreview());
|
|
mockSecretsApi.remoteImportPreview.mockResolvedValue(makeRemoteImportPreview());
|
|
mockSecretsApi.listUserSecretDefinitions.mockResolvedValue([]);
|
|
mockSecretsApi.userSecretDefinitionCoverage.mockResolvedValue(userSecretCoverage);
|
|
mockSecretsApi.listMyUserSecrets.mockResolvedValue([]);
|
|
mockSecretsApi.listProposals.mockResolvedValue([]);
|
|
mockAgentsApi.list.mockResolvedValue([]);
|
|
});
|
|
|
|
afterEach(() => {
|
|
unmountActiveRoots();
|
|
container.remove();
|
|
document.body.innerHTML = "";
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("uses the shared search/filter/tab affordances and keeps vault sections quiet", async () => {
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MemoryRouter>
|
|
<QueryClientProvider client={queryClient}>
|
|
<Secrets />
|
|
</QueryClientProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(container.querySelector('input[data-page-search-target="true"][aria-label="Search secrets"]')).not.toBeNull();
|
|
expect(container.textContent).toContain("Use secrets by binding them to runtime environment variables.");
|
|
expect(container.textContent).toContain("GH_TOKEN");
|
|
expect(container.querySelectorAll("select")).toHaveLength(0);
|
|
expect(container.textContent).not.toContain("Provider warnings detected");
|
|
expect(container.textContent).not.toContain("2/2 active");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
|
|
const vaultRoot = createRoot(container);
|
|
await act(async () => {
|
|
vaultRoot.render(
|
|
<ProviderVaultsTab
|
|
providers={providers}
|
|
providerConfigs={providerConfigs as CompanySecretProviderConfig[]}
|
|
loading={false}
|
|
error={null}
|
|
onRetry={vi.fn()}
|
|
onCreate={vi.fn()}
|
|
onEdit={vi.fn()}
|
|
onDisable={vi.fn()}
|
|
onRemove={vi.fn()}
|
|
onSetDefault={vi.fn()}
|
|
onHealthCheck={vi.fn()}
|
|
onImportSecrets={vi.fn()}
|
|
pendingActionId={null}
|
|
/>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
|
|
expect(container.querySelector('a[href="#provider-vaults-local_encrypted"]')).not.toBeNull();
|
|
expect(container.textContent).toContain("AWS production");
|
|
expect(container.textContent).not.toContain("Managed writes");
|
|
expect(container.textContent).not.toContain("External refs");
|
|
|
|
await act(async () => {
|
|
vaultRoot.unmount();
|
|
});
|
|
});
|
|
|
|
it("refreshes existing AWS secrets from a provider vault card", async () => {
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MemoryRouter>
|
|
<QueryClientProvider client={queryClient}>
|
|
<Secrets />
|
|
</QueryClientProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
const vaultTabButton = [...document.querySelectorAll("button")].find(
|
|
(button) => button.textContent?.includes("Provider vaults"),
|
|
) as HTMLButtonElement | undefined;
|
|
await act(async () => {
|
|
vaultTabButton?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
|
|
vaultTabButton?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
|
|
vaultTabButton?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
const refreshButton = document.querySelector(
|
|
'[data-testid="provider-vault-refresh-secrets-vault-aws"]',
|
|
) as HTMLButtonElement | null;
|
|
expect(refreshButton).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
refreshButton?.click();
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(document.body.textContent).toContain("Import from AWS Secrets Manager");
|
|
expect(mockSecretsApi.remoteImportPreview).toHaveBeenCalledWith("company-1", {
|
|
providerConfigId: "vault-aws",
|
|
query: null,
|
|
nextToken: null,
|
|
pageSize: 50,
|
|
});
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("warns that removing a provider vault only removes Paperclip config", async () => {
|
|
mockSecretsApi.removeProviderConfig.mockResolvedValueOnce(providerConfigs[1]);
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MemoryRouter>
|
|
<QueryClientProvider client={queryClient}>
|
|
<Secrets />
|
|
</QueryClientProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
const vaultTabButton = [...document.querySelectorAll("button")].find(
|
|
(button) => button.textContent?.includes("Provider vaults"),
|
|
) as HTMLButtonElement | undefined;
|
|
await act(async () => {
|
|
vaultTabButton?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
|
|
vaultTabButton?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
|
|
vaultTabButton?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
const removeButtons = [...document.querySelectorAll("button")].filter(
|
|
(button) => button.textContent?.trim() === "Remove",
|
|
) as HTMLButtonElement[];
|
|
await act(async () => {
|
|
removeButtons[1]?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
expect(document.body.textContent).toContain("Remove provider vault");
|
|
expect(document.body.textContent).toContain("from Paperclip only");
|
|
expect(document.body.textContent).toContain("does not delete");
|
|
expect(document.body.textContent).toContain("AWS Secrets Manager");
|
|
|
|
const confirmButton = [...document.querySelectorAll("button")].find(
|
|
(button) => button.textContent?.includes("Remove from Paperclip"),
|
|
) as HTMLButtonElement | undefined;
|
|
await act(async () => {
|
|
confirmButton?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockSecretsApi.removeProviderConfig).toHaveBeenCalledWith("vault-aws");
|
|
expect(mockSecretsApi.disableProviderConfig).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("keeps references reachable from the compact secrets row and detail drawer", async () => {
|
|
mockSecretsApi.list.mockResolvedValue([makeCompanySecret()]);
|
|
mockSecretsApi.usage.mockResolvedValue({
|
|
secretId: "secret-openai",
|
|
bindings: [
|
|
{
|
|
id: "binding-agent",
|
|
companyId: "company-1",
|
|
secretId: "secret-openai",
|
|
targetType: "agent",
|
|
targetId: "agent-1",
|
|
configPath: "env.OPENAI_API_KEY",
|
|
versionSelector: "latest",
|
|
required: true,
|
|
label: null,
|
|
target: {
|
|
type: "agent",
|
|
id: "agent-1",
|
|
label: "CodexCoder",
|
|
href: "/agents/codexcoder",
|
|
status: "idle",
|
|
},
|
|
createdAt: new Date("2026-05-06T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-05-06T00:00:00.000Z"),
|
|
},
|
|
],
|
|
});
|
|
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MemoryRouter>
|
|
<QueryClientProvider client={queryClient}>
|
|
<Secrets />
|
|
</QueryClientProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
const referencesButton = container.querySelector(
|
|
'button[aria-label="Actions for OPENAI_API_KEY"]',
|
|
) as HTMLButtonElement | null;
|
|
expect(referencesButton).not.toBeNull();
|
|
|
|
const companyRow = Array.from(container.querySelectorAll("[role='row']")).find(
|
|
(row) => row.textContent?.includes("OPENAI_API_KEY"),
|
|
) as HTMLElement | undefined;
|
|
await act(async () => {
|
|
companyRow?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
await waitForReact(() => document.body.textContent?.includes("View in Usage") ?? false);
|
|
const viewUsageButton = Array.from(document.body.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.includes("View in Usage"),
|
|
) as HTMLButtonElement | undefined;
|
|
await act(async () => {
|
|
viewUsageButton?.click();
|
|
});
|
|
await waitForReact(() => mockSecretsApi.usage.mock.calls.length > 0);
|
|
|
|
expect(mockSecretsApi.usage).toHaveBeenCalledWith("secret-openai");
|
|
expect(document.body.textContent).toContain("CodexCoder");
|
|
expect(document.body.textContent).toContain("env.OPENAI_API_KEY");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("opens the secret detail sheet from a ?secret= deep link", async () => {
|
|
mockSecretsApi.list.mockResolvedValue([makeCompanySecret()]);
|
|
mockSecretsApi.usage.mockResolvedValue({ secretId: "secret-openai", bindings: [] });
|
|
mockSecretsApi.accessEvents.mockResolvedValue([]);
|
|
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MemoryRouter initialEntries={["/company/settings/secrets?secret=secret-openai"]}>
|
|
<QueryClientProvider client={queryClient}>
|
|
<Secrets />
|
|
</QueryClientProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
// The detail sheet is open without any clicks: the deep link drives selection.
|
|
const copyLinkButton = Array.from(document.body.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.includes("Copy link"),
|
|
);
|
|
expect(copyLinkButton).not.toBeUndefined();
|
|
const sheet = copyLinkButton?.closest("[role='dialog']");
|
|
expect(sheet?.textContent).toContain("OPENAI_API_KEY");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("writes a new value through the provider for external reference secrets", async () => {
|
|
const externalSecret = makeCompanySecret({
|
|
id: "secret-neon",
|
|
key: "neon_admin_api_key",
|
|
name: "paperclip-cloud/prod/provider/neon/admin-api-key",
|
|
provider: "aws_secrets_manager",
|
|
managedMode: "external_reference",
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip-cloud/prod/provider/neon/admin-api-key",
|
|
providerConfigId: "vault-aws",
|
|
});
|
|
mockSecretsApi.list.mockResolvedValue([externalSecret]);
|
|
mockSecretsApi.usage.mockResolvedValue({ secretId: "secret-neon", bindings: [] });
|
|
mockSecretsApi.accessEvents.mockResolvedValue([]);
|
|
mockSecretsApi.rotate.mockResolvedValue({ ...externalSecret, latestVersion: 2 });
|
|
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MemoryRouter initialEntries={["/company/settings/secrets?secret=secret-neon"]}>
|
|
<QueryClientProvider client={queryClient}>
|
|
<Secrets />
|
|
</QueryClientProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
// AWS supports write-through, so the primary action is a value update.
|
|
const updateValueButton = Array.from(document.body.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.trim() === "Update value",
|
|
) as HTMLButtonElement | undefined;
|
|
expect(updateValueButton).not.toBeUndefined();
|
|
await act(async () => {
|
|
updateValueButton?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
// Both modes are offered; "Write new value" is the default.
|
|
expect(document.body.textContent).toContain("Write new value");
|
|
expect(document.body.textContent).toContain("Change reference");
|
|
|
|
const valueTextarea = document.getElementById("rotate-value") as HTMLTextAreaElement | null;
|
|
expect(valueTextarea).not.toBeNull();
|
|
setTextareaValue(valueTextarea!, "rotated-neon-admin-key");
|
|
await flushReact();
|
|
|
|
const dialog = valueTextarea!.closest("[role='dialog']") as HTMLElement;
|
|
const submitButton = Array.from(dialog.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.trim() === "Update value",
|
|
) as HTMLButtonElement | undefined;
|
|
expect(submitButton?.disabled).toBe(false);
|
|
await act(async () => {
|
|
submitButton?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockSecretsApi.rotate).toHaveBeenCalledWith("secret-neon", {
|
|
value: "rotated-neon-admin-key",
|
|
providerConfigId: "vault-aws",
|
|
});
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("merges company secrets and each-user definitions into the Secrets list", async () => {
|
|
mockSecretsApi.list.mockResolvedValue([makeCompanySecret()]);
|
|
mockSecretsApi.listUserSecretDefinitions.mockResolvedValue([makeUserSecretDefinition()]);
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MemoryRouter>
|
|
<QueryClientProvider client={queryClient}>
|
|
<Secrets />
|
|
</QueryClientProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(container.textContent).toContain("OPENAI_API_KEY");
|
|
expect(container.textContent).toContain("Personal GitHub token");
|
|
expect(container.textContent).toContain("Organization");
|
|
expect(container.textContent).toContain("Each user");
|
|
expect(container.textContent).toContain("3/5 set");
|
|
expect(container.textContent).not.toContain("User secret definitions");
|
|
|
|
const listContainer = container.querySelector('[data-testid="secrets-list-container"]');
|
|
const tableView = container.querySelector('[data-testid="secrets-table-view"]');
|
|
expect(listContainer?.parentElement?.className).not.toContain("overflow-y-auto");
|
|
const cardView = container.querySelector('[data-testid="secrets-card-view"]');
|
|
expect(listContainer?.className).toContain("@container");
|
|
expect(tableView?.className).toContain("@min-[40rem]:block");
|
|
expect(tableView?.className).not.toContain("md:block");
|
|
// Grid template lives in the token layer: --gtc-54 in ui/src/index.css
|
|
// carries the original minmax(12rem,2.4fr)... template verbatim.
|
|
expect(tableView?.querySelector("[role='row']")?.className).toContain("grid-cols-(--gtc-54)");
|
|
expect(cardView?.className).toContain("@min-[40rem]:hidden");
|
|
expect(cardView?.className).not.toContain("md:hidden");
|
|
|
|
expect(mockSecretsApi.list).toHaveBeenCalledWith("company-1");
|
|
expect(mockSecretsApi.listUserSecretDefinitions).toHaveBeenCalledWith("company-1");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("creates an each-user secret from the unified New secret dialog", async () => {
|
|
const definition = makeUserSecretDefinition({ name: "Personal GitHub token" });
|
|
mockSecretsApi.createUserSecretDefinition.mockResolvedValueOnce(definition);
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MemoryRouter>
|
|
<QueryClientProvider client={queryClient}>
|
|
<Secrets />
|
|
</QueryClientProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
const newSecretButton = Array.from(container.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.includes("New secret"),
|
|
) as HTMLButtonElement | undefined;
|
|
await act(async () => {
|
|
newSecretButton?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
const companyKeyInput = document.getElementById("new-secret-key") as HTMLInputElement;
|
|
expect(companyKeyInput.readOnly).toBe(true);
|
|
|
|
const editKeyButton = Array.from(document.body.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.trim() === "Edit",
|
|
) as HTMLButtonElement | undefined;
|
|
await act(async () => {
|
|
editKeyButton?.click();
|
|
});
|
|
await flushReact();
|
|
expect(companyKeyInput.readOnly).toBe(false);
|
|
|
|
const eachUserButton = Array.from(document.body.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.trim() === "Each user",
|
|
) as HTMLButtonElement | undefined;
|
|
await act(async () => {
|
|
eachUserButton?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
|
|
eachUserButton?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
|
|
eachUserButton?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
const nameInput = document.getElementById("new-secret-name") as HTMLInputElement;
|
|
const keyInput = document.getElementById("new-secret-key") as HTMLInputElement;
|
|
const usageGuidance = document.getElementById("new-secret-usage-guidance") as HTMLTextAreaElement;
|
|
expect(keyInput.readOnly).toBe(true);
|
|
expect(
|
|
Array.from(document.body.querySelectorAll("button")).some(
|
|
(button) => button.textContent?.trim() === "Edit",
|
|
),
|
|
).toBe(true);
|
|
expect(document.getElementById("new-secret-provider")).toBeNull();
|
|
expect(document.getElementById("new-secret-vault")).toBeNull();
|
|
expect(document.getElementById("new-secret-value")).toBeNull();
|
|
|
|
await act(async () => {
|
|
setInputValue(nameInput, "Personal GitHub token");
|
|
setTextareaValue(usageGuidance, "Create a fine-grained PAT with repo read access.");
|
|
});
|
|
await flushReact();
|
|
|
|
expect(keyInput.value).toBe("PERSONAL_GITHUB_TOKEN");
|
|
|
|
const createButton = Array.from(document.body.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.includes("Create user-provided secret"),
|
|
) as HTMLButtonElement | undefined;
|
|
await act(async () => {
|
|
createButton?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockSecretsApi.createUserSecretDefinition).toHaveBeenCalledWith("company-1", {
|
|
name: "Personal GitHub token",
|
|
description: null,
|
|
usageGuidance: "Create a fine-grained PAT with repo read access.",
|
|
key: "PERSONAL_GITHUB_TOKEN",
|
|
status: "active",
|
|
});
|
|
expect(mockSecretsApi.create).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("opens the New secret dialog when provider queries fail", async () => {
|
|
mockSecretsApi.providers.mockRejectedValueOnce(new ApiError("Providers unavailable", 403, null));
|
|
mockSecretsApi.providerConfigs.mockRejectedValueOnce(new ApiError("Provider vaults unavailable", 403, null));
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MemoryRouter>
|
|
<QueryClientProvider client={queryClient}>
|
|
<Secrets />
|
|
</QueryClientProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
const newSecretButton = Array.from(container.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.includes("New secret"),
|
|
) as HTMLButtonElement | undefined;
|
|
await act(async () => {
|
|
newSecretButton?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
expect(document.body.textContent).toContain("Create secret");
|
|
expect(document.body.textContent).toContain("Select a provider.");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("opens the each-user detail sheet with coverage and set-my-value actions", async () => {
|
|
const definition = makeUserSecretDefinition();
|
|
mockSecretsApi.listUserSecretDefinitions.mockResolvedValue([definition]);
|
|
mockSecretsApi.listMyUserSecrets.mockResolvedValue([{ definition, secret: null }]);
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MemoryRouter>
|
|
<QueryClientProvider client={queryClient}>
|
|
<Secrets />
|
|
</QueryClientProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
const definitionRow = Array.from(container.querySelectorAll("[role='row']")).find(
|
|
(row) => row.textContent?.includes("Personal GitHub token"),
|
|
) as HTMLElement | undefined;
|
|
await act(async () => {
|
|
definitionRow?.click();
|
|
});
|
|
await waitForReact(() => document.body.textContent?.includes("Details") ?? false);
|
|
|
|
expect(document.body.textContent).toContain("Personal GitHub token");
|
|
expect(document.body.textContent).toContain("Details");
|
|
expect(document.body.textContent).toContain("Coverage");
|
|
expect(document.body.textContent).toContain("Usage");
|
|
expect(document.body.textContent).toContain("Access events");
|
|
|
|
const viewCoverageButton = Array.from(document.body.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.includes("View in Coverage"),
|
|
) as HTMLButtonElement | undefined;
|
|
await act(async () => {
|
|
viewCoverageButton?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
expect(document.body.textContent).toContain("3 of 5 set");
|
|
expect(document.body.textContent).toContain("Secret values are never shown here");
|
|
|
|
const setValueButton = Array.from(document.body.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.includes("Set my value"),
|
|
) as HTMLButtonElement | undefined;
|
|
await act(async () => {
|
|
setValueButton?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
expect(document.body.textContent).toContain("Set your value");
|
|
expect(document.body.textContent).toContain("PERSONAL_GH_TOKEN");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("keeps the new secret value textarea width-constrained for long tokens", async () => {
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MemoryRouter>
|
|
<QueryClientProvider client={queryClient}>
|
|
<Secrets />
|
|
</QueryClientProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
const newSecretButton = Array.from(container.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.includes("New secret"),
|
|
) as HTMLButtonElement | undefined;
|
|
expect(newSecretButton).toBeDefined();
|
|
|
|
await act(async () => {
|
|
newSecretButton?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
const secretValueTextarea = document.body.querySelector("#new-secret-value") as HTMLTextAreaElement | null;
|
|
expect(secretValueTextarea).not.toBeNull();
|
|
expect(secretValueTextarea?.className).toContain("min-w-0");
|
|
expect(secretValueTextarea?.className).toContain("overflow-x-hidden");
|
|
expect(secretValueTextarea?.className).toContain("break-all");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("explains AWS managed secret creation failures with actionable safe details", async () => {
|
|
const rawProviderMessage =
|
|
"AccessDeniedException: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized";
|
|
mockSecretsApi.create.mockRejectedValueOnce(
|
|
new ApiError("AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.", 403, {
|
|
details: {
|
|
code: "access_denied",
|
|
provider: "aws_secrets_manager",
|
|
operation: "secret.create",
|
|
providerConfigId: "vault-aws",
|
|
region: "us-east-1",
|
|
credentialPath: "Paperclip server runtime/provider credential path",
|
|
requiredCapability: "secretsmanager:CreateSecret",
|
|
actionableMessage:
|
|
"AWS managed secret creation needs secretsmanager:CreateSecret in the selected region for this provider vault.",
|
|
safeAlternative:
|
|
"If the secret already exists in AWS, link it as an external reference instead of creating a Paperclip-managed value.",
|
|
},
|
|
}),
|
|
);
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MemoryRouter>
|
|
<QueryClientProvider client={queryClient}>
|
|
<Secrets />
|
|
</QueryClientProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
const newSecretButton = Array.from(container.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.includes("New secret"),
|
|
) as HTMLButtonElement | undefined;
|
|
await act(async () => {
|
|
newSecretButton?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
await act(async () => {
|
|
setInputValue(document.getElementById("new-secret-name") as HTMLInputElement, "AWS test token");
|
|
setSelectValue(document.getElementById("new-secret-provider") as HTMLSelectElement, "aws_secrets_manager");
|
|
setTextareaValue(document.getElementById("new-secret-value") as HTMLTextAreaElement, "secret-value");
|
|
});
|
|
await flushReact();
|
|
|
|
const createButton = Array.from(document.body.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.trim() === "Create secret",
|
|
) as HTMLButtonElement | undefined;
|
|
await act(async () => {
|
|
createButton?.click();
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
const errorBanner = document.querySelector('[data-testid="secret-create-error"]');
|
|
expect(errorBanner?.textContent).toContain("AWS secret creation needs CreateSecret permission");
|
|
expect(errorBanner?.textContent).toContain("secretsmanager:CreateSecret");
|
|
expect(errorBanner?.textContent).toContain("us-east-1");
|
|
expect(errorBanner?.textContent).toContain("link it as an external reference");
|
|
expect(errorBanner?.textContent).toContain("vault-aws");
|
|
expect(errorBanner?.textContent).not.toContain(rawProviderMessage);
|
|
expect(errorBanner?.textContent).not.toContain("123456789012");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("renders generic secret creation failures with a stable selector", async () => {
|
|
mockSecretsApi.create.mockRejectedValueOnce(new ApiError("Secret creation failed", 500, null));
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MemoryRouter>
|
|
<QueryClientProvider client={queryClient}>
|
|
<Secrets />
|
|
</QueryClientProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
const newSecretButton = Array.from(container.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.includes("New secret"),
|
|
) as HTMLButtonElement | undefined;
|
|
await act(async () => {
|
|
newSecretButton?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
await act(async () => {
|
|
setInputValue(document.getElementById("new-secret-name") as HTMLInputElement, "Failed token");
|
|
setTextareaValue(document.getElementById("new-secret-value") as HTMLTextAreaElement, "secret-value");
|
|
});
|
|
await flushReact();
|
|
|
|
const createButton = Array.from(document.body.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.trim() === "Create secret",
|
|
) as HTMLButtonElement | undefined;
|
|
await act(async () => {
|
|
createButton?.click();
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
const errorBanner = document.querySelector('[data-testid="secret-create-error"]');
|
|
expect(errorBanner?.textContent).toBe("Secret creation failed");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("discovers AWS provider vault candidates and applies selected values as prefill", async () => {
|
|
mockSecretsApi.providerConfigDiscoveryPreview.mockResolvedValueOnce(makeDiscoveryPreview());
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MemoryRouter>
|
|
<QueryClientProvider client={queryClient}>
|
|
<Secrets />
|
|
</QueryClientProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
await openAwsVaultDialog();
|
|
|
|
const discoveryButton = document.querySelector(
|
|
'[data-testid="aws-vault-discovery-button"]',
|
|
) as HTMLButtonElement | null;
|
|
expect(discoveryButton).not.toBeNull();
|
|
expect(discoveryButton?.disabled).toBe(true);
|
|
|
|
const regionInput = document.getElementById("provider-vault-aws-region") as HTMLInputElement | null;
|
|
const prefixInput = document.getElementById("provider-vault-secret-name-prefix") as HTMLInputElement | null;
|
|
expect(regionInput).not.toBeNull();
|
|
await act(async () => {
|
|
setInputValue(regionInput!, "us-east-1");
|
|
setInputValue(prefixInput!, "paperclip");
|
|
});
|
|
await flushReact();
|
|
|
|
expect(discoveryButton?.disabled).toBe(false);
|
|
await act(async () => {
|
|
discoveryButton?.click();
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(mockSecretsApi.providerConfigDiscoveryPreview).toHaveBeenCalledWith("company-1", {
|
|
provider: "aws_secrets_manager",
|
|
config: {
|
|
region: "us-east-1",
|
|
namespace: null,
|
|
secretNamePrefix: "paperclip",
|
|
kmsKeyId: null,
|
|
ownerTag: null,
|
|
environmentTag: null,
|
|
},
|
|
query: "paperclip",
|
|
pageSize: 25,
|
|
});
|
|
expect(document.body.textContent).toContain("AWS production");
|
|
|
|
const useValuesButton = [...document.querySelectorAll("button")].find(
|
|
(button) => button.textContent?.includes("Use values"),
|
|
) as HTMLButtonElement | undefined;
|
|
await act(async () => {
|
|
useValuesButton?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
expect((document.getElementById("vault-name") as HTMLInputElement).value).toBe("AWS production");
|
|
expect((document.getElementById("provider-vault-namespace") as HTMLInputElement).value).toBe("prod-use1");
|
|
expect((document.getElementById("provider-vault-secret-name-prefix") as HTMLInputElement).value).toBe("paperclip");
|
|
expect((document.getElementById("provider-vault-kms-key-id") as HTMLInputElement).value).toBe("alias/paperclip-secrets");
|
|
expect((document.getElementById("provider-vault-owner-tag") as HTMLInputElement).value).toBe("platform");
|
|
expect((document.getElementById("provider-vault-environment-tag") as HTMLInputElement).value).toBe("production");
|
|
expect(mockSecretsApi.createProviderConfig).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("shows AWS discovery errors without replacing manual vault form values", async () => {
|
|
const rawProviderMessage =
|
|
"AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized";
|
|
mockSecretsApi.providerConfigDiscoveryPreview.mockRejectedValueOnce(
|
|
new ApiError("AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.", 403, {
|
|
details: {
|
|
code: "access_denied",
|
|
provider: "aws_secrets_manager",
|
|
operation: "secret_provider_config.discovery.preview",
|
|
providerConfigId: "discovery-preview",
|
|
providerVaultContext: "draft_config",
|
|
region: "us-west-2",
|
|
credentialPath: "Paperclip server runtime/provider credential path",
|
|
requiredCapability: "secretsmanager:ListSecrets",
|
|
actionableMessage:
|
|
"AWS discovery preview needs secretsmanager:ListSecrets in the selected region for the Paperclip server runtime/provider credential path.",
|
|
safeAlternative:
|
|
"If the operator already knows the exact AWS Secrets Manager ARN, paste/link that ARN instead of using discovery. Exact-resource DescribeSecret and runtime read permissions are still required.",
|
|
},
|
|
}),
|
|
);
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MemoryRouter>
|
|
<QueryClientProvider client={queryClient}>
|
|
<Secrets />
|
|
</QueryClientProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
await openAwsVaultDialog();
|
|
|
|
const regionInput = document.getElementById("provider-vault-aws-region") as HTMLInputElement;
|
|
const namespaceInput = document.getElementById("provider-vault-namespace") as HTMLInputElement;
|
|
await act(async () => {
|
|
setInputValue(regionInput, "us-west-2");
|
|
setInputValue(namespaceInput, "manual-prod");
|
|
});
|
|
await flushReact();
|
|
|
|
const discoveryButton = document.querySelector(
|
|
'[data-testid="aws-vault-discovery-button"]',
|
|
) as HTMLButtonElement | null;
|
|
await act(async () => {
|
|
discoveryButton?.click();
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
const errorBanner = document.querySelector('[data-testid="aws-vault-discovery-error"]');
|
|
expect(errorBanner).not.toBeNull();
|
|
expect(errorBanner?.textContent).toContain("AWS discovery needs ListSecrets permission");
|
|
expect(errorBanner?.textContent).toContain("secretsmanager:ListSecrets");
|
|
expect(errorBanner?.textContent).toContain("Paperclip server runtime/provider credential path");
|
|
expect(errorBanner?.textContent).toContain("paste/link that ARN");
|
|
expect(errorBanner?.textContent).toContain("DescribeSecret");
|
|
expect(errorBanner?.textContent).toContain("us-west-2");
|
|
expect(errorBanner?.textContent).toContain("secret_provider_config.discovery.preview");
|
|
expect(errorBanner?.textContent).toContain("aws_secrets_manager");
|
|
expect(errorBanner?.textContent).toContain("Safe request/error details");
|
|
expect(errorBanner?.textContent).not.toContain(rawProviderMessage);
|
|
expect(errorBanner?.textContent).not.toContain("arn:aws");
|
|
expect(errorBanner?.textContent).not.toContain("123456789012");
|
|
expect(regionInput.value).toBe("us-west-2");
|
|
expect(namespaceInput.value).toBe("manual-prod");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("keeps generic AWS discovery 403 errors on the generic failure path", async () => {
|
|
mockSecretsApi.providerConfigDiscoveryPreview.mockRejectedValueOnce(
|
|
new ApiError("AWS discovery request failed before IAM evaluation.", 403, {
|
|
details: {
|
|
code: "proxy_forbidden",
|
|
provider: "aws_secrets_manager",
|
|
operation: "secret_provider_config.discovery.preview",
|
|
region: "us-west-1",
|
|
},
|
|
}),
|
|
);
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MemoryRouter>
|
|
<QueryClientProvider client={queryClient}>
|
|
<Secrets />
|
|
</QueryClientProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
await openAwsVaultDialog();
|
|
|
|
const regionInput = document.getElementById("provider-vault-aws-region") as HTMLInputElement;
|
|
await act(async () => {
|
|
setInputValue(regionInput, "us-west-1");
|
|
});
|
|
await flushReact();
|
|
|
|
await act(async () => {
|
|
(document.querySelector('[data-testid="aws-vault-discovery-button"]') as HTMLButtonElement | null)?.click();
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
const errorBanner = document.querySelector('[data-testid="aws-vault-discovery-error"]');
|
|
expect(errorBanner).not.toBeNull();
|
|
expect(errorBanner?.textContent).toContain("AWS discovery failed");
|
|
expect(errorBanner?.textContent).toContain("AWS discovery request failed before IAM evaluation.");
|
|
expect(errorBanner?.textContent).toContain("proxy_forbidden");
|
|
expect(errorBanner?.textContent).not.toContain("AWS discovery needs ListSecrets permission");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("auto-generates the key from the name and keeps it read-only until Edit", async () => {
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MemoryRouter>
|
|
<QueryClientProvider client={queryClient}>
|
|
<Secrets />
|
|
</QueryClientProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
const newSecretButton = Array.from(container.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.includes("New secret"),
|
|
) as HTMLButtonElement | undefined;
|
|
await act(async () => {
|
|
newSecretButton?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
const nameInput = document.getElementById("new-secret-name") as HTMLInputElement;
|
|
const keyInput = document.getElementById("new-secret-key") as HTMLInputElement;
|
|
const valueTextarea = document.getElementById("new-secret-value") as HTMLTextAreaElement;
|
|
|
|
// Path-style placeholder and value directly after name for natural tab order.
|
|
expect(nameInput.placeholder).toBe("/dev/foo/bar");
|
|
expect(
|
|
nameInput.compareDocumentPosition(valueTextarea) & Node.DOCUMENT_POSITION_FOLLOWING,
|
|
).toBeTruthy();
|
|
expect(
|
|
valueTextarea.compareDocumentPosition(keyInput) & Node.DOCUMENT_POSITION_FOLLOWING,
|
|
).toBeTruthy();
|
|
|
|
expect(keyInput.readOnly).toBe(true);
|
|
await act(async () => {
|
|
setInputValue(nameInput, "OpenAI API Key");
|
|
});
|
|
await flushReact();
|
|
expect(keyInput.value).toBe("openai-api-key");
|
|
|
|
const editKeyButton = Array.from(document.body.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.trim() === "Edit",
|
|
) as HTMLButtonElement | undefined;
|
|
expect(editKeyButton).toBeDefined();
|
|
await act(async () => {
|
|
editKeyButton?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
expect((document.getElementById("new-secret-key") as HTMLInputElement).readOnly).toBe(false);
|
|
|
|
await act(async () => {
|
|
setInputValue(document.getElementById("new-secret-key") as HTMLInputElement, "custom-key");
|
|
setInputValue(nameInput, "OpenAI API Key v2");
|
|
});
|
|
await flushReact();
|
|
|
|
// Once edited, the key stops following the name.
|
|
expect((document.getElementById("new-secret-key") as HTMLInputElement).value).toBe("custom-key");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("grants and revokes agent access from the secret detail sheet", async () => {
|
|
mockSecretsApi.list.mockResolvedValue([makeCompanySecret()]);
|
|
mockSecretsApi.usage.mockResolvedValue({ secretId: "secret-openai", bindings: [] });
|
|
mockSecretsApi.accessEvents.mockResolvedValue([]);
|
|
const coder = {
|
|
id: "agent-coder",
|
|
name: "CodexCoder",
|
|
status: "active",
|
|
adapterConfig: {},
|
|
};
|
|
const reviewer = {
|
|
id: "agent-reviewer",
|
|
name: "Reviewer",
|
|
status: "active",
|
|
adapterConfig: {
|
|
env: { OPENAI_API_KEY: { type: "secret_ref", secretId: "secret-openai" } },
|
|
},
|
|
};
|
|
mockAgentsApi.list.mockResolvedValue([coder, reviewer]);
|
|
mockAgentsApi.get.mockImplementation(async (id: string) =>
|
|
id === "agent-coder" ? coder : reviewer,
|
|
);
|
|
mockAgentsApi.update.mockImplementation(async (id: string) =>
|
|
id === "agent-coder" ? coder : reviewer,
|
|
);
|
|
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MemoryRouter>
|
|
<QueryClientProvider client={queryClient}>
|
|
<Secrets />
|
|
</QueryClientProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
const companyRow = Array.from(container.querySelectorAll("[role='row']")).find(
|
|
(row) => row.textContent?.includes("OPENAI_API_KEY"),
|
|
) as HTMLElement | undefined;
|
|
await act(async () => {
|
|
companyRow?.click();
|
|
});
|
|
await waitForReact(() => document.body.textContent?.includes("Reviewer") ?? false);
|
|
|
|
// Existing access is listed right in the Details tab.
|
|
expect(document.body.textContent).toContain("Agent access");
|
|
expect(document.body.textContent).toContain("Reviewer");
|
|
|
|
const agentSelect = document.getElementById("agent-access-agent") as HTMLButtonElement;
|
|
const envKeyInput = document.getElementById("agent-access-env-key") as HTMLInputElement;
|
|
expect(envKeyInput.value).toBe("OPENAI_API_KEY");
|
|
|
|
await act(async () => {
|
|
agentSelect.click();
|
|
});
|
|
await flushReact();
|
|
|
|
// Agents that already have access are not offered again.
|
|
expect(document.body.textContent).toContain("CodexCoder");
|
|
expect(document.body.querySelector('[aria-label="Select Reviewer"]')).toBeNull();
|
|
|
|
await act(async () => {
|
|
(document.body.querySelector('[aria-label="Select CodexCoder"]') as HTMLButtonElement | null)?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
const addButton = Array.from(document.body.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.trim() === "Add",
|
|
) as HTMLButtonElement | undefined;
|
|
await act(async () => {
|
|
addButton?.click();
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(mockAgentsApi.update).toHaveBeenCalledWith(
|
|
"agent-coder",
|
|
{
|
|
adapterConfig: {
|
|
env: { OPENAI_API_KEY: { type: "secret_ref", secretId: "secret-openai" } },
|
|
},
|
|
replaceAdapterConfig: true,
|
|
},
|
|
"company-1",
|
|
);
|
|
|
|
const revokeButton = document.body.querySelector(
|
|
'button[aria-label="Remove access for Reviewer"]',
|
|
) as HTMLButtonElement | null;
|
|
expect(revokeButton).not.toBeNull();
|
|
await act(async () => {
|
|
revokeButton?.click();
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(mockAgentsApi.update).toHaveBeenCalledWith(
|
|
"agent-reviewer",
|
|
{ adapterConfig: { env: {} }, replaceAdapterConfig: true },
|
|
"company-1",
|
|
);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("shows an empty AWS discovery result without blocking manual entry", async () => {
|
|
mockSecretsApi.providerConfigDiscoveryPreview.mockResolvedValueOnce(
|
|
makeDiscoveryPreview({ candidates: [], sampledSecretCount: 0 }),
|
|
);
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<MemoryRouter>
|
|
<QueryClientProvider client={queryClient}>
|
|
<Secrets />
|
|
</QueryClientProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
await openAwsVaultDialog();
|
|
|
|
const regionInput = document.getElementById("provider-vault-aws-region") as HTMLInputElement;
|
|
await act(async () => {
|
|
setInputValue(regionInput, "us-east-2");
|
|
});
|
|
await flushReact();
|
|
await act(async () => {
|
|
(document.querySelector('[data-testid="aws-vault-discovery-button"]') as HTMLButtonElement | null)?.click();
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(document.body.textContent).toContain("No AWS vault metadata candidates found");
|
|
expect(regionInput.value).toBe("us-east-2");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Secrets folder view (PAP-14698)", () => {
|
|
let container: HTMLDivElement;
|
|
|
|
function seedFolderSecrets() {
|
|
mockSecretsApi.list.mockResolvedValue([
|
|
makeCompanySecret({ id: "s1", key: "dev_github_oauth_clientid", name: "dev/github/oauth/clientid" }),
|
|
makeCompanySecret({ id: "s2", key: "dev_github_oauth_clientsecret", name: "dev/github/oauth/clientsecret" }),
|
|
makeCompanySecret({ id: "s3", key: "prod_api_token", name: "prod/api/token" }),
|
|
makeCompanySecret({ id: "s4", key: "standalone", name: "standalone" }),
|
|
]);
|
|
mockSecretsApi.providers.mockResolvedValue(providers);
|
|
mockSecretsApi.providerHealth.mockResolvedValue({ providers: [] });
|
|
mockSecretsApi.providerConfigs.mockResolvedValue(providerConfigs);
|
|
mockSecretsApi.listUserSecretDefinitions.mockResolvedValue([]);
|
|
mockSecretsApi.userSecretDefinitionCoverage.mockResolvedValue(userSecretCoverage);
|
|
mockSecretsApi.listMyUserSecrets.mockResolvedValue([]);
|
|
mockAgentsApi.list.mockResolvedValue([]);
|
|
}
|
|
|
|
async function renderAt(path: string) {
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
await act(async () => {
|
|
root.render(
|
|
<MemoryRouter initialEntries={[path]}>
|
|
<QueryClientProvider client={queryClient}>
|
|
<Secrets />
|
|
</QueryClientProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
return root;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
try {
|
|
window.localStorage.clear();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
seedFolderSecrets();
|
|
});
|
|
|
|
afterEach(() => {
|
|
unmountActiveRoots();
|
|
container.remove();
|
|
document.body.innerHTML = "";
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("derives folders at the root with filtered counts and a flat standalone secret", async () => {
|
|
const root = await renderAt("/");
|
|
|
|
const table = container.querySelector('[data-testid="secrets-table-view"]')!;
|
|
expect(table.textContent).toContain("dev");
|
|
expect(table.textContent).toContain("prod");
|
|
expect(table.textContent).toContain("standalone");
|
|
// dev groups both oauth secrets recursively; github and oauth are descendant folders.
|
|
expect(table.textContent).toContain("2 secrets · 2 folders");
|
|
expect(table.textContent).toContain("1 secret · 1 folder");
|
|
// Folder rows are real links carrying ?path=.
|
|
const links = [...container.querySelectorAll("a")].map((a) => a.getAttribute("href") ?? "");
|
|
expect(links.some((href) => href.includes("path=dev"))).toBe(true);
|
|
|
|
await act(async () => root.unmount());
|
|
});
|
|
|
|
it("opens a deep ?path= link into the folder with breadcrumb, leaves, and an up affordance", async () => {
|
|
const root = await renderAt("/?path=dev/github/oauth");
|
|
|
|
const breadcrumb = container.querySelector('nav[aria-label="Breadcrumb"]');
|
|
expect(breadcrumb).not.toBeNull();
|
|
const current = container.querySelector('[aria-current="page"]');
|
|
expect(current?.textContent).toContain("oauth");
|
|
|
|
const table = container.querySelector('[data-testid="secrets-table-view"]')!;
|
|
expect(table.textContent).toContain("clientid");
|
|
expect(table.textContent).toContain("clientsecret");
|
|
expect(table.textContent).toContain("Up to github");
|
|
// Sibling trees are not shown while drilled in.
|
|
expect(table.textContent).not.toContain("standalone");
|
|
|
|
await act(async () => root.unmount());
|
|
});
|
|
|
|
it("renders the empty-folder state (breadcrumb intact) for an unknown path", async () => {
|
|
const root = await renderAt("/?path=does/not/exist");
|
|
|
|
expect(container.querySelector('nav[aria-label="Breadcrumb"]')).not.toBeNull();
|
|
expect(container.textContent).toContain("No secrets in this folder yet.");
|
|
const cta = [...container.querySelectorAll("button")].find((b) =>
|
|
b.textContent?.includes("New secret here"),
|
|
) as HTMLButtonElement;
|
|
expect(cta).toBeDefined();
|
|
await act(async () => cta.click());
|
|
await flushReact();
|
|
|
|
expect(document.body.textContent).toContain("does/not/exist/");
|
|
expect((document.getElementById("new-secret-name") as HTMLInputElement).value).toBe("");
|
|
expect(document.querySelector('button[aria-label="Remove folder prefix"]')).not.toBeNull();
|
|
|
|
await act(async () => root.unmount());
|
|
});
|
|
|
|
it("distinguishes a filtered-empty folder from a genuinely empty folder", async () => {
|
|
const root = await renderAt("/?path=dev/github/oauth");
|
|
const filterButton = document.querySelector('button[title="Filter"]') as HTMLButtonElement;
|
|
await act(async () => filterButton.click());
|
|
await flushReact();
|
|
|
|
const archivedLabel = [...document.querySelectorAll("label")].find(
|
|
(label) => label.textContent?.trim() === "Archived",
|
|
) as HTMLLabelElement;
|
|
await act(async () => archivedLabel.click());
|
|
await waitForReact(() => container.textContent?.includes("No secrets match your filters.") ?? false);
|
|
|
|
expect(container.textContent).toContain("No secrets match your filters.");
|
|
expect(container.textContent).not.toContain("New secret here");
|
|
|
|
await act(async () => root.unmount());
|
|
});
|
|
|
|
it("creates a company secret from a folder prefix and derives the key from the full name", async () => {
|
|
mockSecretsApi.create.mockResolvedValue(
|
|
makeCompanySecret({ id: "created", name: "dev/github/oauth/clientsecret/deeper" }),
|
|
);
|
|
const root = await renderAt("/?path=dev/github/oauth");
|
|
|
|
const newSecretButton = [...document.querySelectorAll("button")].find(
|
|
(button) => button.textContent?.trim() === "New secret",
|
|
) as HTMLButtonElement;
|
|
await act(async () => newSecretButton.click());
|
|
await flushReact();
|
|
|
|
expect(document.body.textContent).toContain("dev/github/oauth/");
|
|
const nameInput = document.getElementById("new-secret-name") as HTMLInputElement;
|
|
expect(nameInput.placeholder).toBe("clientsecret");
|
|
expect(nameInput.value).toBe("");
|
|
await act(async () => setInputValue(nameInput, "clientsecret/deeper"));
|
|
await flushReact();
|
|
|
|
expect((document.getElementById("new-secret-key") as HTMLInputElement).value).toBe(
|
|
"dev-github-oauth-clientsecret-deeper",
|
|
);
|
|
await act(async () =>
|
|
setTextareaValue(document.getElementById("new-secret-value") as HTMLTextAreaElement, "secret-value"),
|
|
);
|
|
const createButton = [...document.querySelectorAll("button")].find(
|
|
(button) => button.textContent?.trim() === "Create secret",
|
|
) as HTMLButtonElement;
|
|
await act(async () => createButton.click());
|
|
await flushReact();
|
|
|
|
expect(mockSecretsApi.create).toHaveBeenCalledWith(
|
|
"company-1",
|
|
expect.objectContaining({ name: "dev/github/oauth/clientsecret/deeper" }),
|
|
);
|
|
|
|
await act(async () => root.unmount());
|
|
});
|
|
|
|
it("keeps the folder prefix for Each user and exposes the full name when the chip is removed", async () => {
|
|
const root = await renderAt("/?path=dev/github/oauth");
|
|
const newSecretButton = [...document.querySelectorAll("button")].find(
|
|
(button) => button.textContent?.trim() === "New secret",
|
|
) as HTMLButtonElement;
|
|
await act(async () => newSecretButton.click());
|
|
await flushReact();
|
|
|
|
const eachUserTab = [...document.querySelectorAll("button")].find(
|
|
(button) => button.textContent?.trim() === "Each user",
|
|
) as HTMLButtonElement;
|
|
await act(async () => {
|
|
eachUserTab.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
|
|
eachUserTab.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
|
|
eachUserTab.click();
|
|
});
|
|
await flushReact();
|
|
|
|
const nameInput = document.getElementById("new-secret-name") as HTMLInputElement;
|
|
await act(async () => setInputValue(nameInput, "personal-token"));
|
|
await flushReact();
|
|
expect((document.getElementById("new-secret-key") as HTMLInputElement).value).toBe(
|
|
"DEV_GITHUB_OAUTH_PERSONAL_TOKEN",
|
|
);
|
|
|
|
const removePrefix = document.querySelector(
|
|
'button[aria-label="Remove folder prefix"]',
|
|
) as HTMLButtonElement;
|
|
await act(async () => removePrefix.click());
|
|
await flushReact();
|
|
expect((document.getElementById("new-secret-name") as HTMLInputElement).value).toBe(
|
|
"dev/github/oauth/personal-token",
|
|
);
|
|
expect(document.querySelector('button[aria-label="Remove folder prefix"]')).toBeNull();
|
|
|
|
await act(async () => root.unmount());
|
|
});
|
|
|
|
it("validates New folder inline and stages the trimmed segment in the URL-backed folder view", async () => {
|
|
const root = await renderAt("/?path=dev/github/oauth");
|
|
const newFolderButton = [...container.querySelectorAll("button")].find(
|
|
(button) => button.textContent?.trim() === "New folder",
|
|
) as HTMLButtonElement;
|
|
await act(async () => newFolderButton.click());
|
|
await flushReact();
|
|
|
|
const folderInput = container.querySelector('input[aria-label="Folder name"]') as HTMLInputElement;
|
|
await act(async () => setInputValue(folderInput, "bad/name"));
|
|
const createFolderButton = [...container.querySelectorAll("button")].find(
|
|
(button) => button.textContent?.trim() === "Create folder",
|
|
) as HTMLButtonElement;
|
|
await act(async () => createFolderButton.click());
|
|
await flushReact();
|
|
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
|
|
"Folder name cannot contain slashes.",
|
|
);
|
|
|
|
await act(async () => setInputValue(folderInput, " staged "));
|
|
await flushReact();
|
|
await act(async () => createFolderButton.click());
|
|
await waitForReact(() =>
|
|
[...container.querySelectorAll('[aria-current="page"]')].some((node) =>
|
|
node.textContent?.includes("staged"),
|
|
),
|
|
);
|
|
|
|
expect(
|
|
[...container.querySelectorAll('[aria-current="page"]')].some((node) =>
|
|
node.textContent?.includes("staged"),
|
|
),
|
|
).toBe(true);
|
|
expect(container.textContent).toContain("No secrets in this folder yet.");
|
|
expect(container.querySelector('input[aria-label="Folder name"]')).toBeNull();
|
|
|
|
await act(async () => root.unmount());
|
|
});
|
|
|
|
it("creates a company secret from a folder prefix and derives the key from the full name", async () => {
|
|
mockSecretsApi.create.mockResolvedValue(
|
|
makeCompanySecret({ id: "created", name: "dev/github/oauth/clientsecret/deeper" }),
|
|
);
|
|
const root = await renderAt("/?path=dev/github/oauth");
|
|
|
|
const newSecretButton = [...document.querySelectorAll("button")].find(
|
|
(button) => button.textContent?.trim() === "New secret",
|
|
) as HTMLButtonElement;
|
|
await act(async () => newSecretButton.click());
|
|
await flushReact();
|
|
|
|
expect(document.body.textContent).toContain("dev/github/oauth/");
|
|
const nameInput = document.getElementById("new-secret-name") as HTMLInputElement;
|
|
expect(nameInput.placeholder).toBe("clientsecret");
|
|
expect(nameInput.value).toBe("");
|
|
await act(async () => setInputValue(nameInput, "clientsecret/deeper"));
|
|
await flushReact();
|
|
|
|
expect((document.getElementById("new-secret-key") as HTMLInputElement).value).toBe(
|
|
"dev-github-oauth-clientsecret-deeper",
|
|
);
|
|
await act(async () =>
|
|
setTextareaValue(document.getElementById("new-secret-value") as HTMLTextAreaElement, "secret-value"),
|
|
);
|
|
const createButton = [...document.querySelectorAll("button")].find(
|
|
(button) => button.textContent?.trim() === "Create secret",
|
|
) as HTMLButtonElement;
|
|
await act(async () => createButton.click());
|
|
await flushReact();
|
|
|
|
expect(mockSecretsApi.create).toHaveBeenCalledWith(
|
|
"company-1",
|
|
expect.objectContaining({ name: "dev/github/oauth/clientsecret/deeper" }),
|
|
);
|
|
|
|
await act(async () => root.unmount());
|
|
});
|
|
|
|
it("keeps the folder prefix for Each user and exposes the full name when the chip is removed", async () => {
|
|
const root = await renderAt("/?path=dev/github/oauth");
|
|
const newSecretButton = [...document.querySelectorAll("button")].find(
|
|
(button) => button.textContent?.trim() === "New secret",
|
|
) as HTMLButtonElement;
|
|
await act(async () => newSecretButton.click());
|
|
await flushReact();
|
|
|
|
const eachUserTab = [...document.querySelectorAll("button")].find(
|
|
(button) => button.textContent?.trim() === "Each user",
|
|
) as HTMLButtonElement;
|
|
await act(async () => {
|
|
eachUserTab.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
|
|
eachUserTab.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
|
|
eachUserTab.click();
|
|
});
|
|
await flushReact();
|
|
|
|
const nameInput = document.getElementById("new-secret-name") as HTMLInputElement;
|
|
await act(async () => setInputValue(nameInput, "personal-token"));
|
|
await flushReact();
|
|
expect((document.getElementById("new-secret-key") as HTMLInputElement).value).toBe(
|
|
"DEV_GITHUB_OAUTH_PERSONAL_TOKEN",
|
|
);
|
|
|
|
const removePrefix = document.querySelector(
|
|
'button[aria-label="Remove folder prefix"]',
|
|
) as HTMLButtonElement;
|
|
await act(async () => removePrefix.click());
|
|
await flushReact();
|
|
expect((document.getElementById("new-secret-name") as HTMLInputElement).value).toBe(
|
|
"dev/github/oauth/personal-token",
|
|
);
|
|
expect(document.querySelector('button[aria-label="Remove folder prefix"]')).toBeNull();
|
|
|
|
await act(async () => root.unmount());
|
|
});
|
|
|
|
it("validates New folder inline and stages the trimmed segment in the URL-backed folder view", async () => {
|
|
const root = await renderAt("/?path=dev/github/oauth");
|
|
const newFolderButton = [...container.querySelectorAll("button")].find(
|
|
(button) => button.textContent?.trim() === "New folder",
|
|
) as HTMLButtonElement;
|
|
await act(async () => newFolderButton.click());
|
|
await flushReact();
|
|
|
|
const folderInput = container.querySelector('input[aria-label="Folder name"]') as HTMLInputElement;
|
|
await act(async () => setInputValue(folderInput, "bad/name"));
|
|
const createFolderButton = [...container.querySelectorAll("button")].find(
|
|
(button) => button.textContent?.trim() === "Create folder",
|
|
) as HTMLButtonElement;
|
|
await act(async () => createFolderButton.click());
|
|
await flushReact();
|
|
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
|
|
"Folder name cannot contain slashes.",
|
|
);
|
|
|
|
await act(async () => setInputValue(folderInput, " staged "));
|
|
await flushReact();
|
|
await act(async () => createFolderButton.click());
|
|
await waitForReact(() =>
|
|
[...container.querySelectorAll('[aria-current="page"]')].some((node) =>
|
|
node.textContent?.includes("staged"),
|
|
),
|
|
);
|
|
|
|
expect(
|
|
[...container.querySelectorAll('[aria-current="page"]')].some((node) =>
|
|
node.textContent?.includes("staged"),
|
|
),
|
|
).toBe(true);
|
|
expect(container.textContent).toContain("No secrets in this folder yet.");
|
|
expect(container.querySelector('input[aria-label="Folder name"]')).toBeNull();
|
|
|
|
await act(async () => root.unmount());
|
|
});
|
|
|
|
it("Flat toggle reproduces the raw, ungrouped list", async () => {
|
|
const root = await renderAt("/");
|
|
|
|
const flatButton = [...container.querySelectorAll("button")].find(
|
|
(b) => b.textContent?.trim().toLowerCase() === "flat",
|
|
) as HTMLButtonElement | undefined;
|
|
expect(flatButton).toBeDefined();
|
|
await act(async () => flatButton!.click());
|
|
await flushReact();
|
|
|
|
const table = container.querySelector('[data-testid="secrets-table-view"]')!;
|
|
expect(table.textContent).toContain("dev/github/oauth/clientid");
|
|
expect(table.textContent).not.toContain("2 secrets · 1 folder");
|
|
|
|
await act(async () => root.unmount());
|
|
});
|
|
|
|
it("search is global across folders and shows full muted-path names", async () => {
|
|
const root = await renderAt("/?path=dev/github/oauth");
|
|
|
|
const input = container.querySelector(
|
|
'input[aria-label="Search secrets"]',
|
|
) as HTMLInputElement;
|
|
await act(async () => setInputValue(input, "token"));
|
|
await flushReact();
|
|
|
|
expect(container.textContent).toContain("Search results");
|
|
expect(container.textContent).toContain("across all folders");
|
|
const table = container.querySelector('[data-testid="secrets-table-view"]')!;
|
|
// prod/api/token lives outside the current folder yet still matches.
|
|
expect(table.textContent).toContain("prod/api/");
|
|
expect(table.textContent).toContain("token");
|
|
|
|
await act(async () => root.unmount());
|
|
});
|
|
});
|
|
|
|
describe("Secrets operator-hidden tabs", () => {
|
|
let container: HTMLDivElement;
|
|
|
|
function seedSecrets() {
|
|
mockSecretsApi.list.mockResolvedValue([makeCompanySecret({ id: "s1", key: "api_key", name: "api_key" })]);
|
|
mockSecretsApi.providers.mockResolvedValue(providers);
|
|
mockSecretsApi.providerHealth.mockResolvedValue({ providers: [] });
|
|
mockSecretsApi.providerConfigs.mockResolvedValue(providerConfigs);
|
|
mockSecretsApi.listUserSecretDefinitions.mockResolvedValue([]);
|
|
mockSecretsApi.userSecretDefinitionCoverage.mockResolvedValue(userSecretCoverage);
|
|
mockSecretsApi.listMyUserSecrets.mockResolvedValue([]);
|
|
mockSecretsApi.listProposals.mockResolvedValue([]);
|
|
mockAgentsApi.list.mockResolvedValue([]);
|
|
}
|
|
|
|
async function renderWithHiddenSettings(hiddenSettings: string[]) {
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
queryClient.setQueryData(queryKeys.health, { hiddenSettings } as never);
|
|
await act(async () => {
|
|
root.render(
|
|
<MemoryRouter initialEntries={["/"]}>
|
|
<QueryClientProvider client={queryClient}>
|
|
<Secrets />
|
|
</QueryClientProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
return root;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
seedSecrets();
|
|
});
|
|
|
|
afterEach(() => {
|
|
unmountActiveRoots();
|
|
container.remove();
|
|
document.body.innerHTML = "";
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("hides the Provider vaults and Proposals tabs and skips the proposals poll", async () => {
|
|
const root = await renderWithHiddenSettings([
|
|
"company.secrets.vaults",
|
|
"company.secrets.proposals",
|
|
]);
|
|
|
|
const tabLabels = [...container.querySelectorAll('[role="tab"]')].map((tab) => tab.textContent);
|
|
expect(tabLabels.join(" ")).toContain("Secrets");
|
|
expect(tabLabels.join(" ")).toContain("My secrets");
|
|
expect(tabLabels.join(" ")).not.toContain("Provider vaults");
|
|
expect(tabLabels.join(" ")).not.toContain("Proposals");
|
|
expect(mockSecretsApi.listProposals).not.toHaveBeenCalled();
|
|
|
|
await act(async () => root.unmount());
|
|
});
|
|
|
|
it("keeps both tabs when nothing is hidden", async () => {
|
|
const root = await renderWithHiddenSettings([]);
|
|
|
|
const tabLabels = [...container.querySelectorAll('[role="tab"]')].map((tab) => tab.textContent);
|
|
expect(tabLabels.join(" ")).toContain("Provider vaults");
|
|
expect(tabLabels.join(" ")).toContain("Proposals");
|
|
expect(mockSecretsApi.listProposals).toHaveBeenCalled();
|
|
|
|
await act(async () => root.unmount());
|
|
});
|
|
});
|