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 { destroy } from "@/RemovalPolicy";
7import * as Test from "@/Test/Alchemy";
8import * as zones from "@distilled.cloud/cloudflare/zones";
9import { expect } from "alchemy-test";
10import * as Cause from "effect/Cause";
11import * as Data from "effect/Data";
12import * as Effect from "effect/Effect";
13import * as Predicate from "effect/Predicate";
14import * as Schedule from "effect/Schedule";
15
16const { test } = Test.make({ providers: Cloudflare.providers() });
17
18// Cloudflare intermittently blocks *all* zone creation on an account with
19// error 1052 ("An error(zone setup) has occurred and it has been logged");
20// the block is account-level and can persist for hours. The zone-provisioning
21// tests below create fresh zones, so soft-skip them while the account is in
22// that state; every other failure propagates unchanged.
23const softSkipWhenZoneCreationBlocked = <A, E, R>(
24 effect: Effect.Effect<A, E, R>,
25): Effect.Effect<A | undefined, E, R> =>
26 effect.pipe(
27 Effect.catchCause((cause) => {
28 // Match by tag rather than `instanceof zones.ZoneCreationBlocked`: the
29 // error can be constructed inside the provider's copy of the distilled
30 // module, so `instanceof` across that module boundary can be false.
31 const blocked = cause.reasons
32 .map((reason) =>
33 Cause.isFailReason(reason)
34 ? reason.error
35 : Cause.isDieReason(reason)
36 ? reason.defect
37 : undefined,
38 )
39 .some(
40 (value) =>
41 Predicate.hasProperty(value, "_tag") &&
42 value._tag === "ZoneCreationBlocked",
43 );
44 return blocked
45 ? Effect.logWarning(
46 "Cloudflare has zone creation blocked on this account (error 1052) — skipping zone-provisioning assertions",
47 ).pipe(Effect.as(undefined))
48 : Effect.failCause(cause);
49 }),
50 );
51
52// Cloudflare's POST /zones rejects reserved pseudo-TLDs (`.test`, `.local`,
53// `.example`) with "unable to identify ... as a registered domain". A
54// syntactically-valid, registerable name is accepted into a `pending` zone
55// even when the domain isn't actually registered to us — which is all these
56// create/delete lifecycle tests need. Derive the name from the test account id
57// so it's deterministic and never collides with a real zone.
58const zoneNameFor = (accountId: string, label: string) =>
59 process.env.TEST_ZONE_NAME ?? `alchemy-${label}-${accountId}.com`;
60
61test.provider.skipIf(!!process.env.FAST)(
62 "create zone retains by default — destroy() opts in to deletion",
63 (stack) =>
64 softSkipWhenZoneCreationBlocked(
65 Effect.gen(function* () {
66 const { accountId } = yield* yield* CloudflareEnvironment;
67 const TEST_ZONE = zoneNameFor(accountId, "destroy");
68
69 const zone = yield* stack.deploy(
70 Effect.gen(function* () {
71 return yield* Cloudflare.Zone.Zone("CreatedZone", {
72 name: TEST_ZONE,
73 }).pipe(destroy());
74 }),
75 );
76
77 expect(zone.name).toBe(TEST_ZONE);
78 expect(zone.accountId).toBe(accountId);
79
80 // The mapped attributes should match what the Cloudflare API reports,
81 // with the API's `null`s normalized to `undefined`.
82 const live = yield* zones.getZone({ zoneId: zone.zoneId });
83 expect(zone.zoneId).toBe(live.id);
84 expect(zone.name).toBe(live.name);
85 expect(zone.accountName).toBe(live.account.name ?? undefined);
86 expect(zone.type).toBe(live.type ?? "full");
87 expect(zone.status).toBe(live.status ?? undefined);
88 expect(zone.paused).toBe(live.paused ?? false);
89 expect(zone.nameServers).toEqual(live.nameServers);
90 expect(zone.originalNameServers).toEqual(
91 live.originalNameServers ?? undefined,
92 );
93 expect(zone.vanityNameServers).toEqual(
94 live.vanityNameServers ?? undefined,
95 );
96 expect(zone.activatedOn).toBe(live.activatedOn ?? undefined);
97 expect(zone.createdOn).toBe(live.createdOn);
98 expect(zone.modifiedOn).toBe(live.modifiedOn);
99 expect(zone.developmentMode).toBe(live.developmentMode);
100 expect(zone.originalDnshost).toBe(live.originalDnshost ?? undefined);
101 expect(zone.originalRegistrar).toBe(
102 live.originalRegistrar ?? undefined,
103 );
104 expect(zone.cnameSuffix).toBe(live.cnameSuffix ?? undefined);
105 expect(zone.verificationKey).toBe(live.verificationKey ?? undefined);
106 expect(zone.owner.id).toBe(live.owner.id ?? undefined);
107 expect(zone.meta.foundationDns).toBe(
108 live.meta.foundationDns ?? undefined,
109 );
110
111 yield* stack.destroy();
112
113 yield* waitForZoneToBeDeleted(zone.zoneId);
114 }),
115 ),
116 { tags: ["provider:cloudflare", "provider:cloudflare:zone", "live"] },
117);
118
119test.provider.skipIf(!!process.env.FAST)(
120 "create zone retains by default — survives stack.destroy()",
121 (stack) =>
122 softSkipWhenZoneCreationBlocked(
123 Effect.gen(function* () {
124 const { accountId } = yield* yield* CloudflareEnvironment;
125 const TEST_ZONE = zoneNameFor(accountId, "retain");
126
127 const zone = yield* stack.deploy(
128 Effect.gen(function* () {
129 return yield* Cloudflare.Zone.Zone("RetainedZone", {
130 name: TEST_ZONE,
131 });
132 }),
133 );
134
135 expect(zone.name).toBe(TEST_ZONE);
136 expect(zone.accountId).toBe(accountId);
137
138 yield* stack.destroy();
139
140 const live = yield* zones.getZone({ zoneId: zone.zoneId });
141 expect(live.id).toBe(zone.zoneId);
142
143 // clean up the retained zone so the test is repeatable
144 yield* zones.deleteZone({ zoneId: zone.zoneId });
145 yield* waitForZoneToBeDeleted(zone.zoneId);
146 }),
147 ),
148 { tags: ["provider:cloudflare", "provider:cloudflare:zone", "live"] },
149);
150
151test.provider.skipIf(!!process.env.FAST)(
152 "adoption — existing zone errors without adopt, takes over with adopt(true)",
153 (stack) =>
154 softSkipWhenZoneCreationBlocked(
155 Effect.gen(function* () {
156 const { accountId } = yield* yield* CloudflareEnvironment;
157 const TEST_ZONE = zoneNameFor(accountId, "adopt");
158
159 // Create the zone out-of-band so the stack has no state of its own for
160 // it — exactly the "the zone already exists" scenario. Tolerate a zone
161 // left behind by an interrupted run so the test stays repeatable.
162 const existing = yield* zones
163 .createZone({
164 account: { id: accountId },
165 name: TEST_ZONE,
166 type: "full",
167 })
168 .pipe(
169 Effect.catchTag("ZoneAlreadyExists", () =>
170 findZoneByName({ accountId, name: TEST_ZONE }).pipe(
171 Effect.flatMap((match) =>
172 match
173 ? Effect.succeed(match)
174 : Effect.die(new Error(`zone ${TEST_ZONE} not found`)),
175 ),
176 ),
177 ),
178 );
179
180 // Without `adopt`: a Cloudflare zone carries no ownership markers, so the
181 // engine cannot prove we created it and refuses to take it over. The
182 // engine surfaces this as a defect, so catch the whole cause and pull the
183 // typed error back out rather than string-matching the message.
184 const error = yield* stack
185 .deploy(
186 Effect.gen(function* () {
187 return yield* Cloudflare.Zone.Zone("AdoptedZone", {
188 name: TEST_ZONE,
189 });
190 }),
191 )
192 .pipe(
193 Effect.as(undefined),
194 Effect.catchCause((cause) => Effect.succeed(findOwnedError(cause))),
195 );
196 expect(error).toBeInstanceOf(OwnedBySomeoneElse);
197
198 // With `adopt(true)`: the engine takes over the pre-existing zone instead
199 // of creating a new one. `destroy()` opts the adopted zone into deletion
200 // on teardown so the test stays repeatable.
201 const adopted = yield* stack
202 .deploy(
203 Effect.gen(function* () {
204 return yield* Cloudflare.Zone.Zone("AdoptedZone", {
205 name: TEST_ZONE,
206 }).pipe(destroy());
207 }),
208 )
209 .pipe(adopt(true));
210 expect(adopted.zoneId).toBe(existing.id);
211 expect(adopted.name).toBe(TEST_ZONE);
212
213 yield* stack.destroy();
214 yield* waitForZoneToBeDeleted(existing.id);
215 }),
216 ),
217 { tags: ["provider:cloudflare", "provider:cloudflare:zone", "live"] },
218);
219
220// Standing test zone — always present in the testing account.
221const TEST_ZONE_NAME = "alchemy-test-2.us";
222
223test.provider.skipIf(!!process.env.FAST)(
224 "list enumerates every zone in the account",
225 (stack) =>
226 Effect.gen(function* () {
227 yield* stack.destroy();
228
229 const { accountId } = yield* yield* CloudflareEnvironment;
230 const testZone = yield* findZoneByName({
231 accountId,
232 name: TEST_ZONE_NAME,
233 });
234 expect(testZone).toBeDefined();
235
236 const provider = yield* Provider.findProvider(Cloudflare.Zone.Zone);
237 const all = yield* provider.list();
238
239 // Exhaustive enumeration must include the standing test zone, returned in
240 // the full `read` Attributes shape.
241 const found = all.find((z) => z.zoneId === testZone!.id);
242 expect(found).toBeDefined();
243 expect(found!.name).toBe(TEST_ZONE_NAME);
244 expect(found!.accountId).toBe(accountId);
245 expect(typeof found!.createdOn).toBe("string");
246 expect(Array.isArray(found!.nameServers)).toBe(true);
247
248 yield* stack.destroy();
249 }),
250 { tags: ["provider:cloudflare", "provider:cloudflare:zone", "live"] },
251);
252
253const waitForZoneToBeDeleted = Effect.fn(function* (zoneId: string) {
254 yield* zones.getZone({ zoneId }).pipe(
255 // A successful read means the zone is still around — force a retry.
256 Effect.flatMap(() => new ZoneStillExists()),
257 // Any other failure (e.g. `Invalid zone identifier` / 404) means the zone
258 // is gone, which is exactly what we're waiting for.
259 Effect.catch((e) =>
260 e instanceof ZoneStillExists ? Effect.fail(e) : Effect.void,
261 ),
262 Effect.retry({
263 while: (e): e is ZoneStillExists => e instanceof ZoneStillExists,
264 schedule: Schedule.exponential(100),
265 times: 20,
266 }),
267 );
268});
269
270class ZoneStillExists extends Data.TaggedError("ZoneStillExists") {}
271
272/**
273 * Pull the {@link OwnedBySomeoneElse} value out of a Cause regardless of
274 * whether the engine raised it as a typed failure or a defect.
275 */
276const findOwnedError = (
277 cause: Cause.Cause<unknown>,
278): OwnedBySomeoneElse | undefined =>
279 cause.reasons
280 .map((reason) =>
281 Cause.isFailReason(reason)
282 ? reason.error
283 : Cause.isDieReason(reason)
284 ? reason.defect
285 : undefined,
286 )
287 .find(
288 (value): value is OwnedBySomeoneElse =>
289 value instanceof OwnedBySomeoneElse,
290 );