1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import * as intel from "@distilled.cloud/cloudflare/intel";
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// Custom indicator feeds require the account to be approved as a
19// Cloudforce One feed provider. On the standard testing account
20// `POST /accounts/{id}/intel/indicator-feeds` fails with HTTP 403
21// "Account does not have permission to create a feed" — surfaced as the
22// typed `IndicatorFeedsNotEntitled` error. The full lifecycle tests below
23// are gated behind an entitled account supplied via env; the probe test
24// always runs and pins the typed tags.
25//
26// NOTE: Cloudflare exposes NO delete endpoint for indicator feeds — the
27// provider adopts same-named feeds across runs (deterministic names), so
28// the entitled lifecycle re-uses one feed instead of leaking.
29const entitled = !!process.env.CLOUDFLARE_TEST_INTEL_FEEDS;
30
31// The scoped API token the test harness mints propagates eventually-
32// consistently across Cloudflare's edge — ride out 403 blips (`Forbidden`,
33// declared in the distilled error union) on out-of-band verification calls.
34// `IndicatorFeedNotFound` also matches 403 (Cloudflare answers 403 for
35// missing feeds), so only retry the cross-cutting Forbidden tag.
36const getFeed = (accountId: string, feedId: number) =>
37 intel.getIndicatorFeed({ accountId, feedId }).pipe(
38 Effect.retry({
39 while: (e) => e._tag === "Forbidden",
40 schedule: Schedule.exponential("500 millis"),
41 times: 8,
42 }),
43 );
44
45// Deterministic name — feeds cannot be deleted, so every run of this suite
46// adopts the same feed instead of leaking a new one.
47const FEED_NAME = "alchemy-intel-test-feed";
48
49test.provider.skipIf(entitled)(
50 "unentitled accounts surface the typed IndicatorFeedsNotEntitled error",
51 (stack) =>
52 Effect.gen(function* () {
53 const { accountId } = yield* yield* CloudflareEnvironment;
54
55 yield* stack.destroy();
56
57 // The testing account is not a feed provider — the distilled create
58 // call must fail with the typed entitlement tag. Reads on the same
59 // account succeed (and show no feeds), proving the gate is on
60 // creation, not on the API token.
61 const error = yield* intel
62 .createIndicatorFeed({
63 accountId,
64 name: "alchemy-intel-entitlement-probe",
65 description: "entitlement probe",
66 })
67 .pipe(Effect.flip);
68 expect(error._tag).toEqual("IndicatorFeedsNotEntitled");
69
70 const list = yield* intel.listIndicatorFeeds({ accountId }).pipe(
71 Effect.retry({
72 while: (e) => e._tag === "Forbidden",
73 schedule: Schedule.exponential("500 millis"),
74 times: 8,
75 }),
76 );
77 expect(list.result).toEqual([]);
78
79 // A feed that does not exist surfaces as the typed
80 // `IndicatorFeedNotFound` (Cloudflare misuses HTTP 403 with a
81 // "does not exist" message for missing feeds).
82 const notFound = yield* intel
83 .getIndicatorFeed({ accountId, feedId: 99999999 })
84 .pipe(Effect.flip);
85 expect(notFound._tag).toEqual("IndicatorFeedNotFound");
86
87 yield* stack.destroy();
88 }).pipe(logLevel),
89 {
90 tags: ["provider:cloudflare", "provider:cloudflare:intel", "live"],
91 timeout: 120_000,
92 },
93);
94
95// Canonical `list()` test (account collection). Enumeration is NOT gated by
96// the feed-provider entitlement (only creation is), so this runs on the
97// standard testing account and returns a well-typed (typically empty) array.
98// On an unentitled account the account simply owns no feeds.
99test.provider(
100 "list returns a well-typed array of indicator feeds",
101 (stack) =>
102 Effect.gen(function* () {
103 yield* stack.destroy();
104
105 const provider = yield* Provider.findProvider(
106 Cloudflare.Intel.IndicatorFeed,
107 );
108 const all = yield* provider.list();
109
110 expect(Array.isArray(all)).toBe(true);
111 // Every element is the exact `read` Attributes shape.
112 for (const feed of all) {
113 expect(typeof feed.feedId).toEqual("number");
114 expect(feed.accountId).toBeTruthy();
115 }
116
117 yield* stack.destroy();
118 }).pipe(logLevel),
119 {
120 tags: ["provider:cloudflare", "provider:cloudflare:intel", "live"],
121 timeout: 120_000,
122 },
123);
124
125// Requires a Cloudforce One feed-provider account to create the feed under
126// test; unlock with CLOUDFLARE_TEST_INTEL_FEEDS=1. Asserts the deployed feed
127// is present in the exhaustively-paginated list().
128test.provider.skipIf(!entitled)(
129 "list enumerates the deployed indicator feed",
130 (stack) =>
131 Effect.gen(function* () {
132 const { accountId } = yield* yield* CloudflareEnvironment;
133
134 yield* stack.destroy();
135
136 const feed = yield* stack.deploy(
137 Cloudflare.Intel.IndicatorFeed("ListFeed", {
138 name: FEED_NAME,
139 description: "alchemy intel list feed",
140 }),
141 );
142
143 const provider = yield* Provider.findProvider(
144 Cloudflare.Intel.IndicatorFeed,
145 );
146 const all = yield* provider.list();
147
148 const found = all.find((f) => f.feedId === feed.feedId);
149 expect(found).toBeDefined();
150 expect(found?.accountId).toEqual(accountId);
151 expect(found?.name).toEqual(FEED_NAME);
152
153 yield* stack.destroy();
154 }).pipe(logLevel),
155 {
156 tags: ["provider:cloudflare", "provider:cloudflare:intel", "live"],
157 timeout: 120_000,
158 },
159);
160
161// Requires a Cloudforce One feed-provider account — unentitled accounts fail with
162// the typed IndicatorFeedsNotEntitled (HTTP 403). Unlock with CLOUDFLARE_TEST_INTEL_FEEDS=1.
163test.provider.skipIf(!entitled)(
164 "create (or adopt), verify out-of-band, update in place, destroy",
165 (stack) =>
166 Effect.gen(function* () {
167 const { accountId } = yield* yield* CloudflareEnvironment;
168
169 yield* stack.destroy();
170
171 const feed = yield* stack.deploy(
172 Cloudflare.Intel.IndicatorFeed("TestFeed", {
173 name: FEED_NAME,
174 description: "alchemy intel lifecycle feed",
175 }),
176 );
177
178 expect(feed.feedId).toBeGreaterThan(0);
179 expect(feed.accountId).toEqual(accountId);
180 expect(feed.name).toEqual(FEED_NAME);
181 // Documented defaults.
182 expect(feed.isAttributable).toEqual(false);
183 expect(feed.isDownloadable).toEqual(false);
184 expect(feed.isPublic).toEqual(false);
185
186 const live = yield* getFeed(accountId, feed.feedId);
187 expect(live.name).toEqual(FEED_NAME);
188
189 // In-place update: description + visibility flags, same id.
190 const updated = yield* stack.deploy(
191 Cloudflare.Intel.IndicatorFeed("TestFeed", {
192 name: FEED_NAME,
193 description: "alchemy intel lifecycle feed v2",
194 isAttributable: true,
195 isDownloadable: true,
196 }),
197 );
198 expect(updated.feedId).toEqual(feed.feedId);
199 expect(updated.description).toEqual("alchemy intel lifecycle feed v2");
200 expect(updated.isAttributable).toEqual(true);
201 expect(updated.isDownloadable).toEqual(true);
202
203 const liveUpdated = yield* getFeed(accountId, feed.feedId);
204 expect(liveUpdated.description).toEqual(
205 "alchemy intel lifecycle feed v2",
206 );
207 expect(liveUpdated.isAttributable).toEqual(true);
208
209 // No-op redeploy keeps the same feed.
210 const noop = yield* stack.deploy(
211 Cloudflare.Intel.IndicatorFeed("TestFeed", {
212 name: FEED_NAME,
213 description: "alchemy intel lifecycle feed v2",
214 isAttributable: true,
215 isDownloadable: true,
216 }),
217 );
218 expect(noop.feedId).toEqual(feed.feedId);
219
220 // Destroy is a documented no-op (no delete API) — the feed must
221 // still exist afterwards, and a fresh deploy adopts it back under
222 // the same id instead of creating a duplicate.
223 yield* stack.destroy();
224 const orphan = yield* getFeed(accountId, feed.feedId);
225 expect(orphan.id).toEqual(feed.feedId);
226
227 const adopted = yield* stack.deploy(
228 Cloudflare.Intel.IndicatorFeed("TestFeed", {
229 name: FEED_NAME,
230 description: "alchemy intel lifecycle feed v2",
231 isAttributable: true,
232 isDownloadable: true,
233 }),
234 );
235 expect(adopted.feedId).toEqual(feed.feedId);
236
237 yield* stack.destroy();
238 }).pipe(logLevel),
239 {
240 tags: ["provider:cloudflare", "provider:cloudflare:intel", "live"],
241 timeout: 120_000,
242 },
243);
244
245// Requires a Cloudflare feed-provider account (typed IndicatorFeedsNotEntitled otherwise);
246// unlock with CLOUDFLARE_TEST_INTEL_FEEDS=1 (+ CLOUDFLARE_TEST_INTEL_CONSUMER_ACCOUNT for the grant step).
247test.provider.skipIf(!entitled)(
248 "uploads a STIX2 snapshot and grants/revokes a consumer permission",
249 (stack) =>
250 Effect.gen(function* () {
251 const consumer = process.env.CLOUDFLARE_TEST_INTEL_CONSUMER_ACCOUNT;
252
253 yield* stack.destroy();
254
255 const snapshot = JSON.stringify({
256 type: "bundle",
257 id: "bundle--3fbf63f2-7e2d-4d5a-9b4b-7f4a5d2f9c01",
258 objects: [
259 {
260 type: "indicator",
261 spec_version: "2.1",
262 id: "indicator--a932fcc6-e032-476c-826f-cb970a5a1ade",
263 created: "2024-01-01T00:00:00.000Z",
264 modified: "2024-01-01T00:00:00.000Z",
265 pattern: "[domain-name:value = 'malicious.example.com']",
266 pattern_type: "stix",
267 valid_from: "2024-01-01T00:00:00Z",
268 },
269 ],
270 });
271
272 const feed = yield* stack.deploy(
273 Cloudflare.Intel.IndicatorFeed("SnapshotFeed", {
274 name: FEED_NAME,
275 description: "alchemy intel snapshot feed",
276 snapshot,
277 }),
278 );
279 expect(feed.snapshotHash).toBeTruthy();
280
281 // Re-deploying the identical snapshot is a no-op (same hash).
282 const noop = yield* stack.deploy(
283 Cloudflare.Intel.IndicatorFeed("SnapshotFeed", {
284 name: FEED_NAME,
285 description: "alchemy intel snapshot feed",
286 snapshot,
287 }),
288 );
289 expect(noop.feedId).toEqual(feed.feedId);
290 expect(noop.snapshotHash).toEqual(feed.snapshotHash);
291
292 if (consumer) {
293 const grant = yield* stack.deploy(
294 Cloudflare.Intel.IndicatorFeedPermission("ConsumerGrant", {
295 feedId: feed.feedId,
296 accountTag: consumer,
297 }),
298 );
299 expect(grant.feedId).toEqual(feed.feedId);
300 expect(grant.accountTag).toEqual(consumer);
301 }
302
303 yield* stack.destroy();
304 }).pipe(logLevel),
305 {
306 tags: ["provider:cloudflare", "provider:cloudflare:intel", "live"],
307 timeout: 120_000,
308 },
309);