| 1 | import * as AWS from "@/AWS"; |
| 2 | import { EventDataStore } from "@/AWS/CloudTrail"; |
| 3 | import { AWSEnvironment } from "@/AWS/Environment.ts"; |
| 4 | import * as Test from "@/Test/Alchemy"; |
| 5 | import * as cloudtrail from "@distilled.cloud/aws/cloudtrail"; |
| 6 | import { expect } from "alchemy-test"; |
| 7 | import type * as Duration from "effect/Duration"; |
| 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as Result from "effect/Result"; |
| 10 | import * as Schedule from "effect/Schedule"; |
| 11 | |
| 12 | const { test } = Test.make({ providers: AWS.providers() }); |
| 13 | |
| 14 | // Ungated typed-error probe: a well-formed ARN pointing at a nonexistent |
| 15 | // store must surface the typed not-found the provider's read/delete paths |
| 16 | // depend on. |
| 17 | test.provider( |
| 18 | "getEventDataStore on a nonexistent store fails with EventDataStoreNotFoundException", |
| 19 | () => |
| 20 | Effect.gen(function* () { |
| 21 | const { accountId, region } = yield* AWSEnvironment.current; |
| 22 | const error = yield* Effect.flip( |
| 23 | cloudtrail.getEventDataStore({ |
| 24 | EventDataStore: `arn:aws:cloudtrail:${region}:${accountId}:eventdatastore/00000000-0000-0000-0000-000000000000`, |
| 25 | }), |
| 26 | ); |
| 27 | expect(error._tag).toBe("EventDataStoreNotFoundException"); |
| 28 | }), |
| 29 | { tags: ["provider:aws", "provider:aws:cloudtrail", "live"] }, |
| 30 | ); |
| 31 | |
| 32 | // Ungated entitlement probe: CloudTrail Lake stopped accepting NEW |
| 33 | // customers (InvalidParameterException "CloudTrail Lake is no longer |
| 34 | // accepting new customers..."), which the distilled patch surfaces as the |
| 35 | // typed `CloudTrailLakeOnboardingClosed` tag. On a non-onboarded account |
| 36 | // this proves the patch; on an onboarded account the create succeeds and |
| 37 | // is immediately deleted (PENDING_DELETION incurs no cost). |
| 38 | const PROBE_STORE_NAME = "alchemy-test-cloudtrail-lake-probe"; |
| 39 | |
| 40 | // Idempotent, name-keyed cleanup for the out-of-band probe store. Because |
| 41 | // the probe's name is deterministic, this reclaims a leftover from a |
| 42 | // previously-killed run as well as the one created by this run. A store |
| 43 | // already in PENDING_DELETION is considered cleaned (Lake deletion always |
| 44 | // goes through a 7-day, zero-cost PENDING_DELETION window — there is no |
| 45 | // harder delete). |
| 46 | const deleteProbeStoreIfExists = Effect.gen(function* () { |
| 47 | const stores = yield* cloudtrail.listEventDataStores({}); |
| 48 | const existing = (stores.EventDataStores ?? []).find( |
| 49 | (s) => s.Name === PROBE_STORE_NAME && s.Status !== "PENDING_DELETION", |
| 50 | ); |
| 51 | if (existing?.EventDataStoreArn !== undefined) { |
| 52 | yield* cloudtrail |
| 53 | .deleteEventDataStore({ EventDataStore: existing.EventDataStoreArn }) |
| 54 | .pipe( |
| 55 | // A store still finishing creation can transiently conflict. |
| 56 | Effect.retry({ |
| 57 | while: (e) => e._tag === "ConflictException", |
| 58 | schedule: Schedule.exponential("2 seconds"), |
| 59 | times: 8, |
| 60 | }), |
| 61 | Effect.catchTag( |
| 62 | [ |
| 63 | "EventDataStoreNotFoundException", |
| 64 | "InactiveEventDataStoreException", |
| 65 | ], |
| 66 | () => Effect.void, |
| 67 | ), |
| 68 | ); |
| 69 | } |
| 70 | }); |
| 71 | |
| 72 | test.provider( |
| 73 | "createEventDataStore is either typed onboarding-closed or a real create", |
| 74 | () => |
| 75 | Effect.gen(function* () { |
| 76 | // Pre-clean: reclaim a leftover probe store from a killed prior run |
| 77 | // so the deterministic name is free and the test converges. |
| 78 | yield* deleteProbeStoreIfExists; |
| 79 | const attempt = yield* Effect.result( |
| 80 | cloudtrail.createEventDataStore({ |
| 81 | Name: PROBE_STORE_NAME, |
| 82 | MultiRegionEnabled: false, |
| 83 | RetentionPeriod: 7, |
| 84 | TerminationProtectionEnabled: false, |
| 85 | }), |
| 86 | ); |
| 87 | if (Result.isFailure(attempt)) { |
| 88 | expect([ |
| 89 | "CloudTrailLakeOnboardingClosed", |
| 90 | // A prior probe's store still pending deletion holds the name on |
| 91 | // an onboarded account. |
| 92 | "EventDataStoreAlreadyExistsException", |
| 93 | ]).toContain(attempt.failure._tag); |
| 94 | } |
| 95 | // Onboarded-account success path: the ensuring finalizer below |
| 96 | // deletes the store (PENDING_DELETION incurs no cost). |
| 97 | }).pipe( |
| 98 | // Guaranteed cleanup on success, failure, AND interruption. Keyed on |
| 99 | // the deterministic name, so it also covers the create-succeeded-but- |
| 100 | // fiber-killed-before-response window on the next run's pre-clean. |
| 101 | // orDie (not swallow): a failed cleanup must fail the test loudly — |
| 102 | // a green run must imply zero leftovers. |
| 103 | Effect.ensuring(deleteProbeStoreIfExists.pipe(Effect.orDie)), |
| 104 | ), |
| 105 | { tags: ["provider:aws", "provider:aws:cloudtrail", "live"] }, |
| 106 | ); |
| 107 | |
| 108 | const STORE_NAME = "alchemy-test-cloudtrail-eds"; |
| 109 | |
| 110 | // A deleted store sits in PENDING_DELETION for 7 days (no cost) and keeps |
| 111 | // its name; the provider restores such a store instead of creating a |
| 112 | // duplicate, which is what makes this test re-runnable day after day. |
| 113 | // |
| 114 | // GATED: CloudTrail Lake is closed to new customers (see the ungated probe |
| 115 | // above) — the full lifecycle only runs on an account that was onboarded |
| 116 | // to Lake before the closure. Set AWS_TEST_CLOUDTRAIL_LAKE=1 to run it. |
| 117 | test.provider.skipIf(!process.env.AWS_TEST_CLOUDTRAIL_LAKE)( |
| 118 | "create event data store, update retention, delete (PENDING_DELETION)", |
| 119 | (stack) => |
| 120 | Effect.gen(function* () { |
| 121 | yield* stack.destroy(); |
| 122 | |
| 123 | const make = (props: { |
| 124 | retentionPeriod: Duration.Input; |
| 125 | tags?: Record<string, string>; |
| 126 | ingestionEnabled?: boolean; |
| 127 | }) => |
| 128 | Effect.gen(function* () { |
| 129 | const store = yield* EventDataStore("Lake", { |
| 130 | name: STORE_NAME, |
| 131 | multiRegionEnabled: false, |
| 132 | retentionPeriod: props.retentionPeriod, |
| 133 | terminationProtectionEnabled: false, |
| 134 | ingestionEnabled: props.ingestionEnabled, |
| 135 | tags: props.tags, |
| 136 | }); |
| 137 | return { store }; |
| 138 | }); |
| 139 | |
| 140 | // 1. Create with the 7-day minimum retention. |
| 141 | const { store } = yield* stack.deploy( |
| 142 | make({ |
| 143 | retentionPeriod: "7 days", |
| 144 | tags: { fixture: "cloudtrail-eds" }, |
| 145 | }), |
| 146 | ); |
| 147 | expect(store.name).toBe(STORE_NAME); |
| 148 | expect(store.eventDataStoreArn).toContain(":eventdatastore/"); |
| 149 | expect(["CREATED", "ENABLED", "STARTING_INGESTION"]).toContain( |
| 150 | store.status, |
| 151 | ); |
| 152 | |
| 153 | // Out-of-band verification via distilled. |
| 154 | const observed = yield* cloudtrail.getEventDataStore({ |
| 155 | EventDataStore: store.eventDataStoreArn, |
| 156 | }); |
| 157 | expect(observed.Name).toBe(STORE_NAME); |
| 158 | expect(observed.RetentionPeriod).toBe(7); |
| 159 | expect(observed.TerminationProtectionEnabled).toBe(false); |
| 160 | expect(observed.MultiRegionEnabled).toBe(false); |
| 161 | const tags = yield* cloudtrail.listTags({ |
| 162 | ResourceIdList: [store.eventDataStoreArn], |
| 163 | }); |
| 164 | const tagRecord = Object.fromEntries( |
| 165 | (tags.ResourceTagList?.[0]?.TagsList ?? []).map((t) => [ |
| 166 | t.Key, |
| 167 | t.Value, |
| 168 | ]), |
| 169 | ); |
| 170 | expect(tagRecord.fixture).toBe("cloudtrail-eds"); |
| 171 | expect(tagRecord["alchemy::id"]).toBe("Lake"); |
| 172 | |
| 173 | // 2. Update retention in place — the ARN is the stable identity. |
| 174 | const { store: updated } = yield* stack.deploy( |
| 175 | make({ retentionPeriod: "14 days", tags: { team: "audit" } }), |
| 176 | ); |
| 177 | expect(updated.eventDataStoreArn).toBe(store.eventDataStoreArn); |
| 178 | const observedAfter = yield* cloudtrail.getEventDataStore({ |
| 179 | EventDataStore: store.eventDataStoreArn, |
| 180 | }); |
| 181 | expect(observedAfter.RetentionPeriod).toBe(14); |
| 182 | const tagsAfter = yield* cloudtrail.listTags({ |
| 183 | ResourceIdList: [store.eventDataStoreArn], |
| 184 | }); |
| 185 | const tagRecordAfter = Object.fromEntries( |
| 186 | (tagsAfter.ResourceTagList?.[0]?.TagsList ?? []).map((t) => [ |
| 187 | t.Key, |
| 188 | t.Value, |
| 189 | ]), |
| 190 | ); |
| 191 | expect(tagRecordAfter.team).toBe("audit"); |
| 192 | expect(tagRecordAfter.fixture).toBeUndefined(); |
| 193 | |
| 194 | // 2b. Stop ingestion — the store's Status converges to |
| 195 | // STOPPED_INGESTION; collected events stay queryable. |
| 196 | const { store: stopped } = yield* stack.deploy( |
| 197 | make({ |
| 198 | retentionPeriod: "14 days", |
| 199 | tags: { team: "audit" }, |
| 200 | ingestionEnabled: false, |
| 201 | }), |
| 202 | ); |
| 203 | expect(stopped.eventDataStoreArn).toBe(store.eventDataStoreArn); |
| 204 | const observedStopped = yield* cloudtrail.getEventDataStore({ |
| 205 | EventDataStore: store.eventDataStoreArn, |
| 206 | }); |
| 207 | expect(["STOPPING_INGESTION", "STOPPED_INGESTION"]).toContain( |
| 208 | observedStopped.Status, |
| 209 | ); |
| 210 | |
| 211 | // 3. Delete — schedules PENDING_DELETION (7-day wait, zero cost); |
| 212 | // do NOT wait for the store to disappear. |
| 213 | yield* stack.destroy(); |
| 214 | const afterDelete = yield* cloudtrail |
| 215 | .getEventDataStore({ EventDataStore: store.eventDataStoreArn }) |
| 216 | .pipe( |
| 217 | Effect.map((r) => r.Status ?? "UNKNOWN"), |
| 218 | Effect.catchTag("EventDataStoreNotFoundException", () => |
| 219 | Effect.succeed("GONE" as const), |
| 220 | ), |
| 221 | ); |
| 222 | expect(["PENDING_DELETION", "GONE"]).toContain(afterDelete); |
| 223 | }), |
| 224 | { |
| 225 | tags: ["provider:aws", "provider:aws:cloudtrail", "live"], |
| 226 | timeout: 180_000, |
| 227 | }, |
| 228 | ); |