| 1 | import { adopt, OwnedBySomeoneElse } from "@/AdoptPolicy"; |
| 2 | import * as Cloudflare from "@/Cloudflare"; |
| 3 | import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; |
| 4 | import { findZoneByName } from "@/Cloudflare/Zone/lookup"; |
| 5 | import * as Provider from "@/Provider"; |
| 6 | import * as Test from "@/Test/Alchemy"; |
| 7 | import * as spectrum from "@distilled.cloud/cloudflare/spectrum"; |
| 8 | import * as zones from "@distilled.cloud/cloudflare/zones"; |
| 9 | import { expect } from "alchemy-test"; |
| 10 | import * as Cause from "effect/Cause"; |
| 11 | import * as Effect from "effect/Effect"; |
| 12 | import { MinimumLogLevel } from "effect/References"; |
| 13 | import * as Schedule from "effect/Schedule"; |
| 14 | import * as Stream from "effect/Stream"; |
| 15 | |
| 16 | const { test } = Test.make({ providers: Cloudflare.providers() }); |
| 17 | |
| 18 | const logLevel = Effect.provideService( |
| 19 | MinimumLogLevel, |
| 20 | process.env.DEBUG ? "Debug" : "Info", |
| 21 | ); |
| 22 | |
| 23 | const 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. |
| 31 | const entitledZoneId = process.env.CLOUDFLARE_TEST_SPECTRUM_ZONE_ID; |
| 32 | |
| 33 | const 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. |
| 47 | const forbiddenRetrySchedule = Schedule.exponential("500 millis"); |
| 48 | |
| 49 | const 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 | |
| 58 | const 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. |
| 72 | const 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.) |
| 93 | const resolveEntitledZoneName = (zoneId: string) => |
| 94 | zones.getZone({ zoneId }).pipe(Effect.map((z) => z.name)); |
| 95 | |
| 96 | test.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 | |
| 135 | test.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 | |
| 209 | test.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. |
| 298 | test.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. |
| 326 | test.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 | */ |
| 372 | const 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 | ); |