From ea55059cb19733bd17e1fd709ceccda0c3cfb6f8 Mon Sep 17 00:00:00 2001 From: "Michael K (Pear)" Date: Wed, 26 Aug 2026 13:27:58 -0400 Subject: [PATCH 1/2] test(cloudflare/queue): decouple native-binding coverage from token minting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Binding.test.ts` deployed the native-binding producer and the HTTP-token producer in one stack. The `WriteQueueHttp` layer mints a scoped `AccountApiToken`, so a credential without token-creation permission failed the whole test — losing the native-binding coverage, which needs no token: Unauthorized: Unauthorized to access requested resource at AccountApiToken.ts (provider.create) Cloudflare OAuth credentials have no token-creation scope at all, so this is not recoverable by re-authorizing. Split into two tests with separate deploys. The native-binding case now runs everywhere; the HTTP case is gated behind `CLOUDFLARE_TEST_API_TOKENS`, matching the `CLOUDFLARE_TEST_USER_TOKENS` gate on the `UserApiToken` lifecycle tests. `pnpm test test/Cloudflare/Queue --profile alchemy-testing` goes from 24 passed / 1 failed to 25 passed / 1 gated. Co-Authored-By: Claude Opus 5 (1M context) --- .../test/Cloudflare/Queue/Binding.test.ts | 89 ++++++++++++------- 1 file changed, 56 insertions(+), 33 deletions(-) diff --git a/packages/alchemy/test/Cloudflare/Queue/Binding.test.ts b/packages/alchemy/test/Cloudflare/Queue/Binding.test.ts index 5a06d8dff8..0d812367e7 100644 --- a/packages/alchemy/test/Cloudflare/Queue/Binding.test.ts +++ b/packages/alchemy/test/Cloudflare/Queue/Binding.test.ts @@ -52,17 +52,34 @@ const post = (base: string, path: string, body?: string) => { }; /** - * Cloudflare Queue is producer-only at the binding layer, so there is - * no Read/ReadWrite split — only a Write producer. This deploys two - * Workers that both bind one shared queue (native Worker binding and - * scoped HTTP API token), then drives every {@link WriteQueueClient} - * method over `fetch` — `send` and `sendBatch`, each in its JSON and - * `text` content-type form — and asserts the producer accepts the - * messages (202), proving the binding/token are wired and reach the - * real queue. + * Drive every {@link WriteQueueClient} method over `fetch` — `send` and + * `sendBatch`, each in its JSON and `text` content-type form — and assert + * the producer accepts the messages (202), proving the binding/token are + * wired and reach the real queue. + * + * Cloudflare Queue is producer-only at the binding layer, so there is no + * Read/ReadWrite split — only a Write producer. + * + * The two implementations deploy separately on purpose. The HTTP producer + * mints a scoped `AccountApiToken`, which the native binding does not need; + * sharing one deploy meant a credential that cannot mint tokens lost the + * native-binding coverage too, rather than just the half it actually gates. */ +const exercise = (base: string, label: string) => + Effect.gen(function* () { + expect((yield* post(base, "/send", `${label}-json`)).status).toBe(202); + expect((yield* post(base, "/send-text", `${label}-text`)).status).toBe(202); + expect((yield* post(base, "/sendBatch")).status).toBe(202); + expect((yield* post(base, "/sendBatch-text")).status).toBe(202); + }); + +const url = (u: unknown) => { + expect(u).toBeTypeOf("string"); + return u as string; +}; + test.provider( - "Queue write producer over binding + http", + "Queue write producer over the native binding", (stack) => Effect.gen(function* () { yield* stack.destroy(); @@ -70,36 +87,42 @@ test.provider( const out = yield* stack.deploy( Effect.gen(function* () { const writeBinding = yield* WriteBindingWorker; - const writeHttp = yield* WriteHttpWorker; - return { - writeBinding: writeBinding.url, - writeHttp: writeHttp.url, - }; + return { writeBinding: writeBinding.url }; }), ); - const url = (u: unknown) => { - expect(u).toBeTypeOf("string"); - return u as string; - }; + yield* exercise(url(out.writeBinding), "binding"); - // Drive the full producer surface (send + sendBatch, json + text) - // against one base url. - const exercise = (base: string, label: string) => + yield* stack.destroy(); + }).pipe(logLevel), + { timeout: 240_000 }, +); + +/** + * Gated: the `WriteQueueHttp` layer mints a scoped `AccountApiToken`, and + * Cloudflare OAuth credentials have no token-creation scope at all — the + * deploy fails at token creation with: + * + * Unauthorized: Unauthorized to access requested resource + * at AccountApiToken.ts (provider.create) + * + * Set `CLOUDFLARE_TEST_API_TOKENS=1` with an API-token credential that is + * permitted to create account tokens. Matches the gate on the + * `UserApiToken` lifecycle tests (`CLOUDFLARE_TEST_USER_TOKENS`). + */ +test.provider.skipIf(!process.env.CLOUDFLARE_TEST_API_TOKENS)( + "Queue write producer over a scoped HTTP token", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const out = yield* stack.deploy( Effect.gen(function* () { - expect((yield* post(base, "/send", `${label}-json`)).status).toBe( - 202, - ); - expect( - (yield* post(base, "/send-text", `${label}-text`)).status, - ).toBe(202); - expect((yield* post(base, "/sendBatch")).status).toBe(202); - expect((yield* post(base, "/sendBatch-text")).status).toBe(202); - }); + const writeHttp = yield* WriteHttpWorker; + return { writeHttp: writeHttp.url }; + }), + ); - // ── Native binding producer ── - yield* exercise(url(out.writeBinding), "binding"); - // ── HTTP token producer ── yield* exercise(url(out.writeHttp), "http"); yield* stack.destroy(); From a5646bb1b206a949cccb8567c33c401197cd11ad Mon Sep 17 00:00:00 2001 From: "Michael K (Pear)" Date: Sat, 29 Aug 2026 23:45:32 -0400 Subject: [PATCH 2/2] test(cloudflare/r2,kv): decouple native-binding coverage from token minting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same defect as the Queue suite in the previous commit. `R2/Binding.test.ts` deployed all six workers (binding + http) from one shared `beforeAll`, and `KV/Binding.test.ts` deployed all six inline in a single test — so minting the `AccountApiToken` the `*Http` layers need took down the native-binding coverage too, which needs no token: Unauthorized: Unauthorized to access requested resource at AccountApiToken.ts (provider.create) R2 splits `fixtures/stack.ts` into a binding stack and a new `fixtures/stack-http.ts`, driven by two `describe`s. The gate sits on the `describe` rather than the tests so the suite's `beforeAll` never runs at all: `runSuite` skips a suite's hooks when every test below it is skipped (`alchemy-test/src/Runner.ts`). Both stacks stay independently inspectable via `alchemy tail`. KV splits its single `test.provider` into a native-binding test and a gated HTTP-token test with separate deploys, matching the Queue shape. `pnpm test test/Cloudflare/R2/Binding.test.ts test/Cloudflare/KV/Binding.test.ts --profile alchemy-testing` goes from 5 failed / 0 passed to 0 failed / 3 passed / 3 gated. Verified the gate is not inert: with CLOUDFLARE_TEST_API_TOKENS=1 the http tests run and reach the recorded token-creation error. Claude-Session: https://claude.ai/code/session_01QShcJ78QmS5g3rTfj6qdA5 --- .../test/Cloudflare/KV/Binding.test.ts | 101 +++++++++----- .../test/Cloudflare/R2/Binding.test.ts | 131 ++++++++++-------- .../test/Cloudflare/R2/fixtures/stack-http.ts | 34 +++++ .../test/Cloudflare/R2/fixtures/stack.ts | 21 ++- 4 files changed, 183 insertions(+), 104 deletions(-) create mode 100644 packages/alchemy/test/Cloudflare/R2/fixtures/stack-http.ts diff --git a/packages/alchemy/test/Cloudflare/KV/Binding.test.ts b/packages/alchemy/test/Cloudflare/KV/Binding.test.ts index 4dfdbb5866..355b3b1696 100644 --- a/packages/alchemy/test/Cloudflare/KV/Binding.test.ts +++ b/packages/alchemy/test/Cloudflare/KV/Binding.test.ts @@ -241,20 +241,36 @@ const exercise = (label: string, base: string) => yield* expectMissing(base, k("v")); }); +/** Write through `writeBase`, read it back (and observe the delete) through `readBase`. */ +const crossWorker = (label: string, writeBase: string, readBase: string) => + Effect.gen(function* () { + const key = `${label}-key`; + expect((yield* put(writeBase, key, `${label}-value`)).status).toBe(200); + expect(yield* expectValue(readBase, key, `${label}-value`)).toBe( + `${label}-value`, + ); + yield* del(writeBase, key); + yield* expectMissing(readBase, key); + }); + +const url = (u: unknown) => { + expect(u).toBeTypeOf("string"); + return u as string; +}; + /** - * Deploys six Workers that all bind one shared KV namespace — read / - * write / read-write, each over the native Worker binding - * (`*NamespaceBinding`) and over a scoped HTTP API token - * (`*NamespaceHttp`) — then drives them over `fetch`: + * Deploys three Workers that all bind one shared KV namespace — read / + * write / read-write — over the native Worker binding + * (`*NamespaceBinding`), then drives them over `fetch`: * * - the read-write worker exercises the entire client surface by itself - * (every read + write method), once per transport; + * (every read + write method); * - the split Read/Write workers prove the separate bindings agree on * the namespace: write through Write, read back through Read, delete * through Write, observe gone through Read. */ test.provider.skipIf(!!process.env.FAST)( - "KV read/write/read-write bindings over binding + http", + "KV read/write/read-write bindings over the native binding", (stack) => Effect.gen(function* () { yield* stack.destroy(); @@ -264,48 +280,61 @@ test.provider.skipIf(!!process.env.FAST)( const readBinding = yield* ReadBindingWorker; const writeBinding = yield* WriteBindingWorker; const readWriteBinding = yield* ReadWriteBindingWorker; - const readHttp = yield* ReadHttpWorker; - const writeHttp = yield* WriteHttpWorker; - const readWriteHttp = yield* ReadWriteHttpWorker; return { readBinding: readBinding.url, writeBinding: writeBinding.url, readWriteBinding: readWriteBinding.url, - readHttp: readHttp.url, - writeHttp: writeHttp.url, - readWriteHttp: readWriteHttp.url, }; }), ); - const url = (u: unknown) => { - expect(u).toBeTypeOf("string"); - return u as string; - }; - - // ── Full client surface through a single read-write worker ── yield* exercise("rw-bind", url(out.readWriteBinding)); - yield* exercise("rw-http", url(out.readWriteHttp)); + yield* crossWorker("bind", url(out.writeBinding), url(out.readBinding)); + + yield* stack.destroy(); + }).pipe(logLevel), + { timeout: 300_000 }, +); + +/** + * The same matrix over the `*NamespaceHttp` clients. + * + * Gated: the `*NamespaceHttp` layers mint a scoped `AccountApiToken`, and + * Cloudflare OAuth credentials have no token-creation scope at all — the + * deploy fails at token creation with: + * + * Unauthorized: Unauthorized to access requested resource + * at AccountApiToken.ts (provider.create) + * + * Deployed separately from the native-binding test above so that minting + * the token cannot take down binding coverage that needs no token. + * + * Set `CLOUDFLARE_TEST_API_TOKENS=1` with an API-token credential that is + * permitted to create account tokens. Matches the gate on the + * `UserApiToken` lifecycle tests (`CLOUDFLARE_TEST_USER_TOKENS`). + */ +test.provider.skipIf( + !!process.env.FAST || !process.env.CLOUDFLARE_TEST_API_TOKENS, +)( + "KV read/write/read-write bindings over a scoped HTTP token", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); - // ── Split Read/Write bindings agree on the shared namespace ── - const crossWorker = ( - label: string, - writeBase: string, - readBase: string, - ) => + const out = yield* stack.deploy( Effect.gen(function* () { - const key = `${label}-key`; - expect((yield* put(writeBase, key, `${label}-value`)).status).toBe( - 200, - ); - expect(yield* expectValue(readBase, key, `${label}-value`)).toBe( - `${label}-value`, - ); - yield* del(writeBase, key); - yield* expectMissing(readBase, key); - }); + const readHttp = yield* ReadHttpWorker; + const writeHttp = yield* WriteHttpWorker; + const readWriteHttp = yield* ReadWriteHttpWorker; + return { + readHttp: readHttp.url, + writeHttp: writeHttp.url, + readWriteHttp: readWriteHttp.url, + }; + }), + ); - yield* crossWorker("bind", url(out.writeBinding), url(out.readBinding)); + yield* exercise("rw-http", url(out.readWriteHttp)); yield* crossWorker("http", url(out.writeHttp), url(out.readHttp)); yield* stack.destroy(); diff --git a/packages/alchemy/test/Cloudflare/R2/Binding.test.ts b/packages/alchemy/test/Cloudflare/R2/Binding.test.ts index 149b445c28..9195279d5b 100644 --- a/packages/alchemy/test/Cloudflare/R2/Binding.test.ts +++ b/packages/alchemy/test/Cloudflare/R2/Binding.test.ts @@ -1,6 +1,6 @@ import * as Cloudflare from "@/Cloudflare"; import * as Test from "@/Test/Alchemy"; -import { expect } from "alchemy-test"; +import { describe, expect } from "alchemy-test"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import { MinimumLogLevel } from "effect/References"; @@ -8,6 +8,7 @@ import * as Schedule from "effect/Schedule"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import HttpStack from "./fixtures/stack-http.ts"; import Stack from "./fixtures/stack.ts"; const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ @@ -227,66 +228,86 @@ const exercise = ( }); /** - * Deploys six Workers that all bind one shared R2 bucket — read / - * write / read-write, each over the native Worker binding - * (`*BucketBinding`) and over a scoped HTTP API token (`*BucketHttp`) - * — via {@link Stack}, then drives each binding flavor over `fetch` in - * its own test: + * Deploys three Workers that all bind one shared R2 bucket — read / + * write / read-write — over the native Worker binding + * (`*BucketBinding`), via {@link Stack}, then drives each flavor over + * `fetch`: * * - write through the Write worker, read it back through the Read * worker (cross-worker, proving both halves agree on the bucket); * - round-trip a key through the ReadWrite worker by itself. - * - * The stack lives in `fixtures/stack.ts` so it can also be inspected - * directly, e.g. `alchemy tail --stage test ./test/Cloudflare/R2/fixtures/stack.ts`. */ -const stack = beforeAll(deploy(Stack), { timeout: HOOK_TIMEOUT }); -afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack), { - timeout: HOOK_TIMEOUT, -}); +describe("native binding", () => { + const stack = beforeAll(deploy(Stack), { timeout: HOOK_TIMEOUT }); + afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack), { + timeout: HOOK_TIMEOUT, + }); -// ── Native Worker binding ── write through the Write worker, read back through -// the Read worker (cross-worker, proving both halves agree on the bucket). -test( - "native binding: write + read across separate workers", - Effect.gen(function* () { - const out = yield* stack; - yield* exercise("bind", out.writeBinding, out.readBinding, true); - }).pipe(logLevel), - { timeout: TEST_TIMEOUT }, -); + test( + "write + read across separate workers", + Effect.gen(function* () { + const out = yield* stack; + yield* exercise("bind", out.writeBinding, out.readBinding, true); + }).pipe(logLevel), + { timeout: TEST_TIMEOUT }, + ); -// The ReadWrite worker round-trips a key by itself over the native binding. -test( - "native binding: read-write round-trip in one worker", - Effect.gen(function* () { - const out = yield* stack; - yield* exercise( - "rw-bind", - out.readWriteBinding, - out.readWriteBinding, - true, - ); - }).pipe(logLevel), - { timeout: TEST_TIMEOUT }, -); + test( + "read-write round-trip in one worker", + Effect.gen(function* () { + const out = yield* stack; + yield* exercise( + "rw-bind", + out.readWriteBinding, + out.readWriteBinding, + true, + ); + }).pipe(logLevel), + { timeout: TEST_TIMEOUT }, + ); +}); -// ── Scoped HTTP API token ── same matrix over the `*BucketHttp` clients -// (multipart is unsupported over the HTTP API, so it is skipped here). -test( - "http token: write + read across separate workers", - Effect.gen(function* () { - const out = yield* stack; - yield* exercise("http", out.writeHttp, out.readHttp, false); - }).pipe(logLevel), - { timeout: TEST_TIMEOUT }, -); +/** + * The same matrix over the `*BucketHttp` clients (multipart is + * unsupported over the HTTP API, so it is skipped here). + * + * Gated: the `*BucketHttp` layers mint a scoped `AccountApiToken`, and + * Cloudflare OAuth credentials have no token-creation scope at all — the + * deploy fails at token creation with: + * + * Unauthorized: Unauthorized to access requested resource + * at AccountApiToken.ts (provider.create) + * + * The gate is on the `describe` rather than the individual tests so the + * suite's `beforeAll` never runs: the runner skips a suite's hooks when + * every test below it is skipped, which is what keeps the token mint out + * of the native-binding path above. + * + * Set `CLOUDFLARE_TEST_API_TOKENS=1` with an API-token credential that is + * permitted to create account tokens. Matches the gate on the + * `UserApiToken` lifecycle tests (`CLOUDFLARE_TEST_USER_TOKENS`). + */ +describe.skipIf(!process.env.CLOUDFLARE_TEST_API_TOKENS)("http token", () => { + const stack = beforeAll(deploy(HttpStack), { timeout: HOOK_TIMEOUT }); + afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(HttpStack), { + timeout: HOOK_TIMEOUT, + }); -test( - "http token: read-write round-trip in one worker", - Effect.gen(function* () { - const out = yield* stack; - yield* exercise("rw-http", out.readWriteHttp, out.readWriteHttp, false); - }).pipe(logLevel), - { timeout: TEST_TIMEOUT }, -); + test( + "write + read across separate workers", + Effect.gen(function* () { + const out = yield* stack; + yield* exercise("http", out.writeHttp, out.readHttp, false); + }).pipe(logLevel), + { timeout: TEST_TIMEOUT }, + ); + + test( + "read-write round-trip in one worker", + Effect.gen(function* () { + const out = yield* stack; + yield* exercise("rw-http", out.readWriteHttp, out.readWriteHttp, false); + }).pipe(logLevel), + { timeout: TEST_TIMEOUT }, + ); +}); diff --git a/packages/alchemy/test/Cloudflare/R2/fixtures/stack-http.ts b/packages/alchemy/test/Cloudflare/R2/fixtures/stack-http.ts new file mode 100644 index 0000000000..47db0ef565 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/R2/fixtures/stack-http.ts @@ -0,0 +1,34 @@ +import * as Cloudflare from "@/Cloudflare"; +import * as Alchemy from "@/index.ts"; +import * as Effect from "effect/Effect"; +import ReadHttpWorker from "./read-http.ts"; +import ReadWriteHttpWorker from "./readwrite-http.ts"; +import WriteHttpWorker from "./write-http.ts"; + +/** + * Deploys the three Workers that reach one shared R2 bucket over a + * **scoped HTTP API token** (`*BucketHttp`) — read / write / + * read-write. + * + * Kept apart from the native-binding stack ({@link ./stack.ts}) so that + * minting the `AccountApiToken` these workers depend on cannot take down + * binding coverage that needs no token at all. Inspect directly with: + * + * ```sh + * alchemy tail --stage test ./test/Cloudflare/R2/fixtures/stack-http.ts + * ``` + */ +export default Alchemy.Stack( + "R2BindingHttpStack", + { providers: Cloudflare.providers(), state: Cloudflare.state() }, + Effect.gen(function* () { + const readHttp = yield* ReadHttpWorker; + const writeHttp = yield* WriteHttpWorker; + const readWriteHttp = yield* ReadWriteHttpWorker; + return { + readHttp: readHttp.url.as(), + writeHttp: writeHttp.url.as(), + readWriteHttp: readWriteHttp.url.as(), + }; + }), +); diff --git a/packages/alchemy/test/Cloudflare/R2/fixtures/stack.ts b/packages/alchemy/test/Cloudflare/R2/fixtures/stack.ts index bec66aa35c..f4727db24a 100644 --- a/packages/alchemy/test/Cloudflare/R2/fixtures/stack.ts +++ b/packages/alchemy/test/Cloudflare/R2/fixtures/stack.ts @@ -2,21 +2,22 @@ import * as Cloudflare from "@/Cloudflare"; import * as Alchemy from "@/index.ts"; import * as Effect from "effect/Effect"; import ReadBindingWorker from "./read-binding.ts"; -import ReadHttpWorker from "./read-http.ts"; import ReadWriteBindingWorker from "./readwrite-binding.ts"; -import ReadWriteHttpWorker from "./readwrite-http.ts"; import WriteBindingWorker from "./write-binding.ts"; -import WriteHttpWorker from "./write-http.ts"; /** - * Deploys six Workers that all bind one shared R2 bucket — read / write / - * read-write, each over the native Worker binding (`*BucketBinding`) and over a - * scoped HTTP API token (`*BucketHttp`). Extracted into its own stack file so - * it can be deployed by the test suite AND inspected directly, e.g. + * Deploys the three Workers that reach one shared R2 bucket over the + * **native Worker binding** (`*BucketBinding`) — read / write / + * read-write. Extracted into its own stack file so it can be deployed + * by the test suite AND inspected directly, e.g. * * ```sh * alchemy tail --stage test ./test/Cloudflare/R2/fixtures/stack.ts * ``` + * + * The HTTP-token half lives in a separate stack ({@link ./stack-http.ts}) + * because deploying it mints an `AccountApiToken`, which not every + * credential is permitted to do — see the gate in `../Binding.test.ts`. */ export default Alchemy.Stack( "R2BindingStack", @@ -25,16 +26,10 @@ export default Alchemy.Stack( const readBinding = yield* ReadBindingWorker; const writeBinding = yield* WriteBindingWorker; const readWriteBinding = yield* ReadWriteBindingWorker; - const readHttp = yield* ReadHttpWorker; - const writeHttp = yield* WriteHttpWorker; - const readWriteHttp = yield* ReadWriteHttpWorker; return { readBinding: readBinding.url.as(), writeBinding: writeBinding.url.as(), readWriteBinding: readWriteBinding.url.as(), - readHttp: readHttp.url.as(), - writeHttp: writeHttp.url.as(), - readWriteHttp: readWriteHttp.url.as(), }; }), );