1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import { findZoneByName } from "@/Cloudflare/Zone/lookup";
4import * as Provider from "@/Provider";
5import { isResourceState, State, type ResourceState } from "@/State";
6import * as Test from "@/Test/Alchemy";
7import * as emailRouting from "@distilled.cloud/cloudflare/email-routing";
8import { describe, expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import { MinimumLogLevel } from "effect/References";
11import * as Schedule from "effect/Schedule";
12import { emailRoutingScoped } from "./scope.ts";
13const { test } = Test.make({ providers: Cloudflare.providers() });
14
15const logLevel = Effect.provideService(
16 MinimumLogLevel,
17 process.env.DEBUG ? "Debug" : "Info",
18);
19
20const zoneName =
21 process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
22
23const resolveZoneId = Effect.gen(function* () {
24 const { accountId } = yield* yield* CloudflareEnvironment;
25 const zone = yield* findZoneByName({ accountId, name: zoneName });
26 if (!zone) {
27 return yield* Effect.die(
28 new Error(`zone "${zoneName}" not found in account`),
29 );
30 }
31 return zone.id;
32});
33
34// The scoped API token the test harness mints propagates eventually-
35// consistently across Cloudflare's edge — a fresh token intermittently 403s
36// with "Unable to authenticate request". Ride out the blips on the test's
37// own out-of-band calls by retrying the typed `Forbidden` error (part of
38// each email-routing operation's error union via distilled patches).
39const forbiddenRetrySchedule = Schedule.exponential("500 millis");
40
41const getCatchAll = (zoneId: string) =>
42 emailRouting.getRuleCatchAll({ zoneId }).pipe(
43 Effect.retry({
44 while: (e) => e._tag === "Forbidden",
45 schedule: forbiddenRetrySchedule,
46 times: 8,
47 }),
48 );
49
50// Normalize the zone to a known baseline so each run starts from the same
51// cloud state regardless of what a previous (possibly interrupted) run left
52// behind: Email Routing enabled, catch-all back at the Cloudflare default
53// (disabled, drop, no name).
54const setBaseline = (zoneId: string) =>
55 emailRouting.enableEmailRouting({ zoneId }).pipe(
56 Effect.andThen(
57 emailRouting.putRuleCatchAll({
58 zoneId,
59 matchers: [{ type: "all" }],
60 actions: [{ type: "drop" }],
61 enabled: false,
62 name: "",
63 }),
64 ),
65 Effect.retry({
66 while: (e) => e._tag === "Forbidden",
67 schedule: forbiddenRetrySchedule,
68 times: 8,
69 }),
70 );
71
72describe.sequential.skipIf(!emailRoutingScoped)(
73 "EmailCatchAll",
74 {
75 tags: [
76 "provider:cloudflare",
77 "provider:cloudflare:email",
78 "provider:cloudflare:zone",
79 "live",
80 ],
81 },
82 () => {
83 test.provider(
84 "configures the catch-all rule and restores the baseline on destroy",
85 (stack) =>
86 Effect.gen(function* () {
87 const zoneId = yield* resolveZoneId;
88
89 yield* stack.destroy();
90 yield* setBaseline(zoneId);
91
92 const catchAll = yield* stack.deploy(
93 Effect.gen(function* () {
94 const routing = yield* Cloudflare.Email.Routing("Routing", {
95 zone: zoneName,
96 });
97 return yield* Cloudflare.Email.CatchAll("CatchAll", {
98 zone: { zoneId: routing.zoneId },
99 name: "alchemy catch-all",
100 actions: [{ type: "drop" }],
101 });
102 }),
103 );
104
105 expect(catchAll.zoneId).toEqual(zoneId);
106 expect(catchAll.ruleId).not.toEqual("");
107 expect(catchAll.name).toEqual("alchemy catch-all");
108 expect(catchAll.enabled).toEqual(true);
109 expect(catchAll.actions).toEqual([{ type: "drop" }]);
110 // The pre-management state was captured for restore-on-destroy.
111 expect(catchAll.initialEnabled).toEqual(false);
112 expect(catchAll.initialName).toEqual("");
113 expect(catchAll.initialActions).toEqual([{ type: "drop" }]);
114
115 // Out-of-band verification against the live API.
116 const live = yield* getCatchAll(zoneId);
117 expect(live.enabled).toEqual(true);
118 expect(live.name).toEqual("alchemy catch-all");
119 expect(live.actions).toEqual([{ type: "drop" }]);
120
121 yield* stack.destroy();
122
123 // Destroy restored the state the catch-all rule had before we
124 // managed it. (The rule itself always exists — it is a singleton.)
125 const restored = yield* getCatchAll(zoneId);
126 expect(restored.enabled).toEqual(false);
127 expect(restored.name ?? "").toEqual("");
128 expect(restored.actions).toEqual([{ type: "drop" }]);
129 }).pipe(logLevel),
130 );
131
132 test.provider(
133 "updates the catch-all rule in place and keeps the captured baseline",
134 (stack) =>
135 Effect.gen(function* () {
136 const zoneId = yield* resolveZoneId;
137
138 yield* stack.destroy();
139 yield* setBaseline(zoneId);
140
141 const deployCatchAll = (props: {
142 name: string;
143 enabled?: boolean;
144 actions: Cloudflare.Email.Action[];
145 }) =>
146 stack.deploy(
147 Effect.gen(function* () {
148 const routing = yield* Cloudflare.Email.Routing("Routing", {
149 zone: zoneName,
150 });
151 return yield* Cloudflare.Email.CatchAll("CatchAll", {
152 zone: { zoneId: routing.zoneId },
153 ...props,
154 });
155 }),
156 );
157
158 const initial = yield* deployCatchAll({
159 name: "alchemy update test",
160 actions: [{ type: "drop" }],
161 });
162
163 expect(initial.enabled).toEqual(true);
164 expect(initial.initialEnabled).toEqual(false);
165 const ruleId = initial.ruleId;
166
167 const updated = yield* deployCatchAll({
168 name: "alchemy update test v2",
169 enabled: false,
170 actions: [{ type: "drop" }],
171 });
172
173 // Same singleton updated in place; the captured baseline survives
174 // the update so destroy still restores the pre-management state.
175 expect(updated.ruleId).toEqual(ruleId);
176 expect(updated.zoneId).toEqual(zoneId);
177 expect(updated.name).toEqual("alchemy update test v2");
178 expect(updated.enabled).toEqual(false);
179 expect(updated.initialEnabled).toEqual(false);
180 expect(updated.initialName).toEqual("");
181
182 const live = yield* getCatchAll(zoneId);
183 expect(live.enabled).toEqual(false);
184 expect(live.name).toEqual("alchemy update test v2");
185
186 yield* stack.destroy();
187
188 const restored = yield* getCatchAll(zoneId);
189 expect(restored.enabled).toEqual(false);
190 expect(restored.name ?? "").toEqual("");
191 expect(restored.actions).toEqual([{ type: "drop" }]);
192 }).pipe(logLevel),
193 );
194
195 test.provider(
196 "recovers a half-created catch-all whose creating-state lost Output-valued props (#736)",
197 (stack) =>
198 Effect.gen(function* () {
199 const zoneId = yield* resolveZoneId;
200
201 yield* stack.destroy();
202 yield* setBaseline(zoneId);
203 // Safety net: normalize the zone singleton back to the Cloudflare
204 // default even if the test dies mid-way.
205 yield* Effect.addFinalizer(() =>
206 setBaseline(zoneId).pipe(Effect.ignore),
207 );
208
209 const deployCatchAll = () =>
210 stack.deploy(
211 Effect.gen(function* () {
212 const routing = yield* Cloudflare.Email.Routing("Routing", {
213 zone: zoneName,
214 });
215 return yield* Cloudflare.Email.CatchAll("CatchAll", {
216 // Output-valued zone reference — the #736 shape.
217 zone: { zoneId: routing.zoneId },
218 name: "alchemy recovery test",
219 actions: [{ type: "drop" }],
220 });
221 }),
222 );
223
224 const created = yield* deployCatchAll();
225 expect(created.zoneId).toEqual(zoneId);
226 expect(created.ruleId).not.toEqual("");
227
228 // Rewrite the persisted row into the wedged shape an interrupted
229 // deploy leaves behind: `creating`, no attributes, and the
230 // Output-valued `zone` prop lost in the round-trip.
231 const state = yield* yield* State;
232 const stage = stack.stage;
233 const fqns = yield* state.list({ stack: stack.name, stage });
234 const rows = yield* Effect.forEach(fqns, (fqn) =>
235 state
236 .get({ stack: stack.name, stage, fqn })
237 .pipe(Effect.map((row) => ({ fqn, row }))),
238 );
239 const wedged = rows.find(
240 (r): r is { fqn: string; row: ResourceState } =>
241 isResourceState(r.row) &&
242 r.row.resourceType === "Cloudflare.Email.CatchAll",
243 );
244 if (!wedged) {
245 return yield* Effect.die(
246 new Error(
247 "no Cloudflare.Email.CatchAll state row found after deploy",
248 ),
249 );
250 }
251 yield* state.set({
252 stack: stack.name,
253 stage,
254 fqn: wedged.fqn,
255 value: {
256 ...wedged.row,
257 status: "creating",
258 attr: undefined,
259 props: { ...wedged.row.props, zone: undefined },
260 },
261 });
262
263 // Before the fix `read` crashed resolving `olds.zone === undefined`
264 // (TypeError reading `name` of undefined inside `resolve`). After
265 // the fix it reports "not found" and reconcile converges on the
266 // same zone singleton — same rule, no duplicate.
267 const recovered = yield* deployCatchAll();
268 expect(recovered.zoneId).toEqual(zoneId);
269 expect(recovered.ruleId).toEqual(created.ruleId);
270 expect(recovered.name).toEqual("alchemy recovery test");
271 expect(recovered.enabled).toEqual(true);
272 expect(recovered.actions).toEqual([{ type: "drop" }]);
273
274 // Out-of-band verification against the live API.
275 const live = yield* getCatchAll(zoneId);
276 expect(live.enabled).toEqual(true);
277 expect(live.name).toEqual("alchemy recovery test");
278
279 yield* stack.destroy();
280 // The recovery re-adopted the singleton with the managed state as
281 // its restore baseline, so destroy restores that state — normalize
282 // the zone back to the Cloudflare default for subsequent tests.
283 yield* setBaseline(zoneId);
284 }).pipe(logLevel),
285 { timeout: 240_000 },
286 );
287
288 // Canonical `list()` test (zone-scoped singleton): there is no account-wide
289 // API for this per-zone catch-all rule, so `list()` enumerates every zone
290 // via `listAllZones` and reads the singleton in each (skipping zones without
291 // Email Routing enabled). Ensure Email Routing is enabled on the standing
292 // test zone, then assert the result is non-empty, well-typed, and contains
293 // the test zone.
294 test.provider(
295 "list enumerates the catch-all rule across all zones",
296 (stack) =>
297 Effect.gen(function* () {
298 const zoneId = yield* resolveZoneId;
299
300 yield* stack.destroy();
301 // Email Routing must be enabled for the test zone's catch-all to be
302 // visible to `list()`; normalize to a known baseline.
303 yield* setBaseline(zoneId);
304
305 const provider = yield* Provider.findProvider(
306 Cloudflare.Email.CatchAll,
307 );
308 const all = yield* provider.list();
309
310 expect(all.length).toBeGreaterThan(0);
311 const row = all.find((r) => r.zoneId === zoneId);
312 expect(row).toBeDefined();
313 expect(row!.ruleId).not.toEqual("");
314 expect(typeof row!.enabled).toBe("boolean");
315 expect(Array.isArray(row!.actions)).toBe(true);
316
317 yield* stack.destroy();
318 }).pipe(logLevel),
319 );
320 },
321);