| 1 | import * as Cloudflare from "@/Cloudflare"; |
| 2 | import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; |
| 3 | import * as Provider from "@/Provider"; |
| 4 | import * as Test from "@/Test/Alchemy"; |
| 5 | import * as intel from "@distilled.cloud/cloudflare/intel"; |
| 6 | import { expect } from "alchemy-test"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import { MinimumLogLevel } from "effect/References"; |
| 9 | import * as Schedule from "effect/Schedule"; |
| 10 | |
| 11 | const { test } = Test.make({ providers: Cloudflare.providers() }); |
| 12 | |
| 13 | const 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. |
| 29 | const 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. |
| 36 | const 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. |
| 47 | const FEED_NAME = "alchemy-intel-test-feed"; |
| 48 | |
| 49 | test.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. |
| 99 | test.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(). |
| 128 | test.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. |
| 163 | test.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). |
| 247 | test.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 | ); |