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 emailSending from "@distilled.cloud/cloudflare/email-sending";
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";
15import { emailRoutingScoped } from "./scope.ts";
16
17const { test } = Test.make({ providers: Cloudflare.providers() });
18
19const logLevel = Effect.provideService(
20 MinimumLogLevel,
21 process.env.DEBUG ? "Debug" : "Info",
22);
23
24const 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).
30const NAME_DEFAULT = `alchemy-sendsub-default.${zoneName}`;
31const NAME_REPLACE_A = `alchemy-sendsub-replace-a.${zoneName}`;
32const NAME_REPLACE_B = `alchemy-sendsub-replace-b.${zoneName}`;
33const NAME_ADOPT = `alchemy-sendsub-adopt.${zoneName}`;
34const NAME_LIST = `alchemy-sendsub-list.${zoneName}`;
35
36const 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).
53const forbiddenRetrySchedule = Schedule.exponential("500 millis");
54
55const 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
67const 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.
78const 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
96test.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
152test.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
208test.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
276test.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 */
333const 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 );