1import { adopt, OwnedBySomeoneElse } 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 healthchecks from "@distilled.cloud/cloudflare/healthchecks";
8import { expect } from "alchemy-test";
9import * as Cause from "effect/Cause";
10import * as Effect from "effect/Effect";
11import * as Option from "effect/Option";
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 names (alphanumeric/hyphen/underscore only). The
27// same name is reused on every run — never derive from Date.now()/random.
28const NAME_UPDATE = "alchemy-healthcheck-update";
29const NAME_UPDATE_V2 = "alchemy-healthcheck-update-v2";
30const NAME_TYPE = "alchemy-healthcheck-type";
31const NAME_ADOPT = "alchemy-healthcheck-adopt";
32const NAME_LIST = "alchemy-healthcheck-list";
33
34const resolveZoneId = Effect.gen(function* () {
35 const { accountId } = yield* yield* CloudflareEnvironment;
36 const zone = yield* findZoneByName({ accountId, name: zoneName });
37 if (!zone) {
38 return yield* Effect.die(
39 new Error(`zone "${zoneName}" not found in account`),
40 );
41 }
42 return zone.id;
43});
44
45// The scoped API token the test harness mints propagates eventually-
46// consistently across Cloudflare's edge — ride out 403 blips
47// (`Forbidden`, declared in the distilled error union via patches) on the
48// test's own out-of-band verification calls.
49const forbiddenRetrySchedule = Schedule.exponential("500 millis");
50
51const getHealthcheck = (zoneId: string, healthcheckId: string) =>
52 healthchecks.getHealthcheck({ zoneId, healthcheckId }).pipe(
53 Effect.retry({
54 while: (e) => e._tag === "Forbidden",
55 schedule: forbiddenRetrySchedule,
56 times: 8,
57 }),
58 );
59
60const findByName = (zoneId: string, name: string) =>
61 healthchecks.listHealthchecks.items({ zoneId }).pipe(
62 Stream.filter((h) => h.name === name),
63 Stream.runHead,
64 Effect.map(Option.getOrUndefined),
65 Effect.retry({
66 while: (e) => e._tag === "Forbidden",
67 schedule: forbiddenRetrySchedule,
68 times: 8,
69 }),
70 );
71
72const expectGone = (zoneId: string, healthcheckId: string) =>
73 getHealthcheck(zoneId, healthcheckId).pipe(
74 Effect.flatMap(() =>
75 Effect.fail({ _tag: "HealthcheckNotDeleted" } as const),
76 ),
77 // A missing health check surfaces as `HealthcheckNotFound` (404) —
78 // that's the success condition here.
79 Effect.catchTag("HealthcheckNotFound", () => Effect.void),
80 Effect.retry({
81 while: (e) => e._tag === "HealthcheckNotDeleted",
82 schedule: Schedule.max([
83 Schedule.exponential("500 millis"),
84 Schedule.recurs(10),
85 ]),
86 }),
87 );
88
89// Purge leftovers from interrupted runs so tests start from a clean
90// slate (and the Pro-plan quota of 2 checks is never exhausted).
91const purgeByName = (zoneId: string, name: string) =>
92 findByName(zoneId, name).pipe(
93 Effect.flatMap((found) =>
94 found?.id
95 ? healthchecks
96 .deleteHealthcheck({ zoneId, healthcheckId: found.id })
97 .pipe(
98 Effect.catchTag("HealthcheckNotFound", () => Effect.void),
99 Effect.retry({
100 while: (e) => e._tag === "Forbidden",
101 schedule: forbiddenRetrySchedule,
102 times: 8,
103 }),
104 )
105 : Effect.void,
106 ),
107 );
108
109test.provider(
110 "create and delete an HTTP health check with default name",
111 (stack) =>
112 Effect.gen(function* () {
113 const zoneId = yield* resolveZoneId;
114
115 yield* stack.destroy();
116
117 const check = yield* stack.deploy(
118 Cloudflare.Healthcheck.Healthcheck("DefaultCheck", {
119 zoneId,
120 address: "www.cloudflare.com",
121 }),
122 );
123
124 expect(check.healthcheckId).toBeDefined();
125 expect(check.zoneId).toEqual(zoneId);
126 expect(check.name).toBeTruthy();
127 expect(check.address).toEqual("www.cloudflare.com");
128 expect(check.type).toEqual("HTTP");
129 expect(check.interval).toEqual(60);
130 expect(check.retries).toEqual(2);
131 expect(check.timeout).toEqual(5);
132 expect(check.suspended).toEqual(false);
133 // Status is eventually consistent — new checks start as "unknown",
134 // so only assert it is one of the documented values.
135 expect(["unknown", "healthy", "unhealthy", "suspended"]).toContain(
136 check.status,
137 );
138
139 const live = yield* getHealthcheck(zoneId, check.healthcheckId);
140 expect(live.id).toEqual(check.healthcheckId);
141 expect(live.name).toEqual(check.name);
142 expect(live.address).toEqual("www.cloudflare.com");
143 expect(live.type).toEqual("HTTP");
144
145 yield* stack.destroy();
146
147 yield* expectGone(zoneId, check.healthcheckId);
148 }).pipe(logLevel),
149 {
150 tags: [
151 "provider:cloudflare",
152 "provider:cloudflare:healthcheck",
153 "provider:cloudflare:zone",
154 "live",
155 ],
156 },
157);
158
159test.provider(
160 "update mutable props in place (same id), then no-op redeploy",
161 (stack) =>
162 Effect.gen(function* () {
163 const zoneId = yield* resolveZoneId;
164
165 yield* stack.destroy();
166 yield* purgeByName(zoneId, NAME_UPDATE);
167 yield* purgeByName(zoneId, NAME_UPDATE_V2);
168
169 const initial = yield* stack.deploy(
170 Cloudflare.Healthcheck.Healthcheck("UpdateCheck", {
171 zoneId,
172 name: NAME_UPDATE,
173 address: "www.cloudflare.com",
174 type: "HTTPS",
175 interval: 60,
176 httpConfig: { path: "/", port: 443 },
177 }),
178 );
179
180 expect(initial.name).toEqual(NAME_UPDATE);
181 expect(initial.type).toEqual("HTTPS");
182 expect(initial.interval).toEqual(60);
183 expect(initial.suspended).toEqual(false);
184
185 const updated = yield* stack.deploy(
186 Cloudflare.Healthcheck.Healthcheck("UpdateCheck", {
187 zoneId,
188 name: NAME_UPDATE_V2,
189 address: "www.cloudflare.com",
190 type: "HTTPS",
191 interval: 120,
192 suspended: true,
193 description: "updated by alchemy test",
194 httpConfig: { path: "/cdn-cgi/trace", port: 443 },
195 }),
196 );
197
198 // Same health check mutated in place — not a replacement.
199 expect(updated.healthcheckId).toEqual(initial.healthcheckId);
200 expect(updated.name).toEqual(NAME_UPDATE_V2);
201 expect(updated.interval).toEqual(120);
202 expect(updated.suspended).toEqual(true);
203
204 const live = yield* getHealthcheck(zoneId, updated.healthcheckId);
205 expect(live.name).toEqual(NAME_UPDATE_V2);
206 expect(live.interval).toEqual(120);
207 expect(live.suspended).toEqual(true);
208 expect(live.description).toEqual("updated by alchemy test");
209 expect(live.httpConfig?.path).toEqual("/cdn-cgi/trace");
210
211 // Redeploying identical props is a no-op (still the same check).
212 const noop = yield* stack.deploy(
213 Cloudflare.Healthcheck.Healthcheck("UpdateCheck", {
214 zoneId,
215 name: NAME_UPDATE_V2,
216 address: "www.cloudflare.com",
217 type: "HTTPS",
218 interval: 120,
219 suspended: true,
220 description: "updated by alchemy test",
221 httpConfig: { path: "/cdn-cgi/trace", port: 443 },
222 }),
223 );
224 expect(noop.healthcheckId).toEqual(initial.healthcheckId);
225
226 yield* stack.destroy();
227
228 yield* expectGone(zoneId, initial.healthcheckId);
229 }).pipe(logLevel),
230 {
231 tags: [
232 "provider:cloudflare",
233 "provider:cloudflare:healthcheck",
234 "provider:cloudflare:zone",
235 "live",
236 ],
237 },
238);
239
240test.provider(
241 "changing type HTTP→TCP updates in place; delete is idempotent",
242 (stack) =>
243 Effect.gen(function* () {
244 const zoneId = yield* resolveZoneId;
245
246 yield* stack.destroy();
247 yield* purgeByName(zoneId, NAME_TYPE);
248
249 const initial = yield* stack.deploy(
250 Cloudflare.Healthcheck.Healthcheck("TypeCheck", {
251 zoneId,
252 name: NAME_TYPE,
253 address: "www.cloudflare.com",
254 type: "HTTP",
255 }),
256 );
257
258 expect(initial.type).toEqual("HTTP");
259
260 const switched = yield* stack.deploy(
261 Cloudflare.Healthcheck.Healthcheck("TypeCheck", {
262 zoneId,
263 name: NAME_TYPE,
264 address: "www.cloudflare.com",
265 type: "TCP",
266 tcpConfig: { port: 443 },
267 }),
268 );
269
270 // Type is mutable — same physical check, not a replacement.
271 expect(switched.healthcheckId).toEqual(initial.healthcheckId);
272 expect(switched.type).toEqual("TCP");
273
274 const live = yield* getHealthcheck(zoneId, switched.healthcheckId);
275 expect(live.type).toEqual("TCP");
276 expect(live.tcpConfig?.port).toEqual(443);
277
278 // Delete out-of-band, then destroy — the provider must treat the
279 // already-gone check as success (idempotent delete).
280 yield* healthchecks
281 .deleteHealthcheck({
282 zoneId,
283 healthcheckId: switched.healthcheckId,
284 })
285 .pipe(
286 Effect.retry({
287 while: (e) => e._tag === "Forbidden",
288 schedule: forbiddenRetrySchedule,
289 times: 8,
290 }),
291 );
292
293 yield* stack.destroy();
294
295 yield* expectGone(zoneId, switched.healthcheckId);
296 }).pipe(logLevel),
297 {
298 tags: [
299 "provider:cloudflare",
300 "provider:cloudflare:healthcheck",
301 "provider:cloudflare:zone",
302 "live",
303 ],
304 },
305);
306
307test.provider(
308 "adoption — existing check errors without adopt, takes over with adopt(true)",
309 (stack) =>
310 Effect.gen(function* () {
311 const zoneId = yield* resolveZoneId;
312
313 yield* stack.destroy();
314 yield* purgeByName(zoneId, NAME_ADOPT);
315
316 // Create the check out-of-band so the stack has no state of its
317 // own for it — exactly the "already exists" scenario.
318 const pre = yield* healthchecks
319 .createHealthcheck({
320 zoneId,
321 name: NAME_ADOPT,
322 address: "www.cloudflare.com",
323 type: "HTTP",
324 })
325 .pipe(
326 Effect.retry({
327 while: (e) => e._tag === "Forbidden",
328 schedule: forbiddenRetrySchedule,
329 times: 8,
330 }),
331 );
332 expect(pre.id).toBeDefined();
333
334 // Without `adopt`: health checks carry no ownership markers, so the
335 // engine cannot prove we created it and refuses to take it over.
336 const error = yield* stack
337 .deploy(
338 Cloudflare.Healthcheck.Healthcheck("AdoptedCheck", {
339 zoneId,
340 name: NAME_ADOPT,
341 address: "www.cloudflare.com",
342 interval: 120,
343 }),
344 )
345 .pipe(
346 Effect.as(undefined),
347 Effect.catchCause((cause) => Effect.succeed(findOwnedError(cause))),
348 );
349 expect(error).toBeInstanceOf(OwnedBySomeoneElse);
350
351 // With `adopt(true)`: the engine takes over the pre-existing check
352 // (same physical id) and converges it to the desired props.
353 const adopted = yield* stack.deploy(
354 Effect.gen(function* () {
355 return yield* Cloudflare.Healthcheck.Healthcheck("AdoptedCheck", {
356 zoneId,
357 name: NAME_ADOPT,
358 address: "www.cloudflare.com",
359 interval: 120,
360 }).pipe(adopt(true));
361 }),
362 );
363
364 expect(adopted.healthcheckId).toEqual(pre.id);
365 expect(adopted.interval).toEqual(120);
366
367 const live = yield* getHealthcheck(zoneId, adopted.healthcheckId);
368 expect(live.interval).toEqual(120);
369
370 yield* stack.destroy();
371
372 const gone = yield* findByName(zoneId, NAME_ADOPT);
373 expect(gone).toBeUndefined();
374 }).pipe(logLevel),
375 {
376 tags: [
377 "provider:cloudflare",
378 "provider:cloudflare:healthcheck",
379 "provider:cloudflare:zone",
380 "live",
381 ],
382 },
383);
384
385test.provider(
386 "list enumerates the deployed health check across zones",
387 (stack) =>
388 Effect.gen(function* () {
389 const zoneId = yield* resolveZoneId;
390
391 yield* stack.destroy();
392 yield* purgeByName(zoneId, NAME_LIST);
393
394 const deployed = yield* stack.deploy(
395 Cloudflare.Healthcheck.Healthcheck("ListCheck", {
396 zoneId,
397 name: NAME_LIST,
398 address: "www.cloudflare.com",
399 }),
400 );
401
402 // Resolve the provider with the typed helper so list()'s element type
403 // is exactly the resource's Attributes (no `any`).
404 const provider = yield* Provider.findProvider(
405 Cloudflare.Healthcheck.Healthcheck,
406 );
407 const all = yield* provider.list();
408
409 // The exhaustively-paginated, all-zones result must contain the check we
410 // just deployed in the standing test zone.
411 expect(all.some((h) => h.healthcheckId === deployed.healthcheckId)).toBe(
412 true,
413 );
414 const found = all.find((h) => h.healthcheckId === deployed.healthcheckId);
415 expect(found?.zoneId).toEqual(zoneId);
416 expect(found?.name).toEqual(NAME_LIST);
417 expect(found?.address).toEqual("www.cloudflare.com");
418
419 yield* stack.destroy();
420
421 yield* expectGone(zoneId, deployed.healthcheckId);
422 }).pipe(logLevel),
423 {
424 tags: [
425 "provider:cloudflare",
426 "provider:cloudflare:healthcheck",
427 "provider:cloudflare:zone",
428 "live",
429 ],
430 },
431);
432
433/**
434 * Pull the {@link OwnedBySomeoneElse} value out of a Cause regardless of
435 * whether the engine raised it as a typed failure or a defect.
436 */
437const findOwnedError = (
438 cause: Cause.Cause<unknown>,
439): OwnedBySomeoneElse | undefined =>
440 cause.reasons
441 .map((reason) =>
442 Cause.isFailReason(reason)
443 ? reason.error
444 : Cause.isDieReason(reason)
445 ? reason.defect
446 : undefined,
447 )
448 .find(
449 (value): value is OwnedBySomeoneElse =>
450 value instanceof OwnedBySomeoneElse,
451 );