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 rum from "@distilled.cloud/cloudflare/rum";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import { MinimumLogLevel } from "effect/References";
10import * as Schedule from "effect/Schedule";
11
12const { test } = Test.make({ providers: Cloudflare.providers() });
13
14const logLevel = Effect.provideService(
15 MinimumLogLevel,
16 process.env.DEBUG ? "Debug" : "Info",
17);
18
19const zoneName = "alchemy-test-2.us";
20
21// The scoped API token the test harness mints propagates eventually-
22// consistently across Cloudflare's edge — ride out 403 blips
23// (`Forbidden`, declared in the distilled error union) on the test's
24// own out-of-band verification calls.
25const getSite = (accountId: string, siteTag: string) =>
26 rum.getSiteInfo({ accountId, siteId: siteTag }).pipe(
27 Effect.retry({
28 while: (e) => e._tag === "Forbidden",
29 schedule: Schedule.exponential("500 millis"),
30 times: 8,
31 }),
32 );
33
34const expectGone = (accountId: string, siteTag: string) =>
35 getSite(accountId, siteTag).pipe(
36 Effect.flatMap(() => Effect.fail({ _tag: "SiteNotDeleted" } as const)),
37 // A missing site surfaces as `SiteNotFound` (Cloudflare error code
38 // 10015) — that's the success condition here.
39 Effect.catchTag("SiteNotFound", () => Effect.void),
40 Effect.retry({
41 while: (e) => e._tag === "SiteNotDeleted",
42 schedule: Schedule.max([
43 Schedule.exponential("500 millis"),
44 Schedule.recurs(10),
45 ]),
46 }),
47 );
48
49test.provider(
50 "create and delete a host (gray-clouded) site",
51 (stack) =>
52 Effect.gen(function* () {
53 const { accountId } = yield* yield* CloudflareEnvironment;
54
55 yield* stack.destroy();
56
57 const site = yield* stack.deploy(
58 Cloudflare.Rum.Site("HostSite", {
59 host: `create.${zoneName}`,
60 }),
61 );
62
63 expect(site.siteTag).toBeTruthy();
64 expect(site.siteToken).toBeTruthy();
65 expect(site.snippet).toContain(site.siteToken);
66 expect(site.accountId).toEqual(accountId);
67 expect(site.host).toEqual(`create.${zoneName}`);
68 expect(site.autoInstall).toEqual(false);
69
70 // Verify out-of-band against the live API.
71 const live = yield* getSite(accountId, site.siteTag);
72 expect(live.siteTag).toEqual(site.siteTag);
73 expect(live.host).toEqual(`create.${zoneName}`);
74
75 yield* stack.destroy();
76
77 yield* expectGone(accountId, site.siteTag);
78 }).pipe(logLevel),
79 { tags: ["provider:cloudflare", "provider:cloudflare:rum", "live"] },
80);
81
82test.provider(
83 "update mutable props in place (same siteTag)",
84 (stack) =>
85 Effect.gen(function* () {
86 const { accountId } = yield* yield* CloudflareEnvironment;
87
88 yield* stack.destroy();
89
90 const initial = yield* stack.deploy(
91 Cloudflare.Rum.Site("UpdateSite", {
92 host: `update.${zoneName}`,
93 }),
94 );
95
96 expect(initial.host).toEqual(`update.${zoneName}`);
97 expect(initial.autoInstall).toEqual(false);
98
99 // Changing the hostname is an in-place update of the same site.
100 const updated = yield* stack.deploy(
101 Cloudflare.Rum.Site("UpdateSite", {
102 host: `update-v2.${zoneName}`,
103 }),
104 );
105
106 expect(updated.siteTag).toEqual(initial.siteTag);
107 expect(updated.siteToken).toEqual(initial.siteToken);
108 expect(updated.host).toEqual(`update-v2.${zoneName}`);
109
110 const live = yield* getSite(accountId, updated.siteTag);
111 expect(live.host).toEqual(`update-v2.${zoneName}`);
112
113 // Redeploying identical props is a no-op (still the same site).
114 const noop = yield* stack.deploy(
115 Cloudflare.Rum.Site("UpdateSite", {
116 host: `update-v2.${zoneName}`,
117 }),
118 );
119 expect(noop.siteTag).toEqual(initial.siteTag);
120
121 yield* stack.destroy();
122
123 yield* expectGone(accountId, initial.siteTag);
124 }).pipe(logLevel),
125 { tags: ["provider:cloudflare", "provider:cloudflare:rum", "live"] },
126);
127
128test.provider(
129 "zone (orange-clouded) site with autoInstall, replaced on identity flip",
130 (stack) =>
131 Effect.gen(function* () {
132 const { accountId } = yield* yield* CloudflareEnvironment;
133 const zone = yield* findZoneByName({ accountId, name: zoneName });
134 if (!zone) {
135 return yield* Effect.die(
136 new Error(`zone "${zoneName}" not found in account`),
137 );
138 }
139
140 yield* stack.destroy();
141
142 const zoneSite = yield* stack.deploy(
143 Cloudflare.Rum.Site("FlipSite", {
144 zoneTag: zone.id,
145 autoInstall: true,
146 }),
147 );
148
149 expect(zoneSite.siteTag).toBeTruthy();
150 expect(zoneSite.zoneTag).toEqual(zone.id);
151 expect(zoneSite.autoInstall).toEqual(true);
152 // Zone-based sites get an implicit ruleset.
153 expect(zoneSite.rulesetId).toBeTruthy();
154
155 // Toggle autoInstall in place — same site.
156 const toggled = yield* stack.deploy(
157 Cloudflare.Rum.Site("FlipSite", {
158 zoneTag: zone.id,
159 autoInstall: false,
160 }),
161 );
162 expect(toggled.siteTag).toEqual(zoneSite.siteTag);
163 expect(toggled.autoInstall).toEqual(false);
164
165 // Switching to host-based measurement changes the identity model —
166 // the site must be replaced (new siteTag) and the old one deleted.
167 const replaced = yield* stack.deploy(
168 Cloudflare.Rum.Site("FlipSite", {
169 host: `flip.${zoneName}`,
170 }),
171 );
172 expect(replaced.siteTag).not.toEqual(zoneSite.siteTag);
173 expect(replaced.host).toEqual(`flip.${zoneName}`);
174
175 yield* expectGone(accountId, zoneSite.siteTag);
176
177 yield* stack.destroy();
178
179 yield* expectGone(accountId, replaced.siteTag);
180 }).pipe(logLevel),
181 {
182 tags: [
183 "provider:cloudflare",
184 "provider:cloudflare:rum",
185 "provider:cloudflare:zone",
186 "live",
187 ],
188 },
189);
190
191test.provider(
192 "list enumerates the deployed RUM site",
193 (stack) =>
194 Effect.gen(function* () {
195 yield* stack.destroy();
196
197 const deployed = yield* stack.deploy(
198 Cloudflare.Rum.Site("ListSite", {
199 host: `list.${zoneName}`,
200 }),
201 );
202
203 const provider = yield* Provider.findProvider(Cloudflare.Rum.Site);
204 const all = yield* provider.list();
205
206 expect(all.some((s) => s.siteTag === deployed.siteTag)).toBe(true);
207
208 yield* stack.destroy();
209 }).pipe(logLevel),
210 { tags: ["provider:cloudflare", "provider:cloudflare:rum", "live"] },
211);
212
213test.provider(
214 "recreates after out-of-band delete",
215 (stack) =>
216 Effect.gen(function* () {
217 const { accountId } = yield* yield* CloudflareEnvironment;
218
219 yield* stack.destroy();
220
221 const site = yield* stack.deploy(
222 Cloudflare.Rum.Site("HealSite", {
223 host: `heal.${zoneName}`,
224 }),
225 );
226
227 // Delete the site out-of-band. A redeploy with identical props is a
228 // planner no-op, so change a prop to force reconcile — it must observe
229 // the site as missing and recreate it instead of failing on a 404.
230 yield* rum.deleteSiteInfo({ accountId, siteId: site.siteTag }).pipe(
231 Effect.retry({
232 while: (e) => e._tag === "Forbidden",
233 schedule: Schedule.exponential("500 millis"),
234 times: 8,
235 }),
236 );
237
238 const healed = yield* stack.deploy(
239 Cloudflare.Rum.Site("HealSite", {
240 host: `heal-v2.${zoneName}`,
241 }),
242 );
243
244 expect(healed.siteTag).not.toEqual(site.siteTag);
245 expect(healed.host).toEqual(`heal-v2.${zoneName}`);
246 const live = yield* getSite(accountId, healed.siteTag);
247 expect(live.host).toEqual(`heal-v2.${zoneName}`);
248
249 yield* stack.destroy();
250
251 yield* expectGone(accountId, healed.siteTag);
252 }).pipe(logLevel),
253 { tags: ["provider:cloudflare", "provider:cloudflare:rum", "live"] },
254);