From 06897f092b3b726914b66dd27053d3247b4bbcec Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 3 May 2026 20:13:47 -0700 Subject: [PATCH 1/2] Add shared WorkOS Vault test client Extract the in-memory `WorkOSVaultClient` previously inlined in `apps/cloud/.../api-harness.ts` into a published subpath `@executor-js/plugin-workos-vault/testing` so other apps and downstream consumers can stand up vault-backed tests without copying the fake. `makeTestWorkOSVaultClient` matches the current Effect-shaped client surface (post v4 migration) and adds optional knobs for exercising secret-store retry paths: `conflictOnNextSecretUpdate`, `rejectNamesWithColon`, and `rejectReadNamesLongerThan`. Errors are tagged (`TestWorkOSVaultNotFoundError`/`Conflict`/`InvalidRequest`) and carry numeric `status` so the production `isStatusError` checks in `secret-store.ts` route 404/409/400 through the same paths the real SDK exercises. Re-cut from #424 against current `main`; the `mcp.ts` and contract test pieces from that PR are intentionally dropped (the audience-fallback removal landed via #429 and the contract test is out of scope for this extraction). --- apps/cloud/src/mcp-session.e2e.node.test.ts | 4 +- .../services/__test-harness__/api-harness.ts | 103 +------- packages/plugins/workos-vault/package.json | 9 +- .../plugins/workos-vault/src/sdk/testing.ts | 231 ++++++++++++++++++ packages/plugins/workos-vault/tsup.config.ts | 1 + 5 files changed, 246 insertions(+), 102 deletions(-) create mode 100644 packages/plugins/workos-vault/src/sdk/testing.ts diff --git a/apps/cloud/src/mcp-session.e2e.node.test.ts b/apps/cloud/src/mcp-session.e2e.node.test.ts index 0f8eac095..b0fb06992 100644 --- a/apps/cloud/src/mcp-session.e2e.node.test.ts +++ b/apps/cloud/src/mcp-session.e2e.node.test.ts @@ -37,9 +37,9 @@ import { makePostgresAdapter, makePostgresBlobStore, } from "@executor-js/storage-postgres"; +import { makeTestWorkOSVaultClient } from "@executor-js/plugin-workos-vault/testing"; import executorConfig from "../executor.config"; import { DbService } from "./services/db"; -import { makeFakeVaultClient } from "./services/__test-harness__/api-harness"; // --------------------------------------------------------------------------- // Test-only plugin: exposes one in-memory tool that elicits once. Lets the @@ -102,7 +102,7 @@ const buildScopedExecutor = ( Effect.gen(function* () { const { db } = yield* DbService; const basePlugins = executorConfig.plugins({ - workosVaultClient: makeFakeVaultClient(), + workosVaultClient: makeTestWorkOSVaultClient(), }); const plugins = options.withElicitingPlugin ? ([...basePlugins, elicitingTestPlugin()] as const) diff --git a/apps/cloud/src/services/__test-harness__/api-harness.ts b/apps/cloud/src/services/__test-harness__/api-harness.ts index 3183f34ab..4ae4d5ff5 100644 --- a/apps/cloud/src/services/__test-harness__/api-harness.ts +++ b/apps/cloud/src/services/__test-harness__/api-harness.ts @@ -41,12 +41,7 @@ import { makePostgresAdapter, makePostgresBlobStore, } from "@executor-js/storage-postgres"; -import { - WorkOSVaultClientError, - type WorkOSVaultClient, - type WorkOSVaultObject, - type WorkOSVaultObjectMetadata, -} from "@executor-js/plugin-workos-vault"; +import { makeTestWorkOSVaultClient } from "@executor-js/plugin-workos-vault/testing"; import executorConfig from "../../../executor.config"; import { AuthContext } from "../../auth/middleware"; @@ -73,103 +68,13 @@ const userOrgScopeId = (userId: string, orgId: string) => // across calls within a single test. const defaultUserFor = (orgId: string) => `default_user_${orgId}`; -// --------------------------------------------------------------------------- -// Fake WorkOS Vault client — in-memory map keyed by name. -// --------------------------------------------------------------------------- - -export const makeFakeVaultClient = (): WorkOSVaultClient => { - const byName = new Map(); - let seq = 0; - const nextId = () => `vault_${++seq}_${crypto.randomUUID().slice(0, 8)}`; - - const create = (opts: { name: string; value: string; context: Record }) => { - const id = nextId(); - const metadata: WorkOSVaultObjectMetadata = { - context: opts.context, - id, - updatedAt: new Date(), - versionId: `v_${seq}`, - }; - byName.set(opts.name, { id, name: opts.name, value: opts.value, metadata }); - return metadata; - }; - - const notFound = (name: string) => - Object.assign(new Error(`not found: ${name}`), { status: 404 }); - - const read = (name: string): WorkOSVaultObject => { - const obj = byName.get(name); - if (!obj) throw notFound(name); - return obj; - }; - - const update = (opts: { id: string; value: string }): WorkOSVaultObject => { - for (const [name, obj] of byName.entries()) { - if (obj.id === opts.id) { - const updated: WorkOSVaultObject = { - ...obj, - value: opts.value, - metadata: { ...obj.metadata, updatedAt: new Date(), versionId: `v_${++seq}` }, - }; - byName.set(name, updated); - return updated; - } - } - throw notFound(opts.id); - }; - - const remove = (opts: { id: string }) => { - for (const [name, obj] of byName.entries()) { - if (obj.id === opts.id) byName.delete(name); - } - }; - - return { - use: (_op, fn) => - Effect.tryPromise({ - try: () => - fn({ - createObject: async (opts) => create(opts), - readObjectByName: async (name) => read(name), - updateObject: async (opts) => update(opts), - deleteObject: async (opts) => remove(opts), - }), - catch: (cause) => - new WorkOSVaultClientError({ cause, operation: _op }), - }), - // The real client wraps SDK rejections in WorkOSVaultClientError so - // provider-side `isStatusError` checks can introspect `cause.status`. - // Mirror that here so our 404s flow through the same unwrap path. - createObject: (opts) => - Effect.try({ - try: () => create(opts), - catch: (cause) => new WorkOSVaultClientError({ cause, operation: "create_object" }), - }), - readObjectByName: (name) => - Effect.try({ - try: () => read(name), - catch: (cause) => - new WorkOSVaultClientError({ cause, operation: "read_object_by_name" }), - }), - updateObject: (opts) => - Effect.try({ - try: () => update(opts), - catch: (cause) => new WorkOSVaultClientError({ cause, operation: "update_object" }), - }), - deleteObject: (opts) => - Effect.try({ - try: () => remove(opts), - catch: (cause) => new WorkOSVaultClientError({ cause, operation: "delete_object" }), - }), - }; -}; - // --------------------------------------------------------------------------- // Executor factory — mirrors apps/cloud/services/executor#createScopedExecutor -// but with a fake vault client. +// but with an in-memory test vault client (see +// `@executor-js/plugin-workos-vault/testing`). // --------------------------------------------------------------------------- -const fakeVault = makeFakeVaultClient(); +const fakeVault = makeTestWorkOSVaultClient(); const testPlugins = executorConfig.plugins({ workosVaultClient: fakeVault }); const createTestScopedExecutor = ( diff --git a/packages/plugins/workos-vault/package.json b/packages/plugins/workos-vault/package.json index 94d5bd8de..6f0fa3ccf 100644 --- a/packages/plugins/workos-vault/package.json +++ b/packages/plugins/workos-vault/package.json @@ -19,7 +19,8 @@ ".": "./src/sdk/index.ts", "./promise": "./src/promise.ts", "./react": "./src/react/index.ts", - "./client": "./src/react/plugin-client.tsx" + "./client": "./src/react/plugin-client.tsx", + "./testing": "./src/sdk/testing.ts" }, "publishConfig": { "access": "public", @@ -35,6 +36,12 @@ "types": "./dist/sdk/index.d.ts", "default": "./dist/core.js" } + }, + "./testing": { + "import": { + "types": "./dist/sdk/testing.d.ts", + "default": "./dist/testing.js" + } } } }, diff --git a/packages/plugins/workos-vault/src/sdk/testing.ts b/packages/plugins/workos-vault/src/sdk/testing.ts new file mode 100644 index 000000000..a0e7a1f90 --- /dev/null +++ b/packages/plugins/workos-vault/src/sdk/testing.ts @@ -0,0 +1,231 @@ +// In-memory test double for `WorkOSVaultClient`. +// +// Mirrors the Effect-shaped surface of the real client (see ./client.ts) but +// stores objects in a `Map` keyed by name so tests +// never hit WorkOS. Errors carry a numeric `status` on `cause` so the +// production `isStatusError` checks in `secret-store.ts` match the same +// 404/409/400 paths the real SDK exercises. + +import { Data, Effect } from "effect"; + +import { + WorkOSVaultClientError, + type WorkOSVaultClient, + type WorkOSVaultObject, + type WorkOSVaultObjectMetadata, + type WorkOSVaultSdk, +} from "./client"; + +export class TestWorkOSVaultNotFoundError extends Data.TaggedError( + "TestWorkOSVaultNotFoundError", +)<{ + readonly message: string; + readonly status: 404; +}> {} + +export class TestWorkOSVaultConflictError extends Data.TaggedError( + "TestWorkOSVaultConflictError", +)<{ + readonly message: string; + readonly status: 409; +}> {} + +export class TestWorkOSVaultInvalidRequestError extends Data.TaggedError( + "TestWorkOSVaultInvalidRequestError", +)<{ + readonly message: string; + readonly status: 400; +}> {} + +type TestWorkOSVaultError = + | TestWorkOSVaultNotFoundError + | TestWorkOSVaultConflictError + | TestWorkOSVaultInvalidRequestError; + +export interface TestWorkOSVaultClientOptions { + /** + * Injects a single 409 on the next update against an object whose name + * ends in `/secrets/conflict`. The retry path in the secret store should + * then re-read and succeed on the second attempt. + */ + readonly conflictOnNextSecretUpdate?: boolean; + /** + * Reject create/read with a 400 when the name contains a colon. Useful + * for exercising the secret store's invalid-name fallback paths. + */ + readonly rejectNamesWithColon?: boolean; + /** + * Reject reads with a 400 when the requested name is longer than this + * threshold. Mirrors WorkOS's own length cap on object names. + */ + readonly rejectReadNamesLongerThan?: number; +} + +const notFound = (message: string) => + new TestWorkOSVaultNotFoundError({ message, status: 404 }); + +const conflict = (message: string) => + new TestWorkOSVaultConflictError({ message, status: 409 }); + +const invalidRequest = (message: string) => + new TestWorkOSVaultInvalidRequestError({ message, status: 400 }); + +const makeMetadata = ( + id: string, + context: Record, + versionId: string, +): WorkOSVaultObjectMetadata => ({ + id, + context, + updatedAt: new Date(), + versionId, +}); + +export const makeTestWorkOSVaultClient = ( + options?: TestWorkOSVaultClientOptions, +): WorkOSVaultClient => { + const objects = new Map(); + let sequence = 0; + let conflictPending = options?.conflictOnNextSecretUpdate ?? false; + + const nextId = () => + `vault_${(sequence += 1)}_${crypto.randomUUID().slice(0, 8)}`; + + const validateObjectName = ( + name: string, + ): Effect.Effect => { + if (options?.rejectNamesWithColon && name.includes(":")) { + return Effect.fail(invalidRequest(`Invalid object name "${name}"`)); + } + return Effect.void; + }; + + const validateReadName = ( + name: string, + ): Effect.Effect => + Effect.gen(function* () { + yield* validateObjectName(name); + if ( + options?.rejectReadNamesLongerThan !== undefined && + name.length > options.rejectReadNamesLongerThan + ) { + return yield* invalidRequest(`Invalid object name "${name}"`); + } + }); + + const createObject = (opts: { + readonly name: string; + readonly value: string; + readonly context: Record; + }): Effect.Effect => + Effect.gen(function* () { + yield* validateObjectName(opts.name); + if (objects.has(opts.name)) { + return yield* conflict(`Object "${opts.name}" already exists`); + } + const id = nextId(); + const metadata = makeMetadata(id, opts.context, `${id}-v1`); + objects.set(opts.name, { + id, + name: opts.name, + value: opts.value, + metadata, + }); + return metadata; + }); + + const readObjectByName = ( + name: string, + ): Effect.Effect => + Effect.gen(function* () { + yield* validateReadName(name); + const object = objects.get(name); + if (!object) { + return yield* notFound(`Object "${name}" not found`); + } + return object; + }); + + const updateObject = (opts: { + readonly id: string; + readonly value: string; + readonly versionCheck?: string; + }): Effect.Effect => + Effect.gen(function* () { + const current = [...objects.values()].find((o) => o.id === opts.id); + if (!current) { + return yield* notFound(`Object "${opts.id}" not found`); + } + if (conflictPending && current.name.endsWith("/secrets/conflict")) { + conflictPending = false; + return yield* conflict(`Injected conflict for "${opts.id}"`); + } + if ( + opts.versionCheck && + current.metadata.versionId !== opts.versionCheck + ) { + return yield* conflict(`Version mismatch for "${opts.id}"`); + } + + const nextVersion = current.metadata.versionId.replace( + /v(\d+)$/, + (_, version) => `v${Number(version) + 1}`, + ); + const next: WorkOSVaultObject = { + ...current, + value: opts.value, + metadata: { + ...current.metadata, + updatedAt: new Date(), + versionId: nextVersion, + }, + }; + objects.set(current.name, next); + return next; + }); + + const deleteObject = (opts: { + readonly id: string; + }): Effect.Effect => + Effect.gen(function* () { + const entry = [...objects.entries()].find( + ([, object]) => object.id === opts.id, + ); + if (!entry) { + return yield* notFound(`Object "${opts.id}" not found`); + } + objects.delete(entry[0]); + }); + + const wrap = ( + operation: string, + effect: Effect.Effect, + ): Effect.Effect => + effect.pipe( + Effect.mapError((cause) => new WorkOSVaultClientError({ cause, operation })), + Effect.withSpan(`workos_vault.test.${operation}`), + ); + + // Promise-shaped facade exposed to `use` callers, which may be plugin code + // that still calls into the underlying SDK directly via `client.use(...)`. + // Each method runs the in-memory effect and rethrows the tagged error so + // callers see the same `.status` shape they would from a real SDK rejection. + const rawClient: WorkOSVaultSdk = { + createObject: (opts) => Effect.runPromise(createObject(opts)), + readObjectByName: (name) => Effect.runPromise(readObjectByName(name)), + updateObject: (opts) => Effect.runPromise(updateObject(opts)), + deleteObject: (opts) => Effect.runPromise(deleteObject(opts)), + }; + + return { + use: (operation, fn) => + Effect.tryPromise({ + try: () => fn(rawClient), + catch: (cause) => new WorkOSVaultClientError({ cause, operation }), + }).pipe(Effect.withSpan(`workos_vault.test.${operation}`)), + createObject: (opts) => wrap("create_object", createObject(opts)), + readObjectByName: (name) => wrap("read_object_by_name", readObjectByName(name)), + updateObject: (opts) => wrap("update_object", updateObject(opts)), + deleteObject: (opts) => wrap("delete_object", deleteObject(opts)), + }; +}; diff --git a/packages/plugins/workos-vault/tsup.config.ts b/packages/plugins/workos-vault/tsup.config.ts index 749e4a502..20c4373bb 100644 --- a/packages/plugins/workos-vault/tsup.config.ts +++ b/packages/plugins/workos-vault/tsup.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ entry: { index: "src/promise.ts", core: "src/sdk/index.ts", + testing: "src/sdk/testing.ts", }, format: ["esm"], dts: false, From f7e010e42701bf0548f6a64817ec176e7b271517 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 3 May 2026 20:18:24 -0700 Subject: [PATCH 2/2] fixup: rename WorkOSVaultSdk -> WorkOSVaultPromiseApi after #481 --- packages/plugins/workos-vault/src/sdk/testing.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugins/workos-vault/src/sdk/testing.ts b/packages/plugins/workos-vault/src/sdk/testing.ts index a0e7a1f90..323f00f3a 100644 --- a/packages/plugins/workos-vault/src/sdk/testing.ts +++ b/packages/plugins/workos-vault/src/sdk/testing.ts @@ -13,7 +13,7 @@ import { type WorkOSVaultClient, type WorkOSVaultObject, type WorkOSVaultObjectMetadata, - type WorkOSVaultSdk, + type WorkOSVaultPromiseApi, } from "./client"; export class TestWorkOSVaultNotFoundError extends Data.TaggedError( @@ -210,7 +210,7 @@ export const makeTestWorkOSVaultClient = ( // that still calls into the underlying SDK directly via `client.use(...)`. // Each method runs the in-memory effect and rethrows the tagged error so // callers see the same `.status` shape they would from a real SDK rejection. - const rawClient: WorkOSVaultSdk = { + const rawClient: WorkOSVaultPromiseApi = { createObject: (opts) => Effect.runPromise(createObject(opts)), readObjectByName: (name) => Effect.runPromise(readObjectByName(name)), updateObject: (opts) => Effect.runPromise(updateObject(opts)),