mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-12 08:19:54 +02:00
## 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>
214 lines
7.4 KiB
TypeScript
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);
|
|
});
|
|
});
|