1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import * as images from "@distilled.cloud/cloudflare/images";
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
19// The Images signing-keys endpoints are gated behind a higher Images
20// entitlement than variants: on the testing account, `GET
21// /accounts/{id}/images/v1/keys` fails with code 5403 — "The given account
22// is not valid or is not authorized to access this service" — surfaced as
23// the typed `ImagesAccessNotEnabled` error, while the variants endpoints on
24// the very same account succeed. The full lifecycle test below is gated
25// behind an entitled account supplied via env.
26const keysEntitled = !!process.env.CLOUDFLARE_TEST_IMAGES_KEYS;
27
28const listKeyNames = (accountId: string) =>
29 images
30 .listV1Keys({ accountId })
31 .pipe(Effect.map((r) => (r.keys ?? []).map((k) => k.name)));
32
33// Poll the key list until the named key disappears — list reads are
34// eventually consistent after a DELETE.
35const expectGone = (accountId: string, name: string) =>
36 listKeyNames(accountId).pipe(
37 Effect.flatMap((names) =>
38 names.includes(name)
39 ? Effect.fail({ _tag: "KeyNotDeleted" } as const)
40 : Effect.void,
41 ),
42 Effect.retry({
43 while: (e) => e._tag === "KeyNotDeleted",
44 schedule: Schedule.max([
45 Schedule.exponential("500 millis"),
46 Schedule.recurs(10),
47 ]),
48 }),
49 );
50
51// Read-only enumeration is always exercised (not entitlement-gated):
52// `list()` swallows the `ImagesAccessNotEnabled` 5403 and returns `[]` on
53// unentitled accounts, so the result is a well-typed Attributes[] in either
54// case. On an entitled account it additionally contains the deployed key.
55test.provider(
56 "list enumerates the account's signing keys",
57 (stack) =>
58 Effect.gen(function* () {
59 const { accountId } = yield* yield* CloudflareEnvironment;
60
61 yield* stack.destroy();
62
63 const provider = yield* Provider.findProvider(
64 Cloudflare.Images.SigningKey,
65 );
66 const all = yield* provider.list();
67
68 expect(Array.isArray(all)).toBe(true);
69 for (const key of all) {
70 expect(typeof key.keyName).toBe("string");
71 expect(key.accountId).toEqual(accountId);
72 expect(typeof Redacted.value(key.value)).toBe("string");
73 }
74
75 yield* stack.destroy();
76 }).pipe(logLevel),
77 { tags: ["provider:cloudflare", "provider:cloudflare:images", "live"] },
78);
79
80test.provider.skipIf(keysEntitled)(
81 "surfaces the typed ImagesAccessNotEnabled error on unentitled accounts",
82 (stack) =>
83 Effect.gen(function* () {
84 const { accountId } = yield* yield* CloudflareEnvironment;
85
86 yield* stack.destroy();
87
88 // The testing account's Images subscription does not include the
89 // signing-keys API — the distilled call must fail with the typed
90 // entitlement tag (Cloudflare error code 5403).
91 const error = yield* images.listV1Keys({ accountId }).pipe(Effect.flip);
92 expect(error._tag).toEqual("ImagesAccessNotEnabled");
93
94 yield* stack.destroy();
95 }).pipe(logLevel),
96 { tags: ["provider:cloudflare", "provider:cloudflare:images", "live"] },
97);
98
99// NOTE: Cloudflare caps signing keys at 2 per account (the account's
100// default key counts as #1) and refuses to delete the last remaining key.
101// This suite creates exactly one extra key with a deterministic name and
102// always cleans it up, so repeated runs stay under the cap.
103test.provider.skipIf(!keysEntitled)(
104 "create a signing key, no-op redeploy keeps the value, delete",
105 (stack) =>
106 Effect.gen(function* () {
107 const { accountId } = yield* yield* CloudflareEnvironment;
108 const keyName = "alchemytestsigningkey";
109
110 yield* stack.destroy();
111
112 const created = yield* stack.deploy(
113 Cloudflare.Images.SigningKey("TestSigningKey", {
114 name: keyName,
115 }),
116 );
117
118 expect(created.keyName).toEqual(keyName);
119 expect(created.accountId).toEqual(accountId);
120 const createdValue = Redacted.value(created.value);
121 expect(createdValue).toBeTruthy();
122
123 // Out-of-band verification — the key shows up in the account list
124 // with the same value.
125 const names = yield* listKeyNames(accountId);
126 expect(names).toContain(keyName);
127
128 // Redeploying identical props must NOT re-PUT (a re-PUT would rotate
129 // the key material) — the value stays byte-identical.
130 const noop = yield* stack.deploy(
131 Cloudflare.Images.SigningKey("TestSigningKey", {
132 name: keyName,
133 }),
134 );
135 expect(noop.keyName).toEqual(keyName);
136 expect(Redacted.value(noop.value)).toEqual(createdValue);
137
138 yield* stack.destroy();
139
140 yield* expectGone(accountId, keyName);
141
142 // Destroy again — delete is idempotent.
143 yield* stack.destroy();
144 }).pipe(logLevel),
145 { tags: ["provider:cloudflare", "provider:cloudflare:images", "live"] },
146);