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 spectrum from "@distilled.cloud/cloudflare/spectrum";
8import * as zones from "@distilled.cloud/cloudflare/zones";
9import { expect } from "alchemy-test";
10import * as Cause from "effect/Cause";
11import * as Effect from "effect/Effect";
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// Spectrum is plan-gated: the standard testing zone (Free plan) rejects
27// every protocol — even `tcp/22` — with Cloudflare code 13002 "The
28// requested protocol is not available", surfaced as the typed
29// `SpectrumProtocolNotAvailable` error. The full lifecycle tests below are
30// gated behind a Spectrum-entitled zone id supplied via env.
31const entitledZoneId = process.env.CLOUDFLARE_TEST_SPECTRUM_ZONE_ID;
32
33const resolveZoneId = Effect.gen(function* () {
34 const { accountId } = yield* yield* CloudflareEnvironment;
35 const zone = yield* findZoneByName({ accountId, name: zoneName });
36 if (!zone) {
37 return yield* Effect.die(
38 new Error(`zone "${zoneName}" not found in account`),
39 );
40 }
41 return zone.id;
42});
43
44// The scoped API token the test harness mints propagates eventually-
45// consistently across Cloudflare's edge — ride out 403 blips on the
46// out-of-band verification calls by retrying the typed `Forbidden` tag.
47const forbiddenRetrySchedule = Schedule.exponential("500 millis");
48
49const getApp = (zoneId: string, appId: string) =>
50 spectrum.getApp({ zoneId, appId }).pipe(
51 Effect.retry({
52 while: (e) => e._tag === "Forbidden",
53 schedule: forbiddenRetrySchedule,
54 times: 8,
55 }),
56 );
57
58const findApp = (zoneId: string, dnsName: string, protocol: string) =>
59 spectrum.listApps.items({ zoneId }).pipe(
60 Stream.filter((a) => a.dns.name === dnsName && a.protocol === protocol),
61 Stream.runCollect,
62 Effect.map((chunk) => Array.from(chunk)[0]),
63 Effect.retry({
64 while: (e) => e._tag === "Forbidden",
65 schedule: forbiddenRetrySchedule,
66 times: 8,
67 }),
68 );
69
70// Delete every app matching (dns.name, protocol) — purges leftovers from
71// interrupted runs so the adoption test starts from a clean slate.
72const purgeApps = (zoneId: string, dnsName: string, protocol: string) =>
73 spectrum.listApps.items({ zoneId }).pipe(
74 Stream.filter((a) => a.dns.name === dnsName && a.protocol === protocol),
75 Stream.runCollect,
76 Effect.flatMap(
77 Effect.forEach((a) =>
78 spectrum
79 .deleteApp({ zoneId, appId: a.id })
80 .pipe(Effect.catchTag("SpectrumAppNotFound", () => Effect.void)),
81 ),
82 ),
83 Effect.retry({
84 while: (e) => e._tag === "Forbidden",
85 schedule: forbiddenRetrySchedule,
86 times: 8,
87 }),
88 );
89
90// Hostname of the entitled zone, resolved from its id so the gated tests
91// can build deterministic edge hostnames inside that zone. (No Forbidden
92// retry here — `zones.getZone`'s typed error union does not include it.)
93const resolveEntitledZoneName = (zoneId: string) =>
94 zones.getZone({ zoneId }).pipe(Effect.map((z) => z.name));
95
96test.provider(
97 "surfaces the typed SpectrumProtocolNotAvailable error on unentitled zones",
98 (stack) =>
99 Effect.gen(function* () {
100 const zoneId = yield* resolveZoneId;
101
102 yield* stack.destroy();
103
104 // The standard testing zone has no Spectrum entitlement — the
105 // distilled create must fail with the typed plan-gate tag.
106 const error = yield* spectrum
107 .createApp({
108 zoneId,
109 dns: { type: "CNAME", name: `alchemy-spectrum-gate.${zoneName}` },
110 protocol: "tcp/22",
111 originDirect: ["tcp://192.0.2.1:22"],
112 })
113 .pipe(
114 Effect.retry({
115 while: (e) => e._tag === "Forbidden",
116 schedule: forbiddenRetrySchedule,
117 times: 8,
118 }),
119 Effect.flip,
120 );
121 expect(error._tag).toEqual("SpectrumProtocolNotAvailable");
122
123 yield* stack.destroy();
124 }).pipe(logLevel),
125 {
126 tags: [
127 "provider:cloudflare",
128 "provider:cloudflare:spectrum",
129 "provider:cloudflare:zone",
130 "live",
131 ],
132 },
133);
134
135test.provider.skipIf(!entitledZoneId)(
136 "create, update in place, and destroy a tcp/22 application",
137 (stack) =>
138 Effect.gen(function* () {
139 const zoneId = entitledZoneId!;
140 const entitledZoneName = yield* resolveEntitledZoneName(zoneId);
141 const dnsName = `alchemy-spectrum-lifecycle.${entitledZoneName}`;
142
143 yield* stack.destroy();
144 yield* purgeApps(zoneId, dnsName, "tcp/22");
145
146 const app = yield* stack.deploy(
147 Effect.gen(function* () {
148 return yield* Cloudflare.Spectrum.Application("Ssh", {
149 zoneId,
150 dns: { type: "CNAME", name: dnsName },
151 protocol: "tcp/22",
152 originDirect: ["tcp://192.0.2.1:22"],
153 }).pipe(adopt(true));
154 }),
155 );
156
157 expect(app.appId).toBeDefined();
158 expect(app.zoneId).toEqual(zoneId);
159 expect(app.dnsName).toEqual(dnsName);
160 expect(app.protocol).toEqual("tcp/22");
161 expect(app.originDirect).toEqual(["tcp://192.0.2.1:22"]);
162 expect(app.ipFirewall).toEqual(false);
163
164 // Out-of-band verification via the distilled API.
165 const live = yield* getApp(zoneId, app.appId);
166 expect(live.id).toEqual(app.appId);
167 expect(live.dns.name).toEqual(dnsName);
168 expect(live.protocol).toEqual("tcp/22");
169
170 // Update in place: new origin address + ipFirewall on. Same appId —
171 // not a replacement.
172 const updated = yield* stack.deploy(
173 Effect.gen(function* () {
174 return yield* Cloudflare.Spectrum.Application("Ssh", {
175 zoneId,
176 dns: { type: "CNAME", name: dnsName },
177 protocol: "tcp/22",
178 originDirect: ["tcp://192.0.2.2:22"],
179 ipFirewall: true,
180 }).pipe(adopt(true));
181 }),
182 );
183
184 expect(updated.appId).toEqual(app.appId);
185 expect(updated.originDirect).toEqual(["tcp://192.0.2.2:22"]);
186 expect(updated.ipFirewall).toEqual(true);
187
188 const converged = yield* getApp(zoneId, updated.appId);
189 expect(converged.originDirect).toEqual(["tcp://192.0.2.2:22"]);
190
191 yield* stack.destroy();
192
193 // Destroy removed the application (and its Spectrum-managed edge
194 // DNS record with it).
195 const gone = yield* findApp(zoneId, dnsName, "tcp/22");
196 expect(gone).toBeUndefined();
197 }).pipe(logLevel),
198 {
199 tags: [
200 "provider:cloudflare",
201 "provider:cloudflare:spectrum",
202 "provider:cloudflare:zone",
203 "live",
204 ],
205 timeout: 120_000,
206 },
207);
208
209test.provider.skipIf(!entitledZoneId)(
210 "adoption — existing app errors without adopt, takes over with adopt(true)",
211 (stack) =>
212 Effect.gen(function* () {
213 const zoneId = entitledZoneId!;
214 const entitledZoneName = yield* resolveEntitledZoneName(zoneId);
215 const dnsName = `alchemy-spectrum-adopt.${entitledZoneName}`;
216
217 yield* stack.destroy();
218 yield* purgeApps(zoneId, dnsName, "tcp/22");
219
220 // Create the application out-of-band so the stack has no state of
221 // its own for it — exactly the "the app already exists" scenario.
222 const pre = yield* spectrum
223 .createApp({
224 zoneId,
225 dns: { type: "CNAME", name: dnsName },
226 protocol: "tcp/22",
227 originDirect: ["tcp://192.0.2.10:22"],
228 })
229 .pipe(
230 Effect.retry({
231 while: (e) => e._tag === "Forbidden",
232 schedule: forbiddenRetrySchedule,
233 times: 8,
234 }),
235 );
236 expect(pre.id).toBeDefined();
237
238 // Without `adopt`: Spectrum apps carry no ownership markers, so the
239 // engine cannot prove we created it and refuses to take it over.
240 const error = yield* stack
241 .deploy(
242 Effect.gen(function* () {
243 return yield* Cloudflare.Spectrum.Application("Adopted", {
244 zoneId,
245 dns: { type: "CNAME", name: dnsName },
246 protocol: "tcp/22",
247 originDirect: ["tcp://192.0.2.11:22"],
248 });
249 }),
250 )
251 .pipe(
252 Effect.as(undefined),
253 Effect.catchCause((cause) => Effect.succeed(findOwnedError(cause))),
254 );
255 expect(error).toBeInstanceOf(OwnedBySomeoneElse);
256
257 // With `adopt(true)`: the engine takes over the pre-existing app
258 // (same physical id) and converges it to the desired origin.
259 const adopted = yield* stack.deploy(
260 Effect.gen(function* () {
261 return yield* Cloudflare.Spectrum.Application("Adopted", {
262 zoneId,
263 dns: { type: "CNAME", name: dnsName },
264 protocol: "tcp/22",
265 originDirect: ["tcp://192.0.2.11:22"],
266 }).pipe(adopt(true));
267 }),
268 );
269
270 expect(adopted.appId).toEqual(pre.id);
271 expect(adopted.originDirect).toEqual(["tcp://192.0.2.11:22"]);
272
273 const live = yield* getApp(zoneId, adopted.appId);
274 expect(live.originDirect).toEqual(["tcp://192.0.2.11:22"]);
275
276 yield* stack.destroy();
277
278 const gone = yield* findApp(zoneId, dnsName, "tcp/22");
279 expect(gone).toBeUndefined();
280 }).pipe(logLevel),
281 {
282 tags: [
283 "provider:cloudflare",
284 "provider:cloudflare:spectrum",
285 "provider:cloudflare:zone",
286 "live",
287 ],
288 timeout: 120_000,
289 },
290);
291
292// Canonical `list()` test (zone-scoped collection): `list()` fans out over
293// every zone via `listAllZones`, exhaustively paginates the Spectrum apps in
294// each, and hydrates them into the exact `read` Attributes shape. Listing is
295// not entitlement-gated — the standing test zone (no Spectrum) simply yields
296// no apps for that zone — so this read-only assertion runs unconditionally:
297// `list()` must resolve to a well-typed array without throwing.
298test.provider(
299 "list enumerates Spectrum applications across all zones",
300 (stack) =>
301 Effect.gen(function* () {
302 yield* stack.destroy();
303
304 const provider = yield* Provider.findProvider(
305 Cloudflare.Spectrum.Application,
306 );
307 const all = yield* provider.list();
308
309 expect(Array.isArray(all)).toBe(true);
310 // Every hydrated item carries the full `read` Attributes shape.
311 for (const app of all) {
312 expect(typeof app.appId).toBe("string");
313 expect(typeof app.zoneId).toBe("string");
314 expect(typeof app.protocol).toBe("string");
315 }
316
317 yield* stack.destroy();
318 }).pipe(logLevel),
319 { tags: ["provider:cloudflare", "provider:cloudflare:spectrum", "live"] },
320);
321
322// Full deploy + list assertion: requires a Spectrum-entitled zone (create is
323// gated by `SpectrumProtocolNotAvailable`, Cloudflare code 13002, on
324// unentitled zones). When supplied, deploy a tcp/22 app and assert `list()`
325// surfaces it.
326test.provider.skipIf(!entitledZoneId)(
327 "list surfaces a deployed Spectrum application (entitled zone)",
328 (stack) =>
329 Effect.gen(function* () {
330 const zoneId = entitledZoneId!;
331 const entitledZoneName = yield* resolveEntitledZoneName(zoneId);
332 const dnsName = `alchemy-spectrum-list.${entitledZoneName}`;
333
334 yield* stack.destroy();
335 yield* purgeApps(zoneId, dnsName, "tcp/22");
336
337 const app = yield* stack.deploy(
338 Effect.gen(function* () {
339 return yield* Cloudflare.Spectrum.Application("Listed", {
340 zoneId,
341 dns: { type: "CNAME", name: dnsName },
342 protocol: "tcp/22",
343 originDirect: ["tcp://192.0.2.1:22"],
344 }).pipe(adopt(true));
345 }),
346 );
347
348 const provider = yield* Provider.findProvider(
349 Cloudflare.Spectrum.Application,
350 );
351 const all = yield* provider.list();
352
353 expect(all.some((a) => a.appId === app.appId)).toBe(true);
354
355 yield* stack.destroy();
356 }).pipe(logLevel),
357 {
358 tags: [
359 "provider:cloudflare",
360 "provider:cloudflare:spectrum",
361 "provider:cloudflare:zone",
362 "live",
363 ],
364 timeout: 120_000,
365 },
366);
367
368/**
369 * Pull the {@link OwnedBySomeoneElse} value out of a Cause regardless of
370 * whether the engine raised it as a typed failure or a defect.
371 */
372const findOwnedError = (
373 cause: Cause.Cause<unknown>,
374): OwnedBySomeoneElse | undefined =>
375 cause.reasons
376 .map((reason) =>
377 Cause.isFailReason(reason)
378 ? reason.error
379 : Cause.isDieReason(reason)
380 ? reason.defect
381 : undefined,
382 )
383 .find(
384 (value): value is OwnedBySomeoneElse =>
385 value instanceof OwnedBySomeoneElse,
386 );