1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import { findZoneByName } from "@/Cloudflare/Zone/lookup";
4import * as Provider from "@/Provider";
5import * as Test from "@/Test/Alchemy";
6import * as urlNormalization from "@distilled.cloud/cloudflare/url-normalization";
7import { describe, expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import { MinimumLogLevel } from "effect/References";
10import * as Schedule from "effect/Schedule";
11const { test } = Test.make({ providers: Cloudflare.providers() });
12
13const logLevel = Effect.provideService(
14 MinimumLogLevel,
15 process.env.DEBUG ? "Debug" : "Info",
16);
17
18const zoneName =
19 process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
20
21const resolveZoneId = Effect.gen(function* () {
22 const { accountId } = yield* yield* CloudflareEnvironment;
23 const zone = yield* findZoneByName({ accountId, name: zoneName });
24 if (!zone) {
25 return yield* Effect.die(
26 new Error(`zone "${zoneName}" not found in account`),
27 );
28 }
29 return zone.id;
30});
31
32// The scoped API token the test harness mints propagates eventually-
33// consistently across Cloudflare's edge — a fresh token intermittently 403s
34// with "Unable to authenticate request". Ride out the blips on the test's
35// own out-of-band calls by retrying the typed `Forbidden` error (part of
36// each url-normalization operation's error union via distilled patches).
37const forbiddenRetrySchedule = Schedule.exponential("500 millis");
38
39const getUrlNormalization = (zoneId: string) =>
40 urlNormalization.getUrlNormalization({ zoneId }).pipe(
41 Effect.retry({
42 while: (e) => e._tag === "Forbidden",
43 schedule: forbiddenRetrySchedule,
44 times: 8,
45 }),
46 );
47
48// Normalize the singleton to Cloudflare defaults so each run starts from
49// the same cloud state regardless of what a previous (possibly interrupted)
50// run left behind. DELETE is the API's true reset operation.
51const resetToDefaults = (zoneId: string) =>
52 urlNormalization.deleteUrlNormalization({ zoneId }).pipe(
53 Effect.retry({
54 while: (e) => e._tag === "Forbidden",
55 schedule: forbiddenRetrySchedule,
56 times: 8,
57 }),
58 );
59
60describe.sequential(
61 "UrlNormalization",
62 {
63 tags: [
64 "provider:cloudflare",
65 "provider:cloudflare:urlnormalization",
66 "provider:cloudflare:zone",
67 "live",
68 ],
69 },
70 () => {
71 test.provider(
72 "configures URL normalization and resets to defaults on destroy",
73 (stack) =>
74 Effect.gen(function* () {
75 const zoneId = yield* resolveZoneId;
76
77 yield* stack.destroy();
78 yield* resetToDefaults(zoneId);
79
80 const created = yield* stack.deploy(
81 Effect.gen(function* () {
82 return yield* Cloudflare.UrlNormalization.UrlNormalization(
83 "UrlNormalization",
84 {
85 zoneId,
86 scope: "both",
87 type: "rfc3986",
88 },
89 );
90 }),
91 );
92
93 expect(created.zoneId).toEqual(zoneId);
94 expect(created.scope).toEqual("both");
95 expect(created.type).toEqual("rfc3986");
96
97 // Out-of-band verification against the live API.
98 const live = yield* getUrlNormalization(zoneId);
99 expect(live.scope).toEqual("both");
100 expect(live.type).toEqual("rfc3986");
101
102 yield* stack.destroy();
103
104 // Destroy issued the true reset op — the zone is back to Cloudflare
105 // defaults.
106 const reset = yield* getUrlNormalization(zoneId);
107 expect(reset.scope).toEqual("incoming");
108 expect(reset.type).toEqual("cloudflare");
109 }).pipe(logLevel),
110 );
111
112 test.provider("updates scope and type in place", (stack) =>
113 Effect.gen(function* () {
114 const zoneId = yield* resolveZoneId;
115
116 yield* stack.destroy();
117 yield* resetToDefaults(zoneId);
118
119 const initial = yield* stack.deploy(
120 Effect.gen(function* () {
121 return yield* Cloudflare.UrlNormalization.UrlNormalization(
122 "UrlNormalization",
123 {
124 zoneId,
125 scope: "both",
126 type: "rfc3986",
127 },
128 );
129 }),
130 );
131
132 expect(initial.scope).toEqual("both");
133 expect(initial.type).toEqual("rfc3986");
134
135 // Same singleton updated in place via a full-replace PUT.
136 const updated = yield* stack.deploy(
137 Effect.gen(function* () {
138 return yield* Cloudflare.UrlNormalization.UrlNormalization(
139 "UrlNormalization",
140 {
141 zoneId,
142 scope: "incoming",
143 type: "cloudflare",
144 },
145 );
146 }),
147 );
148
149 expect(updated.scope).toEqual("incoming");
150 expect(updated.type).toEqual("cloudflare");
151
152 const live = yield* getUrlNormalization(zoneId);
153 expect(live.scope).toEqual("incoming");
154 expect(live.type).toEqual("cloudflare");
155
156 yield* stack.destroy();
157
158 const reset = yield* getUrlNormalization(zoneId);
159 expect(reset.scope).toEqual("incoming");
160 expect(reset.type).toEqual("cloudflare");
161 }).pipe(logLevel),
162 );
163
164 test.provider(
165 "applies Cloudflare defaults when scope and type are omitted",
166 (stack) =>
167 Effect.gen(function* () {
168 const zoneId = yield* resolveZoneId;
169
170 yield* stack.destroy();
171 // Start from a non-default state so the deploy has to converge.
172 yield* urlNormalization
173 .putUrlNormalization({ zoneId, scope: "none", type: "rfc3986" })
174 .pipe(
175 Effect.retry({
176 while: (e) => e._tag === "Forbidden",
177 schedule: forbiddenRetrySchedule,
178 times: 8,
179 }),
180 );
181
182 const created = yield* stack.deploy(
183 Effect.gen(function* () {
184 return yield* Cloudflare.UrlNormalization.UrlNormalization(
185 "UrlNormalization",
186 {
187 zoneId,
188 },
189 );
190 }),
191 );
192
193 // Omitted props fall back to Cloudflare's documented defaults.
194 expect(created.scope).toEqual("incoming");
195 expect(created.type).toEqual("cloudflare");
196
197 const live = yield* getUrlNormalization(zoneId);
198 expect(live.scope).toEqual("incoming");
199 expect(live.type).toEqual("cloudflare");
200
201 yield* stack.destroy();
202
203 const reset = yield* getUrlNormalization(zoneId);
204 expect(reset.scope).toEqual("incoming");
205 expect(reset.type).toEqual("cloudflare");
206 }).pipe(logLevel),
207 );
208
209 // Canonical `list()` test (zone-scoped singleton): there is no account-wide
210 // API for this per-zone setting, so `list()` enumerates every zone via
211 // `listAllZones` and reads the singleton in each. Assert the result is
212 // non-empty and contains the standing test zone.
213 test.provider(
214 "list enumerates URL normalization across all zones",
215 (stack) =>
216 Effect.gen(function* () {
217 const zoneId = yield* resolveZoneId;
218
219 const provider = yield* Provider.findProvider(
220 Cloudflare.UrlNormalization.UrlNormalization,
221 );
222 const all = yield* provider.list();
223
224 expect(all.length).toBeGreaterThan(0);
225 expect(all.some((s) => s.zoneId === zoneId)).toBe(true);
226
227 // `stack` is unused here (the singleton always exists on every zone),
228 // but keep the destroy bookend so the harness state stays clean.
229 yield* stack.destroy();
230 }).pipe(logLevel),
231 );
232 },
233);