Files
PaperClipAI/server/src/__tests__/plugin-secrets-handler.test.ts
T
1477d1ecea test: remove the no-op sequential describe modifier (#15286)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Paperclip runs the server and runner test suites with Vitest.
> - Vitest 5 removes the deprecated `describe.sequential` property, so
the pending Vitest 5 upgrade fails the type-check and test jobs.
> - `describe.sequential` only changes behaviour inside a
`describe.concurrent` suite, or when `sequence.concurrent` is on.
> - This repository has neither, so the modifier changed nothing at run
time.
> - The benefit is that the Vitest 5 upgrade can land, and the test
files lose a modifier that did no work.

## Linked Issues or Issue Description

Refs: #12969

## What Changed

- Replace every `describe.sequential` use with a plain `describe` call.
- Drop the `{ concurrent: false }` suite option from the two runner test
files.
- Add a comment to `server/vitest.config.ts` that records why these
suites must run one test at a time.
- Leave the package manifests and the lockfile unchanged.

## Why the modifier did nothing

The Vitest documentation states that `describe.sequential` is useful to
run tests in sequence inside a `describe.concurrent` suite, or with the
`--sequence.concurrent` option. `sequence.concurrent` defaults to
`false`.

This repository satisfies neither condition:

- No test file uses `describe.concurrent`, `it.concurrent`, or
`test.concurrent`.
- `server/vitest.config.ts` sets `sequence.concurrent: false`, with
`maxWorkers: 1`, `maxConcurrency: 1`, and `isolate: true`.
- `packages/paperclip-runner/vitest.config.ts` sets no `sequence` block,
so the `false` default applies.

`packages/db` and `cli` already run the same embedded-Postgres suites
with a plain `describe`, and those jobs are green. The server package
was the only outlier.

The modifier did carry one real piece of knowledge: these suites need
their tests to run one at a time. The new comment in
`server/vitest.config.ts` records that reason next to the setting that
enforces it.

## Verification

- `git grep` for `describe.sequential` returns nothing outside
`node_modules`.
- The author ran the changed server test files under the installed
Vitest 4, and the results match the results without this change.
- Two very large embedded-Postgres test files exceeded the author's
local memory limit, so the CI test jobs cover those two.
- The two changed runner test files have pre-existing local failures
caused by a missing Rust toolchain and a missing global `pnpm` binary.
The failures are identical with and without this change.
- The author type-checked the changed files and found no new error.
- CI must pass the typecheck, build, server test, and runner verify
jobs.

## Risks

- Low risk. Suite execution stays serial, because the Vitest config
enforces it.
- The change adds no dependency and changes no package manifest or
lockfile.
- A future change that turns `sequence.concurrent` on would break these
suites. The new config comment warns against it.

## Model Used

- Claude Sonnet 5 — code edits and local verification.
- OpenAI Codex, GPT-5 — the earlier revision of this branch.

## Test plan

- [x] Every CI check reaches a terminal green state. A pending or queued
check is not a pass.
- [x] The `Typecheck + Release Registry` job passes. This change must
not introduce a type error.
- [x] The `Build` job passes.
- [x] The server test jobs and the runner verify jobs pass.
- [x] Greptile re-reviews this commit set and posts a passing verdict.
The dependabot waiver does not apply to this pull request.
- [x] `mergeable` reads `MERGEABLE` as a terminal value.

## 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 linked the related public issue with `Refs: #12969`
- [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 and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-Authored-By: Priya Raman <priya.raman@paperclip.ing>

---------

Co-authored-by: Priya Raman <priya.raman@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: nickyleach <331803+nickyleach@users.noreply.github.com>
2026-10-06 07:19:15 -07: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 : 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);
});
});