Files
PaperClipAI/ui/src/pages/Secrets.render.test.tsx
T
e43f187cad feat(secrets): add human-approved secret proposals (#9934)
## Thinking Path

> - Paperclip is the control plane people use to manage AI-agent
companies.
> - Agents can encounter credentials during work.
> - Directly creating live secrets or bindings would bypass human
governance.
> - Proposal records must remain inert and separate from live secret
resolution until an authorized human approves them.
> - Approval must reuse the existing secret-create and protected
agent-config write paths.
> - This pull request adds the propose, review, approve, and reject
lifecycle.
> - The benefit is that agents can safely hand credentials into
Paperclip without exposing plaintext or gaining authority to activate
them.

## Linked Issues or Issue Description

Follow-on to #9921, which established run-bound agent secret access.

**Problem / motivation:**

Agents can receive credentials during work. There is no governed way for
them to propose a credential or binding without exposing plaintext in
work artifacts or immediately creating live access.

**Proposed solution:**

Store agent-authored proposals outside live secret tables. Encrypt each
proposed value and register exact-value redaction when Paperclip
receives it. Require an authorized human to approve or reject each
proposal. Approval executes through the normal write paths as the human
approver. Binding proposals can target only the proposer or its downward
reporting chain under the restrictive V1 policy.

**Alternatives considered:**

We rejected live secrets with a `proposed` status. That design would put
untrusted rows in resolver, list, and sync paths. It would also allow
uniqueness squatting. We rejected direct agent binding writes because a
binding is an agent-config write and must keep the existing human
permission gate.

**Roadmap alignment:**

This change extends the run-bound agent secret-access foundation in
#9921 with a governed proposal workflow.

## Security Verdict

Q0 SecEng verdict: **PASS-with-required-changes**. The review accepted
the separate proposal-table design and required the implementation to:

- fail closed unless both encryption and exact-value run redaction
registration succeed;
- scrub ciphertext idempotently on reject, withdraw, and expiry, with
audit-visible state;
- treat agent justification as hostile input and foreground action,
target, provenance, and approver permissions;
- snapshot and re-check the target agent plus reports-to chain at
approval to prevent org-chart laundering;
- make cascade approval atomic and fail closed if either secret creation
or binding authorization fails;
- deny low-trust, `skill_test`, `task_bridge`, and non-run-bound sources
consistently; and
- execute approval through the normal human secret/config write paths,
including protected-change gates.

Those requirements are implemented and covered by focused service,
route, and UI tests. Residual V1 risk remains the accepted 14-day
encrypted retention window. Proposal-time redaction also cannot clean a
value that leaked before the propose call.

## What Changed

- Added `company_secret_proposals`, migration `0207`, shared proposal
contracts, and a state-machine service for create, approve, reject,
withdraw, cascade, expiry, and ciphertext scrubbing.
- Added run-bound agent proposal routes and board review routes. The
routes derive provenance from authentication and enforce source
restrictions, company isolation, chain-of-command checks,
approval-as-approver, wake-on-resolution, and dual audit trails.
- Added durable per-run exact-value redaction registration so proposal
values remain redacted on later read surfaces.
- Added the Secrets **Proposals** tab and agent configuration **Proposed
access** rows. The UI shows fingerprint and length only. It also frames
agent justification as untrusted input, runs permission preflight,
supports approve and reject actions, and confirms cascades.
- Updated OpenAPI, agent skill guidance, API reference documentation,
and focused server and UI regression coverage.
- Rebased the branch onto current `master` and renumbered the proposal
migration after `0206`.

## QA Acceptance Results

Q5 QA verdict: **PASS — 9/9 acceptance criteria met**, with one Minor
non-blocking follow-up.

- **AC1:** proposed values never echo, never appear in live
lists/resolvers, and expose only fingerprint + length to board
reviewers.
- **AC2:** restrictive `self_and_reports` matrix passes: self/downward
allowed; upward/lateral denied.
- **AC3:** secret approval uses the normal create path, honors rename
overrides, records proposer/approver provenance, and scrubs ciphertext.
- **AC4:** approved bindings materialize and resolve through the target
agent's runtime list/fetch routes.
- **AC5:** pending-secret bindings require cascade; cascade succeeds
atomically and permission failures leave nothing applied.
- **AC6:** reject, withdraw, dependent rejection, and expiry paths scrub
ciphertext and preserve reasons/audit state.
- **AC7:** token/source and approver denial matrix passes through live
checks plus focused route tests.
- **AC8:** proposal lifecycle events and reused
`secret.created`/config-write events form the required dual audit trail;
origin-issue notification and wake are queued.
- **AC9:** both review surfaces render and execute correctly; UI
approval materializes the binding.

QA also confirmed zero plaintext occurrences for all exercised proposal
values in server logs. The single finding is that the company-level
`bindingTargetPolicy` toggle is not wired yet. V1 is hardcoded to the
restrictive `self_and_reports` policy. The matrix is correct and the
follow-up is tracked separately, so QA classified it as non-blocking.

## Verification

- Focused server proposal and redaction suite: 83 tests pass.
- Focused proposal review UI suite: 54 tests pass.
- Embedded-Postgres migration reapply test: 1 test passes with the
documented 30-second timeout.
- `pnpm --filter @paperclipai/db typecheck` passes, including migration
numbering and safety checks.
- `pnpm --filter @paperclipai/shared typecheck` passes.
- `pnpm --filter @paperclipai/ui typecheck` passes.
- `pnpm check:token-gates` passes with all gates clean.
- Q5 exercised the complete propose, review, approve, bind, and
runtime-resolve flow over real HTTP, JWT, and database paths. It
verified 9/9 acceptance criteria.

## Risks

- Proposal ciphertext is retained encrypted for up to 14 days while
pending. Terminal-state and expiry scrub paths reduce but do not remove
server-compromise risk during that window.
- The V1 target policy is restrictive but not yet company-configurable.
A separate follow-up owns that change.
- A new migration can require another renumber if another migration
lands before maintainers merge this pull request.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex coding agent. The exact runtime model ID and
context-window size are not exposed. The agent used reasoning,
repository editing, terminal execution, Paperclip API, and GitHub CLI
capabilities. Q3 UI work also records Claude Opus 4.8 assistance in its
commit trailers.

## 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 or instance-local Paperclip issues
or links
- [x] My branch name describes the change 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 21:49:40 -05:00

1984 lines
70 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";
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("Company");
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"]');
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());
});
});