mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 21:05:21 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent runs often need provider credentials, API tokens, and other environment-bound secrets. > - Company-level secrets work for shared credentials, but they do not model values that should differ by human operator. > - Without a user-scoped model, a run can dispatch without knowing whether the responsible human has supplied the needed value. > - Paperclip also needs run attribution to make those user-scoped runtime checks deterministic and auditable. > - This pull request adds user-specific secret definitions, per-user values, environment bindings, responsible-user attribution, and runtime resolution gates. > - The benefit is that teams can define the secret once, let each user provide their own value, and block runs before dispatch when required user secrets or active definitions are unavailable. ## Linked Issues or Issue Description Refs #224 Refs #6057 This PR implements user-specific secret support as a core secret-management capability rather than a one-off adapter setting. It is related to existing public work on company secrets UI and runtime secret refs, but is distinct because the value is owned by the responsible user and resolved at run dispatch time. Related PR search before opening found existing secrets work such as #1550, #8256, #8614, #8634, and #8647; none of those add the full user-secret definition/value/runtime gate covered here. ## What Changed - Added user-secret definitions and per-user "My secrets" values, keeping stored values out of access metadata. - Added `user_secret_ref` environment bindings and UI affordances to pick them alongside existing secret refs. - Added responsible-user runtime resolution so user-secret refs resolve against the human responsible for the run. - Added pre-dispatch missing-secret gates so runs fail before adapter dispatch when required user values are absent or definitions are inactive. - Added low-trust allowlist hardening for user-secret runtime access. - Added issue, routine, run, and agent API key responsible-user attribution and fail-closed dispatch behavior when attribution cannot be resolved. - Added denial-copy mapping so responsible-user authorization failures surface as actionable run outcomes instead of opaque setup failures. - Added OpenAPI documentation for the user-secret routes. - Rebases cleanly on current `master`; migrations were renumbered incrementally as `0128_user_specific_secrets`, `0129_agent_api_key_responsible_user`, and `0130_run_responsible_user_invariant` after upstream `0126`/`0127` migrations. - Removed previously committed local design screenshots so the PR contains code/docs/tests only. ## Verification - PASS: PR head `2527febd106bcf3ca264ca0da7fca491084192d6` is based on `paperclipai/paperclip:master`. - PASS: `git diff --check` - PASS: `git diff --name-only public/master...HEAD | rg '^(pnpm-lock\\.yaml|\\.github/workflows/|screenshots/)' || true` produced no files. - PASS: migration journal audit confirmed unique indexes through `130` with tail entries `0126_issue_comment_derived_attribution`, `0127_environment_custom_images_instance_scoped`, `0128_user_specific_secrets`, `0129_agent_api_key_responsible_user`, and `0130_run_responsible_user_invariant`. - PASS: `pnpm --filter @paperclipai/ui typecheck` - PASS: `pnpm --filter @paperclipai/server typecheck` - PASS: `pnpm --filter @paperclipai/server exec vitest run src/__tests__/heartbeat-responsible-user-invariant.test.ts` - PASS: `pnpm --filter @paperclipai/server exec vitest run src/__tests__/heartbeat-active-run-output-watchdog.test.ts src/__tests__/heartbeat-stale-queue-invalidation.test.ts src/__tests__/heartbeat-workspace-finalize-branch.test.ts src/__tests__/issue-monitor-scheduler.test.ts` - PASS: `pnpm --filter @paperclipai/server exec vitest run src/__tests__/heartbeat-comment-wake-batching.test.ts src/__tests__/heartbeat-retry-scheduling.test.ts src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts src/__tests__/heartbeat-plugin-environment.test.ts` - PASS: `pnpm --filter @paperclipai/server exec vitest run src/__tests__/low-trust-red-team-routes.test.ts` - PASS: `pnpm --filter @paperclipai/server exec vitest run src/__tests__/secrets-service.test.ts` (55 tests) - PASS: `pnpm vitest run server/src/__tests__/secrets-routes.test.ts server/src/__tests__/secrets-service.test.ts` (89 tests after final Greptile cleanup fixes) - PASS: `pnpm --filter @paperclipai/server exec vitest run src/__tests__/heartbeat-issue-liveness-escalation.test.ts` (17 tests after the final rebase CI fix) - PASS: focused server Vitest batches covering heartbeat recovery, project env, plugin env, routines, low-trust, pipelines, monitors, watchdog, and stale queue paths. - PASS: GitHub checks are green on `2527febd106bcf3ca264ca0da7fca491084192d6`, including Typecheck + Release Registry, Build, General tests, serialized server suites, e2e, Canary Dry Run, verify, security checks, and Greptile Review. - PASS: Greptile Review completed successfully on `2527febd106bcf3ca264ca0da7fca491084192d6` with Confidence Score 5/5, and GraphQL review-thread audit returned zero unresolved non-outdated threads. ## Risks - Runtime behavior now depends on a run having a correct responsible user; missing or incorrect responsibility assignment can block runs before adapter dispatch. - `user_secret_ref` bindings intentionally expose metadata without values, but UI/API callers may need to handle the new binding kind explicitly. - External secret providers and IAM policies are not automatically provisioned by this PR; operators still need to configure provider-side access for non-local vaults. - The PR is broad across db/shared/server/UI/runtime paths, so release validation should include both API and UI secret workflows before merge. - The migration renumbering is intentionally incremental after upstream migrations; the branch migrations use guarded column/table/index/constraint creation so users who tested the older draft numbering should not hit duplicate DDL for the existing objects. > 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, GPT-5-based coding agent (`gpt-5`), Codex local adapter with shell/tool use and code execution. Context window and internal reasoning mode are not exposed by the runtime. ## 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> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
824 lines
25 KiB
TypeScript
824 lines
25 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { act } from "react";
|
|
import { createRoot } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type {
|
|
CompanySecret,
|
|
CompanySecretProviderConfig,
|
|
RemoteSecretImportCandidate,
|
|
RemoteSecretImportPreviewResult,
|
|
RemoteSecretImportResult,
|
|
} from "@paperclipai/shared";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { ApiError } from "../../api/client";
|
|
|
|
const mockSecretsApi = vi.hoisted(() => ({
|
|
remoteImportPreview: vi.fn(),
|
|
remoteImport: vi.fn(),
|
|
}));
|
|
|
|
const mockPushToast = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("../../api/secrets", () => ({
|
|
secretsApi: mockSecretsApi,
|
|
}));
|
|
|
|
vi.mock("../../context/ToastContext", () => ({
|
|
useToastActions: () => ({
|
|
pushToast: mockPushToast,
|
|
dismissToast: vi.fn(),
|
|
clearToasts: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
import { ImportFromVaultDialog } from "./ImportFromVaultDialog";
|
|
|
|
const awsVault: CompanySecretProviderConfig = {
|
|
id: "vault-aws",
|
|
companyId: "company-1",
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS production",
|
|
status: "ready",
|
|
isDefault: true,
|
|
config: { region: "us-east-1" },
|
|
healthStatus: null,
|
|
healthCheckedAt: null,
|
|
healthMessage: null,
|
|
healthDetails: null,
|
|
disabledAt: null,
|
|
createdByAgentId: null,
|
|
createdByUserId: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
function makeCandidate(
|
|
overrides: Partial<RemoteSecretImportCandidate> = {},
|
|
): RemoteSecretImportCandidate {
|
|
return {
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:prod/foo-AbCdEf",
|
|
remoteName: "prod/foo",
|
|
name: "prod/foo",
|
|
key: "prod-foo",
|
|
providerVersionRef: null,
|
|
providerMetadata: { name: "prod/foo" },
|
|
status: "ready",
|
|
importable: true,
|
|
conflicts: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makePreview(
|
|
candidates: RemoteSecretImportCandidate[],
|
|
nextToken: string | null = null,
|
|
): RemoteSecretImportPreviewResult {
|
|
return {
|
|
providerConfigId: awsVault.id,
|
|
provider: "aws_secrets_manager",
|
|
nextToken,
|
|
candidates,
|
|
};
|
|
}
|
|
|
|
async function flush() {
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
|
});
|
|
}
|
|
|
|
async function flushDebounce() {
|
|
await act(async () => {
|
|
await new Promise((resolve) => window.setTimeout(resolve, 300));
|
|
});
|
|
}
|
|
|
|
function makeWrapper() {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
return { queryClient };
|
|
}
|
|
|
|
describe("ImportFromVaultDialog", () => {
|
|
let container: HTMLDivElement;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
container.remove();
|
|
document.body.innerHTML = "";
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("loads candidates and selects rows, persisting through pagination", async () => {
|
|
mockSecretsApi.remoteImportPreview
|
|
.mockResolvedValueOnce(
|
|
makePreview(
|
|
[
|
|
makeCandidate({
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:prod/stripe-ABC",
|
|
remoteName: "prod/stripe",
|
|
name: "prod/stripe",
|
|
key: "prod-stripe",
|
|
}),
|
|
makeCandidate({
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:prod/openai-XYZ",
|
|
remoteName: "prod/openai",
|
|
name: "prod/openai",
|
|
key: "prod-openai",
|
|
}),
|
|
],
|
|
"page-2",
|
|
),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
makePreview(
|
|
[
|
|
makeCandidate({
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:prod/sendgrid-Q9",
|
|
remoteName: "prod/sendgrid",
|
|
name: "prod/sendgrid",
|
|
key: "prod-sendgrid",
|
|
}),
|
|
],
|
|
null,
|
|
),
|
|
);
|
|
|
|
const { queryClient } = makeWrapper();
|
|
const root = createRoot(container);
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<ImportFromVaultDialog
|
|
open
|
|
onOpenChange={vi.fn()}
|
|
companyId="company-1"
|
|
providerConfigs={[awsVault]}
|
|
existingSecrets={[]}
|
|
/>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flush();
|
|
await flush();
|
|
|
|
const tableBody = document.querySelector('[data-testid="vault-table-body"]');
|
|
expect(tableBody).not.toBeNull();
|
|
expect(document.body.textContent).toContain("prod/stripe");
|
|
expect(document.body.textContent).toContain("prod/openai");
|
|
|
|
// Select stripe via row click
|
|
const stripeRow = document.querySelector(
|
|
'[data-testid="vault-row-arn:aws:secretsmanager:us-east-1:1:secret:prod/stripe-ABC"]',
|
|
) as HTMLElement | null;
|
|
expect(stripeRow).not.toBeNull();
|
|
await act(async () => {
|
|
stripeRow?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
expect(document.body.textContent).toContain("1 selected");
|
|
|
|
// Load more page
|
|
const loadMore = document.querySelector('[data-testid="vault-load-more"]') as HTMLButtonElement | null;
|
|
expect(loadMore).not.toBeNull();
|
|
await act(async () => {
|
|
loadMore!.click();
|
|
});
|
|
await flush();
|
|
await flush();
|
|
|
|
expect(document.body.textContent).toContain("prod/sendgrid");
|
|
// Selection persisted through pagination.
|
|
expect(document.body.textContent).toContain("1 selected");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("disables checkboxes for already-imported (duplicate) rows and shows a conflict badge for conflicts", async () => {
|
|
mockSecretsApi.remoteImportPreview.mockResolvedValueOnce(
|
|
makePreview([
|
|
makeCandidate({
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:prod/sendgrid-Q9",
|
|
remoteName: "prod/sendgrid",
|
|
name: "prod/sendgrid",
|
|
key: "prod-sendgrid",
|
|
status: "duplicate",
|
|
importable: false,
|
|
conflicts: [
|
|
{ type: "exact_reference", message: "Already imported", existingSecretId: "secret-sg" },
|
|
],
|
|
}),
|
|
makeCandidate({
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:prod/openai-XYZ",
|
|
remoteName: "prod/openai",
|
|
name: "prod/openai",
|
|
key: "prod-openai",
|
|
status: "conflict",
|
|
importable: true,
|
|
conflicts: [
|
|
{ type: "name", message: "Name already in use" },
|
|
],
|
|
}),
|
|
]),
|
|
);
|
|
|
|
const { queryClient } = makeWrapper();
|
|
const root = createRoot(container);
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<ImportFromVaultDialog
|
|
open
|
|
onOpenChange={vi.fn()}
|
|
companyId="company-1"
|
|
providerConfigs={[awsVault]}
|
|
existingSecrets={[]}
|
|
/>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flush();
|
|
await flush();
|
|
|
|
const duplicateRow = document.querySelector(
|
|
'[data-testid="vault-row-arn:aws:secretsmanager:us-east-1:1:secret:prod/sendgrid-Q9"]',
|
|
);
|
|
expect(duplicateRow?.getAttribute("data-row-state")).toBe("duplicate");
|
|
const duplicateCheckbox = duplicateRow?.querySelector(
|
|
'button[role="checkbox"]',
|
|
) as HTMLButtonElement | null;
|
|
expect(duplicateCheckbox?.getAttribute("data-disabled")).not.toBeNull();
|
|
|
|
expect(document.body.textContent).toContain("Conflict");
|
|
expect(document.body.textContent).toContain("Name already in use");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("blocks import when a review row collides with an existing Paperclip secret", async () => {
|
|
const conflictCandidate = makeCandidate({
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:prod/openai-XYZ",
|
|
remoteName: "prod/openai",
|
|
name: "OPENAI_API_KEY",
|
|
key: "openai_api_key",
|
|
status: "conflict",
|
|
conflicts: [{ type: "key", message: "Key already in use" }],
|
|
});
|
|
mockSecretsApi.remoteImportPreview.mockResolvedValueOnce(
|
|
makePreview([conflictCandidate]),
|
|
);
|
|
|
|
const existing: CompanySecret[] = [
|
|
{
|
|
id: "secret-existing",
|
|
companyId: "company-1",
|
|
scope: "company",
|
|
ownerUserId: null,
|
|
userSecretDefinitionId: null,
|
|
key: "openai_api_key",
|
|
name: "OPENAI_API_KEY",
|
|
provider: "aws_secrets_manager",
|
|
status: "active",
|
|
managedMode: "external_reference",
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:other-XYZ",
|
|
providerConfigId: awsVault.id,
|
|
providerMetadata: null,
|
|
latestVersion: 1,
|
|
description: null,
|
|
lastResolvedAt: null,
|
|
lastRotatedAt: null,
|
|
deletedAt: null,
|
|
createdByAgentId: null,
|
|
createdByUserId: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
];
|
|
|
|
const { queryClient } = makeWrapper();
|
|
const root = createRoot(container);
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<ImportFromVaultDialog
|
|
open
|
|
onOpenChange={vi.fn()}
|
|
companyId="company-1"
|
|
providerConfigs={[awsVault]}
|
|
existingSecrets={existing}
|
|
/>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flush();
|
|
await flush();
|
|
|
|
// Select the conflict row
|
|
const row = document.querySelector(
|
|
'[data-testid="vault-row-arn:aws:secretsmanager:us-east-1:1:secret:prod/openai-XYZ"]',
|
|
) as HTMLElement | null;
|
|
await act(async () => {
|
|
row?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
// Click "Continue → Review" button.
|
|
const continueBtn = Array.from(document.querySelectorAll("button")).find(
|
|
(btn) => btn.textContent?.includes("Continue"),
|
|
);
|
|
expect(continueBtn).toBeTruthy();
|
|
await act(async () => {
|
|
continueBtn!.click();
|
|
});
|
|
await flush();
|
|
|
|
// Review step: error message visible, Import button disabled.
|
|
expect(document.body.textContent?.toLowerCase()).toContain("a paperclip secret already uses this");
|
|
|
|
const importBtn = Array.from(document.querySelectorAll("button")).find(
|
|
(btn) => btn.textContent?.startsWith("Import "),
|
|
) as HTMLButtonElement | undefined;
|
|
expect(importBtn).toBeTruthy();
|
|
expect(importBtn?.disabled).toBe(true);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("requires lowercase operator-entered keys during review", async () => {
|
|
const externalRef = "arn:aws:secretsmanager:us-east-1:1:secret:prod/openai-XYZ";
|
|
mockSecretsApi.remoteImportPreview.mockResolvedValueOnce(
|
|
makePreview([
|
|
makeCandidate({
|
|
externalRef,
|
|
remoteName: "prod/openai",
|
|
name: "OpenAI API key",
|
|
key: "openai-api-key",
|
|
}),
|
|
]),
|
|
);
|
|
|
|
const { queryClient } = makeWrapper();
|
|
const root = createRoot(container);
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<ImportFromVaultDialog
|
|
open
|
|
onOpenChange={vi.fn()}
|
|
companyId="company-1"
|
|
providerConfigs={[awsVault]}
|
|
existingSecrets={[]}
|
|
/>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flush();
|
|
await flush();
|
|
|
|
const row = document.querySelector(
|
|
`[data-testid="vault-row-${externalRef}"]`,
|
|
) as HTMLElement | null;
|
|
await act(async () => {
|
|
row?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
const continueBtn = Array.from(document.querySelectorAll("button")).find(
|
|
(btn) => btn.textContent?.includes("Continue"),
|
|
);
|
|
await act(async () => {
|
|
continueBtn!.click();
|
|
});
|
|
await flush();
|
|
|
|
const keyInput = document.querySelector(
|
|
`[data-testid="review-key-${externalRef}"]`,
|
|
) as HTMLInputElement | null;
|
|
const valueSetter = Object.getOwnPropertyDescriptor(
|
|
window.HTMLInputElement.prototype,
|
|
"value",
|
|
)?.set;
|
|
await act(async () => {
|
|
valueSetter?.call(keyInput, "MY_KEY");
|
|
keyInput!.dispatchEvent(new Event("input", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
expect(document.body.textContent).toContain("lowercase letters");
|
|
const importBtn = Array.from(document.querySelectorAll("button")).find(
|
|
(btn) => btn.textContent?.startsWith("Import "),
|
|
) as HTMLButtonElement | undefined;
|
|
expect(importBtn?.disabled).toBe(true);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("submits the operator-entered review description", async () => {
|
|
const externalRef = "arn:aws:secretsmanager:us-east-1:1:secret:prod/openai-XYZ";
|
|
mockSecretsApi.remoteImportPreview.mockResolvedValueOnce(
|
|
makePreview([
|
|
makeCandidate({
|
|
externalRef,
|
|
remoteName: "prod/openai",
|
|
name: "OpenAI API key",
|
|
key: "openai-api-key",
|
|
providerMetadata: {
|
|
description: "Raw AWS description should not seed the review field",
|
|
},
|
|
}),
|
|
]),
|
|
);
|
|
mockSecretsApi.remoteImport.mockResolvedValueOnce({
|
|
providerConfigId: awsVault.id,
|
|
provider: "aws_secrets_manager",
|
|
importedCount: 1,
|
|
skippedCount: 0,
|
|
errorCount: 0,
|
|
results: [
|
|
{
|
|
externalRef,
|
|
name: "OpenAI API key",
|
|
key: "openai-api-key",
|
|
status: "imported",
|
|
reason: null,
|
|
secretId: "secret-openai",
|
|
conflicts: [],
|
|
},
|
|
],
|
|
});
|
|
|
|
const { queryClient } = makeWrapper();
|
|
const root = createRoot(container);
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<ImportFromVaultDialog
|
|
open
|
|
onOpenChange={vi.fn()}
|
|
companyId="company-1"
|
|
providerConfigs={[awsVault]}
|
|
existingSecrets={[]}
|
|
/>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flush();
|
|
await flush();
|
|
|
|
const row = document.querySelector(
|
|
`[data-testid="vault-row-${externalRef}"]`,
|
|
) as HTMLElement | null;
|
|
await act(async () => {
|
|
row?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
const continueBtn = Array.from(document.querySelectorAll("button")).find(
|
|
(btn) => btn.textContent?.includes("Continue"),
|
|
);
|
|
await act(async () => {
|
|
continueBtn!.click();
|
|
});
|
|
await flush();
|
|
|
|
const descriptionInput = document.querySelector(
|
|
`[data-testid="review-description-${externalRef}"]`,
|
|
) as HTMLInputElement | null;
|
|
expect(descriptionInput?.value).toBe("");
|
|
const valueSetter = Object.getOwnPropertyDescriptor(
|
|
window.HTMLInputElement.prototype,
|
|
"value",
|
|
)?.set;
|
|
await act(async () => {
|
|
valueSetter?.call(descriptionInput, "Operator-entered OpenAI key");
|
|
descriptionInput!.dispatchEvent(new Event("input", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
const importBtn = Array.from(document.querySelectorAll("button")).find(
|
|
(btn) => btn.textContent?.startsWith("Import "),
|
|
) as HTMLButtonElement | undefined;
|
|
await act(async () => {
|
|
importBtn!.click();
|
|
});
|
|
await flush();
|
|
await flush();
|
|
|
|
expect(mockSecretsApi.remoteImport).toHaveBeenCalledWith("company-1", {
|
|
providerConfigId: awsVault.id,
|
|
secrets: [
|
|
expect.objectContaining({
|
|
externalRef,
|
|
description: "Operator-entered OpenAI key",
|
|
providerMetadata: null,
|
|
}),
|
|
],
|
|
});
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("renders mixed import results (created/skipped/failed) and shows error reason", async () => {
|
|
mockSecretsApi.remoteImportPreview.mockResolvedValueOnce(
|
|
makePreview([
|
|
makeCandidate({
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:a-AAA",
|
|
remoteName: "alpha",
|
|
name: "alpha",
|
|
key: "alpha",
|
|
}),
|
|
makeCandidate({
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:b-BBB",
|
|
remoteName: "beta",
|
|
name: "beta",
|
|
key: "beta",
|
|
}),
|
|
makeCandidate({
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:c-CCC",
|
|
remoteName: "gamma",
|
|
name: "gamma",
|
|
key: "gamma",
|
|
}),
|
|
]),
|
|
);
|
|
|
|
const result: RemoteSecretImportResult = {
|
|
providerConfigId: awsVault.id,
|
|
provider: "aws_secrets_manager",
|
|
importedCount: 1,
|
|
skippedCount: 1,
|
|
errorCount: 1,
|
|
results: [
|
|
{
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:a-AAA",
|
|
name: "alpha",
|
|
key: "alpha",
|
|
status: "imported",
|
|
reason: null,
|
|
secretId: "secret-alpha",
|
|
conflicts: [],
|
|
},
|
|
{
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:b-BBB",
|
|
name: "beta",
|
|
key: "beta",
|
|
status: "skipped",
|
|
reason: "exact reference already imported",
|
|
secretId: null,
|
|
conflicts: [
|
|
{ type: "exact_reference", message: "exact reference already imported" },
|
|
],
|
|
},
|
|
{
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:c-CCC",
|
|
name: "gamma",
|
|
key: "gamma",
|
|
status: "error",
|
|
reason: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
|
|
secretId: null,
|
|
conflicts: [],
|
|
},
|
|
],
|
|
};
|
|
mockSecretsApi.remoteImport.mockResolvedValueOnce(result);
|
|
|
|
const { queryClient } = makeWrapper();
|
|
const root = createRoot(container);
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<ImportFromVaultDialog
|
|
open
|
|
onOpenChange={vi.fn()}
|
|
companyId="company-1"
|
|
providerConfigs={[awsVault]}
|
|
existingSecrets={[]}
|
|
/>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flush();
|
|
await flush();
|
|
|
|
// Select all loaded
|
|
const headerCheckbox = document.querySelector(
|
|
'[data-testid="vault-table-body"]',
|
|
)?.parentElement?.querySelector('thead button[role="checkbox"]') as HTMLButtonElement | null;
|
|
expect(headerCheckbox).toBeTruthy();
|
|
await act(async () => {
|
|
headerCheckbox!.click();
|
|
});
|
|
await flush();
|
|
|
|
// Continue
|
|
const continueBtn = Array.from(document.querySelectorAll("button")).find(
|
|
(btn) => btn.textContent?.includes("Continue"),
|
|
);
|
|
await act(async () => {
|
|
continueBtn!.click();
|
|
});
|
|
await flush();
|
|
|
|
// Import
|
|
const importBtn = Array.from(document.querySelectorAll("button")).find(
|
|
(btn) => btn.textContent?.startsWith("Import "),
|
|
) as HTMLButtonElement | undefined;
|
|
expect(importBtn).toBeTruthy();
|
|
await act(async () => {
|
|
importBtn!.click();
|
|
});
|
|
await flush();
|
|
await flush();
|
|
|
|
expect(mockSecretsApi.remoteImport).toHaveBeenCalledTimes(1);
|
|
expect(document.body.textContent).toContain("Import complete");
|
|
expect(document.body.textContent).toContain("1 created");
|
|
expect(document.body.textContent).toContain("1 skipped");
|
|
expect(document.body.textContent).toContain("1 failed");
|
|
expect(document.body.textContent).toContain("AWS Secrets Manager denied the request");
|
|
expect(document.body.textContent).not.toContain("AccessDeniedException");
|
|
expect(document.body.textContent).not.toContain("123456789012");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("shows an empty state when no AWS vault is configured", async () => {
|
|
const { queryClient } = makeWrapper();
|
|
const root = createRoot(container);
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<ImportFromVaultDialog
|
|
open
|
|
onOpenChange={vi.fn()}
|
|
companyId="company-1"
|
|
providerConfigs={[]}
|
|
existingSecrets={[]}
|
|
onManageVaults={vi.fn()}
|
|
/>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flush();
|
|
|
|
expect(document.querySelector('[data-testid="select-empty-vaults"]')).not.toBeNull();
|
|
expect(mockSecretsApi.remoteImportPreview).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("shows a permission-error banner when AWS denies ListSecrets", async () => {
|
|
const error = Object.assign(new Error("AccessDeniedException"), {
|
|
name: "ApiError",
|
|
status: 403,
|
|
body: null,
|
|
});
|
|
mockSecretsApi.remoteImportPreview.mockRejectedValueOnce(error);
|
|
|
|
const { queryClient } = makeWrapper();
|
|
const root = createRoot(container);
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<ImportFromVaultDialog
|
|
open
|
|
onOpenChange={vi.fn()}
|
|
companyId="company-1"
|
|
providerConfigs={[awsVault]}
|
|
existingSecrets={[]}
|
|
/>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flush();
|
|
await flush();
|
|
|
|
const banner = document.querySelector('[data-testid="preview-error-banner"]');
|
|
expect(banner).not.toBeNull();
|
|
expect(banner?.textContent).toContain("Could not load remote secrets");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("renders sanitized preview provider errors without raw AWS exception text", async () => {
|
|
const rawProviderMessage =
|
|
"AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized";
|
|
mockSecretsApi.remoteImportPreview.mockRejectedValueOnce(
|
|
new ApiError(
|
|
"AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
|
|
403,
|
|
{ error: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.", details: { code: "access_denied" } },
|
|
),
|
|
);
|
|
|
|
const { queryClient } = makeWrapper();
|
|
const root = createRoot(container);
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<ImportFromVaultDialog
|
|
open
|
|
onOpenChange={vi.fn()}
|
|
companyId="company-1"
|
|
providerConfigs={[awsVault]}
|
|
existingSecrets={[]}
|
|
/>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flush();
|
|
await flush();
|
|
|
|
const banner = document.querySelector('[data-testid="preview-error-banner"]');
|
|
expect(banner).not.toBeNull();
|
|
expect(banner?.textContent).toContain("AWS denied list access");
|
|
expect(banner?.textContent).toContain("missing secretsmanager:ListSecrets");
|
|
expect(banner?.textContent).not.toContain(rawProviderMessage);
|
|
expect(banner?.textContent).not.toContain("arn:aws");
|
|
expect(banner?.textContent).not.toContain("123456789012");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("debounces search and uses the new query for the next preview", async () => {
|
|
mockSecretsApi.remoteImportPreview
|
|
.mockResolvedValueOnce(makePreview([makeCandidate()]))
|
|
.mockResolvedValueOnce(makePreview([
|
|
makeCandidate({
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:stripe-XYZ",
|
|
remoteName: "stripe",
|
|
name: "stripe",
|
|
key: "stripe",
|
|
}),
|
|
]));
|
|
|
|
const { queryClient } = makeWrapper();
|
|
const root = createRoot(container);
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<ImportFromVaultDialog
|
|
open
|
|
onOpenChange={vi.fn()}
|
|
companyId="company-1"
|
|
providerConfigs={[awsVault]}
|
|
existingSecrets={[]}
|
|
/>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flush();
|
|
await flush();
|
|
|
|
const search = document.querySelector('[data-testid="vault-search"]') as HTMLInputElement;
|
|
expect(search).not.toBeNull();
|
|
const valueSetter = Object.getOwnPropertyDescriptor(
|
|
window.HTMLInputElement.prototype,
|
|
"value",
|
|
)?.set;
|
|
await act(async () => {
|
|
search.focus();
|
|
valueSetter?.call(search, "stripe");
|
|
search.dispatchEvent(new Event("input", { bubbles: true }));
|
|
});
|
|
await flushDebounce();
|
|
await flush();
|
|
|
|
expect(mockSecretsApi.remoteImportPreview).toHaveBeenCalledTimes(2);
|
|
const lastCall = mockSecretsApi.remoteImportPreview.mock.calls.at(-1);
|
|
expect(lastCall?.[1]).toMatchObject({ query: "stripe" });
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
});
|