1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import * as turnstile from "@distilled.cloud/cloudflare/turnstile";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as Redacted from "effect/Redacted";
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 getWidget = (accountId: string, sitekey: string) =>
26 turnstile.getWidget({ accountId, sitekey }).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, sitekey: string) =>
35 getWidget(accountId, sitekey).pipe(
36 Effect.flatMap(() => Effect.fail({ _tag: "WidgetNotDeleted" } as const)),
37 // A missing widget surfaces as `WidgetNotFound` (Cloudflare error
38 // code 10404) — that's the success condition here.
39 Effect.catchTag("WidgetNotFound", () => Effect.void),
40 Effect.retry({
41 while: (e) => e._tag === "WidgetNotDeleted",
42 schedule: Schedule.max([
43 Schedule.exponential("500 millis"),
44 Schedule.recurs(10),
45 ]),
46 }),
47 );
48
49test.provider(
50 "create and delete a widget with default name",
51 (stack) =>
52 Effect.gen(function* () {
53 const { accountId } = yield* yield* CloudflareEnvironment;
54
55 yield* stack.destroy();
56
57 const widget = yield* stack.deploy(
58 Cloudflare.Turnstile.Widget("DefaultWidget", {
59 domains: [zoneName],
60 mode: "managed",
61 }),
62 );
63
64 expect(widget.sitekey).toBeDefined();
65 expect(Redacted.value(widget.secret)).toBeTruthy();
66 expect(widget.accountId).toEqual(accountId);
67 expect(widget.domains).toEqual([zoneName]);
68 expect(widget.mode).toEqual("managed");
69 expect(widget.region).toEqual("world");
70
71 const live = yield* getWidget(accountId, widget.sitekey);
72 expect(live.sitekey).toEqual(widget.sitekey);
73 expect(live.domains).toEqual([zoneName]);
74 expect(live.mode).toEqual("managed");
75
76 yield* stack.destroy();
77
78 yield* expectGone(accountId, widget.sitekey);
79 }).pipe(logLevel),
80 { tags: ["provider:cloudflare", "provider:cloudflare:turnstile", "live"] },
81);
82
83test.provider(
84 "update mutable props in place (same sitekey)",
85 (stack) =>
86 Effect.gen(function* () {
87 const { accountId } = yield* yield* CloudflareEnvironment;
88
89 yield* stack.destroy();
90
91 const initial = yield* stack.deploy(
92 Cloudflare.Turnstile.Widget("UpdateWidget", {
93 name: "alchemy-turnstile-update",
94 domains: [zoneName],
95 mode: "managed",
96 }),
97 );
98
99 expect(initial.name).toEqual("alchemy-turnstile-update");
100 expect(initial.mode).toEqual("managed");
101
102 const updated = yield* stack.deploy(
103 Cloudflare.Turnstile.Widget("UpdateWidget", {
104 name: "alchemy-turnstile-update-v2",
105 domains: [zoneName, `www.${zoneName}`],
106 mode: "invisible",
107 }),
108 );
109
110 // Same widget mutated in place — not a replacement.
111 expect(updated.sitekey).toEqual(initial.sitekey);
112 expect(updated.name).toEqual("alchemy-turnstile-update-v2");
113 expect(updated.mode).toEqual("invisible");
114 expect([...updated.domains].sort()).toEqual(
115 [zoneName, `www.${zoneName}`].sort(),
116 );
117
118 const live = yield* getWidget(accountId, updated.sitekey);
119 expect(live.name).toEqual("alchemy-turnstile-update-v2");
120 expect(live.mode).toEqual("invisible");
121 expect([...live.domains].sort()).toEqual(
122 [zoneName, `www.${zoneName}`].sort(),
123 );
124
125 // Redeploying identical props is a no-op (still the same widget).
126 const noop = yield* stack.deploy(
127 Cloudflare.Turnstile.Widget("UpdateWidget", {
128 name: "alchemy-turnstile-update-v2",
129 domains: [zoneName, `www.${zoneName}`],
130 mode: "invisible",
131 }),
132 );
133 expect(noop.sitekey).toEqual(initial.sitekey);
134
135 yield* stack.destroy();
136
137 yield* expectGone(accountId, initial.sitekey);
138 }).pipe(logLevel),
139 { tags: ["provider:cloudflare", "provider:cloudflare:turnstile", "live"] },
140);
141
142test.provider(
143 "recreates after out-of-band delete",
144 (stack) =>
145 Effect.gen(function* () {
146 const { accountId } = yield* yield* CloudflareEnvironment;
147
148 yield* stack.destroy();
149
150 const widget = yield* stack.deploy(
151 Cloudflare.Turnstile.Widget("HealWidget", {
152 name: "alchemy-turnstile-heal",
153 domains: [zoneName],
154 mode: "non-interactive",
155 }),
156 );
157
158 // Delete the widget out-of-band. A redeploy with identical props is a
159 // planner no-op, so change a prop to force reconcile — it must observe
160 // the widget as missing and recreate it instead of failing on a 404.
161 yield* turnstile
162 .deleteWidget({ accountId, sitekey: widget.sitekey })
163 .pipe(
164 Effect.retry({
165 while: (e) => e._tag === "Forbidden",
166 schedule: Schedule.exponential("500 millis"),
167 times: 8,
168 }),
169 );
170
171 const healed = yield* stack.deploy(
172 Cloudflare.Turnstile.Widget("HealWidget", {
173 name: "alchemy-turnstile-heal",
174 domains: [zoneName],
175 mode: "managed",
176 }),
177 );
178
179 expect(healed.sitekey).not.toEqual(widget.sitekey);
180 expect(healed.mode).toEqual("managed");
181 const live = yield* getWidget(accountId, healed.sitekey);
182 expect(live.name).toEqual("alchemy-turnstile-heal");
183 expect(live.mode).toEqual("managed");
184
185 yield* stack.destroy();
186
187 yield* expectGone(accountId, healed.sitekey);
188 }).pipe(logLevel),
189 { tags: ["provider:cloudflare", "provider:cloudflare:turnstile", "live"] },
190);
191
192test.provider(
193 "list enumerates the deployed widget",
194 (stack) =>
195 Effect.gen(function* () {
196 yield* stack.destroy();
197
198 const widget = yield* stack.deploy(
199 Cloudflare.Turnstile.Widget("ListWidget", {
200 name: "alchemy-turnstile-list",
201 domains: [zoneName],
202 mode: "managed",
203 }),
204 );
205
206 const provider = yield* Provider.findProvider(
207 Cloudflare.Turnstile.Widget,
208 );
209 const all = yield* provider.list();
210
211 const found = all.find((w) => w.sitekey === widget.sitekey);
212 expect(found).toBeDefined();
213 expect(found?.name).toEqual("alchemy-turnstile-list");
214 expect(found?.domains).toEqual([zoneName]);
215 expect(found?.mode).toEqual("managed");
216 // list() hydrates the write-only secret to match the read shape.
217 expect(Redacted.value(found!.secret)).toBeTruthy();
218
219 yield* stack.destroy();
220 }).pipe(logLevel),
221 { tags: ["provider:cloudflare", "provider:cloudflare:turnstile", "live"] },
222);