Files
PaperClipAI/server/src/__tests__/plugin-secrets-handler.test.ts
T
DottaandPaperclip 1de0a3bb1e feat(mcp) [split 2/8]: add governed access contracts (#9557)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Governed MCP access spans contracts, runtime enforcement, adapters,
UI surfaces, and operator verification
> - The parity reference PR #9534 is too large for effective automated
or human review
> - The feature therefore needs a linear stack whose individual diffs
stay below the 100-file review limit
> - This pull request is split 2/8 and focuses on database schema and
shared governance contracts
> - The benefit is a standalone, testable review boundary while
preserving byte-for-byte parity at the top of the stack

## Linked Issues or Issue Description

- Related parity reference: #9534
- Problem: The governed access model needs additive persistence and
synchronized shared types before server enforcement can compile.
- Proposed solution: Adds migrations 0148–0169, tool-access and Smoke
Lab schema, shared types/validators/gallery helpers, and the minimal
compile-required contract consumers identified by boundary testing.
- Alternatives considered: keeping #9534 as one 403-file review, or
rewriting the feature to manufacture seams; both were rejected in favor
of path extraction plus compile-driven boundary moves.
- Roadmap alignment: this advances the existing governed MCP/tool-access
work already represented by #9534; it does not introduce a separate
roadmap initiative.
- Stack position: base branch is `pap10341-split/01-demo-servers`.
- Merge policy: merge bottom-up, in order, only after the complete
eight-PR stack has been reviewed and the top-of-stack parity gate
remains empty.
- Requested review: QA for migrations/validators; Greptile on every PR.

## What Changed

- Adds migrations 0148–0169, tool-access and Smoke Lab schema, shared
types/validators/gallery helpers, and the minimal compile-required
contract consumers identified by boundary testing.
- Keeps this PR below 100 changed files and independently typecheckable.
- Preserves the final tree from #9534 when combined with the other seven
stack levels.

## Verification

- `pnpm typecheck` — passed, including migration numbering and safety
checks
- `pnpm --filter @paperclipai/db test` — passed
- `pnpm --filter @paperclipai/shared test` — passed

## Risks

- Migration or contract mistakes could affect every upper layer; all
migrations are additive/idempotent and compile consumers are included in
this boundary.
- Stack risk: merging out of order can expose incomplete layers;
mitigate by following the documented bottom-up merge policy.
- Parity risk: later edits to an intermediate branch can drift from
#9534; mitigate by re-running the empty top-of-stack diff before merge.

> 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, exact model ID `gpt-5.4`; runtime-managed context
window; medium reasoning with repository, shell, Git, GitHub CLI, and
code-execution tools enabled.

## 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] Internal references are omitted except the execution-plan link
explicitly required for this coordinated split stack
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge


## Stack Coordination

- Internal execution plan:
[PAP-13874](/PAP/issues/PAP-13874#document-plan)
- Parity reference: #9534
- Stack: #9556 → #9557 → #9558 → #9559 → #9560 → #9561 → #9562 → #9563
- Merge bottom-up only after full-stack review and an empty parity diff
at #9563.

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-14 12:57:20 -05:00

214 lines
7.4 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 {
companies,
companySecretBindings,
companySecretProviderConfigs,
companySecrets,
companySecretVersions,
createDb,
plugins,
secretAccessEvents,
} from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import {
createPluginSecretsHandler,
extractSecretRefBindingsFromConfig,
} from "../services/plugin-secrets-handler.js";
import { secretService } from "../services/secrets.js";
const pluginId = "11111111-1111-4111-8111-111111111111";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping plugin secret handler integration tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describe("extractSecretRefBindingsFromConfig", () => {
it("ignores UUID strings outside schema-declared secret fields", () => {
const externalProjectId = "77777777-7777-4777-8777-777777777777";
expect(extractSecretRefBindingsFromConfig(
{ externalProjectId },
{ type: "object", properties: { externalProjectId: { type: "string" } } },
)).toEqual([]);
});
it("rejects legacy UUID strings at schema-declared secret fields", () => {
const secretId = "77777777-7777-4777-8777-777777777777";
expect(() => extractSecretRefBindingsFromConfig(
{ token: secretId },
{ type: "object", properties: { token: { format: "secret-ref" } } },
)).toThrow(/must use.*secret_ref/i);
});
});
describe("createPluginSecretsHandler fail-closed guards", () => {
it("requires company context before touching the database", async () => {
const db = { select: vi.fn(() => { throw new Error("db should not be touched"); }) };
const handler = createPluginSecretsHandler({ db: db as never, pluginId });
await expect(
handler.resolve({ secretRef: { type: "secret_ref", secretId: randomUUID() } }),
).rejects.toThrow(/companyId is required/i);
expect(db.select).not.toHaveBeenCalled();
});
it("rejects legacy string refs before provider resolution", async () => {
const db = { select: vi.fn(() => { throw new Error("db should not be touched"); }) };
const handler = createPluginSecretsHandler({ db: db as never, pluginId });
await expect(
handler.resolve({ companyId: randomUUID(), secretRef: randomUUID() }),
).rejects.toThrow(/use \{ type: "secret_ref"/i);
expect(db.select).not.toHaveBeenCalled();
});
});
describeEmbeddedPostgres("createPluginSecretsHandler shared vault integration", () => {
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-plugin-secrets-${randomUUID()}`);
beforeAll(async () => {
mkdirSync(secretsTmpDir, { recursive: true });
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key");
const started = await startEmbeddedPostgresTestDatabase("plugin-secrets-handler");
stopDb = started.cleanup;
db = createDb(started.connectionString);
});
afterEach(async () => {
await db.delete(secretAccessEvents);
await db.delete(companySecretBindings);
await db.delete(companySecretVersions);
await db.delete(companySecrets);
await db.delete(companySecretProviderConfigs);
await db.delete(plugins);
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: string) {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name,
issuePrefix: `P${companyId.slice(0, 7)}`.toUpperCase(),
status: "active",
createdAt: new Date(),
updatedAt: new Date(),
});
return companyId;
}
async function seedPlugin() {
await db.insert(plugins).values({
id: pluginId,
pluginKey: "paperclip.plugin-secrets-test",
packageName: "@paperclipai/plugin-secrets-test",
version: "0.0.1",
apiVersion: 1,
categories: ["automation"],
manifestJson: {
id: "paperclip.plugin-secrets-test",
apiVersion: 1,
version: "0.0.1",
displayName: "Plugin Secrets Test",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: [],
entrypoints: { worker: "./dist/worker.js" },
},
status: "ready",
installOrder: 1,
});
}
it("resolves bound plugin refs through secretService and emits plugin_worker access events", async () => {
await seedPlugin();
const companyId = await seedCompany("Plugin Co");
const svc = secretService(db);
const secret = await svc.create(companyId, {
name: `plugin-api-key-${randomUUID()}`,
provider: "local_encrypted",
value: "resolved-plugin-secret",
});
await svc.syncSecretRefsForTarget(companyId, { targetType: "plugin", targetId: pluginId }, [
{ secretId: secret.id, configPath: "apiKey" },
], { replaceAll: true });
const handler = createPluginSecretsHandler({ db, pluginId });
await expect(
handler.resolve({
companyId,
secretRef: { type: "secret_ref", secretId: secret.id, version: "latest" },
}),
).resolves.toBe("resolved-plugin-secret");
const events = await db
.select()
.from(secretAccessEvents)
.where(eq(secretAccessEvents.secretId, secret.id));
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
companyId,
secretId: secret.id,
consumerType: "plugin_worker",
consumerId: pluginId,
configPath: "apiKey",
pluginId,
outcome: "success",
errorCode: null,
});
});
it("fails closed for cross-company resolve before secret provider access", async () => {
await seedPlugin();
const companyA = await seedCompany("A");
const companyB = await seedCompany("B");
const svc = secretService(db);
const foreignSecret = await svc.create(companyB, {
name: `foreign-plugin-secret-${randomUUID()}`,
provider: "local_encrypted",
value: "foreign-value",
});
await svc.syncSecretRefsForTarget(companyB, { targetType: "plugin", targetId: pluginId }, [
{ secretId: foreignSecret.id, configPath: "apiKey" },
], { replaceAll: true });
const handler = createPluginSecretsHandler({ db, pluginId });
await expect(
handler.resolve({
companyId: companyA,
secretRef: { type: "secret_ref", secretId: foreignSecret.id, version: "latest" },
}),
).rejects.toThrow(/not bound/i);
const events = await db
.select()
.from(secretAccessEvents)
.where(eq(secretAccessEvents.secretId, foreignSecret.id));
expect(events).toHaveLength(0);
});
});