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 securityTxt from "@distilled.cloud/cloudflare/security-txt";
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 security-txt operation's error union via distilled patches).
37const forbiddenRetrySchedule = Schedule.exponential("500 millis");
38
39const getSecurityTxt = (zoneId: string) =>
40 securityTxt.getSecurityTxt({ zoneId }).pipe(
41 Effect.retry({
42 while: (e) => e._tag === "Forbidden",
43 schedule: forbiddenRetrySchedule,
44 times: 8,
45 }),
46 );
47
48// Normalize the zone to a known baseline (no security.txt configured) so
49// each run starts from the same cloud state regardless of what a previous
50// (possibly interrupted) run left behind. DELETE on an absent file
51// succeeds, so this is safe to run unconditionally.
52const clearBaseline = (zoneId: string) =>
53 securityTxt.deleteSecurityTxt({ zoneId }).pipe(
54 Effect.retry({
55 while: (e) => e._tag === "Forbidden",
56 schedule: forbiddenRetrySchedule,
57 times: 8,
58 }),
59 );
60
61const contact = ["mailto:security@alchemy.run"];
62const expires = "2030-01-01T00:00:00Z";
63
64describe.sequential(
65 "SecurityTxt",
66 {
67 tags: [
68 "provider:cloudflare",
69 "provider:cloudflare:securitytxt",
70 "provider:cloudflare:zone",
71 "live",
72 ],
73 },
74 () => {
75 test.provider(
76 "creates a security.txt, verifies out-of-band, and deletes on destroy",
77 (stack) =>
78 Effect.gen(function* () {
79 const zoneId = yield* resolveZoneId;
80
81 yield* stack.destroy();
82 yield* clearBaseline(zoneId);
83
84 const created = yield* stack.deploy(
85 Effect.gen(function* () {
86 return yield* Cloudflare.SecurityTxt.SecurityTxt("SecurityTxt", {
87 zoneId,
88 contact,
89 expires,
90 });
91 }),
92 );
93
94 expect(created.zoneId).toEqual(zoneId);
95 expect(created.enabled).toEqual(true);
96 expect(created.contact).toEqual(contact);
97 expect(created.expires).toEqual(expires);
98 expect(created.policy).toBeUndefined();
99 expect(created.preferredLanguages).toBeUndefined();
100
101 // Out-of-band: the file exists with the configured fields.
102 const live = yield* getSecurityTxt(zoneId);
103 expect(typeof live).not.toEqual("string");
104 if (typeof live !== "string") {
105 expect(live.enabled).toEqual(true);
106 expect(live.contact).toEqual(contact);
107 expect(live.expires).toEqual(expires);
108 }
109
110 yield* stack.destroy();
111
112 // Destroy removed the file — Cloudflare reports the unconfigured
113 // state as an empty-string sentinel.
114 const gone = yield* getSecurityTxt(zoneId);
115 expect(gone).toEqual("");
116
117 // Destroying again is a no-op (idempotent delete).
118 yield* stack.destroy();
119 }).pipe(logLevel),
120 );
121
122 test.provider(
123 "updates mutable fields in place (full-replace PUT)",
124 (stack) =>
125 Effect.gen(function* () {
126 const zoneId = yield* resolveZoneId;
127
128 yield* stack.destroy();
129 yield* clearBaseline(zoneId);
130
131 const created = yield* stack.deploy(
132 Effect.gen(function* () {
133 return yield* Cloudflare.SecurityTxt.SecurityTxt("SecurityTxt", {
134 zoneId,
135 contact,
136 expires,
137 });
138 }),
139 );
140 expect(created.policy).toBeUndefined();
141
142 const updated = yield* stack.deploy(
143 Effect.gen(function* () {
144 return yield* Cloudflare.SecurityTxt.SecurityTxt("SecurityTxt", {
145 zoneId,
146 contact,
147 expires,
148 policy: ["https://alchemy.run/security-policy"],
149 preferredLanguages: "en, es",
150 });
151 }),
152 );
153
154 // Same singleton replaced in place — zone identity is unchanged.
155 expect(updated.zoneId).toEqual(zoneId);
156 expect(updated.policy).toEqual([
157 "https://alchemy.run/security-policy",
158 ]);
159 expect(updated.preferredLanguages).toEqual("en, es");
160
161 const live = yield* getSecurityTxt(zoneId);
162 expect(typeof live).not.toEqual("string");
163 if (typeof live !== "string") {
164 expect(live.policy).toEqual([
165 "https://alchemy.run/security-policy",
166 ]);
167 expect(live.preferredLanguages).toEqual("en, es");
168 }
169
170 // Dropping the optional fields converges back to the minimal file.
171 const reverted = yield* stack.deploy(
172 Effect.gen(function* () {
173 return yield* Cloudflare.SecurityTxt.SecurityTxt("SecurityTxt", {
174 zoneId,
175 contact,
176 expires,
177 });
178 }),
179 );
180 expect(reverted.policy).toBeUndefined();
181 expect(reverted.preferredLanguages).toBeUndefined();
182
183 yield* stack.destroy();
184
185 const gone = yield* getSecurityTxt(zoneId);
186 expect(gone).toEqual("");
187 }).pipe(logLevel),
188 );
189
190 test.provider(
191 "disables the file without deleting it, then destroy removes it",
192 (stack) =>
193 Effect.gen(function* () {
194 const zoneId = yield* resolveZoneId;
195
196 yield* stack.destroy();
197 yield* clearBaseline(zoneId);
198
199 yield* stack.deploy(
200 Effect.gen(function* () {
201 return yield* Cloudflare.SecurityTxt.SecurityTxt("SecurityTxt", {
202 zoneId,
203 contact,
204 expires,
205 });
206 }),
207 );
208
209 const disabled = yield* stack.deploy(
210 Effect.gen(function* () {
211 return yield* Cloudflare.SecurityTxt.SecurityTxt("SecurityTxt", {
212 zoneId,
213 enabled: false,
214 contact,
215 expires,
216 });
217 }),
218 );
219
220 // The configuration survives but the file is no longer served.
221 expect(disabled.enabled).toEqual(false);
222 expect(disabled.contact).toEqual(contact);
223
224 const live = yield* getSecurityTxt(zoneId);
225 expect(typeof live).not.toEqual("string");
226 if (typeof live !== "string") {
227 expect(live.enabled).toEqual(false);
228 }
229
230 yield* stack.destroy();
231
232 const gone = yield* getSecurityTxt(zoneId);
233 expect(gone).toEqual("");
234 }).pipe(logLevel),
235 );
236
237 // Canonical `list()` test (zone-scoped singleton): there is no account-wide
238 // API for this per-zone file, so `list()` enumerates every zone via
239 // `listAllZones` and reads each. Only configured zones are emitted, so deploy
240 // a security.txt on the standing test zone and assert it appears.
241 test.provider("list enumerates configured security.txt files", (stack) =>
242 Effect.gen(function* () {
243 const zoneId = yield* resolveZoneId;
244
245 yield* stack.destroy();
246 yield* clearBaseline(zoneId);
247
248 const deployed = yield* stack.deploy(
249 Effect.gen(function* () {
250 return yield* Cloudflare.SecurityTxt.SecurityTxt("SecurityTxt", {
251 zoneId,
252 contact,
253 expires,
254 });
255 }),
256 );
257
258 const provider = yield* Provider.findProvider(
259 Cloudflare.SecurityTxt.SecurityTxt,
260 );
261 // Ride out token eventual-consistency 403s on the per-zone reads.
262 const all = yield* provider.list().pipe(
263 Effect.retry({
264 while: (e) => e._tag === "Forbidden",
265 schedule: forbiddenRetrySchedule,
266 times: 8,
267 }),
268 );
269
270 expect(all.length).toBeGreaterThan(0);
271 const entry = all.find((s) => s.zoneId === deployed.zoneId);
272 expect(entry).toBeDefined();
273 expect(entry?.contact).toEqual(contact);
274 expect(entry?.expires).toEqual(expires);
275
276 yield* stack.destroy();
277
278 const gone = yield* getSecurityTxt(zoneId);
279 expect(gone).toEqual("");
280 }).pipe(logLevel),
281 );
282 },
283);