1import * as AWS from "@/AWS";
2import { EventDataStore } from "@/AWS/CloudTrail";
3import { AWSEnvironment } from "@/AWS/Environment.ts";
4import * as Test from "@/Test/Alchemy";
5import * as cloudtrail from "@distilled.cloud/aws/cloudtrail";
6import { expect } from "alchemy-test";
7import type * as Duration from "effect/Duration";
8import * as Effect from "effect/Effect";
9import * as Result from "effect/Result";
10import * as Schedule from "effect/Schedule";
11
12const { 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.
17test.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).
38const 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).
46const 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
72test.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
108const 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.
117test.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);