778e775c35
## Thinking Path > - Paperclip orchestrates AI-agent companies and needs secrets handling to work across local development, hosted operators, and governed agent execution. > - The affected subsystem is the company-scoped secrets control plane: database schema, server services/routes, CLI workflows, and the Secrets settings UI. > - The gap was that secrets were local-only and operators could not manage provider vaults or import existing remote references without exposing plaintext. > - This branch adds provider vault configuration plus an AWS Secrets Manager remote-import path while preserving company boundaries, binding context, and audit trails. > - I kept the PR to a single branch PR, removed unrelated lockfile/package drift, rebased the full branch onto the current `public-gh/master`, and addressed fresh Greptile findings. > - The benefit is a reviewable implementation of provider-backed secrets with focused tests covering provider selection, import conflicts, deleted secret reuse, rotation guards, and AWS signing behavior. ## What Changed - Added provider vault support for company secrets, including provider config storage, default vault handling, health checks, binding usage, access events, and remote import preview/commit. - Added an AWS Secrets Manager provider using SigV4 request signing, bounded request timeouts, namespace guardrails, cached runtime credential resolution, and external-reference linking without plaintext reads. - Added Secrets UI surfaces for vault management and remote import, plus CLI/API documentation for setup and operations. - Stabilized routine webhook secret binding paths and SSH environment-driver fixture bindings discovered during verification. - Addressed Greptile and CI findings: no lockfile/package drift, monotonic migration metadata, disabled-vault default races, soft-deleted secret hiding/recreate behavior, remove behavior with disabled vaults, soft-deleted external-reference re-import, non-active rotation guards, managed-secret soft deletion through PATCH, and per-call AWS SDK credential client churn. - Rebased this branch onto `public-gh/master` at `0e1a5828` and force-pushed with lease to keep this as the single PR for the branch. ## Verification - `git fetch public-gh master` - `git rebase public-gh/master` - `git diff --name-only public-gh/master...HEAD | grep '^pnpm-lock\.yaml$' || true` confirmed `pnpm-lock.yaml` is not in the PR diff. - Confirmed migration ordering: master ends at `0081_optimal_dormammu`; this PR adds `0082_dry_vision` and `0083_company_secret_provider_configs`. - Inspected migrations for repeat safety: new tables/indexes use `IF NOT EXISTS`; foreign keys are guarded by `DO $$ ... IF NOT EXISTS`; column additions use `ADD COLUMN IF NOT EXISTS`. - `pnpm -r typecheck` passed before the Greptile follow-up commits. - `pnpm test:run` ran the full stable Vitest path before the Greptile follow-up commits; it completed with 3 timing-related failures under parallel load: `codex-local-execute.test.ts`, `cursor-local-execute.test.ts`, and `environment-service.test.ts`. - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/codex-local-execute.test.ts src/__tests__/cursor-local-execute.test.ts src/__tests__/environment-service.test.ts` passed on targeted rerun (`24/24`). - `pnpm build` passed before the Greptile follow-up commits. Vite reported existing chunk-size/dynamic-import warnings. - After Greptile follow-up commits: `pnpm --filter @paperclipai/server exec vitest run src/__tests__/secrets-service.test.ts` passed (`26/26`). - After Greptile follow-up commits: `pnpm --filter @paperclipai/server exec vitest run src/__tests__/aws-secrets-manager-provider.test.ts src/__tests__/secrets-service.test.ts` passed (`39/39`). - After Greptile follow-up commits: `pnpm --filter @paperclipai/server typecheck` passed. - Captured Storybook screenshots from `ui/storybook-static` for visual review. - Latest PR checks on `5ca3a5cf`: `policy`, serialized server suites 1/4-4/4, `Canary Dry Run`, `e2e`, `security/snyk`, and `Greptile Review` pass; aggregate `verify` is still registering the completed child checks. - Greptile review loop continued through the latest requested pass; all Greptile review threads are resolved and the latest `Greptile Review` check on `5ca3a5cf` passed with 0 comments added. ## Screenshots Before: the provider-vault and remote-import surfaces did not exist on `master`; these are after-state screenshots from the Storybook fixtures.    ## Risks - Migration risk: this adds new secret provider tables and extends existing secret rows. The migrations were checked for monotonic ordering and idempotent guards, but reviewers should still inspect upgrade behavior carefully. - Provider risk: AWS support uses direct SigV4 requests. Automated tests cover signing, request timeouts, vault-config selection, namespace guardrails, pending-version archival, sanitized provider errors, and service-level cleanup paths. A real-vault AWS smoke test remains deployment validation for an operator with AWS credentials rather than an unverified merge blocker in this local branch. - UI risk: the Secrets page and import dialog are large new surfaces; screenshots are included above for reviewer inspection. - Verification risk: the full local stable test command hit parallel-load timing failures, although the exact failed files passed when rerun directly. - Operational risk: remote import intentionally avoids plaintext reads; operators must understand that imported external references resolve at runtime and may fail if AWS permissions change. > 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 coding agent with local shell/tool use in the Paperclip worktree. Exact context-window size was 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 - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1673 lines
58 KiB
TypeScript
1673 lines
58 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { mkdirSync, rmSync } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { eq } from "drizzle-orm";
|
|
import {
|
|
agents,
|
|
companies,
|
|
companySecretBindings,
|
|
companySecretProviderConfigs,
|
|
companySecretVersions,
|
|
companySecrets,
|
|
createDb,
|
|
secretAccessEvents,
|
|
} from "@paperclipai/db";
|
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
import { awsSecretsManagerProvider } from "../secrets/aws-secrets-manager-provider.js";
|
|
import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js";
|
|
import { SecretProviderClientError } from "../secrets/types.js";
|
|
import { secretService } from "../services/secrets.js";
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
if (!embeddedPostgresSupport.supported) {
|
|
console.warn(
|
|
`Skipping secrets service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
|
);
|
|
}
|
|
|
|
describeEmbeddedPostgres("secretService", () => {
|
|
let stopDb: (() => Promise<void>) | null = null;
|
|
let db!: ReturnType<typeof createDb>;
|
|
const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
|
const secretsTmpDir = path.join(os.tmpdir(), `paperclip-secrets-service-${randomUUID()}`);
|
|
|
|
beforeAll(async () => {
|
|
mkdirSync(secretsTmpDir, { recursive: true });
|
|
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key");
|
|
const started = await startEmbeddedPostgresTestDatabase("secrets-service");
|
|
stopDb = started.cleanup;
|
|
db = createDb(started.connectionString);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
await db.delete(secretAccessEvents);
|
|
await db.delete(companySecretBindings);
|
|
await db.delete(companySecretVersions);
|
|
await db.delete(companySecrets);
|
|
await db.delete(companySecretProviderConfigs);
|
|
await db.delete(agents);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await stopDb?.();
|
|
if (previousKeyFile === undefined) {
|
|
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
|
} else {
|
|
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile;
|
|
}
|
|
rmSync(secretsTmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
async function seedCompany(name = "Acme") {
|
|
const companyId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name,
|
|
issuePrefix: `T${companyId.slice(0, 7)}`.toUpperCase(),
|
|
status: "active",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
return companyId;
|
|
}
|
|
|
|
it("rejects cross-company secret references during env normalization", async () => {
|
|
const companyA = await seedCompany("A");
|
|
const companyB = await seedCompany("B");
|
|
const svc = secretService(db);
|
|
const foreignSecret = await svc.create(companyB, {
|
|
name: `foreign-${randomUUID()}`,
|
|
provider: "local_encrypted",
|
|
value: "secret-value",
|
|
});
|
|
|
|
await expect(
|
|
svc.normalizeEnvBindingsForPersistence(companyA, {
|
|
API_KEY: { type: "secret_ref", secretId: foreignSecret.id, version: "latest" },
|
|
}),
|
|
).rejects.toThrow(/same company/i);
|
|
});
|
|
|
|
it("prevents duplicate bindings for a target config path", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const firstSecret = await svc.create(companyId, {
|
|
name: `first-${randomUUID()}`,
|
|
provider: "local_encrypted",
|
|
value: "one",
|
|
});
|
|
const secondSecret = await svc.create(companyId, {
|
|
name: `second-${randomUUID()}`,
|
|
provider: "local_encrypted",
|
|
value: "two",
|
|
});
|
|
|
|
await svc.createBinding({
|
|
companyId,
|
|
secretId: firstSecret.id,
|
|
targetType: "agent",
|
|
targetId: "agent-1",
|
|
configPath: "env.API_KEY",
|
|
});
|
|
|
|
await expect(
|
|
svc.createBinding({
|
|
companyId,
|
|
secretId: secondSecret.id,
|
|
targetType: "agent",
|
|
targetId: "agent-1",
|
|
configPath: "env.API_KEY",
|
|
}),
|
|
).rejects.toThrow(/already exists/i);
|
|
});
|
|
|
|
it("reports reference counts and resolves binding target labels", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const secret = await svc.create(companyId, {
|
|
name: `referenced-${randomUUID()}`,
|
|
provider: "local_encrypted",
|
|
value: "runtime-secret",
|
|
});
|
|
const [agent] = await db
|
|
.insert(agents)
|
|
.values({
|
|
companyId,
|
|
name: "CodexCoder",
|
|
role: "engineer",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
})
|
|
.returning();
|
|
|
|
await svc.syncEnvBindingsForTarget(
|
|
companyId,
|
|
{ targetType: "agent", targetId: agent!.id },
|
|
{
|
|
OPENAI_API_KEY: { type: "secret_ref", secretId: secret.id, version: "latest" },
|
|
},
|
|
);
|
|
|
|
const listed = await svc.list(companyId);
|
|
expect(listed.find((row) => row.id === secret.id)?.referenceCount).toBe(1);
|
|
|
|
const bindings = await svc.listBindingReferences(companyId, secret.id);
|
|
expect(bindings).toHaveLength(1);
|
|
expect(bindings[0]?.target).toMatchObject({
|
|
type: "agent",
|
|
id: agent!.id,
|
|
label: "CodexCoder",
|
|
href: "/agents/codexcoder",
|
|
status: "idle",
|
|
});
|
|
});
|
|
|
|
it("enforces binding context and records value-free access events", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const secret = await svc.create(companyId, {
|
|
name: `runtime-${randomUUID()}`,
|
|
provider: "local_encrypted",
|
|
value: "runtime-secret",
|
|
});
|
|
const env = {
|
|
API_KEY: { type: "secret_ref" as const, secretId: secret.id, version: "latest" as const },
|
|
};
|
|
|
|
await svc.syncEnvBindingsForTarget(companyId, { targetType: "agent", targetId: "agent-1" }, env);
|
|
|
|
await expect(
|
|
svc.resolveEnvBindings(companyId, env, {
|
|
consumerType: "agent",
|
|
consumerId: "agent-2",
|
|
actorType: "agent",
|
|
actorId: "agent-2",
|
|
}),
|
|
).rejects.toThrow(/not bound/i);
|
|
|
|
const resolved = await svc.resolveEnvBindings(companyId, env, {
|
|
consumerType: "agent",
|
|
consumerId: "agent-1",
|
|
actorType: "agent",
|
|
actorId: "agent-1",
|
|
});
|
|
|
|
expect(resolved.env.API_KEY).toBe("runtime-secret");
|
|
const events = await svc.listAccessEvents(companyId, secret.id);
|
|
expect(events).toHaveLength(2);
|
|
expect(events.map((event) => event.outcome).sort()).toEqual(["failure", "success"]);
|
|
expect(JSON.stringify(events)).not.toContain("runtime-secret");
|
|
});
|
|
|
|
it("scopes env binding sync deletes to the env path prefix", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const runtimeSecret = await svc.create(companyId, {
|
|
name: `runtime-ref-${randomUUID()}`,
|
|
provider: "local_encrypted",
|
|
value: "runtime-secret",
|
|
});
|
|
const envSecret = await svc.create(companyId, {
|
|
name: `env-ref-${randomUUID()}`,
|
|
provider: "local_encrypted",
|
|
value: "env-secret",
|
|
});
|
|
|
|
await svc.createBinding({
|
|
companyId,
|
|
secretId: runtimeSecret.id,
|
|
targetType: "agent",
|
|
targetId: "agent-1",
|
|
configPath: "runtime.token",
|
|
});
|
|
await svc.syncEnvBindingsForTarget(
|
|
companyId,
|
|
{ targetType: "agent", targetId: "agent-1" },
|
|
{
|
|
API_KEY: { type: "secret_ref", secretId: envSecret.id, version: "latest" },
|
|
},
|
|
);
|
|
await svc.syncEnvBindingsForTarget(
|
|
companyId,
|
|
{ targetType: "agent", targetId: "agent-1" },
|
|
{},
|
|
);
|
|
|
|
const bindings = await db
|
|
.select()
|
|
.from(companySecretBindings)
|
|
.where(eq(companySecretBindings.targetId, "agent-1"));
|
|
expect(bindings.map((binding) => binding.configPath)).toEqual(["runtime.token"]);
|
|
});
|
|
|
|
it("returns resolved secrets even when success metadata writes fail", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const secret = await svc.create(companyId, {
|
|
name: `metadata-write-${randomUUID()}`,
|
|
provider: "local_encrypted",
|
|
value: "runtime-secret",
|
|
});
|
|
const env = {
|
|
API_KEY: { type: "secret_ref" as const, secretId: secret.id, version: "latest" as const },
|
|
};
|
|
await svc.syncEnvBindingsForTarget(companyId, { targetType: "agent", targetId: "agent-1" }, env);
|
|
|
|
vi.spyOn(db, "update").mockImplementationOnce(
|
|
() => ({
|
|
set: () => ({
|
|
where: () => Promise.reject(new Error("metadata write failed")),
|
|
}),
|
|
}) as ReturnType<typeof db.update>,
|
|
);
|
|
|
|
const resolved = await svc.resolveEnvBindings(companyId, env, {
|
|
consumerType: "agent",
|
|
consumerId: "agent-1",
|
|
actorType: "agent",
|
|
actorId: "agent-1",
|
|
});
|
|
|
|
expect(resolved.env.API_KEY).toBe("runtime-secret");
|
|
});
|
|
|
|
it("stores external references without requiring or persisting secret values", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const awsVault = await svc.createProviderConfig(companyId, {
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS production",
|
|
config: { region: "us-east-1", namespace: "prod-use1" },
|
|
});
|
|
|
|
const secret = await svc.create(companyId, {
|
|
name: `external-${randomUUID()}`,
|
|
provider: "aws_secrets_manager",
|
|
providerConfigId: awsVault.id,
|
|
managedMode: "external_reference",
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/test",
|
|
providerVersionRef: "version-1",
|
|
});
|
|
|
|
expect(secret.managedMode).toBe("external_reference");
|
|
expect(secret.externalRef).toBe("arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/test");
|
|
|
|
const versions = await db
|
|
.select()
|
|
.from(companySecretVersions)
|
|
.where(eq(companySecretVersions.secretId, secret.id));
|
|
expect(versions).toHaveLength(1);
|
|
expect(versions[0]?.providerVersionRef).toBe("version-1");
|
|
expect(JSON.stringify(versions[0])).not.toContain("runtime-secret");
|
|
expect(JSON.stringify(versions[0])).not.toContain("sk-");
|
|
|
|
await expect(
|
|
svc.resolveSecretValue(companyId, secret.id, "latest", {
|
|
consumerType: "system",
|
|
consumerId: "system",
|
|
configPath: "env.EXTERNAL_SECRET",
|
|
}),
|
|
).rejects.toThrow(/not bound/i);
|
|
});
|
|
|
|
it("preserves the original resolution error when failure access logging fails", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const secret = await svc.create(companyId, {
|
|
name: `resolution-failure-${randomUUID()}`,
|
|
provider: "local_encrypted",
|
|
value: "runtime-secret",
|
|
});
|
|
await svc.createBinding({
|
|
companyId,
|
|
secretId: secret.id,
|
|
targetType: "system",
|
|
targetId: "system",
|
|
configPath: "env.API_KEY",
|
|
});
|
|
vi.spyOn(localEncryptedProvider, "resolveVersion").mockRejectedValueOnce(
|
|
new Error("provider resolution failed"),
|
|
);
|
|
|
|
await expect(
|
|
svc.resolveSecretValue(companyId, secret.id, "latest", {
|
|
consumerType: "system",
|
|
consumerId: "system",
|
|
configPath: "env.API_KEY",
|
|
heartbeatRunId: randomUUID(),
|
|
}),
|
|
).rejects.toThrow("provider resolution failed");
|
|
});
|
|
|
|
it("keeps one default provider vault per company provider", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
|
|
const first = await svc.createProviderConfig(companyId, {
|
|
provider: "local_encrypted",
|
|
displayName: "Local primary",
|
|
isDefault: true,
|
|
config: {},
|
|
});
|
|
const second = await svc.createProviderConfig(companyId, {
|
|
provider: "local_encrypted",
|
|
displayName: "Local secondary",
|
|
isDefault: true,
|
|
config: {},
|
|
});
|
|
|
|
const rows = await svc.listProviderConfigs(companyId);
|
|
expect(rows.find((row) => row.id === first.id)?.isDefault).toBe(false);
|
|
expect(rows.find((row) => row.id === second.id)?.isDefault).toBe(true);
|
|
});
|
|
|
|
it("does not set a disabled provider vault as default", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const vault = await svc.createProviderConfig(companyId, {
|
|
provider: "local_encrypted",
|
|
displayName: "Local disabled",
|
|
config: {},
|
|
});
|
|
|
|
await svc.disableProviderConfig(vault.id);
|
|
await expect(svc.setDefaultProviderConfig(vault.id)).rejects.toThrow(
|
|
/ready or warning/i,
|
|
);
|
|
});
|
|
|
|
it("hides soft-deleted secrets and allows name/key reuse", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const secretName = `reusable-${randomUUID()}`;
|
|
const secret = await svc.create(companyId, {
|
|
name: secretName,
|
|
key: "reusable-key",
|
|
provider: "local_encrypted",
|
|
value: "first-value",
|
|
});
|
|
|
|
await svc.remove(secret.id);
|
|
const listed = await svc.list(companyId);
|
|
const recreated = await svc.create(companyId, {
|
|
name: secretName,
|
|
key: "reusable-key",
|
|
provider: "local_encrypted",
|
|
value: "second-value",
|
|
});
|
|
|
|
expect(listed.map((row) => row.id)).not.toContain(secret.id);
|
|
expect(recreated.id).not.toBe(secret.id);
|
|
expect(recreated.name).toBe(secretName);
|
|
expect(recreated.key).toBe("reusable-key");
|
|
});
|
|
|
|
it("rejects bindings and env refs to soft-deleted external reference secrets", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const awsVault = await svc.createProviderConfig(companyId, {
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS production",
|
|
config: { region: "us-east-1", namespace: "prod-use1" },
|
|
});
|
|
const deleted = await svc.create(companyId, {
|
|
name: "Deleted external",
|
|
key: "deleted-external",
|
|
provider: "aws_secrets_manager",
|
|
providerConfigId: awsVault.id,
|
|
managedMode: "external_reference",
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/deleted",
|
|
});
|
|
await svc.update(deleted.id, { status: "deleted" });
|
|
|
|
await expect(
|
|
svc.createBinding({
|
|
companyId,
|
|
secretId: deleted.id,
|
|
targetType: "agent",
|
|
targetId: "agent-1",
|
|
configPath: "env.API_KEY",
|
|
}),
|
|
).rejects.toThrow(/not found/i);
|
|
await expect(
|
|
svc.normalizeEnvBindingsForPersistence(companyId, {
|
|
API_KEY: { type: "secret_ref", secretId: deleted.id, version: "latest" },
|
|
}),
|
|
).rejects.toThrow(/not found/i);
|
|
});
|
|
|
|
it("rejects updates to already soft-deleted external reference secrets", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const awsVault = await svc.createProviderConfig(companyId, {
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS production",
|
|
config: { region: "us-east-1", namespace: "prod-use1" },
|
|
});
|
|
const deleted = await svc.create(companyId, {
|
|
name: "Deleted patch target",
|
|
key: "deleted-patch-target",
|
|
provider: "aws_secrets_manager",
|
|
providerConfigId: awsVault.id,
|
|
managedMode: "external_reference",
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/deleted-patch-target",
|
|
});
|
|
await svc.update(deleted.id, { status: "deleted" });
|
|
|
|
await expect(svc.update(deleted.id, { status: "active" })).rejects.toThrow(
|
|
/not found/i,
|
|
);
|
|
});
|
|
|
|
it("allows re-importing a remote secret after the prior external reference is soft-deleted", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const awsVault = await svc.createProviderConfig(companyId, {
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS production",
|
|
config: { region: "us-east-1", namespace: "prod-use1" },
|
|
});
|
|
const externalRef =
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/reimportable";
|
|
const deleted = await svc.create(companyId, {
|
|
name: "Deleted external",
|
|
key: "deleted-external",
|
|
provider: "aws_secrets_manager",
|
|
providerConfigId: awsVault.id,
|
|
managedMode: "external_reference",
|
|
externalRef,
|
|
});
|
|
|
|
await svc.update(deleted.id, { status: "deleted" });
|
|
vi.spyOn(awsSecretsManagerProvider, "listRemoteSecrets").mockResolvedValue({
|
|
secrets: [
|
|
{
|
|
externalRef,
|
|
name: "prod/reimportable",
|
|
providerVersionRef: null,
|
|
metadata: { arn: externalRef },
|
|
},
|
|
],
|
|
});
|
|
|
|
const preview = await svc.previewRemoteImport(companyId, {
|
|
providerConfigId: awsVault.id,
|
|
});
|
|
const result = await svc.importRemoteSecrets(companyId, {
|
|
providerConfigId: awsVault.id,
|
|
secrets: [
|
|
{
|
|
externalRef,
|
|
name: "Reimported external",
|
|
key: "reimported-external",
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(preview.candidates[0]).toMatchObject({
|
|
status: "ready",
|
|
importable: true,
|
|
conflicts: [],
|
|
});
|
|
expect(result).toMatchObject({ importedCount: 1, skippedCount: 0, errorCount: 0 });
|
|
});
|
|
|
|
it("ignores soft-deleted name and key conflicts during remote import", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const awsVault = await svc.createProviderConfig(companyId, {
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS production",
|
|
config: { region: "us-east-1", namespace: "prod-use1" },
|
|
});
|
|
const deleted = await svc.create(companyId, {
|
|
name: "Deleted external",
|
|
key: "deleted-external",
|
|
provider: "aws_secrets_manager",
|
|
providerConfigId: awsVault.id,
|
|
managedMode: "external_reference",
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/deleted-old",
|
|
});
|
|
await svc.update(deleted.id, { status: "deleted" });
|
|
vi.spyOn(awsSecretsManagerProvider, "listRemoteSecrets").mockResolvedValue({
|
|
secrets: [
|
|
{
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/deleted-new",
|
|
name: "Deleted external",
|
|
providerVersionRef: null,
|
|
metadata: {},
|
|
},
|
|
],
|
|
});
|
|
|
|
const preview = await svc.previewRemoteImport(companyId, {
|
|
providerConfigId: awsVault.id,
|
|
});
|
|
const result = await svc.importRemoteSecrets(companyId, {
|
|
providerConfigId: awsVault.id,
|
|
secrets: [
|
|
{
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/deleted-new",
|
|
name: "Deleted external",
|
|
key: "deleted-external",
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(preview.candidates[0]).toMatchObject({
|
|
status: "ready",
|
|
importable: true,
|
|
conflicts: [],
|
|
});
|
|
expect(result).toMatchObject({ importedCount: 1, skippedCount: 0, errorCount: 0 });
|
|
});
|
|
|
|
it("rejects provider vaults from another company when creating a secret", async () => {
|
|
const companyA = await seedCompany("A");
|
|
const companyB = await seedCompany("B");
|
|
const svc = secretService(db);
|
|
const foreignVault = await svc.createProviderConfig(companyB, {
|
|
provider: "local_encrypted",
|
|
displayName: "Foreign vault",
|
|
config: {},
|
|
});
|
|
|
|
await expect(
|
|
svc.create(companyA, {
|
|
name: `managed-${randomUUID()}`,
|
|
provider: "local_encrypted",
|
|
providerConfigId: foreignVault.id,
|
|
value: "runtime-secret",
|
|
}),
|
|
).rejects.toThrow(/same company/i);
|
|
});
|
|
|
|
it("blocks coming-soon provider vaults from secret selection", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const draftVault = await svc.createProviderConfig(companyId, {
|
|
provider: "gcp_secret_manager",
|
|
displayName: "GCP draft",
|
|
config: { projectId: "paperclip-prod1" },
|
|
});
|
|
|
|
expect(draftVault.status).toBe("coming_soon");
|
|
await expect(
|
|
svc.create(companyId, {
|
|
name: `draft-${randomUUID()}`,
|
|
provider: "gcp_secret_manager",
|
|
providerConfigId: draftVault.id,
|
|
value: "runtime-secret",
|
|
}),
|
|
).rejects.toThrow(/coming soon/i);
|
|
});
|
|
|
|
it("passes selected provider vault config through create, rotate, and resolve", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const awsVault = await svc.createProviderConfig(companyId, {
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS production",
|
|
config: {
|
|
region: "us-east-1",
|
|
namespace: "prod-use1",
|
|
secretNamePrefix: "paperclip",
|
|
},
|
|
});
|
|
|
|
const createSpy = vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({
|
|
material: {
|
|
scheme: "aws_secrets_manager_v1",
|
|
secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/openai-api-key",
|
|
versionId: "aws-version-1",
|
|
source: "managed",
|
|
},
|
|
valueSha256: "value-sha-1",
|
|
fingerprintSha256: "fingerprint-sha-1",
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/openai-api-key",
|
|
providerVersionRef: "aws-version-1",
|
|
});
|
|
const createVersionSpy = vi.spyOn(awsSecretsManagerProvider, "createVersion").mockResolvedValue({
|
|
material: {
|
|
scheme: "aws_secrets_manager_v1",
|
|
secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/openai-api-key",
|
|
versionId: "aws-version-2",
|
|
source: "managed",
|
|
},
|
|
valueSha256: "value-sha-2",
|
|
fingerprintSha256: "fingerprint-sha-2",
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/openai-api-key",
|
|
providerVersionRef: "aws-version-2",
|
|
});
|
|
const resolveSpy = vi.spyOn(awsSecretsManagerProvider, "resolveVersion").mockResolvedValue("resolved-secret");
|
|
|
|
const secret = await svc.create(companyId, {
|
|
name: `aws-managed-${randomUUID()}`,
|
|
provider: "aws_secrets_manager",
|
|
providerConfigId: awsVault.id,
|
|
value: "runtime-secret",
|
|
});
|
|
const rotated = await svc.rotate(secret.id, { value: "rotated-runtime-secret" });
|
|
const resolved = await svc.resolveSecretValue(companyId, rotated.id, "latest");
|
|
|
|
expect(resolved).toBe("resolved-secret");
|
|
expect(createSpy).toHaveBeenCalledWith(expect.objectContaining({
|
|
providerConfig: expect.objectContaining({
|
|
id: awsVault.id,
|
|
provider: "aws_secrets_manager",
|
|
config: expect.objectContaining({ region: "us-east-1", namespace: "prod-use1" }),
|
|
}),
|
|
}));
|
|
expect(createVersionSpy).toHaveBeenCalledWith(expect.objectContaining({
|
|
providerConfig: expect.objectContaining({ id: awsVault.id }),
|
|
}));
|
|
expect(resolveSpy).toHaveBeenCalledWith(expect.objectContaining({
|
|
providerConfig: expect.objectContaining({ id: awsVault.id }),
|
|
providerVersionRef: "aws-version-2",
|
|
}));
|
|
expect(JSON.stringify(resolveSpy.mock.calls[0]?.[0])).not.toContain("resolved-secret");
|
|
});
|
|
|
|
it("cleans up managed provider secrets when create persistence fails", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const awsVault = await svc.createProviderConfig(companyId, {
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS production",
|
|
config: { region: "us-east-1", namespace: "prod-use1" },
|
|
});
|
|
const prepared = {
|
|
material: {
|
|
scheme: "aws_secrets_manager_v1",
|
|
secretId:
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-rollback",
|
|
versionId: "aws-version-1",
|
|
source: "managed",
|
|
},
|
|
valueSha256: "value-sha-1",
|
|
fingerprintSha256: "fingerprint-sha-1",
|
|
externalRef:
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-rollback",
|
|
providerVersionRef: "aws-version-1",
|
|
};
|
|
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(prepared);
|
|
const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue();
|
|
vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("db insert failed"));
|
|
|
|
await expect(
|
|
svc.create(companyId, {
|
|
name: "Create Rollback",
|
|
key: "create-rollback",
|
|
provider: "aws_secrets_manager",
|
|
providerConfigId: awsVault.id,
|
|
value: "runtime-secret",
|
|
}),
|
|
).rejects.toThrow("db insert failed");
|
|
|
|
expect(deleteSpy).toHaveBeenCalledWith(expect.objectContaining({
|
|
material: prepared.material,
|
|
externalRef: prepared.externalRef,
|
|
mode: "delete",
|
|
providerConfig: expect.objectContaining({ id: awsVault.id }),
|
|
context: {
|
|
companyId,
|
|
secretKey: "create-rollback",
|
|
secretName: "Create Rollback",
|
|
version: 1,
|
|
},
|
|
}));
|
|
});
|
|
|
|
it("keeps a local cleanup handle when create rollback cleanup fails", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const awsVault = await svc.createProviderConfig(companyId, {
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS production",
|
|
config: { region: "us-east-1", namespace: "prod-use1" },
|
|
});
|
|
const prepared = {
|
|
material: {
|
|
scheme: "aws_secrets_manager_v1",
|
|
secretId:
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-cleanup-handle",
|
|
versionId: "aws-version-1",
|
|
source: "managed",
|
|
},
|
|
valueSha256: "value-sha-1",
|
|
fingerprintSha256: "fingerprint-sha-1",
|
|
externalRef:
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-cleanup-handle",
|
|
providerVersionRef: "aws-version-1",
|
|
};
|
|
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(prepared);
|
|
vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockRejectedValue(
|
|
new Error("cleanup failed"),
|
|
);
|
|
vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("db activate failed"));
|
|
|
|
await expect(
|
|
svc.create(companyId, {
|
|
name: "Create Cleanup Handle",
|
|
key: "create-cleanup-handle",
|
|
provider: "aws_secrets_manager",
|
|
providerConfigId: awsVault.id,
|
|
value: "runtime-secret",
|
|
}),
|
|
).rejects.toThrow("db activate failed");
|
|
|
|
const persisted = await svc.getByName(companyId, "Create Cleanup Handle");
|
|
expect(persisted).toMatchObject({
|
|
key: "create-cleanup-handle",
|
|
status: "archived",
|
|
externalRef: prepared.externalRef,
|
|
latestVersion: 1,
|
|
});
|
|
|
|
const version = await db
|
|
.select()
|
|
.from(companySecretVersions)
|
|
.where(eq(companySecretVersions.secretId, persisted!.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
expect(version).toMatchObject({
|
|
version: 1,
|
|
status: "disabled",
|
|
material: prepared.material,
|
|
});
|
|
});
|
|
|
|
it("archives managed provider versions when rotate persistence fails", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const awsVault = await svc.createProviderConfig(companyId, {
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS production",
|
|
config: { region: "us-east-1", namespace: "prod-use1" },
|
|
});
|
|
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({
|
|
material: {
|
|
scheme: "aws_secrets_manager_v1",
|
|
secretId:
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-rollback",
|
|
versionId: "aws-version-1",
|
|
source: "managed",
|
|
},
|
|
valueSha256: "value-sha-1",
|
|
fingerprintSha256: "fingerprint-sha-1",
|
|
externalRef:
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-rollback",
|
|
providerVersionRef: "aws-version-1",
|
|
});
|
|
const secret = await svc.create(companyId, {
|
|
name: "Rotate Rollback",
|
|
key: "rotate-rollback",
|
|
provider: "aws_secrets_manager",
|
|
providerConfigId: awsVault.id,
|
|
value: "runtime-secret",
|
|
});
|
|
const prepared = {
|
|
material: {
|
|
scheme: "aws_secrets_manager_v1",
|
|
secretId:
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-rollback",
|
|
versionId: "aws-version-2",
|
|
source: "managed",
|
|
},
|
|
valueSha256: "value-sha-2",
|
|
fingerprintSha256: "fingerprint-sha-2",
|
|
externalRef:
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-rollback",
|
|
providerVersionRef: "aws-version-2",
|
|
};
|
|
vi.spyOn(awsSecretsManagerProvider, "createVersion").mockResolvedValue(prepared);
|
|
const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue();
|
|
vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("db rotate failed"));
|
|
|
|
await expect(svc.rotate(secret.id, { value: "rotated-runtime-secret" })).rejects.toThrow(
|
|
"db rotate failed",
|
|
);
|
|
|
|
expect(deleteSpy).toHaveBeenCalledWith(expect.objectContaining({
|
|
material: prepared.material,
|
|
externalRef: prepared.externalRef,
|
|
mode: "archive",
|
|
providerConfig: expect.objectContaining({ id: awsVault.id }),
|
|
context: {
|
|
companyId,
|
|
secretKey: "rotate-rollback",
|
|
secretName: "Rotate Rollback",
|
|
version: 2,
|
|
},
|
|
}));
|
|
});
|
|
|
|
it("keeps a disabled version cleanup handle when rotate rollback cleanup fails", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const awsVault = await svc.createProviderConfig(companyId, {
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS production",
|
|
config: { region: "us-east-1", namespace: "prod-use1" },
|
|
});
|
|
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({
|
|
material: {
|
|
scheme: "aws_secrets_manager_v1",
|
|
secretId:
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-cleanup-handle",
|
|
versionId: "aws-version-1",
|
|
source: "managed",
|
|
},
|
|
valueSha256: "value-sha-1",
|
|
fingerprintSha256: "fingerprint-sha-1",
|
|
externalRef:
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-cleanup-handle",
|
|
providerVersionRef: "aws-version-1",
|
|
});
|
|
const secret = await svc.create(companyId, {
|
|
name: "Rotate Cleanup Handle",
|
|
key: "rotate-cleanup-handle",
|
|
provider: "aws_secrets_manager",
|
|
providerConfigId: awsVault.id,
|
|
value: "runtime-secret",
|
|
});
|
|
const prepared = {
|
|
material: {
|
|
scheme: "aws_secrets_manager_v1",
|
|
secretId:
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-cleanup-handle",
|
|
versionId: "aws-version-2",
|
|
source: "managed",
|
|
},
|
|
valueSha256: "value-sha-2",
|
|
fingerprintSha256: "fingerprint-sha-2",
|
|
externalRef:
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-cleanup-handle",
|
|
providerVersionRef: "aws-version-2",
|
|
};
|
|
vi.spyOn(awsSecretsManagerProvider, "createVersion").mockResolvedValue(prepared);
|
|
vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockRejectedValue(
|
|
new Error("cleanup failed"),
|
|
);
|
|
vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("db rotate failed"));
|
|
|
|
await expect(svc.rotate(secret.id, { value: "rotated-runtime-secret" })).rejects.toThrow(
|
|
"db rotate failed",
|
|
);
|
|
|
|
const persisted = await svc.getById(secret.id);
|
|
expect(persisted?.latestVersion).toBe(1);
|
|
|
|
const versions = await db
|
|
.select()
|
|
.from(companySecretVersions)
|
|
.where(eq(companySecretVersions.secretId, secret.id));
|
|
expect(versions).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ version: 1, status: "current" }),
|
|
expect.objectContaining({
|
|
version: 2,
|
|
status: "disabled",
|
|
material: prepared.material,
|
|
}),
|
|
]));
|
|
});
|
|
|
|
it("rejects generic provider vault reassignment for managed secrets", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const firstVault = await svc.createProviderConfig(companyId, {
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS primary",
|
|
config: { region: "us-east-1", namespace: "prod-use1" },
|
|
});
|
|
const secondVault = await svc.createProviderConfig(companyId, {
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS secondary",
|
|
config: { region: "us-west-2", namespace: "prod-usw2" },
|
|
});
|
|
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({
|
|
material: {
|
|
scheme: "aws_secrets_manager_v1",
|
|
secretId:
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/vault-reassign",
|
|
versionId: "aws-version-1",
|
|
source: "managed",
|
|
},
|
|
valueSha256: "value-sha-1",
|
|
fingerprintSha256: "fingerprint-sha-1",
|
|
externalRef:
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/vault-reassign",
|
|
providerVersionRef: "aws-version-1",
|
|
});
|
|
const secret = await svc.create(companyId, {
|
|
name: "Vault Reassign",
|
|
key: "vault-reassign",
|
|
provider: "aws_secrets_manager",
|
|
providerConfigId: firstVault.id,
|
|
value: "runtime-secret",
|
|
});
|
|
|
|
await expect(svc.update(secret.id, { providerConfigId: secondVault.id })).rejects.toThrow(
|
|
/managed secrets cannot change provider vault/i,
|
|
);
|
|
const persisted = await svc.getById(secret.id);
|
|
expect(persisted?.providerConfigId).toBe(firstVault.id);
|
|
});
|
|
|
|
it("rejects rotation for non-active secrets", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const secret = await svc.create(companyId, {
|
|
name: `disabled-rotation-${randomUUID()}`,
|
|
provider: "local_encrypted",
|
|
value: "runtime-secret",
|
|
});
|
|
|
|
await svc.update(secret.id, { status: "disabled" });
|
|
await expect(svc.rotate(secret.id, { value: "rotated-runtime-secret" })).rejects.toThrow(
|
|
/non-active/i,
|
|
);
|
|
|
|
const stored = await db
|
|
.select({ latestVersion: companySecrets.latestVersion })
|
|
.from(companySecrets)
|
|
.where(eq(companySecrets.id, secret.id))
|
|
.then((rows) => rows[0]);
|
|
expect(stored?.latestVersion).toBe(1);
|
|
});
|
|
|
|
it("previews AWS remote import candidates with duplicate and collision enrichment", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const awsVault = await svc.createProviderConfig(companyId, {
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS production",
|
|
config: { region: "us-east-1", namespace: "prod-use1" },
|
|
});
|
|
const duplicate = await svc.create(companyId, {
|
|
name: "Existing duplicate",
|
|
provider: "aws_secrets_manager",
|
|
providerConfigId: awsVault.id,
|
|
managedMode: "external_reference",
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/duplicate",
|
|
});
|
|
const nameConflict = await svc.create(companyId, {
|
|
name: "Prod Conflict",
|
|
provider: "local_encrypted",
|
|
value: "runtime-secret",
|
|
});
|
|
|
|
const listSpy = vi.spyOn(awsSecretsManagerProvider, "listRemoteSecrets").mockResolvedValue({
|
|
nextToken: "next-page",
|
|
secrets: [
|
|
{
|
|
externalRef: duplicate.externalRef!,
|
|
name: "prod/duplicate",
|
|
providerVersionRef: null,
|
|
metadata: { arn: duplicate.externalRef },
|
|
},
|
|
{
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/conflict",
|
|
name: nameConflict.name,
|
|
providerVersionRef: null,
|
|
metadata: { arn: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/conflict" },
|
|
},
|
|
{
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/ready",
|
|
name: "prod/ready",
|
|
providerVersionRef: null,
|
|
metadata: { arn: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/ready" },
|
|
},
|
|
],
|
|
});
|
|
|
|
const preview = await svc.previewRemoteImport(companyId, {
|
|
providerConfigId: awsVault.id,
|
|
query: "prod",
|
|
pageSize: 25,
|
|
});
|
|
|
|
expect(listSpy).toHaveBeenCalledWith(expect.objectContaining({
|
|
providerConfig: expect.objectContaining({ id: awsVault.id }),
|
|
query: "prod",
|
|
pageSize: 25,
|
|
}));
|
|
expect(preview.nextToken).toBe("next-page");
|
|
expect(preview.candidates.map((candidate) => candidate.status)).toEqual([
|
|
"duplicate",
|
|
"conflict",
|
|
"ready",
|
|
]);
|
|
expect(preview.candidates[0]?.conflicts[0]).toMatchObject({
|
|
type: "exact_reference",
|
|
existingSecretId: duplicate.id,
|
|
});
|
|
expect(preview.candidates[1]?.conflicts[0]).toMatchObject({
|
|
type: "name",
|
|
existingSecretId: nameConflict.id,
|
|
});
|
|
expect(preview.candidates[2]).toMatchObject({
|
|
importable: true,
|
|
name: "prod/ready",
|
|
key: "prod-ready",
|
|
});
|
|
expect(preview.candidates[2]?.providerMetadata).toBeNull();
|
|
});
|
|
|
|
it("sanitizes AWS remote import preview provider errors before crossing the service boundary", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const awsVault = await svc.createProviderConfig(companyId, {
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS production",
|
|
config: { region: "us-east-1", namespace: "prod-use1" },
|
|
});
|
|
const rawProviderMessage =
|
|
"AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:ListSecrets";
|
|
|
|
vi.spyOn(awsSecretsManagerProvider, "listRemoteSecrets").mockRejectedValueOnce(
|
|
new SecretProviderClientError({
|
|
code: "access_denied",
|
|
provider: "aws_secrets_manager",
|
|
operation: "listSecrets",
|
|
message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
|
|
rawMessage: rawProviderMessage,
|
|
}),
|
|
);
|
|
|
|
let thrown: unknown;
|
|
try {
|
|
await svc.previewRemoteImport(companyId, { providerConfigId: awsVault.id });
|
|
} catch (error) {
|
|
thrown = error;
|
|
}
|
|
|
|
expect(thrown).toMatchObject({
|
|
status: 403,
|
|
message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
|
|
details: { code: "access_denied" },
|
|
});
|
|
expect(JSON.stringify(thrown)).not.toContain("arn:aws");
|
|
expect(JSON.stringify(thrown)).not.toContain("123456789012");
|
|
expect(thrown instanceof Error ? thrown.message : String(thrown)).not.toContain("arn:aws");
|
|
});
|
|
|
|
it("imports AWS remote references row-by-row without fetching plaintext", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const awsVault = await svc.createProviderConfig(companyId, {
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS production",
|
|
config: { region: "us-east-1", namespace: "prod-use1" },
|
|
});
|
|
const duplicate = await svc.create(companyId, {
|
|
name: "Existing duplicate",
|
|
provider: "aws_secrets_manager",
|
|
providerConfigId: awsVault.id,
|
|
managedMode: "external_reference",
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/duplicate",
|
|
});
|
|
|
|
const resolveSpy = vi.spyOn(awsSecretsManagerProvider, "resolveVersion");
|
|
const result = await svc.importRemoteSecrets(
|
|
companyId,
|
|
{
|
|
providerConfigId: awsVault.id,
|
|
secrets: [
|
|
{
|
|
externalRef: duplicate.externalRef!,
|
|
name: "Existing duplicate",
|
|
key: "existing-duplicate",
|
|
},
|
|
{
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
|
|
name: "OpenAI API key",
|
|
key: "openai-api-key",
|
|
description: " Operator-entered production OpenAI key ",
|
|
providerMetadata: { arn: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai" },
|
|
},
|
|
],
|
|
},
|
|
{ userId: "user-1" },
|
|
);
|
|
|
|
expect(result.importedCount).toBe(1);
|
|
expect(result.skippedCount).toBe(1);
|
|
expect(result.results.map((row) => row.status)).toEqual(["skipped", "imported"]);
|
|
expect(result.results[0]).toMatchObject({
|
|
reason: "exact_reference_duplicate",
|
|
conflicts: [expect.objectContaining({ type: "exact_reference", existingSecretId: duplicate.id })],
|
|
});
|
|
expect(resolveSpy).not.toHaveBeenCalled();
|
|
|
|
const imported = await db
|
|
.select()
|
|
.from(companySecrets)
|
|
.where(eq(companySecrets.key, "openai-api-key"))
|
|
.then((rows) => rows[0]);
|
|
expect(imported).toMatchObject({
|
|
companyId,
|
|
provider: "aws_secrets_manager",
|
|
providerConfigId: awsVault.id,
|
|
managedMode: "external_reference",
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
|
|
createdByUserId: "user-1",
|
|
providerMetadata: null,
|
|
description: "Operator-entered production OpenAI key",
|
|
});
|
|
|
|
const versions = await db
|
|
.select()
|
|
.from(companySecretVersions)
|
|
.where(eq(companySecretVersions.secretId, imported!.id));
|
|
expect(versions).toHaveLength(1);
|
|
expect(JSON.stringify(versions[0])).not.toContain("runtime-secret");
|
|
expect(JSON.stringify(versions[0])).not.toContain("sk-");
|
|
});
|
|
|
|
it("sanitizes AWS remote import row provider errors", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const awsVault = await svc.createProviderConfig(companyId, {
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS production",
|
|
config: { region: "us-east-1", namespace: "prod-use1" },
|
|
});
|
|
const rawProviderMessage =
|
|
"AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:DescribeSecret on arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai";
|
|
vi.spyOn(awsSecretsManagerProvider, "linkExternalSecret").mockRejectedValueOnce(
|
|
new SecretProviderClientError({
|
|
code: "access_denied",
|
|
provider: "aws_secrets_manager",
|
|
operation: "linkExternalSecret",
|
|
message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
|
|
rawMessage: rawProviderMessage,
|
|
}),
|
|
);
|
|
|
|
const result = await svc.importRemoteSecrets(companyId, {
|
|
providerConfigId: awsVault.id,
|
|
secrets: [
|
|
{
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
|
|
name: "OpenAI API key",
|
|
key: "openai-api-key",
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
importedCount: 0,
|
|
skippedCount: 0,
|
|
errorCount: 1,
|
|
results: [
|
|
expect.objectContaining({
|
|
status: "error",
|
|
reason: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
|
|
}),
|
|
],
|
|
});
|
|
expect(JSON.stringify(result)).not.toContain(rawProviderMessage);
|
|
expect(JSON.stringify(result.results[0]?.reason)).not.toContain("arn:aws");
|
|
expect(JSON.stringify(result.results[0]?.reason)).not.toContain("123456789012");
|
|
});
|
|
|
|
it("rejects Paperclip-managed AWS namespace refs during preview and import commit", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const awsVault = await svc.createProviderConfig(companyId, {
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS production",
|
|
config: { region: "us-east-1", namespace: "prod-use1" },
|
|
});
|
|
|
|
vi.spyOn(awsSecretsManagerProvider, "listRemoteSecrets").mockResolvedValue({
|
|
secrets: [
|
|
{
|
|
externalRef:
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-b/openai",
|
|
name: "paperclip/prod-use1/company-b/openai",
|
|
providerVersionRef: null,
|
|
metadata: {
|
|
arn: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-b/openai",
|
|
description: "must not leak",
|
|
tags: [{ Key: "paperclip:company-id", Value: "company-b" }],
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
const preview = await svc.previewRemoteImport(companyId, {
|
|
providerConfigId: awsVault.id,
|
|
});
|
|
|
|
expect(preview.candidates[0]).toMatchObject({
|
|
status: "conflict",
|
|
importable: false,
|
|
conflicts: [expect.objectContaining({ type: "provider_guardrail" })],
|
|
providerMetadata: null,
|
|
});
|
|
expect(JSON.stringify(preview)).not.toContain("must not leak");
|
|
expect(JSON.stringify(preview)).not.toContain("paperclip:company-id");
|
|
|
|
const result = await svc.importRemoteSecrets(companyId, {
|
|
providerConfigId: awsVault.id,
|
|
secrets: [
|
|
{
|
|
externalRef:
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-b/openai",
|
|
name: "Foreign managed secret",
|
|
key: "foreign-managed-secret",
|
|
providerMetadata: {
|
|
description: "client-submitted metadata must not persist",
|
|
tags: [{ Key: "paperclip:company-id", Value: "company-b" }],
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
importedCount: 0,
|
|
skippedCount: 0,
|
|
errorCount: 1,
|
|
results: [expect.objectContaining({ status: "error" })],
|
|
});
|
|
expect(result.results[0]?.reason).toMatch(/Paperclip-managed namespace/i);
|
|
const imported = await db.select().from(companySecrets).where(eq(companySecrets.key, "foreign-managed-secret"));
|
|
expect(imported).toHaveLength(0);
|
|
});
|
|
|
|
it("skips duplicate AWS remote imports for the same provider vault and canonical ref", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const awsVault = await svc.createProviderConfig(companyId, {
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS production",
|
|
config: { region: "us-east-1", namespace: "prod-use1" },
|
|
});
|
|
|
|
const first = await svc.importRemoteSecrets(companyId, {
|
|
providerConfigId: awsVault.id,
|
|
secrets: [
|
|
{
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
|
|
name: "OpenAI API key",
|
|
key: "openai-api-key",
|
|
},
|
|
],
|
|
});
|
|
const second = await svc.importRemoteSecrets(companyId, {
|
|
providerConfigId: awsVault.id,
|
|
secrets: [
|
|
{
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
|
|
name: "OpenAI API key duplicate",
|
|
key: "openai-api-key-duplicate",
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(first.importedCount).toBe(1);
|
|
expect(second).toMatchObject({
|
|
importedCount: 0,
|
|
skippedCount: 1,
|
|
errorCount: 0,
|
|
results: [expect.objectContaining({ reason: "exact_reference_duplicate" })],
|
|
});
|
|
const imported = await db.select().from(companySecrets).where(eq(companySecrets.providerConfigId, awsVault.id));
|
|
expect(imported).toHaveLength(1);
|
|
});
|
|
|
|
it("rejects remote import for disabled or cross-company provider vaults", async () => {
|
|
const companyA = await seedCompany("A");
|
|
const companyB = await seedCompany("B");
|
|
const svc = secretService(db);
|
|
const disabledVault = await svc.createProviderConfig(companyA, {
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS disabled",
|
|
status: "disabled",
|
|
config: { region: "us-east-1" },
|
|
});
|
|
const foreignVault = await svc.createProviderConfig(companyB, {
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS foreign",
|
|
config: { region: "us-east-1" },
|
|
});
|
|
|
|
await expect(
|
|
svc.previewRemoteImport(companyA, { providerConfigId: disabledVault.id }),
|
|
).rejects.toThrow(/disabled/i);
|
|
await expect(
|
|
svc.previewRemoteImport(companyA, { providerConfigId: foreignVault.id }),
|
|
).rejects.toThrow(/same company/i);
|
|
});
|
|
|
|
it("rejects externalRef overrides on managed secrets", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const secret = await svc.create(companyId, {
|
|
name: `managed-${randomUUID()}`,
|
|
provider: "local_encrypted",
|
|
value: "runtime-secret",
|
|
});
|
|
|
|
await expect(
|
|
svc.update(secret.id, {
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod/company-b/openai-api-key",
|
|
}),
|
|
).rejects.toThrow(/Managed secrets cannot override externalRef/i);
|
|
|
|
await expect(
|
|
svc.rotate(secret.id, {
|
|
value: "rotated-runtime-secret",
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod/company-b/openai-api-key",
|
|
}),
|
|
).rejects.toThrow(/Managed secrets cannot override externalRef/i);
|
|
});
|
|
|
|
it("rejects generic update retargeting for external reference secrets", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const awsVault = await svc.createProviderConfig(companyId, {
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS production",
|
|
config: { region: "us-east-1", namespace: "prod-use1" },
|
|
});
|
|
const secret = await svc.create(companyId, {
|
|
name: `external-${randomUUID()}`,
|
|
provider: "aws_secrets_manager",
|
|
providerConfigId: awsVault.id,
|
|
managedMode: "external_reference",
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/original",
|
|
});
|
|
|
|
await expect(
|
|
svc.update(secret.id, {
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/repointed",
|
|
}),
|
|
).rejects.toThrow(/cannot be retargeted/i);
|
|
|
|
const persisted = await svc.getById(secret.id);
|
|
expect(persisted?.externalRef).toBe(
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/original",
|
|
);
|
|
});
|
|
|
|
it("rejects generic soft deletion for managed secrets", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const secret = await svc.create(companyId, {
|
|
name: `managed-delete-${randomUUID()}`,
|
|
provider: "local_encrypted",
|
|
value: "runtime-secret",
|
|
});
|
|
|
|
await expect(svc.update(secret.id, { status: "deleted" })).rejects.toThrow(
|
|
/DELETE \/secrets\/:id/i,
|
|
);
|
|
|
|
const persisted = await svc.getById(secret.id);
|
|
expect(persisted?.status).toBe("active");
|
|
});
|
|
|
|
it("passes managed AWS secret context into provider delete during removal", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const externalRef =
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key";
|
|
|
|
const secret = await db
|
|
.insert(companySecrets)
|
|
.values({
|
|
companyId,
|
|
key: "openai-api-key",
|
|
name: "OpenAI API Key",
|
|
provider: "aws_secrets_manager",
|
|
managedMode: "paperclip_managed",
|
|
externalRef,
|
|
latestVersion: 1,
|
|
status: "active",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
|
|
await db.insert(companySecretVersions).values({
|
|
secretId: secret.id,
|
|
version: 1,
|
|
material: {
|
|
scheme: "aws_secrets_manager_v1",
|
|
secretId: externalRef,
|
|
versionId: "aws-version-1",
|
|
source: "managed",
|
|
},
|
|
valueSha256: "value-sha-1",
|
|
fingerprintSha256: "fingerprint-sha-1",
|
|
providerVersionRef: "aws-version-1",
|
|
status: "current",
|
|
});
|
|
|
|
const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue();
|
|
|
|
const removed = await svc.remove(secret.id);
|
|
const persisted = await db
|
|
.select()
|
|
.from(companySecrets)
|
|
.where(eq(companySecrets.id, secret.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
expect(removed?.id).toBe(secret.id);
|
|
expect(deleteSpy).toHaveBeenCalledTimes(1);
|
|
expect(deleteSpy).toHaveBeenCalledWith({
|
|
material: {
|
|
scheme: "aws_secrets_manager_v1",
|
|
secretId: externalRef,
|
|
versionId: "aws-version-1",
|
|
source: "managed",
|
|
},
|
|
externalRef,
|
|
context: {
|
|
companyId,
|
|
secretKey: "openai-api-key",
|
|
secretName: "OpenAI API Key",
|
|
version: 1,
|
|
},
|
|
mode: "delete",
|
|
providerConfig: null,
|
|
});
|
|
expect(persisted).toBeNull();
|
|
});
|
|
|
|
it("renames name and key during removal before provider deletion", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const externalRef =
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/remove-failure";
|
|
const secret = await db
|
|
.insert(companySecrets)
|
|
.values({
|
|
companyId,
|
|
key: "remove-failure",
|
|
name: "Remove Failure",
|
|
provider: "aws_secrets_manager",
|
|
managedMode: "paperclip_managed",
|
|
externalRef,
|
|
latestVersion: 1,
|
|
status: "active",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
|
|
await db.insert(companySecretVersions).values({
|
|
secretId: secret.id,
|
|
version: 1,
|
|
material: {
|
|
scheme: "aws_secrets_manager_v1",
|
|
secretId: externalRef,
|
|
versionId: "aws-version-1",
|
|
source: "managed",
|
|
},
|
|
valueSha256: "value-sha-1",
|
|
fingerprintSha256: "fingerprint-sha-1",
|
|
providerVersionRef: "aws-version-1",
|
|
status: "current",
|
|
});
|
|
vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockRejectedValueOnce(
|
|
new Error("provider delete failed"),
|
|
);
|
|
|
|
await expect(svc.remove(secret.id)).rejects.toThrow("provider delete failed");
|
|
const persisted = await db
|
|
.select()
|
|
.from(companySecrets)
|
|
.where(eq(companySecrets.id, secret.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
const recreated = await svc.create(companyId, {
|
|
name: "Remove Failure",
|
|
key: "remove-failure",
|
|
provider: "local_encrypted",
|
|
value: "replacement",
|
|
});
|
|
|
|
expect(persisted).toMatchObject({
|
|
status: "deleted",
|
|
key: `remove-failure__deleted__${secret.id}`,
|
|
name: `Remove Failure__deleted__${secret.id}`,
|
|
});
|
|
expect(recreated.id).not.toBe(secret.id);
|
|
});
|
|
|
|
it("treats missing provider secrets as already removed during removal retry", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const externalRef =
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/retry-delete";
|
|
const secretId = randomUUID();
|
|
await db.insert(companySecrets).values({
|
|
id: secretId,
|
|
companyId,
|
|
key: `retry-delete__deleted__${secretId}`,
|
|
name: `Retry Delete__deleted__${secretId}`,
|
|
provider: "aws_secrets_manager",
|
|
managedMode: "paperclip_managed",
|
|
externalRef,
|
|
latestVersion: 1,
|
|
status: "deleted",
|
|
deletedAt: new Date(),
|
|
});
|
|
await db.insert(companySecretVersions).values({
|
|
secretId,
|
|
version: 1,
|
|
material: {
|
|
scheme: "aws_secrets_manager_v1",
|
|
secretId: externalRef,
|
|
versionId: "aws-version-1",
|
|
source: "managed",
|
|
},
|
|
valueSha256: "value-sha-1",
|
|
fingerprintSha256: "fingerprint-sha-1",
|
|
providerVersionRef: "aws-version-1",
|
|
status: "current",
|
|
});
|
|
const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockRejectedValueOnce(
|
|
new SecretProviderClientError({
|
|
code: "not_found",
|
|
provider: "aws_secrets_manager",
|
|
operation: "delete_secret",
|
|
message: "Secret not found.",
|
|
}),
|
|
);
|
|
|
|
await expect(svc.remove(secretId)).resolves.toMatchObject({ id: secretId });
|
|
const persisted = await db
|
|
.select()
|
|
.from(companySecrets)
|
|
.where(eq(companySecrets.id, secretId))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
expect(deleteSpy).toHaveBeenCalledTimes(1);
|
|
expect(persisted).toBeNull();
|
|
});
|
|
|
|
it("removes DB rows even when the attached provider vault is disabled", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const vault = await svc.createProviderConfig(companyId, {
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS disabled later",
|
|
config: {
|
|
region: "us-east-1",
|
|
namespace: "prod",
|
|
},
|
|
});
|
|
const externalRef =
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod/company-1/openai-api-key";
|
|
const secret = await db
|
|
.insert(companySecrets)
|
|
.values({
|
|
companyId,
|
|
key: "openai-api-key",
|
|
name: "OpenAI API Key",
|
|
provider: "aws_secrets_manager",
|
|
providerConfigId: vault.id,
|
|
managedMode: "paperclip_managed",
|
|
externalRef,
|
|
latestVersion: 1,
|
|
status: "active",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
|
|
await db.insert(companySecretVersions).values({
|
|
secretId: secret.id,
|
|
version: 1,
|
|
material: {
|
|
scheme: "aws_secrets_manager_v1",
|
|
secretId: externalRef,
|
|
versionId: "aws-version-1",
|
|
source: "managed",
|
|
},
|
|
valueSha256: "value-sha-1",
|
|
fingerprintSha256: "fingerprint-sha-1",
|
|
providerVersionRef: "aws-version-1",
|
|
status: "current",
|
|
});
|
|
await svc.disableProviderConfig(vault.id);
|
|
const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue();
|
|
|
|
await expect(svc.remove(secret.id)).resolves.toMatchObject({ id: secret.id });
|
|
const persisted = await db
|
|
.select()
|
|
.from(companySecrets)
|
|
.where(eq(companySecrets.id, secret.id))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
expect(deleteSpy).not.toHaveBeenCalled();
|
|
expect(persisted).toBeNull();
|
|
});
|
|
|
|
it("refuses to resolve secrets once they are disabled or archived", async () => {
|
|
const companyId = await seedCompany();
|
|
const svc = secretService(db);
|
|
const secret = await svc.create(companyId, {
|
|
name: `managed-${randomUUID()}`,
|
|
provider: "local_encrypted",
|
|
value: "runtime-secret",
|
|
});
|
|
|
|
await svc.update(secret.id, { status: "disabled" });
|
|
await expect(svc.resolveSecretValue(companyId, secret.id, "latest")).rejects.toThrow(
|
|
/not active/i,
|
|
);
|
|
|
|
await svc.update(secret.id, { status: "archived" });
|
|
await expect(svc.resolveSecretValue(companyId, secret.id, "latest")).rejects.toThrow(
|
|
/not active/i,
|
|
);
|
|
});
|
|
});
|