1import { adopt } from "@/AdoptPolicy";
2import * as Cloudflare from "@/Cloudflare";
3import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
4import { findZoneByName } from "@/Cloudflare/Zone/lookup";
5import * as Provider from "@/Provider";
6import * as Test from "@/Test/Alchemy";
7import * as apiGateway from "@distilled.cloud/cloudflare/api-gateway";
8import { expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import * as FileSystem from "effect/FileSystem";
11import * as Path from "effect/Path";
12import { MinimumLogLevel } from "effect/References";
13import * as Schedule from "effect/Schedule";
14import * as Stream from "effect/Stream";
15
16const { test } = Test.make({ providers: Cloudflare.providers() });
17
18const logLevel = Effect.provideService(
19 MinimumLogLevel,
20 process.env.DEBUG ? "Debug" : "Info",
21);
22
23const zoneName =
24 process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
25
26// Deterministic per-test schema names.
27const NAME_DEFAULT = "alch-userschema-default";
28const NAME_REPLACE = "alch-userschema-replace";
29const NAME_LIST = "alch-userschema-list";
30
31const resolveZoneId = Effect.gen(function* () {
32 const { accountId } = yield* yield* CloudflareEnvironment;
33 const zone = yield* findZoneByName({ accountId, name: zoneName });
34 if (!zone) {
35 return yield* Effect.die(
36 new Error(`zone "${zoneName}" not found in account`),
37 );
38 }
39 return zone.id;
40});
41
42const fixture = (file: string) =>
43 Effect.gen(function* () {
44 const fs = yield* FileSystem.FileSystem;
45 const path = yield* Path.Path;
46 return yield* fs.readFileString(
47 path.join(import.meta.dirname, "fixtures", file),
48 );
49 });
50
51// The scoped API token the test harness mints propagates eventually-
52// consistently — a fresh token intermittently 403s. Ride out the blips on
53// the test's own out-of-band calls by retrying the typed `Forbidden` error.
54const forbiddenRetrySchedule = Schedule.exponential("500 millis");
55
56// Read a schema out-of-band; `undefined` when gone.
57const getSchema = (zoneId: string, schemaId: string) =>
58 apiGateway.getUserSchema({ zoneId, schemaId }).pipe(
59 Effect.map(
60 (schema): apiGateway.GetUserSchemaResponse | undefined => schema,
61 ),
62 Effect.catchTag("SchemaNotFound", () => Effect.succeed(undefined)),
63 Effect.retry({
64 while: (e) => e._tag === "Forbidden",
65 schedule: forbiddenRetrySchedule,
66 times: 8,
67 }),
68 );
69
70// Purge schemas left over from interrupted runs so each test starts clean.
71const purgeSchemasNamed = (zoneId: string, name: string) =>
72 apiGateway.listUserSchemas.items({ zoneId, omitSource: true }).pipe(
73 Stream.runCollect,
74 Effect.flatMap((chunk) =>
75 Effect.forEach(
76 Array.from(chunk).filter((schema) => schema.name === name),
77 (schema) =>
78 apiGateway
79 .deleteUserSchema({ zoneId, schemaId: schema.schemaId })
80 .pipe(Effect.catchTag("SchemaNotFound", () => Effect.void)),
81 ),
82 ),
83 Effect.retry({
84 while: (e) => e._tag === "Forbidden",
85 schedule: forbiddenRetrySchedule,
86 times: 8,
87 }),
88 );
89
90test.provider(
91 "create, enable validation in place, destroy a user schema",
92 (stack) =>
93 Effect.gen(function* () {
94 const zoneId = yield* resolveZoneId;
95 const source = yield* fixture("openapi-v1.json");
96
97 yield* stack.destroy();
98 yield* purgeSchemasNamed(zoneId, NAME_DEFAULT);
99
100 const schema = yield* stack.deploy(
101 Effect.gen(function* () {
102 return yield* Cloudflare.ApiShield.UserSchema("DefaultSchema", {
103 zoneId,
104 name: NAME_DEFAULT,
105 schema: source,
106 }).pipe(adopt(true));
107 }),
108 );
109
110 expect(schema.zoneId).toEqual(zoneId);
111 expect(schema.name).toEqual(NAME_DEFAULT);
112 expect(schema.kind).toEqual("openapi_v3");
113 expect(schema.source).toEqual(source);
114 expect(schema.validationEnabled).toEqual(false);
115 expect(schema.schemaId.length).toBeGreaterThan(0);
116
117 const live = yield* getSchema(zoneId, schema.schemaId);
118 expect(live?.name).toEqual(NAME_DEFAULT);
119 expect(live?.source).toEqual(source);
120
121 // Enable validation — same identity, patched in place.
122 const enabled = yield* stack.deploy(
123 Effect.gen(function* () {
124 return yield* Cloudflare.ApiShield.UserSchema("DefaultSchema", {
125 zoneId,
126 name: NAME_DEFAULT,
127 schema: source,
128 validationEnabled: true,
129 }).pipe(adopt(true));
130 }),
131 );
132 expect(enabled.schemaId).toEqual(schema.schemaId);
133 expect(enabled.validationEnabled).toEqual(true);
134
135 const patched = yield* getSchema(zoneId, schema.schemaId);
136 expect(patched?.validationEnabled).toEqual(true);
137
138 yield* stack.destroy();
139
140 const gone = yield* getSchema(zoneId, schema.schemaId);
141 expect(gone).toBeUndefined();
142 }).pipe(logLevel),
143 {
144 tags: [
145 "provider:cloudflare",
146 "provider:cloudflare:apishield",
147 "provider:cloudflare:zone",
148 "live",
149 ],
150 timeout: 120_000,
151 },
152);
153
154test.provider(
155 "changing the schema source triggers replacement",
156 (stack) =>
157 Effect.gen(function* () {
158 const zoneId = yield* resolveZoneId;
159 const sourceV1 = yield* fixture("openapi-v1.json");
160 const sourceV2 = yield* fixture("openapi-v2.json");
161
162 yield* stack.destroy();
163 yield* purgeSchemasNamed(zoneId, NAME_REPLACE);
164
165 const initial = yield* stack.deploy(
166 Effect.gen(function* () {
167 return yield* Cloudflare.ApiShield.UserSchema("ReplaceSchema", {
168 zoneId,
169 name: NAME_REPLACE,
170 schema: sourceV1,
171 }).pipe(adopt(true));
172 }),
173 );
174 expect(initial.source).toEqual(sourceV1);
175
176 const replaced = yield* stack.deploy(
177 Effect.gen(function* () {
178 return yield* Cloudflare.ApiShield.UserSchema("ReplaceSchema", {
179 zoneId,
180 name: NAME_REPLACE,
181 schema: sourceV2,
182 }).pipe(adopt(true));
183 }),
184 );
185
186 // The source is immutable — a new physical schema exists.
187 expect(replaced.schemaId).not.toEqual(initial.schemaId);
188 expect(replaced.source).toEqual(sourceV2);
189
190 // The old schema was deleted as part of the replacement.
191 const oldSchema = yield* getSchema(zoneId, initial.schemaId);
192 expect(oldSchema).toBeUndefined();
193
194 const live = yield* getSchema(zoneId, replaced.schemaId);
195 expect(live?.source).toEqual(sourceV2);
196
197 yield* stack.destroy();
198
199 const gone = yield* getSchema(zoneId, replaced.schemaId);
200 expect(gone).toBeUndefined();
201 }).pipe(logLevel),
202 {
203 tags: [
204 "provider:cloudflare",
205 "provider:cloudflare:apishield",
206 "provider:cloudflare:zone",
207 "live",
208 ],
209 timeout: 120_000,
210 },
211);
212
213// Canonical `list()` test (zone-scoped collection): `list()` fans out over
214// every zone via `listAllZones` and exhaustively paginates each zone's
215// schemas. Deploy a schema, then assert its id appears in the result.
216test.provider(
217 "list enumerates the deployed user schema",
218 (stack) =>
219 Effect.gen(function* () {
220 const zoneId = yield* resolveZoneId;
221 const source = yield* fixture("openapi-v1.json");
222
223 yield* stack.destroy();
224 yield* purgeSchemasNamed(zoneId, NAME_LIST);
225
226 const schema = yield* stack.deploy(
227 Effect.gen(function* () {
228 return yield* Cloudflare.ApiShield.UserSchema("ListSchema", {
229 zoneId,
230 name: NAME_LIST,
231 schema: source,
232 }).pipe(adopt(true));
233 }),
234 );
235
236 const provider = yield* Provider.findProvider(
237 Cloudflare.ApiShield.UserSchema,
238 );
239 const all = yield* provider.list();
240
241 const found = all.find((s) => s.schemaId === schema.schemaId);
242 expect(found).toBeDefined();
243 expect(found?.zoneId).toEqual(zoneId);
244 expect(found?.name).toEqual(NAME_LIST);
245 expect(found?.kind).toEqual("openapi_v3");
246 expect(found?.source).toEqual(source);
247
248 yield* stack.destroy();
249 }).pipe(logLevel),
250 {
251 tags: [
252 "provider:cloudflare",
253 "provider:cloudflare:apishield",
254 "provider:cloudflare:zone",
255 "live",
256 ],
257 timeout: 120_000,
258 },
259);