diff --git a/.changeset/vitest-pool-workers-secrets-store-persist.md b/.changeset/vitest-pool-workers-secrets-store-persist.md new file mode 100644 index 0000000000..26d44bb4fe --- /dev/null +++ b/.changeset/vitest-pool-workers-secrets-store-persist.md @@ -0,0 +1,17 @@ +--- +"@cloudflare/vitest-pool-workers": patch +--- + +Add `adminSecretsStore()` to `cloudflare:test` for seeding secrets in tests + +Secrets store bindings only expose a read-only `.get()` method, so there was previously no way to seed secret values from within a test. The new `adminSecretsStore()` helper returns Miniflare's admin API for a secrets store binding, giving tests full control over create, update, and delete operations. + +```ts +import { adminSecretsStore } from "cloudflare:test"; +import { env } from "cloudflare:workers"; + +const admin = adminSecretsStore(env.MY_SECRET); +await admin.create("test-value"); + +const value = await env.MY_SECRET.get(); // "test-value" +``` diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index 6eca63cd3c..977f48440d 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -2711,16 +2711,7 @@ export class Miniflare { getSecretsStoreSecretAPI( bindingName: string, workerName?: string - ): Promise< - () => { - create: (value: string) => Promise; - update: (value: string, id: string) => Promise; - duplicate: (id: string, newName: string) => Promise; - delete: (id: string) => Promise; - list: () => Promise[]>; - get: (id: string) => Promise; - } - > { + ): Promise<() => SecretsStoreSecretAdmin> { return this.#getProxy( SECRET_STORE_PLUGIN_NAME, bindingName, @@ -2827,6 +2818,15 @@ export class Miniflare { export type { WorkerdStructuredLog } from "./plugins/core"; +export interface SecretsStoreSecretAdmin { + create(value: string): Promise; + update(value: string, id: string): Promise; + duplicate(id: string, newName: string): Promise; + delete(id: string): Promise; + list(): Promise[]>; + get(id: string): Promise; +} + export * from "./http"; export * from "./plugins"; export * from "./runtime"; diff --git a/packages/vitest-pool-workers/src/worker/lib/cloudflare/test-internal.ts b/packages/vitest-pool-workers/src/worker/lib/cloudflare/test-internal.ts index 595f283a80..89e406fe62 100644 --- a/packages/vitest-pool-workers/src/worker/lib/cloudflare/test-internal.ts +++ b/packages/vitest-pool-workers/src/worker/lib/cloudflare/test-internal.ts @@ -7,5 +7,6 @@ export * from "../../durable-objects"; export * from "../../entrypoints"; export * from "../../env"; export * from "../../events"; +export * from "../../secrets-store"; export * from "../../wait-until"; export * from "../../workflows"; diff --git a/packages/vitest-pool-workers/src/worker/lib/cloudflare/test.ts b/packages/vitest-pool-workers/src/worker/lib/cloudflare/test.ts index a89acf2a8e..6104caecf6 100644 --- a/packages/vitest-pool-workers/src/worker/lib/cloudflare/test.ts +++ b/packages/vitest-pool-workers/src/worker/lib/cloudflare/test.ts @@ -16,6 +16,7 @@ export { createMessageBatch, getQueueResult, applyD1Migrations, + adminSecretsStore, createPagesEventContext, introspectWorkflowInstance, introspectWorkflow, diff --git a/packages/vitest-pool-workers/src/worker/secrets-store.ts b/packages/vitest-pool-workers/src/worker/secrets-store.ts new file mode 100644 index 0000000000..9a3089153d --- /dev/null +++ b/packages/vitest-pool-workers/src/worker/secrets-store.ts @@ -0,0 +1,31 @@ +import type { SecretsStoreSecretAdmin } from "miniflare"; + +// Must match ADMIN_API in miniflare/src/workers/secrets-store/constants.ts +const ADMIN_API = "SecretsStoreSecret::admin_api"; + +/** + * Returns the admin API for a secrets store binding, allowing tests to + * create, update, and delete secrets that would otherwise be read-only. + * + * ```ts + * import { adminSecretsStore } from "cloudflare:test"; + * + * const admin = adminSecretsStore(env.MY_SECRET); + * await admin.create("my-secret-value"); + * ``` + */ +export function adminSecretsStore(binding: unknown): SecretsStoreSecretAdmin { + if ( + typeof binding !== "object" || + binding === null || + typeof (binding as Record)[ADMIN_API] !== "function" + ) { + throw new TypeError( + "Failed to execute 'adminSecretsStore': parameter 1 is not a secrets store binding." + ); + } + + return (binding as Record unknown>)[ + ADMIN_API + ]() as SecretsStoreSecretAdmin; +} diff --git a/packages/vitest-pool-workers/test/bindings.test.ts b/packages/vitest-pool-workers/test/bindings.test.ts index d9f25ecba4..7fc5417a9a 100644 --- a/packages/vitest-pool-workers/test/bindings.test.ts +++ b/packages/vitest-pool-workers/test/bindings.test.ts @@ -51,3 +51,63 @@ test("hello_world support", async ({ expect, seed, vitestRun }) => { await expect(result.exitCode).resolves.toBe(0); }); + +test("adminSecretsStore seeds and reads secrets", async ({ + expect, + seed, + vitestRun, +}) => { + await seed({ + "vitest.config.mts": vitestConfig({ + wrangler: { configPath: "./wrangler.jsonc" }, + }), + "wrangler.jsonc": dedent` + { + "name": "test-worker", + "compatibility_date": "2025-12-02", + "compatibility_flags": ["nodejs_compat"], + "secrets_store_secrets": [ + { + "binding": "MY_SECRET", + "secret_name": "my-secret", + "store_id": "aaaabbbbccccdddd0000000000000000" + } + ] + } + `, + "index.test.ts": dedent` + import { adminSecretsStore } from "cloudflare:test"; + import { env } from "cloudflare:workers"; + import { it } from "vitest"; + + it("create, update, list, and delete a secret", async ({ expect }) => { + const admin = adminSecretsStore(env.MY_SECRET); + + // create + const id = await admin.create("initial-value"); + expect(typeof id).toBe("string"); + expect(await env.MY_SECRET.get()).toBe("initial-value"); + + // update + await admin.update("updated-value", id); + expect(await env.MY_SECRET.get()).toBe("updated-value"); + + // list + const secrets = await admin.list(); + expect(secrets.length).toBeGreaterThan(0); + + // delete + await admin.delete(id); + try { + await env.MY_SECRET.get(); + expect.unreachable("expected get() to throw after delete"); + } catch (e) { + expect(String(e)).toContain("not found"); + } + }); + `, + }); + + const result = await vitestRun(); + await expect(result.exitCode).resolves.toBe(0); +}); diff --git a/packages/vitest-pool-workers/types/cloudflare-test.d.ts b/packages/vitest-pool-workers/types/cloudflare-test.d.ts index 002f540029..8e63b1e52f 100644 --- a/packages/vitest-pool-workers/types/cloudflare-test.d.ts +++ b/packages/vitest-pool-workers/types/cloudflare-test.d.ts @@ -102,6 +102,44 @@ declare module "cloudflare:test" { migrationsTableName?: string ): Promise; + /** + * Admin API for a secrets store binding. Returned by `adminSecretsStore()`. + */ + export interface SecretsStoreSecretAdmin { + /** Create a new secret with the given value. Returns the secret's ID. */ + create(value: string): Promise; + /** Update an existing secret (identified by ID) with a new value. Returns the secret's ID. */ + update(value: string, id: string): Promise; + /** Duplicate a secret (identified by ID) under a new name. Returns the new secret's ID. */ + duplicate(id: string, newName: string): Promise; + /** Delete a secret by ID. */ + delete(id: string): Promise; + /** List all secrets in the store. */ + list(): Promise<{ name: string; metadata?: { uuid: string } }[]>; + /** Get a secret's name by ID. */ + get(id: string): Promise; + } + + /** + * Returns the admin API for a secrets store binding, allowing tests to + * create, update, and delete secrets that would otherwise be read-only + * via `binding.get()`. + * + * @example + * ```ts + * import { adminSecretsStore } from "cloudflare:test"; + * import { env } from "cloudflare:workers"; + * + * const admin = adminSecretsStore(env.MY_SECRET); + * await admin.create("my-secret-value"); + * + * // Now env.MY_SECRET.get() will return "my-secret-value" + * ``` + */ + export function adminSecretsStore(binding: { + get(): Promise; + }): SecretsStoreSecretAdmin; + /** * Creates an introspector for a specific Workflow instance, used to * modify its behavior and await outcomes.