1import * as Cloudflare from "@/Cloudflare";
2import * as Provider from "@/Provider";
3import * as Test from "@/Test/Alchemy";
4import {
5 Credentials,
6 apiTokenCredentials,
7} from "@distilled.cloud/cloudflare/Credentials";
8import * as secretsStore from "@distilled.cloud/cloudflare/secrets-store";
9import { expect, it } from "alchemy-test";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import * as HttpBody from "effect/http/HttpBody";
13import * as HttpClient from "effect/http/HttpClient";
14import * as HttpClientResponse from "effect/http/HttpClientResponse";
15
16const { test } = Test.make({ providers: Cloudflare.providers() });
17
18/**
19 * Test harness that captures the outbound `HttpClientRequest` and lets
20 * the caller plug in a canned `Response`. The motivating regression
21 * (Cloudflare's `secrets_store/stores` POST receiving an array body
22 * and rejecting it with `invalid_json_body`) was invisible to the type
23 * system because the upstream SDK schema modeled the body as
24 * `{ name }[]`. The fix is to upgrade `@distilled.cloud/cloudflare` to
25 * a release with the corrected single-object schema; these tests pin
26 * the wire format so a future SDK regression is caught immediately.
27 */
28interface Captured {
29 url: string;
30 method: string;
31 contentType: string | undefined;
32 bodyJson: unknown;
33 authorization: string | undefined;
34}
35
36const harness = (response: Response) => {
37 let captured: Captured | undefined;
38 const client = HttpClient.make((request) =>
39 Effect.sync(() => {
40 const body = request.body as HttpBody.HttpBody;
41 const bodyText =
42 body._tag === "Uint8Array" ? new TextDecoder().decode(body.body) : "";
43 captured = {
44 url: request.url,
45 method: request.method,
46 contentType: body._tag === "Uint8Array" ? body.contentType : undefined,
47 bodyJson: bodyText ? JSON.parse(bodyText) : undefined,
48 authorization: request.headers.authorization,
49 };
50 return HttpClientResponse.fromWeb(request, response);
51 }),
52 );
53 const layer = Layer.mergeAll(
54 Layer.succeed(HttpClient.HttpClient, client),
55 Layer.succeed(
56 Credentials,
57 Effect.succeed(apiTokenCredentials({ apiToken: "test-token" })),
58 ),
59 );
60 return { layer, get: () => captured! };
61};
62
63const successResponse = () =>
64 new Response(
65 JSON.stringify({
66 success: true,
67 errors: [],
68 messages: [],
69 result: {
70 id: "store-id-123",
71 name: "default_secrets_store",
72 created: "2026-01-01T00:00:00Z",
73 modified: "2026-01-01T00:00:00Z",
74 },
75 }),
76 { status: 200, headers: { "content-type": "application/json" } },
77 );
78
79const errorResponse = (
80 status: number,
81 errors: Array<{ code: number; message: string }>,
82) =>
83 new Response(
84 JSON.stringify({ success: false, errors, messages: [], result: null }),
85 { status, headers: { "content-type": "application/json" } },
86 );
87
88it.live(
89 "createStore POSTs a single JSON object body (regression: invalid_json_body)",
90 () =>
91 Effect.gen(function* () {
92 const { layer, get } = harness(successResponse());
93
94 const result = yield* Effect.gen(function* () {
95 const create = yield* secretsStore.createStore;
96 return yield* create({
97 accountId: "acct-abc",
98 name: "default_secrets_store",
99 });
100 }).pipe(Effect.provide(layer));
101
102 expect(result.id).toBe("store-id-123");
103 expect(result.name).toBe("default_secrets_store");
104
105 const sent = get();
106 expect(sent.method).toBe("POST");
107 expect(sent.url).toBe(
108 "https://api.cloudflare.com/client/v4/accounts/acct-abc/secrets_store/stores",
109 );
110 expect(sent.contentType).toMatch(/application\/json/);
111 expect(sent.authorization).toBe("Bearer test-token");
112
113 // Cloudflare's REST API expects `{"name": "..."}` and rejects
114 // `[{"name": "..."}]` with code 1001 `invalid_json_body`.
115 // Earlier `@distilled.cloud/cloudflare` releases sent the array
116 // shape; this test pins the corrected single-object body.
117 expect(sent.bodyJson).toEqual({ name: "default_secrets_store" });
118 expect(Array.isArray(sent.bodyJson)).toBe(false);
119 }),
120 {
121 tags: [
122 "unit",
123 "provider:cloudflare",
124 "provider:cloudflare:secretsstore",
125 "local",
126 ],
127 },
128);
129
130it.live(
131 "createStore surfaces MaximumStoresExceeded as a tagged error so callers can catchTag it",
132 () =>
133 Effect.gen(function* () {
134 const { layer } = harness(
135 errorResponse(409, [
136 { code: 1003, message: "maximum_stores_exceeded" },
137 ]),
138 );
139
140 const result = yield* Effect.gen(function* () {
141 const create = yield* secretsStore.createStore;
142 return yield* create({ accountId: "acct-abc", name: "x" }).pipe(
143 Effect.catchTag("MaximumStoresExceeded", (e) => Effect.succeed(e)),
144 );
145 }).pipe(Effect.provide(layer));
146
147 // Assert on the tag, not `instanceof`: distilled errors are defined as
148 // `class X extends applyErrorMatchers(Schema.TaggedError(...))`, so
149 // the decoder constructs instances of the inner schema class — they
150 // carry the right `_tag` but are not `instanceof` the exported subclass.
151 const err = result as secretsStore.MaximumStoresExceeded;
152 expect(err._tag).toBe("MaximumStoresExceeded");
153 expect(err.code).toBe(1003);
154 expect(err.message).toBe("maximum_stores_exceeded");
155 }),
156 {
157 tags: [
158 "unit",
159 "provider:cloudflare",
160 "provider:cloudflare:secretsstore",
161 "local",
162 ],
163 },
164);
165
166// Canonical `list()` test (account-scoped collection). Deploy/adopt the
167// account's Secrets Store, then enumerate via the provider's `list()` and
168// assert the deployed store's id is present in the exhaustively-paginated
169// result. Bracketed with `stack.destroy()` at start and end; note the
170// SecretsStore provider's `delete` is an intentional no-op (account-level
171// infra), so destroy never tears down the shared store.
172test.provider(
173 "list enumerates the deployed secrets store",
174 (stack) =>
175 Effect.gen(function* () {
176 yield* stack.destroy();
177
178 const deployed = yield* stack.deploy(
179 Effect.gen(function* () {
180 return yield* Cloudflare.SecretsStore.Store("ListStore");
181 }),
182 );
183
184 const provider = yield* Provider.findProvider(
185 Cloudflare.SecretsStore.Store,
186 );
187 const all = yield* provider.list();
188
189 expect(all.length).toBeGreaterThan(0);
190 expect(all.some((s) => s.storeId === deployed.storeId)).toBe(true);
191
192 yield* stack.destroy();
193 }),
194 { tags: ["provider:cloudflare", "provider:cloudflare:secretsstore", "live"] },
195);