1import { adopt } from "@/AdoptPolicy";
2import * as Cloudflare from "@/Cloudflare";
3import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
4import { normalizeEndpoint } from "@/Cloudflare/ApiShield/Operation";
5import * as Provider from "@/Provider";
6import { findZoneByName } from "@/Cloudflare/Zone/lookup";
7import * as Test from "@/Test/Alchemy";
8import * as apiGateway from "@distilled.cloud/cloudflare/api-gateway";
9import { expect } from "alchemy-test";
10import * as Effect from "effect/Effect";
11import { MinimumLogLevel } from "effect/References";
12import * as Schedule from "effect/Schedule";
13import * as Stream from "effect/Stream";
14
15const { test } = Test.make({ providers: Cloudflare.providers() });
16
17const logLevel = Effect.provideService(
18 MinimumLogLevel,
19 process.env.DEBUG ? "Debug" : "Info",
20);
21
22const zoneName =
23 process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
24
25// Deterministic per-test endpoints. Each test owns a disjoint path so reruns
26// and parallel runs never collide (never derive identity from Date.now()).
27const ENDPOINT_DEFAULT = "/alchemy-apishield/default/{thingId}";
28const ENDPOINT_REPLACE = "/alchemy-apishield/replace";
29const ENDPOINT_LIST = "/alchemy-apishield/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
42// The scoped API token the test harness mints propagates eventually-
43// consistently — a fresh token intermittently 403s. Ride out the blips on
44// the test's own out-of-band calls by retrying the typed `Forbidden` error.
45const forbiddenRetrySchedule = Schedule.exponential("500 millis");
46
47const findOperation = (
48 zoneId: string,
49 tuple: { method: string; host: string; endpoint: string },
50) =>
51 apiGateway.listOperations
52 .items({ zoneId, host: [tuple.host], method: [tuple.method] })
53 .pipe(
54 Stream.filter(
55 (op) =>
56 op.method === tuple.method &&
57 op.host === tuple.host &&
58 op.endpoint === normalizeEndpoint(tuple.endpoint),
59 ),
60 Stream.runCollect,
61 Effect.map((chunk) => Array.from(chunk)[0]),
62 Effect.retry({
63 while: (e) => e._tag === "Forbidden",
64 schedule: forbiddenRetrySchedule,
65 times: 8,
66 }),
67 );
68
69const getOperation = (zoneId: string, operationId: string) =>
70 apiGateway.getOperation({ zoneId, operationId }).pipe(
71 Effect.retry({
72 while: (e) => e._tag === "Forbidden",
73 schedule: forbiddenRetrySchedule,
74 times: 8,
75 }),
76 );
77
78// Purge a tuple left over from interrupted runs so each test starts clean.
79const purgeOperation = (
80 zoneId: string,
81 tuple: { method: string; host: string; endpoint: string },
82) =>
83 findOperation(zoneId, tuple).pipe(
84 Effect.flatMap((op) =>
85 op
86 ? apiGateway
87 .deleteOperation({ zoneId, operationId: op.operationId })
88 .pipe(
89 Effect.catchTag("OperationNotFound", () => Effect.void),
90 Effect.retry({
91 while: (e) => e._tag === "Forbidden",
92 schedule: forbiddenRetrySchedule,
93 times: 8,
94 }),
95 )
96 : Effect.void,
97 ),
98 );
99
100test.provider(
101 "create, no-op redeploy, destroy an API Shield operation",
102 (stack) =>
103 Effect.gen(function* () {
104 const zoneId = yield* resolveZoneId;
105 const tuple = {
106 method: "GET",
107 host: zoneName,
108 endpoint: ENDPOINT_DEFAULT,
109 };
110
111 yield* stack.destroy();
112 yield* purgeOperation(zoneId, tuple);
113
114 const op = yield* stack.deploy(
115 Effect.gen(function* () {
116 return yield* Cloudflare.ApiShield.Operation("DefaultOp", {
117 zoneId,
118 method: "GET",
119 host: zoneName,
120 endpoint: ENDPOINT_DEFAULT,
121 }).pipe(adopt(true));
122 }),
123 );
124
125 expect(op.operationId).toBeDefined();
126 expect(op.zoneId).toEqual(zoneId);
127 expect(op.method).toEqual("GET");
128 expect(op.host).toEqual(zoneName);
129 // Cloudflare normalizes `{thingId}` to `{var1}`.
130 expect(op.endpoint).toEqual("/alchemy-apishield/default/{var1}");
131
132 const live = yield* getOperation(zoneId, op.operationId);
133 expect(live.operationId).toEqual(op.operationId);
134 expect(live.endpoint).toEqual("/alchemy-apishield/default/{var1}");
135
136 // No-op redeploy of the same (raw) endpoint converges on the same
137 // physical operation — the normalized forms match.
138 const redeployed = yield* stack.deploy(
139 Effect.gen(function* () {
140 return yield* Cloudflare.ApiShield.Operation("DefaultOp", {
141 zoneId,
142 method: "GET",
143 host: zoneName,
144 endpoint: ENDPOINT_DEFAULT,
145 }).pipe(adopt(true));
146 }),
147 );
148 expect(redeployed.operationId).toEqual(op.operationId);
149
150 yield* stack.destroy();
151
152 const gone = yield* findOperation(zoneId, tuple);
153 expect(gone).toBeUndefined();
154 }).pipe(logLevel),
155 {
156 tags: [
157 "provider:cloudflare",
158 "provider:cloudflare:apishield",
159 "provider:cloudflare:zone",
160 "live",
161 ],
162 },
163);
164
165test.provider(
166 "changing the method triggers replacement",
167 (stack) =>
168 Effect.gen(function* () {
169 const zoneId = yield* resolveZoneId;
170 const getTuple = {
171 method: "GET",
172 host: zoneName,
173 endpoint: ENDPOINT_REPLACE,
174 };
175 const postTuple = { ...getTuple, method: "POST" };
176
177 yield* stack.destroy();
178 yield* purgeOperation(zoneId, getTuple);
179 yield* purgeOperation(zoneId, postTuple);
180
181 const initial = yield* stack.deploy(
182 Effect.gen(function* () {
183 return yield* Cloudflare.ApiShield.Operation("ReplaceOp", {
184 zoneId,
185 method: "GET",
186 host: zoneName,
187 endpoint: ENDPOINT_REPLACE,
188 }).pipe(adopt(true));
189 }),
190 );
191 expect(initial.method).toEqual("GET");
192
193 const replaced = yield* stack.deploy(
194 Effect.gen(function* () {
195 return yield* Cloudflare.ApiShield.Operation("ReplaceOp", {
196 zoneId,
197 method: "POST",
198 host: zoneName,
199 endpoint: ENDPOINT_REPLACE,
200 }).pipe(adopt(true));
201 }),
202 );
203
204 // The tuple is the operation's identity — a new physical operation.
205 expect(replaced.operationId).not.toEqual(initial.operationId);
206 expect(replaced.method).toEqual("POST");
207
208 // The old GET operation was deleted as part of the replacement.
209 const oldOp = yield* findOperation(zoneId, getTuple);
210 expect(oldOp).toBeUndefined();
211
212 yield* stack.destroy();
213
214 const gone = yield* findOperation(zoneId, postTuple);
215 expect(gone).toBeUndefined();
216 }).pipe(logLevel),
217 {
218 tags: [
219 "provider:cloudflare",
220 "provider:cloudflare:apishield",
221 "provider:cloudflare:zone",
222 "live",
223 ],
224 },
225);
226
227test.provider(
228 "list enumerates the deployed API Shield operation",
229 (stack) =>
230 Effect.gen(function* () {
231 const zoneId = yield* resolveZoneId;
232 const tuple = {
233 method: "GET",
234 host: zoneName,
235 endpoint: ENDPOINT_LIST,
236 };
237
238 yield* stack.destroy();
239 yield* purgeOperation(zoneId, tuple);
240
241 const op = yield* stack.deploy(
242 Effect.gen(function* () {
243 return yield* Cloudflare.ApiShield.Operation("ListOp", {
244 zoneId,
245 method: "GET",
246 host: zoneName,
247 endpoint: ENDPOINT_LIST,
248 }).pipe(adopt(true));
249 }),
250 );
251
252 const provider = yield* Provider.findProvider(
253 Cloudflare.ApiShield.Operation,
254 );
255 const all = yield* provider.list();
256
257 // The deployed operation appears in the exhaustively-paginated,
258 // all-zones result with the exact `read` shape.
259 const found = all.find((x) => x.operationId === op.operationId);
260 expect(found).toBeDefined();
261 expect(found?.zoneId).toEqual(zoneId);
262 expect(found?.method).toEqual("GET");
263 expect(found?.host).toEqual(zoneName);
264 expect(found?.endpoint).toEqual("/alchemy-apishield/list");
265 expect(found?.lastUpdated).toBeDefined();
266
267 yield* stack.destroy();
268
269 const gone = yield* findOperation(zoneId, tuple);
270 expect(gone).toBeUndefined();
271 }).pipe(logLevel),
272 {
273 tags: [
274 "provider:cloudflare",
275 "provider:cloudflare:apishield",
276 "provider:cloudflare:zone",
277 "live",
278 ],
279 },
280);