1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import * as aisearch from "@distilled.cloud/cloudflare/aisearch";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10
11const { test } = Test.make({ providers: Cloudflare.providers() });
12
13const logLevel = Effect.provideService(
14 MinimumLogLevel,
15 process.env.DEBUG ? "Debug" : "Info",
16);
17
18// The scoped API token the test harness mints propagates eventually-
19// consistently across Cloudflare's edge — ride out 403 blips
20// (`Forbidden`, declared in the distilled error union) on the test's
21// own out-of-band verification calls. The backoff is CAPPED at 3s: an
22// uncapped exponential balloons to 64s+ gaps within a handful of
23// attempts, polling so sparsely that a settled token is detected tens
24// of seconds late — under full-suite parallel load (when propagation
25// windows stretch) that alone can blow the test timeout.
26const propagationSchedule = Schedule.min([
27 Schedule.exponential("500 millis"),
28 Schedule.spaced("3 seconds"),
29]);
30
31const getToken = (accountId: string, id: string) =>
32 aisearch.readToken({ accountId, id }).pipe(
33 Effect.retry({
34 while: (e) => e._tag === "Forbidden",
35 schedule: propagationSchedule,
36 times: 15,
37 }),
38 );
39
40const expectGone = (accountId: string, id: string) =>
41 getToken(accountId, id).pipe(
42 Effect.flatMap(() => Effect.fail({ _tag: "TokenNotDeleted" } as const)),
43 // A missing token surfaces as `TokenNotFound` (Cloudflare error code
44 // 7075) — that's the success condition here.
45 Effect.catchTag("TokenNotFound", () => Effect.void),
46 Effect.retry({
47 while: (e) => e._tag === "TokenNotDeleted",
48 schedule: propagationSchedule,
49 times: 15,
50 }),
51 );
52
53// AI Search validates the underlying credential on create/update — it
54// must carry the "AI Search Index Engine" permission group, so the test
55// mints a scoped account API token in the same stack and feeds its id +
56// value into the service token.
57const program = (
58 accountId: string,
59 props?: Partial<Cloudflare.AI.SearchTokenProps>,
60) =>
61 Effect.gen(function* () {
62 const apiToken = yield* Cloudflare.ApiToken.AccountApiToken("TokenSource", {
63 policies: [
64 {
65 effect: "allow",
66 permissionGroups: ["AI Search Index Engine"],
67 resources: { [`com.cloudflare.api.account.${accountId}`]: "*" },
68 },
69 ],
70 });
71 const token = yield* Cloudflare.AI.SearchToken("Token", {
72 cfApiId: apiToken.tokenId,
73 cfApiKey: apiToken.value,
74 ...props,
75 });
76 return { apiToken, token };
77 });
78
79test.provider(
80 "create, rename in place, and delete a service token",
81 (stack) =>
82 Effect.gen(function* () {
83 const { accountId } = yield* yield* CloudflareEnvironment;
84 const user = (process.env.USER ?? "test").toLowerCase();
85 const renamed = `${user}-alchemy-aisearch-token-renamed`;
86
87 yield* stack.destroy();
88
89 // Create — engine-generated display name.
90 const initial = yield* stack.deploy(program(accountId));
91
92 expect(initial.token.id).toBeTruthy();
93 expect(initial.token.accountId).toEqual(accountId);
94 expect(initial.token.cfApiId).toEqual(initial.apiToken.tokenId);
95 expect(initial.token.name).toBeTruthy();
96
97 const live = yield* getToken(accountId, initial.token.id);
98 expect(live.id).toEqual(initial.token.id);
99 expect(live.cfApiId).toEqual(initial.apiToken.tokenId);
100 expect(live.name).toEqual(initial.token.name);
101
102 // Rename in place — the service token id is stable.
103 const updated = yield* stack.deploy(
104 program(accountId, { name: renamed }),
105 );
106
107 expect(updated.token.id).toEqual(initial.token.id);
108 expect(updated.token.name).toEqual(renamed);
109
110 const liveUpdated = yield* getToken(accountId, updated.token.id);
111 expect(liveUpdated.name).toEqual(renamed);
112
113 // Redeploying identical props is a no-op (still the same token).
114 const noop = yield* stack.deploy(program(accountId, { name: renamed }));
115 expect(noop.token.id).toEqual(initial.token.id);
116
117 yield* stack.destroy();
118
119 yield* expectGone(accountId, initial.token.id);
120
121 // Destroy again — delete must be idempotent (already gone).
122 yield* stack.destroy();
123 }).pipe(logLevel),
124 {
125 tags: [
126 "provider:cloudflare",
127 "provider:cloudflare:ai",
128 "provider:cloudflare:apitoken",
129 "live",
130 ],
131 timeout: 240_000,
132 },
133);
134
135test.provider(
136 "recreates after out-of-band delete",
137 (stack) =>
138 Effect.gen(function* () {
139 const { accountId } = yield* yield* CloudflareEnvironment;
140 const user = (process.env.USER ?? "test").toLowerCase();
141
142 yield* stack.destroy();
143
144 const initial = yield* stack.deploy(program(accountId));
145
146 // Delete the service token out-of-band. A redeploy with identical
147 // props is a planner no-op, so change a mutable prop to force
148 // reconcile — it must observe the token as missing and recreate it
149 // instead of failing on a 404.
150 yield* aisearch.deleteToken({ accountId, id: initial.token.id }).pipe(
151 Effect.retry({
152 while: (e) => e._tag === "Forbidden",
153 schedule: propagationSchedule,
154 times: 15,
155 }),
156 );
157 yield* expectGone(accountId, initial.token.id);
158
159 const healed = yield* stack.deploy(
160 program(accountId, { name: `${user}-alchemy-aisearch-token-healed` }),
161 );
162
163 // A new physical token exists (the id is assigned by Cloudflare).
164 expect(healed.token.id).not.toEqual(initial.token.id);
165 const live = yield* getToken(accountId, healed.token.id);
166 expect(live.id).toEqual(healed.token.id);
167
168 yield* stack.destroy();
169
170 yield* expectGone(accountId, healed.token.id);
171 }).pipe(logLevel),
172 {
173 tags: [
174 "provider:cloudflare",
175 "provider:cloudflare:ai",
176 "provider:cloudflare:apitoken",
177 "live",
178 ],
179 timeout: 240_000,
180 },
181);
182
183test.provider(
184 "list enumerates the deployed service token",
185 (stack) =>
186 Effect.gen(function* () {
187 const { accountId } = yield* yield* CloudflareEnvironment;
188
189 yield* stack.destroy();
190
191 const deployed = yield* stack.deploy(program(accountId));
192
193 const provider = yield* Provider.findProvider(Cloudflare.AI.SearchToken);
194 const all = yield* provider.list();
195
196 const found = all.find((t) => t.id === deployed.token.id);
197 expect(found).toBeDefined();
198 // list() emits the exact `read` Attributes shape.
199 expect(found?.accountId).toEqual(accountId);
200 expect(found?.cfApiId).toEqual(deployed.apiToken.tokenId);
201 expect(found?.name).toEqual(deployed.token.name);
202
203 yield* stack.destroy();
204
205 yield* expectGone(accountId, deployed.token.id);
206 }).pipe(logLevel),
207 {
208 tags: [
209 "provider:cloudflare",
210 "provider:cloudflare:ai",
211 "provider:cloudflare:apitoken",
212 "live",
213 ],
214 timeout: 240_000,
215 },
216);
217
218test.provider(
219 "an AI Search instance syncs with a stack-minted service token",
220 (stack) =>
221 Effect.gen(function* () {
222 const { accountId } = yield* yield* CloudflareEnvironment;
223
224 yield* stack.destroy();
225
226 // Full composition: AccountApiToken → AiSearchToken → Bucket →
227 // AiSearchInstance wired to the service token via `tokenId`.
228 const deployed = yield* stack.deploy(
229 Effect.gen(function* () {
230 const { apiToken, token } = yield* program(accountId);
231 const bucket = yield* Cloudflare.R2.Bucket("AiSearchTokenSource", {
232 forceDestroy: true,
233 });
234 const instance = yield* Cloudflare.AI.Search("Search", {
235 // Pass the Bucket resource (not `bucket.bucketName`) so the
236 // construct selects the R2 source path; a bare string is treated
237 // as a web-crawler seed URL.
238 source: bucket,
239 tokenId: token.id,
240 });
241 return { apiToken, token, bucket, instance };
242 }),
243 );
244
245 expect(deployed.instance.tokenId).toEqual(deployed.token.id);
246
247 const live = yield* aisearch
248 .readInstance({ accountId, id: deployed.instance.instanceId })
249 .pipe(
250 Effect.retry({
251 while: (e) => e._tag === "Forbidden",
252 schedule: propagationSchedule,
253 times: 15,
254 }),
255 );
256 expect(live.tokenId).toEqual(deployed.token.id);
257
258 yield* stack.destroy();
259
260 // The instance deletes before the token it references.
261 yield* expectGone(accountId, deployed.token.id);
262 }).pipe(logLevel),
263 // The instance create validates the freshly-minted service token and can
264 // legitimately ride the provider's `InvalidTokenCredentials` propagation
265 // window (~2 min under full-suite parallel load) before the deploy even
266 // returns — 240s leaves no room for the destroy + gone-poll that follow.
267 {
268 tags: [
269 "provider:cloudflare",
270 "provider:cloudflare:ai",
271 "provider:cloudflare:apitoken",
272 "provider:cloudflare:r2",
273 "live",
274 ],
275 timeout: 360_000,
276 },
277);