mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agents rely on local skills, provider-backed secrets, and workspace file previews during normal execution. > - Local skill imports need bounded reference-file inventory so direct skill discovery stays accurate without accidentally walking too much of the filesystem. > - Secrets provider setup needs actionable AWS discovery errors so operators can recover from IAM/config problems without losing manual form input. > - The issue detail file viewer should reopen cleanly after the first close so users can keep inspecting files during task review. > - This pull request collects the small fixes and regression tests for those related operator workflows. > - The benefit is more predictable local skill imports, clearer secrets setup failure states, and a less brittle file preview interaction. ## Linked Issues or Issue Description - No public GitHub issue was found for this extracted local work. - Related prior sync context: #8536. - Problem: local skill reference discovery, AWS provider-vault discovery errors, and issue file preview reopening each had narrow workflow regressions that made operator recovery harder. - Expected behavior: skill imports inventory reference files within bounded local skill directories, AWS discovery failures present safe actionable guidance while preserving manual values, and closing the first file preview does not prevent opening another preview. - Reproduction scope: import a local skill with referenced files, attempt AWS Secrets Manager discovery with insufficient IAM/list permissions, and open/close/reopen file previews from an issue detail page. - Duplicate search: searched GitHub PRs/issues for `skill inventory secrets file viewer` and `skill inventory secrets AWS file viewer`; no matching public duplicate was found. ## What Changed - Bounded direct local skill file inventory discovery and added regression coverage for reference file imports. - Preserved and surfaced safe, actionable AWS Secrets Manager discovery/import errors in server responses and the secrets UI. - Kept AWS provider-vault manual form values intact when discovery fails or returns no candidates. - Fixed issue file viewer state so closing the first preview still allows later file previews to open. - Updated the secrets render test harness to avoid the missing `React.act` export in the current React package set. ## Verification - `pnpm run preflight:workspace-links && pnpm exec vitest run server/src/__tests__/company-skills-service.test.ts server/src/__tests__/secrets-routes.test.ts server/src/__tests__/secrets-service.test.ts ui/src/context/FileViewerContext.test.ts ui/src/pages/Secrets.render.test.tsx` - Result: 5 test files passed, 116 tests passed. - Install note: the isolated worktree needed `NODE_ENV=development pnpm install --frozen-lockfile --prod=false --force` before local verification because it initially had no dev dependencies installed. ## Risks - Low-to-medium risk: this touches skill import inventory, secrets-provider error handling, and file-viewer UI state, but each change is covered by focused regression tests. - No migrations. - No dependency or lockfile changes. - CI is rerunning on the latest head after review fixes; Greptile is 5/5 with no unresolved Greptile threads. > 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 — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex coding agent, GPT-5-family model as provided in the Paperclip run environment, with repository tool use and local command execution. ## 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 - [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>
746 lines
24 KiB
TypeScript
746 lines
24 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { createRoot } from "react-dom/client";
|
|
import { flushSync } from "react-dom";
|
|
import { MemoryRouter } from "react-router-dom";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type {
|
|
CompanySecretProviderConfig,
|
|
SecretProviderConfigDiscoveryPreviewResult,
|
|
SecretProviderDescriptor,
|
|
} 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(),
|
|
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(),
|
|
}));
|
|
|
|
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
|
const mockPushToast = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("../api/secrets", () => ({
|
|
secretsApi: mockSecretsApi,
|
|
}));
|
|
|
|
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,
|
|
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>[];
|
|
|
|
async function act(callback: () => void | Promise<void>) {
|
|
let result: void | Promise<void> = undefined;
|
|
flushSync(() => {
|
|
result = callback();
|
|
});
|
|
await result;
|
|
}
|
|
|
|
async function flushReact() {
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
|
});
|
|
}
|
|
|
|
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 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 }));
|
|
}
|
|
|
|
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());
|
|
});
|
|
|
|
afterEach(() => {
|
|
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(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Secrets />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
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()}
|
|
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("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("opens reference details from the secrets table count", async () => {
|
|
mockSecretsApi.list.mockResolvedValue([
|
|
{
|
|
id: "secret-openai",
|
|
companyId: "company-1",
|
|
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"),
|
|
},
|
|
]);
|
|
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="View references for OPENAI_API_KEY"]',
|
|
) as HTMLButtonElement | null;
|
|
expect(referencesButton?.textContent).toBe("2");
|
|
|
|
await act(async () => {
|
|
referencesButton?.click();
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockSecretsApi.usage).toHaveBeenCalledWith("secret-openai");
|
|
expect(document.body.textContent).toContain("Secret references");
|
|
expect(document.body.textContent).toContain("CodexCoder");
|
|
expect(document.body.textContent).toContain("env.OPENAI_API_KEY");
|
|
|
|
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("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("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();
|
|
});
|
|
});
|
|
});
|