1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import { findZoneByName } from "@/Cloudflare/Zone/lookup";
4import * as Test from "@/Test/Alchemy";
5import * as emailRouting from "@distilled.cloud/cloudflare/email-routing";
6import { describe, expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10import RuleTargetWorker from "./fixtures/rule-target-worker.ts";
11import { emailRoutingScoped } from "./scope.ts";
12
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.
36const enableRouting = (zoneId: string) =>
37 emailRouting.enableEmailRouting({ zoneId }).pipe(
38 Effect.retry({
39 while: (e) => e._tag === "Forbidden",
40 schedule: Schedule.exponential("500 millis"),
41 times: 8,
42 }),
43 );
44
45describe.sequential.skipIf(!emailRoutingScoped)(
46 "Email.Rule -> Worker in one apply",
47 {
48 tags: [
49 "provider:cloudflare",
50 "provider:cloudflare:email",
51 "provider:cloudflare:worker",
52 "provider:cloudflare:zone",
53 "live",
54 ],
55 },
56 () => {
57 // Coverage for the shape reported in #1348: a Worker plus a routing
58 // resource whose `{ type: "worker", value: [scriptName] }` action names
59 // it, both created in the same apply, into a stage that did not exist.
60 //
61 // NOTE: this is a smoke test, NOT the regression guard for #1348. It was
62 // confirmed to still pass with the provider's retry removed — by us and
63 // independently by the reporter, who mutated the retry away and got three
64 // green runs. The Worker provider pre-creates a stub script before the
65 // rule is created (see "pre-creating" in a deploy log), so by the time
66 // Cloudflare validates the action the name already resolves.
67 //
68 // The retry itself is guarded deterministically in `retry.test.ts`, which
69 // injects the failure and does fail when the retry is removed. The error
70 // is real and typed (`WorkerScriptNotFound`, Cloudflare code 2016,
71 // observed live on both createRule and putRuleCatchAll).
72 test.provider(
73 "first deploy of a fresh stage creates the rule targeting the worker",
74 (stack) =>
75 Effect.gen(function* () {
76 const zoneId = yield* resolveZoneId;
77
78 // A never-before-deployed stage is the precondition — start from a
79 // torn-down stack so the Worker really is created in this apply.
80 yield* stack.destroy();
81 yield* enableRouting(zoneId);
82
83 const { rule, workerName } = yield* stack.deploy(
84 Effect.gen(function* () {
85 const routing = yield* Cloudflare.Email.Routing("Routing", {
86 zone: zoneName,
87 });
88 const worker = yield* RuleTargetWorker;
89 const rule = yield* Cloudflare.Email.Rule("WorkerRule", {
90 zone: { zoneId: routing.zoneId },
91 name: "alchemy worker-target test",
92 matchers: [
93 {
94 type: "literal",
95 field: "to",
96 value: `worker-target@${zoneName}`,
97 },
98 ],
99 actions: [{ type: "worker", value: [worker.workerName] }],
100 });
101 return { rule, workerName: worker.workerName };
102 }),
103 );
104
105 expect(rule.ruleId).not.toEqual("");
106 expect(rule.zoneId).toEqual(zoneId);
107 expect(rule.actions).toEqual([
108 { type: "worker", value: [workerName] },
109 ]);
110
111 // Verify out-of-band that Cloudflare really stored the worker action.
112 const live = yield* emailRouting
113 .getRule({ zoneId, ruleIdentifier: rule.ruleId })
114 .pipe(
115 Effect.retry({
116 while: (e) => e._tag === "Forbidden",
117 schedule: Schedule.exponential("500 millis"),
118 times: 8,
119 }),
120 );
121 expect(live.actions?.[0]?.type).toEqual("worker");
122 expect(live.actions?.[0]?.value).toEqual([workerName]);
123
124 yield* stack.destroy();
125 }).pipe(logLevel),
126 { timeout: 180_000 },
127 );
128
129 // The catch-all rule takes a `worker` action through a different endpoint
130 // (`/rules/catch_all`) and hits the same validation window.
131 test.provider(
132 "catch-all pointed at a Worker created in the same apply",
133 (stack) =>
134 Effect.gen(function* () {
135 const zoneId = yield* resolveZoneId;
136
137 yield* stack.destroy();
138 yield* enableRouting(zoneId);
139
140 const { catchAll, workerName } = yield* stack.deploy(
141 Effect.gen(function* () {
142 const routing = yield* Cloudflare.Email.Routing("Routing", {
143 zone: zoneName,
144 });
145 const worker = yield* RuleTargetWorker;
146 const catchAll = yield* Cloudflare.Email.CatchAll("CatchAll", {
147 zone: { zoneId: routing.zoneId },
148 name: "alchemy worker-target catch-all",
149 actions: [{ type: "worker", value: [worker.workerName] }],
150 });
151 return { catchAll, workerName: worker.workerName };
152 }),
153 );
154
155 expect(catchAll.zoneId).toEqual(zoneId);
156 expect(catchAll.actions).toEqual([
157 { type: "worker", value: [workerName] },
158 ]);
159
160 yield* stack.destroy();
161 }).pipe(logLevel),
162 { timeout: 180_000 },
163 );
164 },
165);