// 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 WorkOSVaultPromiseApi, } 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: WorkOSVaultPromiseApi = { 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)), }; };