mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Hosting operators (a managed cloud, an internal shared server) tune the settings surface with `PAPERCLIP_HIDDEN_SETTINGS`, which today hides whole pages > - The Secrets page bundles four tabs, and two of them — Provider vaults and Proposals — do not apply to deployments where the operator provisions provider credentials itself > - Hiding the whole Secrets page is too coarse: the Secrets and My secrets tabs stay essential everywhere > - This pull request adds per-tab visibility keys (`company.secrets.vaults`, `company.secrets.proposals`) as a new company-section registry group > - The benefit is that any hosting operator can trim the Secrets page to what fits their deployment, with self-hosted behavior unchanged by default ## Linked Issues or Issue Description No public issue exists; following the enhancement template: **What existing behavior does this improve?** `PAPERCLIP_HIDDEN_SETTINGS` can hide the whole Secrets page (`company.secrets`) but not individual tabs. Operators of managed deployments need to hide the Provider vaults and Proposals tabs while keeping the rest of the page. **Subsystem affected** Settings visibility (`packages/shared/src/settings-visibility.ts`) and the Secrets page UI (`ui/src/pages/Secrets.tsx`). **Current behavior** The Secrets page always renders all four tabs (Secrets, My secrets, Provider vaults, Proposals), polls pending proposals for the badge, and offers "manage vaults" affordances that jump to the vaults tab. **Proposed behavior** Two new registry keys, `company.secrets.vaults` and `company.secrets.proposals`, hide the corresponding tab: the tab-bar entry disappears, an active hidden tab snaps back to Secrets, the manage-vaults affordances are suppressed, and the pending-proposals poll stops. UI visibility only — the provider-config and proposal APIs stay live for agents and integrations, matching the existing `company.*` precedent. Nothing changes when the variable is unset. **Reason and benefit** Any hosting operator (a managed cloud, an internal shared server) can trim the Secrets page to what fits their deployment — for example when the operator provisions provider credentials itself, so the vault and proposal flows do not apply — without losing the Secrets and My secrets tabs, which stay essential everywhere. **Breaking changes** None. With `PAPERCLIP_HIDDEN_SETTINGS` unset (or set to existing keys only) nothing changes; older app versions receiving the new keys ignore them with a warning by design. ## What Changed - `packages/shared/src/settings-visibility.ts`: new `HIDEABLE_COMPANY_SECTIONS` group (`company.secrets.vaults`, `company.secrets.proposals`), `HideableCompanySection` type, `hidesCompanySection()` helper, wired into `HideableSettingKey` / `HIDEABLE_SETTING_KEYS`, re-exported from the package index. - `ui/src/pages/Secrets.tsx`: tab-bar filtering, hidden-tab snap-back effect, gated pending-proposals query, conditional `onManageVaults` on both the import button and dialog (the button's "AWS vault disabled — manage" affordance renders nothing when vaults are hidden), hidden `TabsContent` blocks. - Docs: new bullet in `docs/deploy/environment-variables.md` under "Hiding settings surfaces". - Tests: registry membership/parse cases in `settings-visibility.test.ts`; new render cases in `Secrets.render.test.tsx` (hidden tabs absent + proposals poll skipped; default render keeps both tabs and the poll). ## Verification - `npx vitest run packages/shared/src/settings-visibility.test.ts ui/src/pages/Secrets.render.test.tsx` — 43 tests passing. - `pnpm --filter @paperclipai/shared typecheck` and `pnpm --filter @paperclipai/ui typecheck` — clean. ## Risks - Low. Nothing changes with `PAPERCLIP_HIDDEN_SETTINGS` unset (covered by the default-render test). The keys are UI-visibility only, so agent/integration API access is unaffected. Older app versions receiving the new keys ignore them with a warning by design. ## Model Used Claude (Anthropic), model id `claude-fable-5`, extended thinking, agentic tool use via Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
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());
|
|
});
|
|
});
|