| 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 emailSending from "@distilled.cloud/cloudflare/email-sending"; |
| 8 | import { expect } from "alchemy-test"; |
| 9 | import * as Cause from "effect/Cause"; |
| 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as Option from "effect/Option"; |
| 12 | import { MinimumLogLevel } from "effect/References"; |
| 13 | import * as Schedule from "effect/Schedule"; |
| 14 | import * as Stream from "effect/Stream"; |
| 15 | import { emailRoutingScoped } from "./scope.ts"; |
| 16 | |
| 17 | const { test } = Test.make({ providers: Cloudflare.providers() }); |
| 18 | |
| 19 | const logLevel = Effect.provideService( |
| 20 | MinimumLogLevel, |
| 21 | process.env.DEBUG ? "Debug" : "Info", |
| 22 | ); |
| 23 | |
| 24 | const zoneName = |
| 25 | process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us"; |
| 26 | |
| 27 | // Deterministic per-test subdomain names. Each test owns a disjoint |
| 28 | // subdomain so reruns and parallel runs never collide, and the same name is |
| 29 | // reused on every run (never derive physical names from Date.now()/random). |
| 30 | const NAME_DEFAULT = `alchemy-sendsub-default.${zoneName}`; |
| 31 | const NAME_REPLACE_A = `alchemy-sendsub-replace-a.${zoneName}`; |
| 32 | const NAME_REPLACE_B = `alchemy-sendsub-replace-b.${zoneName}`; |
| 33 | const NAME_ADOPT = `alchemy-sendsub-adopt.${zoneName}`; |
| 34 | const NAME_LIST = `alchemy-sendsub-list.${zoneName}`; |
| 35 | |
| 36 | const resolveZoneId = Effect.gen(function* () { |
| 37 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 38 | const zone = yield* findZoneByName({ accountId, name: zoneName }); |
| 39 | if (!zone) { |
| 40 | return yield* Effect.die( |
| 41 | new Error(`zone "${zoneName}" not found in account`), |
| 42 | ); |
| 43 | } |
| 44 | return zone.id; |
| 45 | }); |
| 46 | |
| 47 | // The scoped API token the test harness mints propagates eventually- |
| 48 | // consistently across Cloudflare's edge — a fresh token intermittently 403s |
| 49 | // with "Unable to authenticate request". Ride out the blips on the test's |
| 50 | // own out-of-band verification calls by retrying the typed `Forbidden` |
| 51 | // error (part of each email-sending operation's error union via distilled |
| 52 | // patches). |
| 53 | const forbiddenRetrySchedule = Schedule.exponential("500 millis"); |
| 54 | |
| 55 | const findByName = (zoneId: string, name: string) => |
| 56 | emailSending.listSubdomains.items({ zoneId }).pipe( |
| 57 | Stream.filter((s) => s.name === name), |
| 58 | Stream.runHead, |
| 59 | Effect.map(Option.getOrUndefined), |
| 60 | Effect.retry({ |
| 61 | while: (e) => e._tag === "Forbidden", |
| 62 | schedule: forbiddenRetrySchedule, |
| 63 | times: 8, |
| 64 | }), |
| 65 | ); |
| 66 | |
| 67 | const getSubdomain = (zoneId: string, subdomainId: string) => |
| 68 | emailSending.getSubdomain({ zoneId, subdomainId }).pipe( |
| 69 | Effect.retry({ |
| 70 | while: (e) => e._tag === "Forbidden", |
| 71 | schedule: forbiddenRetrySchedule, |
| 72 | times: 8, |
| 73 | }), |
| 74 | ); |
| 75 | |
| 76 | // Delete any subdomain matching `name` — used to purge leftovers from |
| 77 | // interrupted runs so tests start from a clean slate. |
| 78 | const purgeSubdomain = (zoneId: string, name: string) => |
| 79 | findByName(zoneId, name).pipe( |
| 80 | Effect.flatMap((existing) => |
| 81 | existing |
| 82 | ? emailSending |
| 83 | .deleteSubdomain({ zoneId, subdomainId: existing.tag }) |
| 84 | .pipe( |
| 85 | Effect.catchTag("SendingSubdomainNotFound", () => Effect.void), |
| 86 | Effect.retry({ |
| 87 | while: (e) => e._tag === "Forbidden", |
| 88 | schedule: forbiddenRetrySchedule, |
| 89 | times: 8, |
| 90 | }), |
| 91 | ) |
| 92 | : Effect.void, |
| 93 | ), |
| 94 | ); |
| 95 | |
| 96 | test.provider.skipIf(!emailRoutingScoped)( |
| 97 | "create and destroy a sending subdomain", |
| 98 | (stack) => |
| 99 | Effect.gen(function* () { |
| 100 | const zoneId = yield* resolveZoneId; |
| 101 | |
| 102 | yield* stack.destroy(); |
| 103 | yield* purgeSubdomain(zoneId, NAME_DEFAULT); |
| 104 | |
| 105 | const sending = yield* stack.deploy( |
| 106 | Effect.gen(function* () { |
| 107 | return yield* Cloudflare.Email.SendingSubdomain("Sending", { |
| 108 | zoneId, |
| 109 | name: NAME_DEFAULT, |
| 110 | }); |
| 111 | }), |
| 112 | ); |
| 113 | |
| 114 | expect(sending.subdomainId).toBeDefined(); |
| 115 | expect(sending.zoneId).toEqual(zoneId); |
| 116 | expect(sending.name).toEqual(NAME_DEFAULT); |
| 117 | // CF-hosted zone — DNS records are auto-created and validate |
| 118 | // immediately; the reconciler polls briefly for `enabled`. |
| 119 | expect(sending.enabled).toEqual(true); |
| 120 | expect(sending.dkimSelector).toBeDefined(); |
| 121 | expect(sending.returnPathDomain).toContain(NAME_DEFAULT); |
| 122 | |
| 123 | // Out-of-band verification against the live API. |
| 124 | const live = yield* getSubdomain(zoneId, sending.subdomainId); |
| 125 | expect(live.tag).toEqual(sending.subdomainId); |
| 126 | expect(live.name).toEqual(NAME_DEFAULT); |
| 127 | |
| 128 | yield* stack.destroy(); |
| 129 | |
| 130 | // Gone after destroy — the typed not-found is the success signal. |
| 131 | const gone = yield* getSubdomain(zoneId, sending.subdomainId).pipe( |
| 132 | Effect.map(() => "still-there" as const), |
| 133 | Effect.catchTag("SendingSubdomainNotFound", () => |
| 134 | Effect.succeed("gone" as const), |
| 135 | ), |
| 136 | ); |
| 137 | expect(gone).toEqual("gone"); |
| 138 | |
| 139 | // Destroy again — delete is idempotent. |
| 140 | yield* stack.destroy(); |
| 141 | }).pipe(logLevel), |
| 142 | { |
| 143 | tags: [ |
| 144 | "provider:cloudflare", |
| 145 | "provider:cloudflare:email", |
| 146 | "provider:cloudflare:zone", |
| 147 | "live", |
| 148 | ], |
| 149 | }, |
| 150 | ); |
| 151 | |
| 152 | test.provider.skipIf(!emailRoutingScoped)( |
| 153 | "changing the name triggers replacement", |
| 154 | (stack) => |
| 155 | Effect.gen(function* () { |
| 156 | const zoneId = yield* resolveZoneId; |
| 157 | |
| 158 | yield* stack.destroy(); |
| 159 | yield* purgeSubdomain(zoneId, NAME_REPLACE_A); |
| 160 | yield* purgeSubdomain(zoneId, NAME_REPLACE_B); |
| 161 | |
| 162 | const initial = yield* stack.deploy( |
| 163 | Effect.gen(function* () { |
| 164 | return yield* Cloudflare.Email.SendingSubdomain("ReplaceSending", { |
| 165 | zoneId, |
| 166 | name: NAME_REPLACE_A, |
| 167 | }); |
| 168 | }), |
| 169 | ); |
| 170 | |
| 171 | expect(initial.name).toEqual(NAME_REPLACE_A); |
| 172 | |
| 173 | const replaced = yield* stack.deploy( |
| 174 | Effect.gen(function* () { |
| 175 | return yield* Cloudflare.Email.SendingSubdomain("ReplaceSending", { |
| 176 | zoneId, |
| 177 | name: NAME_REPLACE_B, |
| 178 | }); |
| 179 | }), |
| 180 | ); |
| 181 | |
| 182 | // The name is the subdomain's identity — a new physical resource. |
| 183 | expect(replaced.subdomainId).not.toEqual(initial.subdomainId); |
| 184 | expect(replaced.name).toEqual(NAME_REPLACE_B); |
| 185 | |
| 186 | // The old subdomain was deleted as part of the replacement. |
| 187 | const old = yield* findByName(zoneId, NAME_REPLACE_A); |
| 188 | expect(old).toBeUndefined(); |
| 189 | |
| 190 | const live = yield* findByName(zoneId, NAME_REPLACE_B); |
| 191 | expect(live?.tag).toEqual(replaced.subdomainId); |
| 192 | |
| 193 | yield* stack.destroy(); |
| 194 | |
| 195 | const gone = yield* findByName(zoneId, NAME_REPLACE_B); |
| 196 | expect(gone).toBeUndefined(); |
| 197 | }).pipe(logLevel), |
| 198 | { |
| 199 | tags: [ |
| 200 | "provider:cloudflare", |
| 201 | "provider:cloudflare:email", |
| 202 | "provider:cloudflare:zone", |
| 203 | "live", |
| 204 | ], |
| 205 | }, |
| 206 | ); |
| 207 | |
| 208 | test.provider.skipIf(!emailRoutingScoped)( |
| 209 | "adoption — existing subdomain errors without adopt, takes over with adopt(true)", |
| 210 | (stack) => |
| 211 | Effect.gen(function* () { |
| 212 | const zoneId = yield* resolveZoneId; |
| 213 | |
| 214 | yield* stack.destroy(); |
| 215 | yield* purgeSubdomain(zoneId, NAME_ADOPT); |
| 216 | |
| 217 | // Create the subdomain out-of-band so the stack has no state of its |
| 218 | // own for it — exactly the "already exists" scenario. |
| 219 | const pre = yield* emailSending |
| 220 | .createSubdomain({ zoneId, name: NAME_ADOPT }) |
| 221 | .pipe( |
| 222 | Effect.retry({ |
| 223 | while: (e) => e._tag === "Forbidden", |
| 224 | schedule: forbiddenRetrySchedule, |
| 225 | times: 8, |
| 226 | }), |
| 227 | ); |
| 228 | expect(pre.tag).toBeDefined(); |
| 229 | |
| 230 | // Without `adopt`: sending subdomains carry no ownership markers, so |
| 231 | // the engine cannot prove we created it and refuses to take it over. |
| 232 | const error = yield* stack |
| 233 | .deploy( |
| 234 | Effect.gen(function* () { |
| 235 | return yield* Cloudflare.Email.SendingSubdomain("AdoptSending", { |
| 236 | zoneId, |
| 237 | name: NAME_ADOPT, |
| 238 | }); |
| 239 | }), |
| 240 | ) |
| 241 | .pipe( |
| 242 | Effect.as(undefined), |
| 243 | Effect.catchCause((cause) => Effect.succeed(findOwnedError(cause))), |
| 244 | ); |
| 245 | expect(error).toBeInstanceOf(OwnedBySomeoneElse); |
| 246 | |
| 247 | // With adopt(true): the engine takes over the existing subdomain |
| 248 | // instead of failing. |
| 249 | const adopted = yield* stack.deploy( |
| 250 | Effect.gen(function* () { |
| 251 | return yield* Cloudflare.Email.SendingSubdomain("AdoptSending", { |
| 252 | zoneId, |
| 253 | name: NAME_ADOPT, |
| 254 | }).pipe(adopt(true)); |
| 255 | }), |
| 256 | ); |
| 257 | expect(adopted.subdomainId).toEqual(pre.tag); |
| 258 | expect(adopted.name).toEqual(NAME_ADOPT); |
| 259 | |
| 260 | yield* stack.destroy(); |
| 261 | |
| 262 | const gone = yield* findByName(zoneId, NAME_ADOPT); |
| 263 | expect(gone).toBeUndefined(); |
| 264 | }).pipe(logLevel), |
| 265 | { |
| 266 | tags: [ |
| 267 | "provider:cloudflare", |
| 268 | "provider:cloudflare:email", |
| 269 | "provider:cloudflare:zone", |
| 270 | "live", |
| 271 | ], |
| 272 | timeout: 180_000, |
| 273 | }, |
| 274 | ); |
| 275 | |
| 276 | test.provider.skipIf(!emailRoutingScoped)( |
| 277 | "list enumerates the deployed sending subdomain", |
| 278 | (stack) => |
| 279 | Effect.gen(function* () { |
| 280 | const zoneId = yield* resolveZoneId; |
| 281 | |
| 282 | yield* stack.destroy(); |
| 283 | yield* purgeSubdomain(zoneId, NAME_LIST); |
| 284 | |
| 285 | const sending = yield* stack.deploy( |
| 286 | Effect.gen(function* () { |
| 287 | return yield* Cloudflare.Email.SendingSubdomain("ListSending", { |
| 288 | zoneId, |
| 289 | name: NAME_LIST, |
| 290 | }); |
| 291 | }), |
| 292 | ); |
| 293 | |
| 294 | const provider = yield* Provider.findProvider( |
| 295 | Cloudflare.Email.SendingSubdomain, |
| 296 | ); |
| 297 | // The scoped token may still be propagating across the edge; ride out |
| 298 | // the typed Forbidden blips on the enumeration itself. |
| 299 | const all = yield* provider.list().pipe( |
| 300 | Effect.retry({ |
| 301 | while: (e) => e._tag === "Forbidden", |
| 302 | schedule: forbiddenRetrySchedule, |
| 303 | times: 8, |
| 304 | }), |
| 305 | ); |
| 306 | |
| 307 | // The deployed subdomain is present, with the full read Attributes shape. |
| 308 | const found = all.find((s) => s.subdomainId === sending.subdomainId); |
| 309 | expect(found).toBeDefined(); |
| 310 | expect(found?.zoneId).toEqual(zoneId); |
| 311 | expect(found?.name).toEqual(NAME_LIST); |
| 312 | |
| 313 | yield* stack.destroy(); |
| 314 | |
| 315 | const gone = yield* findByName(zoneId, NAME_LIST); |
| 316 | expect(gone).toBeUndefined(); |
| 317 | }).pipe(logLevel), |
| 318 | { |
| 319 | tags: [ |
| 320 | "provider:cloudflare", |
| 321 | "provider:cloudflare:email", |
| 322 | "provider:cloudflare:zone", |
| 323 | "live", |
| 324 | ], |
| 325 | timeout: 180_000, |
| 326 | }, |
| 327 | ); |
| 328 | |
| 329 | /** |
| 330 | * Pull the {@link OwnedBySomeoneElse} value out of a Cause regardless of |
| 331 | * whether the engine raised it as a typed failure or a defect. |
| 332 | */ |
| 333 | const findOwnedError = ( |
| 334 | cause: Cause.Cause<unknown>, |
| 335 | ): OwnedBySomeoneElse | undefined => |
| 336 | cause.reasons |
| 337 | .map((reason) => |
| 338 | Cause.isFailReason(reason) |
| 339 | ? reason.error |
| 340 | : Cause.isDieReason(reason) |
| 341 | ? reason.defect |
| 342 | : undefined, |
| 343 | ) |
| 344 | .find( |
| 345 | (value): value is OwnedBySomeoneElse => |
| 346 | value instanceof OwnedBySomeoneElse, |
| 347 | ); |