1import { adopt } 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 cloudConnector from "@distilled.cloud/cloudflare/cloud-connector";
8import { describe, expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import { MinimumLogLevel } from "effect/References";
11import * as Schedule from "effect/Schedule";
12const { test } = Test.make({ providers: Cloudflare.providers() });
13
14const logLevel = Effect.provideService(
15 MinimumLogLevel,
16 process.env.DEBUG ? "Debug" : "Info",
17);
18
19const zoneName =
20 process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
21
22// Deterministic test constants — same values on every run.
23const HOST_A = "alchemy-cloud-connector-a.s3.amazonaws.com";
24const HOST_B = "alchemy-cloud-connector-b.s3.amazonaws.com";
25const EXPRESSION_V1 = 'http.request.uri.path wildcard "/alchemy-cc/*"';
26const EXPRESSION_V2 = 'http.request.uri.path wildcard "/alchemy-cc-v2/*"';
27const EXPRESSION_B = 'http.request.uri.path wildcard "/alchemy-cc-b/*"';
28
29const resolveZoneId = Effect.gen(function* () {
30 const { accountId } = yield* yield* CloudflareEnvironment;
31 const zone = yield* findZoneByName({ accountId, name: zoneName });
32 if (!zone) {
33 return yield* Effect.die(
34 new Error(`zone "${zoneName}" not found in account`),
35 );
36 }
37 return zone.id;
38});
39
40// Ride out eventually-consistent 403s (freshly-minted scoped tokens
41// propagate slowly across Cloudflare's edge) on the test's own
42// out-of-band verification calls. `Forbidden` is part of the typed error
43// union of both cloud-connector operations via distilled patches.
44const forbiddenRetryPolicy = {
45 schedule: Schedule.exponential("500 millis"),
46 times: 8,
47} as const;
48
49const listLiveRules = (zoneId: string) =>
50 cloudConnector.listRules({ zoneId }).pipe(
51 Effect.map((response) => response.result),
52 Effect.retry({
53 while: (e) => e._tag === "Forbidden",
54 ...forbiddenRetryPolicy,
55 }),
56 );
57
58// The zone's Cloud Connector rule list is a singleton; normalize it to the
59// empty baseline so reruns are stable regardless of what a previous
60// (possibly interrupted) run left behind.
61const purgeRules = (zoneId: string) =>
62 cloudConnector.putRule({ zoneId, rules: [] }).pipe(
63 Effect.retry({
64 while: (e) => e._tag === "Forbidden",
65 ...forbiddenRetryPolicy,
66 }),
67 );
68
69describe.sequential(
70 "Rules",
71 {
72 tags: [
73 "provider:cloudflare",
74 "provider:cloudflare:cloudconnector",
75 "provider:cloudflare:zone",
76 "live",
77 ],
78 },
79 () => {
80 test.provider(
81 "cloud connector rules — create, update in place, destroy clears the list",
82 (stack) =>
83 Effect.gen(function* () {
84 const zoneId = yield* resolveZoneId;
85
86 yield* stack.destroy();
87 yield* purgeRules(zoneId);
88
89 // Create the singleton with a single S3 rule.
90 const initial = yield* stack.deploy(
91 Effect.gen(function* () {
92 return yield* Cloudflare.CloudConnector.Rules("Rules", {
93 zoneId,
94 rules: [
95 {
96 provider: "aws_s3",
97 expression: EXPRESSION_V1,
98 host: HOST_A,
99 description: "alchemy cloud connector test",
100 },
101 ],
102 }).pipe(adopt(true));
103 }),
104 );
105
106 expect(initial.zoneId).toEqual(zoneId);
107 expect(initial.rules).toHaveLength(1);
108 expect(initial.rules[0].provider).toEqual("aws_s3");
109 expect(initial.rules[0].expression).toEqual(EXPRESSION_V1);
110 expect(initial.rules[0].host).toEqual(HOST_A);
111 expect(initial.rules[0].enabled).toEqual(true);
112 expect(initial.rules[0].id).toBeDefined();
113
114 // Out-of-band verification against the live API.
115 const live = yield* listLiveRules(zoneId);
116 expect(live).toHaveLength(1);
117 expect(live[0].expression).toEqual(EXPRESSION_V1);
118 expect(live[0].parameters?.host).toEqual(HOST_A);
119
120 // Update in place: change the first rule's expression and append a
121 // second, disabled rule. Same identity — the singleton is replaced
122 // atomically, not the resource.
123 const updated = yield* stack.deploy(
124 Effect.gen(function* () {
125 return yield* Cloudflare.CloudConnector.Rules("Rules", {
126 zoneId,
127 rules: [
128 {
129 provider: "aws_s3",
130 expression: EXPRESSION_V2,
131 host: HOST_A,
132 description: "alchemy cloud connector test v2",
133 },
134 {
135 provider: "aws_s3",
136 expression: EXPRESSION_B,
137 host: HOST_B,
138 enabled: false,
139 },
140 ],
141 }).pipe(adopt(true));
142 }),
143 );
144
145 expect(updated.zoneId).toEqual(zoneId);
146 expect(updated.rules).toHaveLength(2);
147 expect(updated.rules[0].expression).toEqual(EXPRESSION_V2);
148 expect(updated.rules[0].host).toEqual(HOST_A);
149 expect(updated.rules[1].expression).toEqual(EXPRESSION_B);
150 expect(updated.rules[1].host).toEqual(HOST_B);
151 expect(updated.rules[1].enabled).toEqual(false);
152
153 const liveUpdated = yield* listLiveRules(zoneId);
154 expect(liveUpdated).toHaveLength(2);
155 expect(liveUpdated[0].expression).toEqual(EXPRESSION_V2);
156 expect(liveUpdated[1].parameters?.host).toEqual(HOST_B);
157
158 // Destroy clears the zone's rule list entirely.
159 yield* stack.destroy();
160
161 const liveGone = yield* listLiveRules(zoneId);
162 expect(liveGone).toHaveLength(0);
163 }).pipe(logLevel),
164 { timeout: 180_000 },
165 );
166
167 test.provider(
168 "no-op redeploy leaves the rule list untouched and ids stable",
169 (stack) =>
170 Effect.gen(function* () {
171 const zoneId = yield* resolveZoneId;
172
173 yield* stack.destroy();
174 yield* purgeRules(zoneId);
175
176 const deployOnce = stack.deploy(
177 Effect.gen(function* () {
178 return yield* Cloudflare.CloudConnector.Rules("Rules", {
179 zoneId,
180 rules: [
181 {
182 provider: "aws_s3",
183 expression: EXPRESSION_V1,
184 host: HOST_A,
185 description: "alchemy cloud connector noop test",
186 },
187 ],
188 }).pipe(adopt(true));
189 }),
190 );
191
192 const first = yield* deployOnce;
193 expect(first.rules).toHaveLength(1);
194 const firstId = first.rules[0].id;
195 expect(firstId).toBeDefined();
196
197 // Redeploying identical desired state skips the PUT — the
198 // server-assigned rule id survives.
199 const second = yield* deployOnce;
200 expect(second.rules).toHaveLength(1);
201 expect(second.rules[0].id).toEqual(firstId);
202
203 yield* stack.destroy();
204
205 const liveGone = yield* listLiveRules(zoneId);
206 expect(liveGone).toHaveLength(0);
207 }).pipe(logLevel),
208 { timeout: 180_000 },
209 );
210
211 // Canonical `list()` test (zone-scoped singleton): there is no account-wide
212 // API for the per-zone Cloud Connector rule list, so `list()` enumerates
213 // every zone via `listAllZones` and reads its rules. Deploy a rule on the
214 // standing test zone, then assert the test zone appears in the result with
215 // the rule we created.
216 test.provider(
217 "list enumerates rule lists across all zones",
218 (stack) =>
219 Effect.gen(function* () {
220 const zoneId = yield* resolveZoneId;
221
222 yield* stack.destroy();
223 yield* purgeRules(zoneId);
224
225 yield* stack.deploy(
226 Effect.gen(function* () {
227 return yield* Cloudflare.CloudConnector.Rules("Rules", {
228 zoneId,
229 rules: [
230 {
231 provider: "aws_s3",
232 expression: EXPRESSION_V1,
233 host: HOST_A,
234 description: "alchemy cloud connector list test",
235 },
236 ],
237 }).pipe(adopt(true));
238 }),
239 );
240
241 const provider = yield* Provider.findProvider(
242 Cloudflare.CloudConnector.Rules,
243 );
244 const all = yield* provider.list();
245
246 const entry = all.find((r) => r.zoneId === zoneId);
247 expect(entry).toBeDefined();
248 expect(
249 entry!.rules.some((rule) => rule.expression === EXPRESSION_V1),
250 ).toBe(true);
251
252 yield* stack.destroy();
253 yield* purgeRules(zoneId);
254 }).pipe(logLevel),
255 { timeout: 180_000 },
256 );
257 },
258);