1import * as Cloudflare from "@/Cloudflare";
2import { adopt } from "@/AdoptPolicy";
3import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
4import * as Provider from "@/Provider";
5import * as Test from "@/Test/Alchemy";
6import * as vulnScanner from "@distilled.cloud/cloudflare/vulnerability-scanner";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import * as Redacted from "effect/Redacted";
10import { MinimumLogLevel } from "effect/References";
11import * as Schedule from "effect/Schedule";
12
13const { test } = Test.make({ providers: Cloudflare.providers() });
14
15const logLevel = Effect.provideService(
16 MinimumLogLevel,
17 process.env.DEBUG ? "Debug" : "Info",
18);
19
20// Deterministic per-test names — same on every run, disjoint between tests.
21const SET_DEFAULT = "alchemy-vulnscanner-credset-default";
22const SET_CRED = "alchemy-vulnscanner-credset-withcred";
23const SET_LIST = "alchemy-vulnscanner-credset-list";
24const SET_MOVE_A = "alchemy-vulnscanner-credset-move-a";
25const SET_MOVE_B = "alchemy-vulnscanner-credset-move-b";
26const CRED_NAME = "alchemy-vulnscanner-credential";
27const CRED_MOVE = "alchemy-vulnscanner-credential-move";
28
29const accountIdOf = Effect.gen(function* () {
30 const { accountId } = yield* yield* CloudflareEnvironment;
31 return accountId;
32});
33
34// Freshly minted scoped tokens propagate eventually-consistently across
35// Cloudflare's edge; ride out 403 blips on out-of-band verification calls.
36const forbiddenRetrySchedule = Schedule.exponential("500 millis");
37
38const getCredentialSet = (accountId: string, credentialSetId: string) =>
39 vulnScanner.getCredentialSet({ accountId, credentialSetId }).pipe(
40 Effect.retry({
41 while: (e) => e._tag === "Forbidden",
42 schedule: forbiddenRetrySchedule,
43 times: 8,
44 }),
45 );
46
47// Typed "is it gone?" probes — not-found tags map to undefined.
48const findCredentialSet = (accountId: string, credentialSetId: string) =>
49 getCredentialSet(accountId, credentialSetId).pipe(
50 Effect.catchTag("CredentialSetNotFound", () => Effect.succeed(undefined)),
51 );
52
53const getCredential = (
54 accountId: string,
55 credentialSetId: string,
56 credentialId: string,
57) =>
58 vulnScanner
59 .getCredentialSetCredential({ accountId, credentialSetId, credentialId })
60 .pipe(
61 Effect.retry({
62 while: (e) => e._tag === "Forbidden",
63 schedule: forbiddenRetrySchedule,
64 times: 8,
65 }),
66 );
67
68const findCredential = (
69 accountId: string,
70 credentialSetId: string,
71 credentialId: string,
72) =>
73 getCredential(accountId, credentialSetId, credentialId).pipe(
74 Effect.catchTag(["CredentialNotFound", "CredentialSetNotFound"], () =>
75 Effect.succeed(undefined),
76 ),
77 );
78
79test.provider(
80 "create, rename, and destroy a credential set",
81 (stack) =>
82 Effect.gen(function* () {
83 const accountId = yield* accountIdOf;
84
85 yield* stack.destroy();
86
87 const initial = yield* stack.deploy(
88 Effect.gen(function* () {
89 return yield* Cloudflare.VulnerabilityScanner.VulnScannerCredentialSet(
90 "DefaultSet",
91 {
92 name: SET_DEFAULT,
93 },
94 ).pipe(adopt(true));
95 }),
96 );
97
98 expect(initial.credentialSetId).toBeDefined();
99 expect(initial.accountId).toEqual(accountId);
100 expect(initial.name).toEqual(SET_DEFAULT);
101
102 // Out-of-band verification against the live API.
103 const live = yield* getCredentialSet(accountId, initial.credentialSetId);
104 expect(live.id).toEqual(initial.credentialSetId);
105 expect(live.name).toEqual(SET_DEFAULT);
106
107 // Rename in place — same UUID.
108 const renamed = yield* stack.deploy(
109 Effect.gen(function* () {
110 return yield* Cloudflare.VulnerabilityScanner.VulnScannerCredentialSet(
111 "DefaultSet",
112 {
113 name: `${SET_DEFAULT}-renamed`,
114 },
115 ).pipe(adopt(true));
116 }),
117 );
118 expect(renamed.credentialSetId).toEqual(initial.credentialSetId);
119 expect(renamed.name).toEqual(`${SET_DEFAULT}-renamed`);
120
121 const liveRenamed = yield* getCredentialSet(
122 accountId,
123 renamed.credentialSetId,
124 );
125 expect(liveRenamed.name).toEqual(`${SET_DEFAULT}-renamed`);
126
127 yield* stack.destroy();
128
129 const gone = yield* findCredentialSet(accountId, initial.credentialSetId);
130 expect(gone).toBeUndefined();
131 }).pipe(logLevel),
132 {
133 tags: [
134 "provider:cloudflare",
135 "provider:cloudflare:vulnerabilityscanner",
136 "live",
137 ],
138 },
139);
140
141test.provider(
142 "list enumerates the deployed credential set",
143 (stack) =>
144 Effect.gen(function* () {
145 yield* stack.destroy();
146
147 const deployed = yield* stack.deploy(
148 Effect.gen(function* () {
149 return yield* Cloudflare.VulnerabilityScanner.VulnScannerCredentialSet(
150 "ListSet",
151 {
152 name: SET_LIST,
153 },
154 ).pipe(adopt(true));
155 }),
156 );
157
158 const provider = yield* Provider.findProvider(
159 Cloudflare.VulnerabilityScanner.VulnScannerCredentialSet,
160 );
161 const all = yield* provider.list();
162
163 // Exhaustively-paginated account collection contains the deployed set,
164 // each element shaped exactly like `read`'s Attributes.
165 expect(
166 all.some((x) => x.credentialSetId === deployed.credentialSetId),
167 ).toBe(true);
168 const found = all.find(
169 (x) => x.credentialSetId === deployed.credentialSetId,
170 );
171 expect(found?.name).toEqual(SET_LIST);
172 expect(found?.accountId).toEqual(deployed.accountId);
173
174 yield* stack.destroy();
175 }).pipe(logLevel),
176 {
177 tags: [
178 "provider:cloudflare",
179 "provider:cloudflare:vulnerabilityscanner",
180 "live",
181 ],
182 },
183);
184
185test.provider(
186 "credential lifecycle — create, mutate in place, rotate value, destroy",
187 (stack) =>
188 Effect.gen(function* () {
189 const accountId = yield* accountIdOf;
190
191 yield* stack.destroy();
192
193 const initial = yield* stack.deploy(
194 Effect.gen(function* () {
195 const set =
196 yield* Cloudflare.VulnerabilityScanner.VulnScannerCredentialSet(
197 "CredSet",
198 {
199 name: SET_CRED,
200 },
201 ).pipe(adopt(true));
202 const credential =
203 yield* Cloudflare.VulnerabilityScanner.VulnScannerCredential(
204 "Cred",
205 {
206 credentialSetId: set.credentialSetId,
207 name: CRED_NAME,
208 location: "header",
209 locationName: "authorization",
210 value: Redacted.make("Bearer alchemy-test-v1"),
211 },
212 ).pipe(adopt(true));
213 return { set, credential };
214 }),
215 );
216
217 expect(initial.credential.credentialId).toBeDefined();
218 expect(initial.credential.credentialSetId).toEqual(
219 initial.set.credentialSetId,
220 );
221 expect(initial.credential.location).toEqual("header");
222 expect(initial.credential.locationName).toEqual("authorization");
223
224 // Out-of-band verification — the value is write-only and never echoed.
225 const live = yield* getCredential(
226 accountId,
227 initial.set.credentialSetId,
228 initial.credential.credentialId,
229 );
230 expect(live.name).toEqual(CRED_NAME);
231 expect(live.location).toEqual("header");
232 expect(live.locationName).toEqual("authorization");
233
234 // Mutate observable fields + rotate the write-only value in place.
235 const updated = yield* stack.deploy(
236 Effect.gen(function* () {
237 const set =
238 yield* Cloudflare.VulnerabilityScanner.VulnScannerCredentialSet(
239 "CredSet",
240 {
241 name: SET_CRED,
242 },
243 ).pipe(adopt(true));
244 const credential =
245 yield* Cloudflare.VulnerabilityScanner.VulnScannerCredential(
246 "Cred",
247 {
248 credentialSetId: set.credentialSetId,
249 name: CRED_NAME,
250 location: "cookie",
251 locationName: "session_id",
252 value: Redacted.make("alchemy-test-v2"),
253 },
254 ).pipe(adopt(true));
255 return { set, credential };
256 }),
257 );
258
259 // Same credential updated in place — not a replacement.
260 expect(updated.credential.credentialId).toEqual(
261 initial.credential.credentialId,
262 );
263 expect(updated.credential.location).toEqual("cookie");
264 expect(updated.credential.locationName).toEqual("session_id");
265
266 const liveUpdated = yield* getCredential(
267 accountId,
268 updated.set.credentialSetId,
269 updated.credential.credentialId,
270 );
271 expect(liveUpdated.location).toEqual("cookie");
272 expect(liveUpdated.locationName).toEqual("session_id");
273
274 yield* stack.destroy();
275
276 const credGone = yield* findCredential(
277 accountId,
278 initial.set.credentialSetId,
279 initial.credential.credentialId,
280 );
281 expect(credGone).toBeUndefined();
282 const setGone = yield* findCredentialSet(
283 accountId,
284 initial.set.credentialSetId,
285 );
286 expect(setGone).toBeUndefined();
287 }).pipe(logLevel),
288 {
289 tags: [
290 "provider:cloudflare",
291 "provider:cloudflare:vulnerabilityscanner",
292 "live",
293 ],
294 },
295);
296
297test.provider(
298 "moving a credential to a different set triggers replacement",
299 (stack) =>
300 Effect.gen(function* () {
301 const accountId = yield* accountIdOf;
302
303 yield* stack.destroy();
304
305 // Keep BOTH sets deployed across the replacement step — replacing a
306 // resource while removing its old dependency in the same deploy is a
307 // known engine deadlock.
308 const initial = yield* stack.deploy(
309 Effect.gen(function* () {
310 const setA =
311 yield* Cloudflare.VulnerabilityScanner.VulnScannerCredentialSet(
312 "SetA",
313 {
314 name: SET_MOVE_A,
315 },
316 ).pipe(adopt(true));
317 const setB =
318 yield* Cloudflare.VulnerabilityScanner.VulnScannerCredentialSet(
319 "SetB",
320 {
321 name: SET_MOVE_B,
322 },
323 ).pipe(adopt(true));
324 const credential =
325 yield* Cloudflare.VulnerabilityScanner.VulnScannerCredential(
326 "MovingCred",
327 {
328 credentialSetId: setA.credentialSetId,
329 name: CRED_MOVE,
330 location: "header",
331 locationName: "x-api-key",
332 value: Redacted.make("alchemy-move-v1"),
333 },
334 ).pipe(adopt(true));
335 return { setA, setB, credential };
336 }),
337 );
338
339 expect(initial.credential.credentialSetId).toEqual(
340 initial.setA.credentialSetId,
341 );
342
343 const moved = yield* stack.deploy(
344 Effect.gen(function* () {
345 const setA =
346 yield* Cloudflare.VulnerabilityScanner.VulnScannerCredentialSet(
347 "SetA",
348 {
349 name: SET_MOVE_A,
350 },
351 ).pipe(adopt(true));
352 const setB =
353 yield* Cloudflare.VulnerabilityScanner.VulnScannerCredentialSet(
354 "SetB",
355 {
356 name: SET_MOVE_B,
357 },
358 ).pipe(adopt(true));
359 const credential =
360 yield* Cloudflare.VulnerabilityScanner.VulnScannerCredential(
361 "MovingCred",
362 {
363 credentialSetId: setB.credentialSetId,
364 name: CRED_MOVE,
365 location: "header",
366 locationName: "x-api-key",
367 value: Redacted.make("alchemy-move-v1"),
368 },
369 ).pipe(adopt(true));
370 return { setA, setB, credential };
371 }),
372 );
373
374 // The parent set is the credential's identity — a new physical
375 // credential exists under set B.
376 expect(moved.credential.credentialSetId).toEqual(
377 moved.setB.credentialSetId,
378 );
379 expect(moved.credential.credentialId).not.toEqual(
380 initial.credential.credentialId,
381 );
382
383 // The old credential was deleted from set A as part of replacement.
384 const oldGone = yield* findCredential(
385 accountId,
386 initial.setA.credentialSetId,
387 initial.credential.credentialId,
388 );
389 expect(oldGone).toBeUndefined();
390
391 const liveNew = yield* getCredential(
392 accountId,
393 moved.setB.credentialSetId,
394 moved.credential.credentialId,
395 );
396 expect(liveNew.name).toEqual(CRED_MOVE);
397
398 yield* stack.destroy();
399
400 const setAGone = yield* findCredentialSet(
401 accountId,
402 initial.setA.credentialSetId,
403 );
404 expect(setAGone).toBeUndefined();
405 const setBGone = yield* findCredentialSet(
406 accountId,
407 initial.setB.credentialSetId,
408 );
409 expect(setBGone).toBeUndefined();
410 }).pipe(logLevel),
411 {
412 tags: [
413 "provider:cloudflare",
414 "provider:cloudflare:vulnerabilityscanner",
415 "live",
416 ],
417 },
418);