From 08a9f358c02695b3d725096f5f945f3bad53d941 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:25:33 -0700 Subject: [PATCH] Add shared WorkOS Vault test client --- apps/cloud/src/mcp-session.e2e.node.test.ts | 21 +- apps/cloud/src/mcp.ts | 32 +- .../services/__test-harness__/api-harness.ts | 173 ++------ packages/plugins/workos-vault/package.json | 8 + .../workos-vault/src/sdk/secret-store.test.ts | 396 +++++------------- .../plugins/workos-vault/src/sdk/testing.ts | 195 +++++++++ .../src/sdk/workos-vault.contract.test.ts | 144 +++++++ packages/plugins/workos-vault/tsup.config.ts | 1 + 8 files changed, 493 insertions(+), 477 deletions(-) create mode 100644 packages/plugins/workos-vault/src/sdk/testing.ts create mode 100644 packages/plugins/workos-vault/src/sdk/workos-vault.contract.test.ts diff --git a/apps/cloud/src/mcp-session.e2e.node.test.ts b/apps/cloud/src/mcp-session.e2e.node.test.ts index b22c699b2..3b39d7f94 100644 --- a/apps/cloud/src/mcp-session.e2e.node.test.ts +++ b/apps/cloud/src/mcp-session.e2e.node.test.ts @@ -33,17 +33,14 @@ import { createExecutor, definePlugin, } from "@executor/sdk"; -import { - makePostgresAdapter, - makePostgresBlobStore, -} from "@executor/storage-postgres"; +import { makePostgresAdapter, makePostgresBlobStore } from "@executor/storage-postgres"; import { openApiPlugin } from "@executor/plugin-openapi"; import { mcpPlugin } from "@executor/plugin-mcp"; import { graphqlPlugin } from "@executor/plugin-graphql"; import { workosVaultPlugin } from "@executor/plugin-workos-vault"; +import { makeTestWorkOSVaultClient } from "@executor/plugin-workos-vault/testing"; 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 @@ -69,7 +66,11 @@ const elicitingTestPlugin = definePlugin(() => ({ name: "needsApproval", description: "Tool that asks the caller to approve before returning.", inputSchema: EMPTY_INPUT_SCHEMA, - handler: ({ elicit }: { elicit: (r: FormElicitation) => Effect.Effect }) => + handler: ({ + elicit, + }: { + elicit: (r: FormElicitation) => Effect.Effect; + }) => Effect.gen(function* () { const response = yield* elicit( new FormElicitation({ @@ -98,18 +99,14 @@ const ELICITATION_CAPS: ClientCapabilities = { type BuildOptions = { readonly withElicitingPlugin?: boolean }; -const buildScopedExecutor = ( - scopeId: string, - scopeName: string, - options: BuildOptions = {}, -) => +const buildScopedExecutor = (scopeId: string, scopeName: string, options: BuildOptions = {}) => Effect.gen(function* () { const { db } = yield* DbService; const basePlugins = [ openApiPlugin(), mcpPlugin({ dangerouslyAllowStdioMCP: false }), graphqlPlugin(), - workosVaultPlugin({ client: makeFakeVaultClient() }), + workosVaultPlugin({ client: makeTestWorkOSVaultClient() }), ] as const; const plugins = options.withElicitingPlugin ? ([...basePlugins, elicitingTestPlugin()] as const) diff --git a/apps/cloud/src/mcp.ts b/apps/cloud/src/mcp.ts index 195e2167e..9a962fe82 100644 --- a/apps/cloud/src/mcp.ts +++ b/apps/cloud/src/mcp.ts @@ -17,15 +17,11 @@ import { env } from "cloudflare:workers"; import { HttpApp, HttpServerRequest, HttpServerResponse } from "@effect/platform"; import * as Sentry from "@sentry/cloudflare"; -import { Context, Effect, Either, Layer, Option, Schema } from "effect"; +import { Context, Effect, Layer, Option, Schema } from "effect"; import { createRemoteJWKSet } from "jose"; import { TelemetryLive } from "./services/telemetry"; -import { - McpJwtVerificationError, - verifyMcpAccessToken, - type VerifiedToken, -} from "./mcp-auth"; +import { verifyMcpAccessToken, type VerifiedToken } from "./mcp-auth"; // --------------------------------------------------------------------------- // Constants @@ -96,27 +92,9 @@ export class McpAuth extends Context.Tag("@executor/cloud/McpAuth")< >() {} const verifyJwt = (token: string) => - Effect.gen(function* () { - const strictResult = yield* verifyMcpAccessToken(token, jwks, { - issuer: AUTHKIT_DOMAIN, - audience: RESOURCE_URL, - }).pipe(Effect.either); - - if (Either.isRight(strictResult)) { - return strictResult.right; - } - - if (env.MCP_STRICT_AUDIENCE === "true") { - return yield* Effect.fail(strictResult.left); - } - - const verified = yield* verifyMcpAccessToken(token, jwks, { - issuer: AUTHKIT_DOMAIN, - }); - yield* Effect.annotateCurrentSpan({ - "mcp.auth.audience_fallback": true, - }); - return verified; + verifyMcpAccessToken(token, jwks, { + issuer: AUTHKIT_DOMAIN, + audience: RESOURCE_URL, }); export const McpAuthLive = Layer.succeed(McpAuth, { diff --git a/apps/cloud/src/services/__test-harness__/api-harness.ts b/apps/cloud/src/services/__test-harness__/api-harness.ts index c89580e09..dde832e91 100644 --- a/apps/cloud/src/services/__test-harness__/api-harness.ts +++ b/apps/cloud/src/services/__test-harness__/api-harness.ts @@ -26,32 +26,16 @@ import { HttpServerRequest, } from "@effect/platform"; -import { - ExecutionEngineService, - ExecutorService, -} from "@executor/api/server"; +import { ExecutionEngineService, ExecutorService } from "@executor/api/server"; import { createExecutionEngine } from "@executor/execution"; import { makeQuickJsExecutor } from "@executor/runtime-quickjs"; -import { - Scope, - ScopeId, - collectSchemas, - createExecutor, -} from "@executor/sdk"; -import { - makePostgresAdapter, - makePostgresBlobStore, -} from "@executor/storage-postgres"; +import { Scope, ScopeId, collectSchemas, createExecutor } from "@executor/sdk"; +import { makePostgresAdapter, makePostgresBlobStore } from "@executor/storage-postgres"; import { openApiPlugin } from "@executor/plugin-openapi"; import { mcpPlugin } from "@executor/plugin-mcp"; import { graphqlPlugin } from "@executor/plugin-graphql"; -import { - workosVaultPlugin, - WorkOSVaultClientError, - type WorkOSVaultClient, - type WorkOSVaultObject, - type WorkOSVaultObjectMetadata, -} from "@executor/plugin-workos-vault"; +import { workosVaultPlugin } from "@executor/plugin-workos-vault"; +import { makeTestWorkOSVaultClient } from "@executor/plugin-workos-vault/testing"; import { OpenApiExtensionService } from "@executor/plugin-openapi/api"; import { McpExtensionService } from "@executor/plugin-mcp/api"; import { GraphqlExtensionService } from "@executor/plugin-graphql/api"; @@ -71,8 +55,7 @@ export const TEST_USER_HEADER = "x-test-user-id"; // Mirrors apps/cloud/src/services/executor.ts#createScopedExecutor — the // per-user scope id bakes in the org so the same user id in a different // org gets a distinct scope row. -const userOrgScopeId = (userId: string, orgId: string) => - `user-org:${userId}:${orgId}`; +const userOrgScopeId = (userId: string, orgId: string) => `user-org:${userId}:${orgId}`; // `asOrg(orgId, …)` callers don't care which specific user they are, only // that the executor has a valid user-org scope. We give each org a stable @@ -80,115 +63,21 @@ 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 Error(String(cause)) as never, - }) as never, - // 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 the plugin package's shared test vault client. // --------------------------------------------------------------------------- -const fakeVault = makeFakeVaultClient(); +const testVault = makeTestWorkOSVaultClient(); -const createTestScopedExecutor = ( - userId: string, - orgId: string, - orgName: string, -) => +const createTestScopedExecutor = (userId: string, orgId: string, orgName: string) => Effect.gen(function* () { const { db } = yield* DbService; const plugins = [ openApiPlugin(), mcpPlugin({ dangerouslyAllowStdioMCP: false }), graphqlPlugin(), - workosVaultPlugin({ client: fakeVault }), + workosVaultPlugin({ client: testVault }), ] as const; const schema = collectSchemas(plugins); const adapter = makePostgresAdapter({ db, schema }); @@ -279,17 +168,12 @@ const RouterApp = Effect.gen(function* () { } const userHeader = request.headers[TEST_USER_HEADER]; const userId = - typeof userHeader === "string" && userHeader.length > 0 - ? userHeader - : defaultUserFor(orgId); + typeof userHeader === "string" && userHeader.length > 0 ? userHeader : defaultUserFor(orgId); return yield* yield* buildAppForScope(userId, orgId, `Org ${orgId}`); }); const handler = HttpApp.toWebHandler( - RouterApp.pipe( - Effect.provide(DbService.Live), - Effect.provide(HttpServer.layerContext), - ), + RouterApp.pipe(Effect.provide(DbService.Live), Effect.provide(HttpServer.layerContext)), ); export const fetchForOrg = (orgId: string): typeof globalThis.fetch => @@ -301,10 +185,7 @@ export const fetchForOrg = (orgId: string): typeof globalThis.fetch => return handler(req); }) as typeof globalThis.fetch; -export const fetchForUser = ( - userId: string, - orgId: string, -): typeof globalThis.fetch => +export const fetchForUser = (userId: string, orgId: string): typeof globalThis.fetch => ((input: RequestInfo | URL, init?: RequestInit) => { const base = input instanceof Request ? input : new Request(input, init); const req = new Request(base, { @@ -324,22 +205,21 @@ export const clientLayerForOrg = (orgId: string) => export const clientLayerForUser = (userId: string, orgId: string) => FetchHttpClient.layer.pipe( - Layer.provide( - Layer.succeed(FetchHttpClient.Fetch, fetchForUser(userId, orgId)), - ), + Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchForUser(userId, orgId))), ); // Constructs an HttpApiClient bound to the given org, hands it to `body`, // and provides the org-scoped fetch layer in one step. Keeps per-test // Effect blocks focused on the actual assertions. -type ApiShape = typeof ProtectedCloudApi extends HttpApi.HttpApi< - infer _Id, - infer Groups, - infer ApiError, - infer _ApiR -> - ? HttpApiClient.Client - : never; +type ApiShape = + typeof ProtectedCloudApi extends HttpApi.HttpApi< + infer _Id, + infer Groups, + infer ApiError, + infer _ApiR + > + ? HttpApiClient.Client + : never; export const asOrg = ( orgId: string, @@ -362,14 +242,11 @@ export const asUser = ( Effect.gen(function* () { const client = yield* HttpApiClient.make(ProtectedCloudApi, { baseUrl: TEST_BASE_URL }); return yield* body(client); - }).pipe( - Effect.provide(clientLayerForUser(userId, orgId)), - ) as Effect.Effect; + }).pipe(Effect.provide(clientLayerForUser(userId, orgId))) as Effect.Effect; // Exposed so tests can build the same user-org scope id the harness uses // when writing at a specific user's scope. -export const testUserOrgScopeId = (userId: string, orgId: string) => - userOrgScopeId(userId, orgId); +export const testUserOrgScopeId = (userId: string, orgId: string) => userOrgScopeId(userId, orgId); // Re-exports so call sites don't need a second import. export { ProtectedCloudApi }; diff --git a/packages/plugins/workos-vault/package.json b/packages/plugins/workos-vault/package.json index c98a69945..3d824657c 100644 --- a/packages/plugins/workos-vault/package.json +++ b/packages/plugins/workos-vault/package.json @@ -17,6 +17,7 @@ "type": "module", "exports": { ".": "./src/sdk/index.ts", + "./testing": "./src/sdk/testing.ts", "./promise": "./src/promise.ts", "./react": "./src/react/index.ts" }, @@ -34,6 +35,12 @@ "types": "./dist/sdk/index.d.ts", "default": "./dist/core.js" } + }, + "./testing": { + "import": { + "types": "./dist/sdk/testing.d.ts", + "default": "./dist/testing.js" + } } } }, @@ -41,6 +48,7 @@ "build": "tsup && (tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src || true)", "typecheck": "tsgo --noEmit", "test": "vitest run", + "test:contract:workos-vault": "WORKOS_VAULT_CONTRACT=1 vitest run src/sdk/workos-vault.contract.test.ts", "test:watch": "vitest", "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json" }, diff --git a/packages/plugins/workos-vault/src/sdk/secret-store.test.ts b/packages/plugins/workos-vault/src/sdk/secret-store.test.ts index 5bfaf1abc..5dc782a35 100644 --- a/packages/plugins/workos-vault/src/sdk/secret-store.test.ts +++ b/packages/plugins/workos-vault/src/sdk/secret-store.test.ts @@ -14,176 +14,12 @@ import { SetSecretInput, } from "@executor/sdk"; -import { - WorkOSVaultClientError, - type WorkOSVaultClient, - type WorkOSVaultObject, - type WorkOSVaultObjectMetadata, -} from "./client"; +import { type WorkOSVaultClient } from "./client"; import { workosVaultPlugin } from "./plugin"; - -// --------------------------------------------------------------------------- -// Fake status errors — the real provider's isStatusError check pattern- -// matches on a `status` field, so these bare Error subclasses are -// enough to simulate 404/409 responses from the WorkOS SDK. -// --------------------------------------------------------------------------- - -class FakeNotFoundError extends Error { - readonly status = 404; -} - -class FakeConflictError extends Error { - readonly status = 409; -} - -class FakeInvalidRequestError extends Error { - readonly status = 400; -} - -const makeMetadata = ( - id: string, - context: Record, - versionId: string = `${id}-v1`, -): WorkOSVaultObjectMetadata => ({ - id, - context, - updatedAt: new Date(), - versionId, -}); - -// --------------------------------------------------------------------------- -// makeFakeClient — in-memory WorkOS Vault mock. -// -// `conflictOnNextSecretUpdate` injects a single 409 on the next -// `updateObject` call against an object whose name ends in -// `/secrets/conflict`. After consuming the conflict it behaves -// normally, so the retry loop's second attempt re-reads the current -// version and succeeds. -// --------------------------------------------------------------------------- - -const makeFakeClient = (options?: { - readonly conflictOnNextSecretUpdate?: boolean; - readonly rejectNamesWithColon?: boolean; - readonly rejectReadNamesLongerThan?: number; -}): WorkOSVaultClient => { - const objects = new Map(); - let sequence = 0; - let conflictPending = options?.conflictOnNextSecretUpdate ?? false; - - const nextId = () => `obj_${(sequence += 1)}`; - - const wrap = ( - operation: string, - fn: () => Promise, - ): Effect.Effect => - Effect.tryPromise({ - try: fn, - catch: (cause) => new WorkOSVaultClientError({ cause, operation }), - }); - - const rawClient = { - createObject: async ({ - name, - value, - context, - }: { - readonly name: string; - readonly value: string; - readonly context: Record; - }) => { - if (options?.rejectNamesWithColon && name.includes(":")) { - throw new FakeInvalidRequestError(`Invalid object name "${name}"`); - } - if (objects.has(name)) { - throw new FakeConflictError(`Object "${name}" already exists`); - } - const id = nextId(); - const metadata = makeMetadata(id, context); - objects.set(name, { id, name, value, metadata }); - return metadata; - }, - - readObjectByName: async (name: string) => { - if (options?.rejectNamesWithColon && name.includes(":")) { - throw new FakeInvalidRequestError(`Invalid object name "${name}"`); - } - if ( - options?.rejectReadNamesLongerThan !== undefined && - name.length > options.rejectReadNamesLongerThan - ) { - throw new FakeInvalidRequestError(`Invalid object name "${name}"`); - } - const object = objects.get(name); - if (!object) throw new FakeNotFoundError(`Object "${name}" not found`); - return object; - }, - - updateObject: async ({ - id, - value, - versionCheck, - }: { - readonly id: string; - readonly value: string; - readonly versionCheck?: string; - }) => { - const current = [...objects.values()].find((o) => o.id === id); - if (!current) throw new FakeNotFoundError(`Object "${id}" not found`); - if ( - conflictPending && - current.name.endsWith("/secrets/conflict") - ) { - conflictPending = false; - throw new FakeConflictError(`Injected conflict for "${id}"`); - } - if (versionCheck && current.metadata.versionId !== versionCheck) { - throw new FakeConflictError(`Version mismatch for "${id}"`); - } - const nextVersion = current.metadata.versionId.replace( - /v(\d+)$/, - (_, version) => `v${Number(version) + 1}`, - ); - const next: WorkOSVaultObject = { - ...current, - value, - metadata: { - ...current.metadata, - updatedAt: new Date(), - versionId: nextVersion, - }, - }; - objects.set(current.name, next); - return next; - }, - - deleteObject: async ({ id }: { readonly id: string }) => { - const entry = [...objects.entries()].find(([, o]) => o.id === id); - if (!entry) throw new FakeNotFoundError(`Object "${id}" not found`); - objects.delete(entry[0]); - }, - }; - - return { - use: (operation, fn) => - Effect.tryPromise({ - try: () => fn(rawClient), - catch: (cause) => new WorkOSVaultClientError({ cause, operation }), - }), - createObject: (opts) => - wrap("create_object", () => rawClient.createObject(opts)), - readObjectByName: (name) => - wrap("read_object_by_name", () => rawClient.readObjectByName(name)), - updateObject: (opts) => - wrap("update_object", () => rawClient.updateObject(opts)), - deleteObject: (opts) => - wrap("delete_object", () => rawClient.deleteObject(opts)), - }; -}; +import { makeTestWorkOSVaultClient } from "./testing"; const makeExecutor = (client: WorkOSVaultClient) => - createExecutor( - makeTestConfig({ plugins: [workosVaultPlugin({ client })] as const }), - ); + createExecutor(makeTestConfig({ plugins: [workosVaultPlugin({ client })] as const })); // --------------------------------------------------------------------------- // Tests — drive the provider through the real executor's secrets facade @@ -194,7 +30,7 @@ const makeExecutor = (client: WorkOSVaultClient) => describe("WorkOS Vault secret provider", () => { it.effect("stores and resolves secrets through WorkOS Vault", () => Effect.gen(function* () { - const executor = yield* makeExecutor(makeFakeClient()); + const executor = yield* makeExecutor(makeTestWorkOSVaultClient()); yield* executor.secrets.set( new SetSecretInput({ @@ -217,7 +53,7 @@ describe("WorkOS Vault secret provider", () => { it.effect("updates metadata and secret values in place", () => Effect.gen(function* () { - const executor = yield* makeExecutor(makeFakeClient()); + const executor = yield* makeExecutor(makeTestWorkOSVaultClient()); yield* executor.secrets.set( new SetSecretInput({ @@ -247,7 +83,7 @@ describe("WorkOS Vault secret provider", () => { it.effect("removes secrets from Vault and metadata store", () => Effect.gen(function* () { - const executor = yield* makeExecutor(makeFakeClient()); + const executor = yield* makeExecutor(makeTestWorkOSVaultClient()); yield* executor.secrets.set( new SetSecretInput({ @@ -269,7 +105,7 @@ describe("WorkOS Vault secret provider", () => { it.effect("treats invalid Vault object names as missing during removal", () => Effect.gen(function* () { - const client = makeFakeClient({ rejectReadNamesLongerThan: 80 }); + const client = makeTestWorkOSVaultClient({ rejectReadNamesLongerThan: 80 }); const executor = yield* makeExecutor(client); const longSecretId = SecretId.make( "openapi-oauth-dealcloud-api-oauth2-user-org-user-01kp6xm1zpvqvtpj77f0yv4eax.access_token", @@ -297,7 +133,7 @@ describe("WorkOS Vault secret provider", () => { // takes the update path and hits the injected conflict; the retry // loop re-reads and succeeds on the second attempt. const executor = yield* makeExecutor( - makeFakeClient({ conflictOnNextSecretUpdate: true }), + makeTestWorkOSVaultClient({ conflictOnNextSecretUpdate: true }), ); yield* executor.secrets.set( @@ -375,61 +211,56 @@ const makeLayeredExecutors = (client: WorkOSVaultClient) => }); describe("WorkOS Vault secret provider — multi-scope isolation", () => { - it.effect( - "encodes personal scope ids before using them in Vault object names", - () => - Effect.gen(function* () { - const client = makeFakeClient({ rejectNamesWithColon: true }); - const { execInner, innerId } = yield* makeLayeredExecutors(client); + it.effect("encodes personal scope ids before using them in Vault object names", () => + Effect.gen(function* () { + const client = makeTestWorkOSVaultClient({ rejectNamesWithColon: true }); + const { execInner, innerId } = yield* makeLayeredExecutors(client); - yield* execInner.secrets.set( - new SetSecretInput({ - id: SecretId.make("api-token"), - scope: innerId, - name: "Personal token", - value: "personal", - }), - ); + yield* execInner.secrets.set( + new SetSecretInput({ + id: SecretId.make("api-token"), + scope: innerId, + name: "Personal token", + value: "personal", + }), + ); - expect(yield* execInner.secrets.get("api-token")).toBe("personal"); - }), + expect(yield* execInner.secrets.get("api-token")).toBe("personal"); + }), ); - it.effect( - "secrets.remove at the inner scope does not wipe outer-scope metadata", - () => - Effect.gen(function* () { - const client = makeFakeClient(); - const { execOuter, execInner, outerId, innerId } = - yield* makeLayeredExecutors(client); + it.effect("secrets.remove at the inner scope does not wipe outer-scope metadata", () => + Effect.gen(function* () { + const client = makeTestWorkOSVaultClient(); + const { execOuter, execInner, outerId, innerId } = yield* makeLayeredExecutors(client); - // Outer admin writes the org-wide default. - yield* execOuter.secrets.set( - new SetSecretInput({ - id: SecretId.make("api-token"), - scope: outerId, - name: "Org default", - value: "org-default", - }), - ); - // Inner user writes their personal override at the inner scope. - yield* execInner.secrets.set( - new SetSecretInput({ - id: SecretId.make("api-token"), - scope: innerId, - name: "Personal override", - value: "personal-override", - }), - ); + // Outer admin writes the org-wide default. + yield* execOuter.secrets.set( + new SetSecretInput({ + id: SecretId.make("api-token"), + scope: outerId, + name: "Org default", + value: "org-default", + }), + ); + // Inner user writes their personal override at the inner scope. + yield* execInner.secrets.set( + new SetSecretInput({ + id: SecretId.make("api-token"), + scope: innerId, + name: "Personal override", + value: "personal-override", + }), + ); - // Inner caller removes. Should only drop the inner row. - yield* execInner.secrets.remove("api-token"); + // Inner caller removes. Should only drop the inner row. + yield* execInner.secrets.remove("api-token"); - // The outer executor must still see its row and resolve its value. - const outer = yield* execOuter.secrets.list(); - expect(outer.map((r) => r.id)).toContain("api-token"); - expect(yield* execOuter.secrets.get("api-token")).toBe("org-default"); - }), + // The outer executor must still see its row and resolve its value. + const outer = yield* execOuter.secrets.list(); + expect(outer.map((r) => r.id)).toContain("api-token"); + expect(yield* execOuter.secrets.get("api-token")).toBe("org-default"); + }), ); it.effect( @@ -442,7 +273,7 @@ describe("WorkOS Vault secret provider — multi-scope isolation", () => { // plugin table directly so we exercise the store contract, not // just the SDK's defensive shielding. Effect.gen(function* () { - const client = makeFakeClient(); + const client = makeTestWorkOSVaultClient(); const { execOuter, execInner, outerId, innerId, adapter } = yield* makeLayeredExecutors(client); @@ -467,45 +298,38 @@ describe("WorkOS Vault secret provider — multi-scope isolation", () => { model: "workos_vault_metadata", where: [{ field: "id", value: "api-token" }], }); - const scopes = rows - .map((r) => (r as { scope_id: string }).scope_id) - .sort(); + const scopes = rows.map((r) => (r as { scope_id: string }).scope_id).sort(); expect(scopes).toEqual([outerId, innerId].sort()); }), ); - it.effect( - "shadowed secrets produce independent metadata rows per scope", - () => - Effect.gen(function* () { - const client = makeFakeClient(); - const { execOuter, execInner, outerId, innerId } = - yield* makeLayeredExecutors(client); + it.effect("shadowed secrets produce independent metadata rows per scope", () => + Effect.gen(function* () { + const client = makeTestWorkOSVaultClient(); + const { execOuter, execInner, outerId, innerId } = yield* makeLayeredExecutors(client); - yield* execOuter.secrets.set( - new SetSecretInput({ - id: SecretId.make("api-token"), - scope: outerId, - name: "Org default", - value: "org-default", - }), - ); - yield* execInner.secrets.set( - new SetSecretInput({ - id: SecretId.make("api-token"), - scope: innerId, - name: "Personal override", - value: "personal-override", - }), - ); + yield* execOuter.secrets.set( + new SetSecretInput({ + id: SecretId.make("api-token"), + scope: outerId, + name: "Org default", + value: "org-default", + }), + ); + yield* execInner.secrets.set( + new SetSecretInput({ + id: SecretId.make("api-token"), + scope: innerId, + name: "Personal override", + value: "personal-override", + }), + ); - // Inner sees its override value. - expect(yield* execInner.secrets.get("api-token")).toBe( - "personal-override", - ); - // Outer sees the unshadowed default. - expect(yield* execOuter.secrets.get("api-token")).toBe("org-default"); - }), + // Inner sees its override value. + expect(yield* execInner.secrets.get("api-token")).toBe("personal-override"); + // Outer sees the unshadowed default. + expect(yield* execOuter.secrets.get("api-token")).toBe("org-default"); + }), ); }); @@ -517,10 +341,7 @@ describe("WorkOS Vault secret provider — multi-scope isolation", () => { // context value itself contained a `:`. // --------------------------------------------------------------------------- -const makeExecutorForScope = ( - client: WorkOSVaultClient, - scopeId: string, -) => +const makeExecutorForScope = (client: WorkOSVaultClient, scopeId: string) => Effect.gen(function* () { const plugins = [workosVaultPlugin({ client })] as const; const schema = collectSchemas(plugins); @@ -545,7 +366,7 @@ describe("WorkOS Vault secret provider — KEK context", () => { () => Effect.gen(function* () { const contexts: Record[] = []; - const fake = makeFakeClient(); + const fake = makeTestWorkOSVaultClient(); const recording: WorkOSVaultClient = { ...fake, createObject: (opts) => { @@ -553,10 +374,7 @@ describe("WorkOS Vault secret provider — KEK context", () => { return fake.createObject(opts); }, }; - const executor = yield* makeExecutorForScope( - recording, - "user-org:u1:org42", - ); + const executor = yield* makeExecutorForScope(recording, "user-org:u1:org42"); yield* executor.secrets.set( new SetSecretInput({ @@ -576,34 +394,32 @@ describe("WorkOS Vault secret provider — KEK context", () => { }), ); - it.effect( - "falls back to `{app, organization_id: scopeId}` for bare scope ids", - () => - Effect.gen(function* () { - const contexts: Record[] = []; - const fake = makeFakeClient(); - const recording: WorkOSVaultClient = { - ...fake, - createObject: (opts) => { - contexts.push(opts.context); - return fake.createObject(opts); - }, - }; - const executor = yield* makeExecutorForScope(recording, "org42"); + it.effect("falls back to `{app, organization_id: scopeId}` for bare scope ids", () => + Effect.gen(function* () { + const contexts: Record[] = []; + const fake = makeTestWorkOSVaultClient(); + const recording: WorkOSVaultClient = { + ...fake, + createObject: (opts) => { + contexts.push(opts.context); + return fake.createObject(opts); + }, + }; + const executor = yield* makeExecutorForScope(recording, "org42"); - yield* executor.secrets.set( - new SetSecretInput({ - id: SecretId.make("api-token"), - scope: ScopeId.make("org42"), - name: "Org default", - value: "v", - }), - ); + yield* executor.secrets.set( + new SetSecretInput({ + id: SecretId.make("api-token"), + scope: ScopeId.make("org42"), + name: "Org default", + value: "v", + }), + ); - expect(contexts[0]).toEqual({ - app: "executor", - organization_id: "org42", - }); - }), + expect(contexts[0]).toEqual({ + app: "executor", + organization_id: "org42", + }); + }), ); }); 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..b4433adaf --- /dev/null +++ b/packages/plugins/workos-vault/src/sdk/testing.ts @@ -0,0 +1,195 @@ +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 should then re-read and + * succeed. + */ + readonly conflictOnNextSecretUpdate?: boolean; + readonly rejectNamesWithColon?: boolean; + readonly rejectReadNamesLongerThan?: number; +} + +const makeMetadata = ( + id: string, + context: Record, + versionId: string = `${id}-v1`, +): WorkOSVaultObjectMetadata => ({ + id, + context, + updatedAt: new Date(), + versionId, +}); + +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 }); + +export const makeTestWorkOSVaultClient = ( + options?: TestWorkOSVaultClientOptions, +): WorkOSVaultClient => { + const objects = new Map(); + let sequence = 0; + let conflictPending = options?.conflictOnNextSecretUpdate ?? false; + + const nextId = () => `obj_${(sequence += 1)}`; + + 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 = (options: { + readonly name: string; + readonly value: string; + readonly context: Record; + }): Effect.Effect => + Effect.gen(function* () { + yield* validateObjectName(options.name); + if (objects.has(options.name)) { + return yield* conflict(`Object "${options.name}" already exists`); + } + + const id = nextId(); + const metadata = makeMetadata(id, options.context); + objects.set(options.name, { + id, + name: options.name, + value: options.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 = (options: { + readonly id: string; + readonly value: string; + readonly versionCheck?: string; + }): Effect.Effect => + Effect.gen(function* () { + const current = [...objects.values()].find((o) => o.id === options.id); + if (!current) { + return yield* notFound(`Object "${options.id}" not found`); + } + if (conflictPending && current.name.endsWith("/secrets/conflict")) { + conflictPending = false; + return yield* conflict(`Injected conflict for "${options.id}"`); + } + if (options.versionCheck && current.metadata.versionId !== options.versionCheck) { + return yield* conflict(`Version mismatch for "${options.id}"`); + } + + const nextVersion = current.metadata.versionId.replace( + /v(\d+)$/, + (_, version) => `v${Number(version) + 1}`, + ); + const next: WorkOSVaultObject = { + ...current, + value: options.value, + metadata: { + ...current.metadata, + updatedAt: new Date(), + versionId: nextVersion, + }, + }; + objects.set(current.name, next); + return next; + }); + + const deleteObject = (options: { + readonly id: string; + }): Effect.Effect => + Effect.gen(function* () { + const entry = [...objects.entries()].find(([, object]) => object.id === options.id); + if (!entry) { + return yield* notFound(`Object "${options.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}`), + ); + + const rawClient: WorkOSVaultSdk = { + createObject: (options) => Effect.runPromise(createObject(options)), + readObjectByName: (name) => Effect.runPromise(readObjectByName(name)), + updateObject: (options) => Effect.runPromise(updateObject(options)), + deleteObject: (options) => Effect.runPromise(deleteObject(options)), + }; + + return { + use: (operation, fn) => + Effect.tryPromise({ + try: () => fn(rawClient), + catch: (cause) => new WorkOSVaultClientError({ cause, operation }), + }).pipe(Effect.withSpan(`workos_vault.test.${operation}`)), + createObject: (options) => wrap("create_object", createObject(options)), + readObjectByName: (name) => wrap("read_object_by_name", readObjectByName(name)), + updateObject: (options) => wrap("update_object", updateObject(options)), + deleteObject: (options) => wrap("delete_object", deleteObject(options)), + }; +}; diff --git a/packages/plugins/workos-vault/src/sdk/workos-vault.contract.test.ts b/packages/plugins/workos-vault/src/sdk/workos-vault.contract.test.ts new file mode 100644 index 000000000..21e0657b5 --- /dev/null +++ b/packages/plugins/workos-vault/src/sdk/workos-vault.contract.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Either, FastCheck } from "effect"; + +import { + WorkOSVaultClientError, + makeConfiguredWorkOSVaultClient, + type WorkOSVaultClient, +} from "./client"; + +const hasWorkOSDevCredentials = + Boolean(process.env.WORKOS_API_KEY) && Boolean(process.env.WORKOS_CLIENT_ID); +const contractRunEnabled = process.env.WORKOS_VAULT_CONTRACT === "1"; + +const unwrapVaultError = (error: unknown): unknown => + error instanceof WorkOSVaultClientError ? error.cause : error; + +const statusOf = (error: unknown): number | undefined => { + const cause = unwrapVaultError(error); + if (typeof cause !== "object" || cause === null || !("status" in cause)) { + return undefined; + } + const status = Reflect.get(cause, "status"); + return typeof status === "number" ? status : undefined; +}; + +const messageOf = (error: unknown): string => { + const cause = unwrapVaultError(error); + if (cause instanceof Error) { + return cause.message; + } + if (typeof cause === "object" && cause !== null && "message" in cause) { + return String(Reflect.get(cause, "message")); + } + return String(cause); +}; + +const makeClient = (): Effect.Effect => + makeConfiguredWorkOSVaultClient({ + apiKey: process.env.WORKOS_API_KEY!, + clientId: process.env.WORKOS_CLIENT_ID!, + }).pipe(Effect.orDie); + +const generatedName = (runId: string, candidate: string): string => + `executor-contract/${runId}/${candidate}`; + +const candidateString = FastCheck.string({ + minLength: 0, + maxLength: 512, +}).chain((value) => + FastCheck.constantFrom( + value, + `colon:${value}`, + `slash/${value}`, + `space ${value}`, + `percent%${value}`, + `query?${value}`, + `hash#${value}`, + `unicode-${value}-☃-🔥`, + `${value}${"x".repeat(300)}`, + ), +); + +describe("WorkOS Vault contract", () => { + it.effect( + "discovers object-name constraints against the dev Vault API", + () => + Effect.gen(function* () { + if (!contractRunEnabled || !hasWorkOSDevCredentials) { + console.warn( + "[workos-vault contract] skipping: run `bun run test:contract:workos-vault` with WORKOS_API_KEY and WORKOS_CLIENT_ID", + ); + return; + } + + const client = yield* makeClient(); + const runId = `${Date.now()}-${crypto.randomUUID()}`; + const accepted: string[] = []; + const rejected: Array<{ + readonly name: string; + readonly status: number | undefined; + readonly message: string; + }> = []; + + yield* Effect.promise(() => + FastCheck.assert( + FastCheck.asyncProperty(candidateString, async (candidate) => { + const name = generatedName(runId, candidate); + const result = await Effect.runPromise( + Effect.either( + client.createObject({ + name, + value: "contract-test", + context: { + app: "executor", + contract_test_run_id: runId, + }, + }), + ), + ); + + if (Either.isRight(result)) { + accepted.push(name); + await Effect.runPromise( + client.deleteObject({ id: result.right.id }).pipe(Effect.ignore), + ); + return true; + } + + const status = statusOf(result.left); + rejected.push({ name, status, message: messageOf(result.left) }); + + // Contract-discovery failures are expected to be validation + // style rejections. Anything else should stop the run. + return status === 400 || status === 409; + }), + { + numRuns: Number(process.env.WORKOS_VAULT_CONTRACT_RUNS ?? 40), + seed: + process.env.WORKOS_VAULT_CONTRACT_SEED === undefined + ? undefined + : Number(process.env.WORKOS_VAULT_CONTRACT_SEED), + }, + ), + ); + + console.info( + JSON.stringify( + { + runId, + acceptedCount: accepted.length, + rejectedCount: rejected.length, + acceptedExamples: accepted.slice(0, 10), + rejectedExamples: rejected.slice(0, 20), + }, + null, + 2, + ), + ); + + expect(accepted.length + rejected.length).toBeGreaterThan(0); + }), + 60_000, + ); +}); diff --git a/packages/plugins/workos-vault/tsup.config.ts b/packages/plugins/workos-vault/tsup.config.ts index 30d94aeb2..764d84dc8 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,