Files
PaperClipAI/server/src/__tests__/secrets-routes.test.ts
T
DottaandPaperclip e54f9fbf82 fix(secrets): expose secure human entry in native discovery (#18927)
## Thinking Path

> - Paperclip helps people manage AI agents and their work.
> - Some tasks need a company credential or the current person's
credential.
> - Paperclip already has secure human entry forms for these values.
> - Native connection guidance did not distinguish storing a credential
from connecting an external service.
> - Native API search also omitted the existing secure entry route from
secret operation help.
> - This change makes that route discoverable while preserving human and
owner authority.

## Linked Issues or Issue Description

Refs #18780. Related #18921 corrects a separate observer false positive.
Neither PR owns this production guidance change.

**What happened?**

A request to store an existing company or personal credential could lead
to an unnecessary external provider question. Native API search did not
explain the existing human entry surface.

**Expected behavior**

An agent can find the existing secure form, direct the human to the
matching entry, and verify the result through its authorized interfaces.
The agent must not ask for values in chat or act as the human.

**Steps to reproduce**

Search the native API for a company credential or personal publishing
credential. The result should name the supported Secrets settings route
and its human authority boundary. Open a known personal definition link
and confirm that the existing Set my value action appears.

## What Changed

- Distinguish Paperclip credential storage from external connection
discovery in shared native guidance.
- Describe the existing Secrets settings route, secure entry actions and
current-person boundary in OpenAPI help. Qualify definition detail links
as admin-only; other members use My secrets and Set my value.
- Exercise discovery through the actual OpenCode MCP bridge and native
API catalog.
- Retain agent denials for personal definition and value routes.
Exercise the real Secrets UI definition link.

## Verification

- Focused local checks: four suites, 78 tests passed. These cover native
discovery, secret-route permissions, shared guidance and the Secrets UI.
- The new discovery test fails against unchanged master OpenAPI help. It
passes with this change.
- Token gates and diff checks passed.
- Full workspace typecheck passed. The first attempt found missing SDK
links after script-free installation; the existing root postinstall
helper restored them. An unprivileged retry also hit the local tsx IPC
sandbox restriction. The corrected owned setup passed without source
changes.
- Full local workspace build passed. The full local test run stopped in
chat shard 2: one Discord receiver-lease test failed during initial
configuration. That shard passed 263 tests; the first shard passed 310.
The test and owning lease/configuration sources are unchanged from
master. One focused isolation passed without code changes. Its cause
remains unknown; this is not a complete local test pass. No unrelated
Discord code was changed. [Normal hosted
CI](https://github.com/paperclipai/paperclip/actions/runs/41773266687)
passed on exact head `0abb8b722779368109b9639f68122d7df02a8ab1`.
- Fresh Greptile review reports 5/5 on the exact current head; all
review threads are resolved. Paperclip Review remains in progress. The
optional Superagent scan is neutral/inconclusive: Security Graph context
retrieval returned HTTP 503 and assessment did not start. It supplies no
security PASS credit.
- A retained representative native attempt with this guidance produced
the correct definition-scoped secure-entry link. Its observer then
rejected an explicit warning against chat entry. That failed result
remains unchanged. Human entry, saving the value and worker delivery
were not exercised. This PR claims no complete product-eval pass.

## Risks

This changes agent guidance and search ranking. It grants no new
permission and changes no secret resolution or owner checks. Broader
service-connection discovery still applies when a task needs a
connection. The secure human forms are unchanged.

## Model Used

OpenAI `gpt-6.1-sol` through Codex, with repository tools, code
execution and test analysis. The host does not expose the context-window
limit.

## 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 — the 78 owning controls
pass; the broad local failure remains disclosed above
- [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 — normal CI passed; optional
review states are disclosed above
- [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>
2026-11-04 07:01:57 -06:00

1152 lines
40 KiB
TypeScript

import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { secretRoutes } from "../routes/secrets.js";
import { errorHandler } from "../middleware/error-handler.js";
import { HttpError, unprocessable } from "../errors.js";
const mockSecretService = vi.hoisted(() => ({
listProviders: vi.fn(),
checkProviders: vi.fn(),
listProviderConfigs: vi.fn(),
previewProviderConfigDiscovery: vi.fn(),
getProviderConfigById: vi.fn(),
createProviderConfig: vi.fn(),
updateProviderConfig: vi.fn(),
disableProviderConfig: vi.fn(),
removeProviderConfig: vi.fn(),
setDefaultProviderConfig: vi.fn(),
checkProviderConfigHealth: vi.fn(),
getById: vi.fn(),
list: vi.fn(),
getByKey: vi.fn(),
create: vi.fn(),
rotate: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
listUserSecretDefinitions: vi.fn(),
createUserSecretDefinition: vi.fn(),
updateUserSecretDefinition: vi.fn(),
removeUserSecretDefinition: vi.fn(),
getUserSecretDefinitionCoverage: vi.fn(),
listCurrentUserSecretValues: vi.fn(),
createCurrentUserSecretValue: vi.fn(),
updateCurrentUserSecretValue: vi.fn(),
rotateCurrentUserSecretValue: vi.fn(),
removeCurrentUserSecretValue: vi.fn(),
previewRemoteImport: vi.fn(),
importRemoteSecrets: vi.fn(),
listBindingReferences: vi.fn(),
listAccessEvents: vi.fn(),
listAgentSecretAccess: vi.fn(),
resolveSecretValueForAgentAccess: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockAccessService = vi.hoisted(() => ({
decide: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
secretService: () => mockSecretService,
logActivity: mockLogActivity,
}));
vi.mock("../services/access.js", () => ({
accessService: () => mockAccessService,
}));
function createApp(actor: Record<string, unknown> = {
type: "board",
userId: "user-1",
source: "session",
companyIds: ["company-1"],
memberships: [{ companyId: "company-1", status: "active", membershipRole: "admin" }],
}) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api", secretRoutes({} as any));
app.use(errorHandler);
return app;
}
describe("secret routes", () => {
beforeEach(() => {
for (const mock of Object.values(mockSecretService)) {
mock.mockReset();
}
mockLogActivity.mockReset();
mockAccessService.decide.mockReset();
mockAccessService.decide.mockResolvedValue({
allowed: true,
reason: "allow_standard_agent",
explanation: "Allowed by test policy",
});
});
it("returns an opaque secretRef in agent secret metadata without internal binding details", async () => {
const secretId = "11111111-1111-4111-8111-111111111111";
mockSecretService.listAgentSecretAccess.mockResolvedValue([{
secretId,
bindingId: "22222222-2222-4222-8222-222222222222",
configPath: "env.OPENAI_API_KEY",
key: "openai_api_key",
name: "OpenAI API key",
description: "Used for model access",
delivery: "env",
projectionClass: "unclassified",
latestVersion: 3,
versionSelector: "latest",
resolvedVersion: 3,
}]);
const res = await request(createApp({
type: "agent",
agentId: "33333333-3333-4333-8333-333333333333",
companyId: "44444444-4444-4444-8444-444444444444",
runId: "55555555-5555-4555-8555-555555555555",
source: "agent_jwt",
keyScope: { kind: "standard" },
})).get("/api/agents/me/secrets");
expect(res.status).toBe(200);
expect(res.body.secrets).toEqual([{
secretRef: secretId,
key: "openai_api_key",
name: "OpenAI API key",
description: "Used for model access",
delivery: "env",
projectionClass: "unclassified",
latestVersion: 3,
versionSelector: "latest",
resolvedVersion: 3,
}]);
expect(res.body.secrets[0]).not.toHaveProperty("secretId");
expect(res.body.secrets[0]).not.toHaveProperty("bindingId");
expect(res.body.secrets[0]).not.toHaveProperty("configPath");
});
it("returns provider health checks for board callers with company access", async () => {
mockSecretService.checkProviders.mockResolvedValue([
{
provider: "local_encrypted",
status: "ok",
message: "Local encrypted provider configured",
backupGuidance: ["Back up the key file together with database backups."],
},
]);
const res = await request(createApp()).get("/api/companies/company-1/secret-providers/health");
expect(res.status).toBe(200);
expect(res.body).toEqual({
providers: [
{
provider: "local_encrypted",
status: "ok",
message: "Local encrypted provider configured",
backupGuidance: ["Back up the key file together with database backups."],
},
],
});
});
it("rejects managed secret creation when externalRef is supplied", async () => {
const res = await request(createApp()).post("/api/companies/company-1/secrets").send({
name: "OpenAI API Key",
managedMode: "paperclip_managed",
value: "secret-value",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/other",
});
expect(res.status).toBe(400);
expect(JSON.stringify(res.body)).toMatch(/Managed secrets cannot set externalRef/);
expect(mockSecretService.create).not.toHaveBeenCalled();
});
it("returns sanitized AWS provider errors when managed secret creation fails", async () => {
mockSecretService.create.mockRejectedValue(
new HttpError(
403,
"AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
{
code: "access_denied",
provider: "aws_secrets_manager",
operation: "secret.create",
providerConfigId: "11111111-1111-4111-8111-111111111111",
region: "us-east-1",
credentialPath: "Paperclip server runtime/provider credential path",
requiredCapability: "secretsmanager:CreateSecret",
actionableMessage:
"AWS managed secret creation needs secretsmanager:CreateSecret in the selected region for this provider vault.",
safeAlternative:
"If the secret already exists in AWS, link it as an external reference instead of creating a Paperclip-managed value.",
},
),
);
const res = await request(createApp()).post("/api/companies/company-1/secrets").send({
name: "Vercel token",
key: "vercel_token",
provider: "aws_secrets_manager",
providerConfigId: "11111111-1111-4111-8111-111111111111",
managedMode: "paperclip_managed",
value: "vcp_test",
});
expect(res.status).toBe(403);
expect(res.body).toMatchObject({
code: "access_denied",
error: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
details: {
code: "access_denied",
provider: "aws_secrets_manager",
operation: "secret.create",
providerConfigId: "11111111-1111-4111-8111-111111111111",
region: "us-east-1",
requiredCapability: "secretsmanager:CreateSecret",
},
});
expect(JSON.stringify(res.body)).not.toContain("arn:aws");
expect(JSON.stringify(res.body)).not.toContain("123456789012");
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("restricts user secret definition management to company admins", async () => {
const res = await request(createApp({
type: "board",
userId: "user-1",
source: "session",
companyIds: ["company-1"],
memberships: [{ companyId: "company-1", status: "active", membershipRole: "member" }],
})).post("/api/companies/company-1/user-secret-definitions").send({
key: "github_token",
name: "GitHub token",
provider: "local_encrypted",
});
expect(res.status).toBe(403);
expect(mockSecretService.createUserSecretDefinition).not.toHaveBeenCalled();
});
it("records implicit user-secret definition admins as system actors instead of board pseudo-users", async () => {
mockSecretService.createUserSecretDefinition.mockResolvedValue({
id: "definition-1",
companyId: "company-1",
key: "github_token",
name: "GitHub token",
provider: "local_encrypted",
status: "active",
});
const res = await request(createApp({
type: "board",
source: "local_implicit",
isInstanceAdmin: true,
companyIds: ["company-1"],
memberships: [],
})).post("/api/companies/company-1/user-secret-definitions").send({
key: "github_token",
name: "GitHub token",
provider: "local_encrypted",
});
expect(res.status).toBe(201);
expect(mockSecretService.createUserSecretDefinition).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({ key: "github_token" }),
{ userId: null, agentId: null },
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
actorType: "system",
actorId: "local_implicit",
action: "user_secret_definition.created",
}),
);
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("\"board\"");
});
it("logs patched user-secret definition deletion as deletion activity", async () => {
mockSecretService.updateUserSecretDefinition.mockResolvedValue({
id: "definition-1",
companyId: "company-1",
key: "github_token__deleted__definition-1",
name: "GitHub token",
provider: "local_encrypted",
status: "deleted",
});
const res = await request(createApp())
.patch("/api/companies/company-1/user-secret-definitions/definition-1")
.send({ status: "deleted" });
expect(res.status).toBe(200);
expect(mockSecretService.updateUserSecretDefinition).toHaveBeenCalledWith(
"company-1",
"definition-1",
expect.objectContaining({ status: "deleted" }),
{ userId: "user-1", agentId: null },
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "user_secret_definition.deleted",
entityType: "user_secret_definition",
entityId: "definition-1",
}),
);
});
it("does not turn agent discovery of personal entry into human identity authority", async () => {
const app = createApp({ type: "agent", agentId: "agent-1", companyId: "company-1" });
expect((await request(app).get("/api/companies/company-1/user-secret-definitions")).status).toBe(403);
expect((await request(app).get("/api/companies/company-1/me/user-secrets")).status).toBe(403);
expect((await request(app).post("/api/companies/company-1/me/user-secrets").send({
definitionKey: "github_token", value: "synthetic-value",
})).status).toBe(403);
expect(mockSecretService.listUserSecretDefinitions).not.toHaveBeenCalled();
expect(mockSecretService.createCurrentUserSecretValue).not.toHaveBeenCalled();
});
it("creates current-user secret values for the authenticated user only", async () => {
mockSecretService.createCurrentUserSecretValue.mockResolvedValue({
id: "secret-1",
companyId: "company-1",
scope: "user",
ownerUserId: "user-1",
userSecretDefinitionId: "definition-1",
provider: "local_encrypted",
latestVersion: 1,
});
const res = await request(createApp()).post("/api/companies/company-1/me/user-secrets").send({
definitionKey: "github_token",
value: "secret-value",
});
expect(res.status).toBe(201);
expect(mockSecretService.createCurrentUserSecretValue).toHaveBeenCalledWith(
"company-1",
"user-1",
{
definitionKey: "github_token",
definitionId: undefined,
value: "secret-value",
externalRef: undefined,
providerVersionRef: undefined,
providerConfigId: undefined,
},
{ userId: "user-1", agentId: null },
);
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("secret-value");
});
it("rejects current-user secret values without a concrete user identity", async () => {
const res = await request(createApp({
type: "board",
source: "local_implicit",
companyIds: ["company-1"],
memberships: [{ companyId: "company-1", status: "active", membershipRole: "admin" }],
})).post("/api/companies/company-1/me/user-secrets").send({
definitionKey: "github_token",
value: "secret-value",
});
expect(res.status).toBe(401);
expect(res.body).toMatchObject({ error: "User identity required for user-specific secrets" });
expect(mockSecretService.createCurrentUserSecretValue).not.toHaveBeenCalled();
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("rejects empty current-user secret rotation payloads", async () => {
const res = await request(createApp())
.post("/api/companies/company-1/me/user-secrets/secret-1/rotate")
.send({});
expect(res.status).toBe(400);
expect(JSON.stringify(res.body)).toMatch(/requires value, externalRef/);
expect(mockSecretService.rotateCurrentUserSecretValue).not.toHaveBeenCalled();
});
it("hides user-scoped secrets from legacy company secret mutation routes", async () => {
mockSecretService.getById.mockResolvedValue({
id: "secret-1",
companyId: "company-1",
scope: "user",
ownerUserId: "user-2",
status: "active",
});
const res = await request(createApp()).post("/api/secrets/secret-1/rotate").send({
value: "new-secret-value",
});
expect(res.status).toBe(404);
expect(mockSecretService.rotate).not.toHaveBeenCalled();
});
it("rejects provider vault routes for non-board actors", async () => {
const res = await request(createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
})).get("/api/companies/company-1/secret-provider-configs");
expect(res.status).toBe(403);
expect(mockSecretService.listProviderConfigs).not.toHaveBeenCalled();
});
it("rejects provider vault cross-company access before calling the service", async () => {
const res = await request(createApp({
type: "board",
userId: "user-1",
source: "session",
companyIds: ["company-2"],
memberships: [{ companyId: "company-2", status: "active", membershipRole: "admin" }],
})).get("/api/companies/company-1/secret-provider-configs");
expect(res.status).toBe(403);
expect(mockSecretService.listProviderConfigs).not.toHaveBeenCalled();
});
it("rejects provider vault discovery preview for non-board actors", async () => {
const res = await request(createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
}))
.post("/api/companies/company-1/secret-provider-configs/discovery/preview")
.send({
provider: "aws_secrets_manager",
config: { region: "us-east-1" },
});
expect(res.status).toBe(403);
expect(mockSecretService.previewProviderConfigDiscovery).not.toHaveBeenCalled();
});
it("rejects sensitive provider vault config fields", async () => {
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
provider: "aws_secrets_manager",
displayName: "AWS prod",
config: {
region: "us-east-1",
accessKeyId: "AKIA...",
},
});
expect(res.status).toBe(400);
expect(JSON.stringify(res.body)).toMatch(/sensitive field/i);
expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled();
});
it("rejects sensitive provider vault discovery draft config fields", async () => {
const res = await request(createApp())
.post("/api/companies/company-1/secret-provider-configs/discovery/preview")
.send({
provider: "aws_secrets_manager",
config: {
region: "us-east-1",
secretAccessKey: "secret",
},
});
expect(res.status).toBe(400);
expect(JSON.stringify(res.body)).toMatch(/sensitive field/i);
expect(mockSecretService.previewProviderConfigDiscovery).not.toHaveBeenCalled();
});
it("previews provider vault discovery and logs only aggregate metadata", async () => {
mockSecretService.previewProviderConfigDiscovery.mockResolvedValue({
provider: "aws_secrets_manager",
nextToken: null,
sampledSecretCount: 2,
skippedForeignPaperclipSampleCount: 0,
candidates: [
{
provider: "aws_secrets_manager",
displayName: "AWS production",
config: {
region: "us-east-1",
namespace: "prod-use1",
secretNamePrefix: "paperclip",
environmentTag: "production",
ownerTag: "platform",
kmsKeyId: null,
},
sampleCount: 2,
samples: [
{ name: "paperclip/prod-use1/company-1/openai", hasKmsKey: false, tagKeys: ["environment"] },
],
signals: {
namespace: "prod-use1",
secretNamePrefix: "paperclip",
environmentTag: "production",
ownerTag: "platform",
kmsKeyId: null,
hasKmsKey: false,
sampleCount: 2,
paperclipManagedSampleCount: 0,
skippedForeignPaperclipSampleCount: 0,
},
warnings: [],
},
],
warnings: [],
});
const res = await request(createApp())
.post("/api/companies/company-1/secret-provider-configs/discovery/preview")
.send({
provider: "aws_secrets_manager",
config: { region: "us-east-1" },
query: "paperclip",
pageSize: 25,
});
expect(res.status).toBe(200);
expect(mockSecretService.previewProviderConfigDiscovery).toHaveBeenCalledWith("company-1", {
provider: "aws_secrets_manager",
config: { region: "us-east-1" },
query: "paperclip",
nextToken: undefined,
pageSize: 25,
});
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "secret_provider_config.discovery_previewed",
entityType: "secret_provider_config_discovery",
entityId: "company-1",
details: {
provider: "aws_secrets_manager",
candidateCount: 1,
sampledSecretCount: 2,
warningCount: 0,
},
}));
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("paperclip/prod-use1/company-1/openai");
});
it("returns actionable sanitized provider vault discovery errors", async () => {
mockSecretService.previewProviderConfigDiscovery.mockRejectedValue(
new HttpError(
403,
"AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
{
code: "access_denied",
provider: "aws_secrets_manager",
operation: "secret_provider_config.discovery.preview",
providerConfigId: "discovery-preview",
providerVaultContext: "draft_config",
region: "us-east-1",
credentialPath: "Paperclip server runtime/provider credential path",
requiredCapability: "secretsmanager:ListSecrets",
actionableMessage:
"AWS discovery preview needs secretsmanager:ListSecrets in the selected region for the Paperclip server runtime/provider credential path.",
safeAlternative:
"If the operator already knows the exact AWS Secrets Manager ARN, paste/link that ARN instead of using discovery. Exact-resource DescribeSecret and runtime read permissions are still required.",
},
),
);
const res = await request(createApp())
.post("/api/companies/company-1/secret-provider-configs/discovery/preview")
.send({
provider: "aws_secrets_manager",
config: { region: "us-east-1" },
pageSize: 25,
});
expect(res.status).toBe(403);
expect(res.body).toMatchObject({
error: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
details: {
code: "access_denied",
provider: "aws_secrets_manager",
operation: "secret_provider_config.discovery.preview",
providerVaultContext: "draft_config",
region: "us-east-1",
requiredCapability: "secretsmanager:ListSecrets",
},
});
expect(res.body.details.actionableMessage).toContain("Paperclip server runtime/provider credential path");
expect(res.body.details.safeAlternative).toContain("paste/link that ARN");
expect(JSON.stringify(res.body)).not.toContain("arn:aws");
expect(JSON.stringify(res.body)).not.toContain("123456789012");
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("rejects ready status for coming-soon provider vaults", async () => {
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
provider: "vault",
displayName: "Vault draft",
status: "ready",
config: {
address: "https://vault.example.com",
},
});
expect(res.status).toBe(400);
expect(JSON.stringify(res.body)).toMatch(/locked while coming soon/i);
expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled();
});
it("rejects credential-bearing Vault provider vault addresses before persistence", async () => {
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
provider: "vault",
displayName: "Vault draft",
config: {
address: "https://user:pass@vault.example.com",
},
});
expect(res.status).toBe(400);
expect(JSON.stringify(res.body)).toMatch(/origin-only HTTP\(S\) URL/i);
expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled();
});
it.each([
"https://vault.example.com?token=hvs.x",
"https://vault.example.com#token=hvs.x",
])("rejects token-bearing Vault provider vault address %s before persistence", async (address) => {
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
provider: "vault",
displayName: "Vault draft",
config: { address },
});
expect(res.status).toBe(400);
expect(JSON.stringify(res.body)).toMatch(/origin-only HTTP\(S\) URL/i);
expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled();
});
it("rejects unsafe Vault provider vault address patches before persistence", async () => {
const res = await request(createApp()).patch("/api/secret-provider-configs/vault-1").send({
config: {
address: "https://vault.example.com#token=hvs.x",
},
});
expect(res.status).toBe(400);
expect(JSON.stringify(res.body)).toMatch(/origin-only HTTP\(S\) URL/i);
expect(mockSecretService.getProviderConfigById).not.toHaveBeenCalled();
expect(mockSecretService.updateProviderConfig).not.toHaveBeenCalled();
});
it("creates provider vaults and logs safe activity details", async () => {
const createdAt = new Date("2026-05-06T00:00:00.000Z");
mockSecretService.createProviderConfig.mockResolvedValue({
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
provider: "aws_secrets_manager",
displayName: "AWS prod",
status: "ready",
isDefault: true,
config: { region: "us-east-1" },
healthStatus: null,
healthCheckedAt: null,
healthMessage: null,
healthDetails: null,
disabledAt: null,
createdByAgentId: null,
createdByUserId: "user-1",
createdAt,
updatedAt: createdAt,
});
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
provider: "aws_secrets_manager",
displayName: "AWS prod",
isDefault: true,
config: { region: "us-east-1" },
});
expect(res.status).toBe(201);
expect(mockSecretService.createProviderConfig).toHaveBeenCalledWith(
"company-1",
{
provider: "aws_secrets_manager",
displayName: "AWS prod",
status: undefined,
isDefault: true,
config: { region: "us-east-1" },
},
{ userId: "user-1", agentId: null },
);
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "secret_provider_config.created",
details: {
provider: "aws_secrets_manager",
displayName: "AWS prod",
status: "ready",
isDefault: true,
},
}));
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("accessKey");
});
it("removes provider vault config locally without deleting remote provider data", async () => {
const createdAt = new Date("2026-05-06T00:00:00.000Z");
const providerConfig = {
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
provider: "aws_secrets_manager",
displayName: "AWS prod",
status: "ready",
isDefault: false,
config: { region: "us-east-1" },
healthStatus: null,
healthCheckedAt: null,
healthMessage: null,
healthDetails: null,
disabledAt: null,
createdByAgentId: null,
createdByUserId: "user-1",
createdAt,
updatedAt: createdAt,
};
mockSecretService.getProviderConfigById.mockResolvedValue(providerConfig);
mockSecretService.removeProviderConfig.mockResolvedValue(providerConfig);
const res = await request(createApp()).delete(
"/api/secret-provider-configs/11111111-1111-4111-8111-111111111111",
);
expect(res.status).toBe(200);
expect(mockSecretService.removeProviderConfig).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
);
expect(mockSecretService.disableProviderConfig).not.toHaveBeenCalled();
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "secret_provider_config.removed",
details: {
provider: "aws_secrets_manager",
displayName: "AWS prod",
remoteDeleted: false,
},
}));
});
it("rejects remote import preview for non-board actors", async () => {
const res = await request(createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
})).post("/api/companies/company-1/secrets/remote-import/preview").send({
providerConfigId: "11111111-1111-4111-8111-111111111111",
});
expect(res.status).toBe(403);
expect(mockSecretService.previewRemoteImport).not.toHaveBeenCalled();
});
it("previews remote imports and logs only aggregate metadata", async () => {
mockSecretService.previewRemoteImport.mockResolvedValue({
providerConfigId: "11111111-1111-4111-8111-111111111111",
provider: "aws_secrets_manager",
nextToken: null,
candidates: [
{
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
remoteName: "prod/openai",
name: "openai",
key: "openai",
providerVersionRef: null,
providerMetadata: { description: "OpenAI API key" },
status: "ready",
importable: true,
conflicts: [],
},
],
});
const res = await request(createApp())
.post("/api/companies/company-1/secrets/remote-import/preview")
.send({
providerConfigId: "11111111-1111-4111-8111-111111111111",
query: "openai",
pageSize: 25,
});
expect(res.status).toBe(200);
expect(mockSecretService.previewRemoteImport).toHaveBeenCalledWith("company-1", {
providerConfigId: "11111111-1111-4111-8111-111111111111",
query: "openai",
nextToken: undefined,
pageSize: 25,
});
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "secret.remote_import.previewed",
details: {
provider: "aws_secrets_manager",
candidateCount: 1,
readyCount: 1,
duplicateCount: 0,
conflictCount: 0,
},
}));
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("prod/openai");
});
it("returns sanitized remote import preview provider errors", async () => {
mockSecretService.previewRemoteImport.mockRejectedValue(
new HttpError(
403,
"AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
{ code: "access_denied" },
),
);
const res = await request(createApp())
.post("/api/companies/company-1/secrets/remote-import/preview")
.send({
providerConfigId: "11111111-1111-4111-8111-111111111111",
});
expect(res.status).toBe(403);
expect(res.body).toEqual({
code: "access_denied",
error: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
details: { code: "access_denied" },
});
expect(JSON.stringify(res.body)).not.toContain("arn:aws");
expect(JSON.stringify(res.body)).not.toContain("123456789012");
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("imports remote references and logs aggregate row counts", async () => {
mockSecretService.importRemoteSecrets.mockResolvedValue({
providerConfigId: "11111111-1111-4111-8111-111111111111",
provider: "aws_secrets_manager",
importedCount: 1,
skippedCount: 0,
errorCount: 0,
results: [
{
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
name: "OpenAI API key",
key: "openai-api-key",
status: "imported",
reason: null,
secretId: "22222222-2222-4222-8222-222222222222",
conflicts: [],
},
],
});
const res = await request(createApp())
.post("/api/companies/company-1/secrets/remote-import")
.send({
providerConfigId: "11111111-1111-4111-8111-111111111111",
secrets: [
{
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
name: "OpenAI API key",
key: "openai-api-key",
description: "Operator-entered Paperclip description",
},
],
});
expect(res.status).toBe(200);
expect(mockSecretService.importRemoteSecrets).toHaveBeenCalledWith(
"company-1",
{
providerConfigId: "11111111-1111-4111-8111-111111111111",
secrets: [
{
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
name: "OpenAI API key",
key: "openai-api-key",
description: "Operator-entered Paperclip description",
},
],
},
{ userId: "user-1", agentId: null },
);
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "secret.remote_import.completed",
details: {
provider: "aws_secrets_manager",
importedCount: 1,
skippedCount: 0,
errorCount: 0,
},
}));
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("prod/openai");
});
it("surfaces update-route externalRef retarget rejection without logging raw refs", async () => {
mockSecretService.getById.mockResolvedValue({
id: "22222222-2222-4222-8222-222222222222",
companyId: "company-1",
name: "OpenAI API key",
key: "openai-api-key",
provider: "aws_secrets_manager",
managedMode: "external_reference",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/original",
});
mockSecretService.update.mockRejectedValue(
unprocessable("External reference secrets cannot be retargeted through generic update"),
);
const res = await request(createApp())
.patch("/api/secrets/22222222-2222-4222-8222-222222222222")
.send({
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/repointed",
});
expect(res.status).toBe(422);
expect(mockSecretService.update).toHaveBeenCalledWith(
"22222222-2222-4222-8222-222222222222",
expect.objectContaining({
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/repointed",
}),
);
expect(mockLogActivity).not.toHaveBeenCalled();
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("shared/repointed");
});
it("returns 404 for cross-tenant GET /secrets/:id/usage without leaking existence", async () => {
mockSecretService.getById.mockResolvedValue({
id: "44444444-4444-4444-8444-444444444444",
companyId: "company-2",
name: "Other tenant secret",
key: "other-secret",
provider: "aws_secrets_manager",
managedMode: "paperclip_managed",
});
const crossTenantApp = createApp({
type: "board",
userId: "mallory",
source: "session",
companyIds: ["company-1"],
memberships: [{ companyId: "company-1", status: "active", membershipRole: "admin" }],
isInstanceAdmin: false,
});
const res = await request(crossTenantApp).get(
"/api/secrets/44444444-4444-4444-8444-444444444444/usage",
);
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: "Secret not found" });
expect(mockSecretService.listBindingReferences).not.toHaveBeenCalled();
});
it("returns 404 for missing GET /secrets/:id/usage with identical response shape", async () => {
mockSecretService.getById.mockResolvedValue(null);
const res = await request(createApp()).get(
"/api/secrets/55555555-5555-4555-8555-555555555555/usage",
);
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: "Secret not found" });
expect(mockSecretService.listBindingReferences).not.toHaveBeenCalled();
});
it("returns 404 for cross-tenant GET /secrets/:id/access-events without leaking existence", async () => {
mockSecretService.getById.mockResolvedValue({
id: "66666666-6666-4666-8666-666666666666",
companyId: "company-2",
name: "Other tenant secret",
key: "other-secret",
provider: "aws_secrets_manager",
managedMode: "paperclip_managed",
});
const crossTenantApp = createApp({
type: "board",
userId: "mallory",
source: "session",
companyIds: ["company-1"],
memberships: [{ companyId: "company-1", status: "active", membershipRole: "admin" }],
isInstanceAdmin: false,
});
const res = await request(crossTenantApp).get(
"/api/secrets/66666666-6666-4666-8666-666666666666/access-events",
);
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: "Secret not found" });
expect(mockSecretService.listAccessEvents).not.toHaveBeenCalled();
});
it("returns 404 for missing GET /secrets/:id/access-events with identical response shape", async () => {
mockSecretService.getById.mockResolvedValue(null);
const res = await request(createApp()).get(
"/api/secrets/77777777-7777-4777-8777-777777777777/access-events",
);
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: "Secret not found" });
expect(mockSecretService.listAccessEvents).not.toHaveBeenCalled();
});
it("returns usage bindings for in-tenant GET /secrets/:id/usage", async () => {
mockSecretService.getById.mockResolvedValue({
id: "88888888-8888-4888-8888-888888888888",
companyId: "company-1",
name: "OpenAI",
key: "openai",
provider: "aws_secrets_manager",
managedMode: "paperclip_managed",
});
mockSecretService.listBindingReferences.mockResolvedValue([]);
const res = await request(createApp()).get(
"/api/secrets/88888888-8888-4888-8888-888888888888/usage",
);
expect(res.status).toBe(200);
expect(res.body).toEqual({
secretId: "88888888-8888-4888-8888-888888888888",
bindings: [],
});
expect(mockSecretService.listBindingReferences).toHaveBeenCalledWith(
"company-1",
"88888888-8888-4888-8888-888888888888",
);
});
it("allows DELETE to retry cleanup for already soft-deleted secrets", async () => {
const secret = {
id: "33333333-3333-4333-8333-333333333333",
companyId: "company-1",
name: "OpenAI API Key__deleted__33333333-3333-4333-8333-333333333333",
key: "openai-api-key__deleted__33333333-3333-4333-8333-333333333333",
provider: "aws_secrets_manager",
managedMode: "paperclip_managed",
status: "deleted",
};
mockSecretService.getById.mockResolvedValue(secret);
mockSecretService.remove.mockResolvedValue(secret);
const res = await request(createApp()).delete(
"/api/secrets/33333333-3333-4333-8333-333333333333",
);
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
expect(mockSecretService.remove).toHaveBeenCalledWith(
"33333333-3333-4333-8333-333333333333",
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "secret.deleted",
companyId: "company-1",
entityId: secret.id,
}),
);
});
describe("GET /companies/:companyId/secrets/catalog", () => {
const fullSecrets = [
{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
name: "MY_API_KEY",
key: "my_api_key",
status: "active",
companyId: "company-1",
provider: "local_encrypted",
providerMetadata: null,
referenceCount: 2,
},
{
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
name: "DB_PASSWORD",
key: "db_password",
status: "active",
companyId: "company-1",
provider: "local_encrypted",
providerMetadata: null,
referenceCount: 0,
},
];
it("returns id/name/key/status only for board callers", async () => {
mockSecretService.list.mockResolvedValue(fullSecrets);
const res = await request(createApp()).get("/api/companies/company-1/secrets/catalog");
expect(res.status).toBe(200);
expect(res.body).toEqual([
{ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", name: "MY_API_KEY", key: "my_api_key", status: "active" },
{ id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", name: "DB_PASSWORD", key: "db_password", status: "active" },
]);
expect(res.body[0]).not.toHaveProperty("provider");
expect(res.body[0]).not.toHaveProperty("referenceCount");
});
it("returns id/name/key/status only for agent callers in the same company", async () => {
mockSecretService.list.mockResolvedValue(fullSecrets);
const agentApp = createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
});
const res = await request(agentApp).get("/api/companies/company-1/secrets/catalog");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(2);
expect(res.body[0]).toMatchObject({ id: expect.any(String), name: "MY_API_KEY", key: "my_api_key", status: "active" });
});
it.each(["skill_test", "task_bridge"])(
"rejects %s keys from the company secret catalog",
async (kind) => {
mockAccessService.decide.mockResolvedValue({
allowed: false,
reason: "deny_key_scope",
explanation: "Restricted keys cannot read the company secret catalog.",
});
const res = await request(createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
keyScope: { kind },
})).get("/api/companies/company-1/secrets/catalog");
expect(res.status).toBe(403);
expect(mockSecretService.list).not.toHaveBeenCalled();
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
action: "secrets:read",
resource: { type: "company", companyId: "company-1" },
}));
},
);
it("rejects unauthenticated requests", async () => {
const res = await request(createApp({ type: "none" }))
.get("/api/companies/company-1/secrets/catalog");
// assertBoardOrAgent throws forbidden (403) for all non-agent/non-board actors;
// it does not call assertAuthenticated first, so type:"none" gets 403, not 401.
expect(res.status).toBe(403);
});
it("rejects agents from a different company", async () => {
const res = await request(createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-2",
})).get("/api/companies/company-1/secrets/catalog");
expect(res.status).toBe(403);
});
});
});