1101 lines
38 KiB
TypeScript
1101 lines
38 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());
|
|
|
|
vi.mock("../services/index.js", () => ({
|
|
secretService: () => mockSecretService,
|
|
logActivity: mockLogActivity,
|
|
}));
|
|
|
|
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();
|
|
});
|
|
|
|
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("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("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);
|
|
});
|
|
});
|
|
});
|