1import * as AWS from "@/AWS";
2import {
3 Trail,
4 type TrailAdvancedEventSelector,
5 type TrailInsightSelector,
6} from "@/AWS/CloudTrail";
7import { AWSEnvironment } from "@/AWS/Environment.ts";
8import { Bucket } from "@/AWS/S3/Bucket.ts";
9import * as Test from "@/Test/Alchemy";
10import * as cloudtrail from "@distilled.cloud/aws/cloudtrail";
11import { expect } from "alchemy-test";
12import * as Effect from "effect/Effect";
13
14const { test } = Test.make({ providers: AWS.providers() });
15
16// Ungated typed-error probe: prove the distilled error union carries the
17// not-found tag this provider's read/delete paths depend on. Runs in every
18// CI pass at near-zero cost.
19test.provider(
20 "getTrail on a nonexistent trail fails with TrailNotFoundException",
21 () =>
22 Effect.gen(function* () {
23 const error = yield* Effect.flip(
24 cloudtrail.getTrail({ Name: "alchemy-nonexistent-trail-probe" }),
25 );
26 expect(error._tag).toBe("TrailNotFoundException");
27 }),
28 { tags: ["provider:aws", "provider:aws:cloudtrail", "live"] },
29);
30
31const TRAIL_NAME = "alchemy-test-cloudtrail-trail";
32
33test.provider(
34 "create trail with S3 bucket + policy, update settings and logging, delete",
35 (stack) =>
36 Effect.gen(function* () {
37 yield* stack.destroy();
38
39 const { accountId, region } = yield* AWSEnvironment.current;
40 // Bucket names are globally unique — key ours to the account. The
41 // trail ARN is deterministic, so the bucket policy can reference it
42 // before the trail exists (no circular dependency).
43 const bucketName = `alchemy-test-cloudtrail-logs-${accountId}`;
44 const trailArn = `arn:aws:cloudtrail:${region}:${accountId}:trail/${TRAIL_NAME}`;
45
46 const make = (props: {
47 isLogging?: boolean;
48 includeGlobalServiceEvents?: boolean;
49 s3KeyPrefix?: string;
50 tags?: Record<string, string>;
51 advancedEventSelectors?: TrailAdvancedEventSelector[];
52 insightSelectors?: TrailInsightSelector[];
53 }) =>
54 Effect.gen(function* () {
55 const bucket = yield* Bucket("TrailLogs", {
56 bucketName,
57 forceDestroy: true,
58 policy: [
59 {
60 Effect: "Allow",
61 Principal: { Service: "cloudtrail.amazonaws.com" },
62 Action: ["s3:GetBucketAcl"],
63 Resource: `arn:aws:s3:::${bucketName}`,
64 Condition: { StringEquals: { "aws:SourceArn": trailArn } },
65 },
66 {
67 Effect: "Allow",
68 Principal: { Service: "cloudtrail.amazonaws.com" },
69 Action: ["s3:PutObject"],
70 // Cover both the default `AWSLogs/...` path and the
71 // `audit/AWSLogs/...` path used after the s3KeyPrefix
72 // update below. Still constrained by aws:SourceArn.
73 Resource: [
74 `arn:aws:s3:::${bucketName}/AWSLogs/${accountId}/*`,
75 `arn:aws:s3:::${bucketName}/audit/AWSLogs/${accountId}/*`,
76 ],
77 Condition: {
78 StringEquals: {
79 "s3:x-amz-acl": "bucket-owner-full-control",
80 "aws:SourceArn": trailArn,
81 },
82 },
83 },
84 ],
85 });
86 const trail = yield* Trail("Audit", {
87 trailName: TRAIL_NAME,
88 s3BucketName: bucket.bucketName,
89 ...props,
90 });
91 return { trail };
92 });
93
94 // 1. Create — logging on by default.
95 const { trail } = yield* stack.deploy(
96 make({ tags: { fixture: "cloudtrail-trail" } }),
97 );
98 expect(trail.trailName).toBe(TRAIL_NAME);
99 expect(trail.trailArn).toBe(trailArn);
100 expect(trail.homeRegion).toBe(region);
101 expect(trail.s3BucketName).toBe(bucketName);
102 expect(trail.isLogging).toBe(true);
103
104 // Out-of-band verification via distilled.
105 const observed = yield* cloudtrail.getTrail({ Name: TRAIL_NAME });
106 expect(observed.Trail?.TrailARN).toBe(trailArn);
107 expect(observed.Trail?.S3BucketName).toBe(bucketName);
108 expect(observed.Trail?.IncludeGlobalServiceEvents).toBe(true);
109 expect(observed.Trail?.IsMultiRegionTrail).toBe(false);
110 const status = yield* cloudtrail.getTrailStatus({ Name: trailArn });
111 expect(status.IsLogging).toBe(true);
112 const tags = yield* cloudtrail.listTags({ ResourceIdList: [trailArn] });
113 const tagRecord = Object.fromEntries(
114 (tags.ResourceTagList?.[0]?.TagsList ?? []).map((t) => [
115 t.Key,
116 t.Value,
117 ]),
118 );
119 expect(tagRecord.fixture).toBe("cloudtrail-trail");
120 expect(tagRecord["alchemy::id"]).toBe("Audit");
121
122 // 2. Sync event + Insights selectors while logging is still on.
123 const { trail: withSelectors } = yield* stack.deploy(
124 make({
125 tags: { fixture: "cloudtrail-trail" },
126 advancedEventSelectors: [
127 {
128 name: "Management events",
129 fieldSelectors: [
130 { field: "eventCategory", equals: ["Management"] },
131 ],
132 },
133 ],
134 insightSelectors: [{ insightType: "ApiCallRateInsight" }],
135 }),
136 );
137 expect(withSelectors.trailArn).toBe(trailArn);
138 const selectors = yield* cloudtrail.getEventSelectors({
139 TrailName: trailArn,
140 });
141 expect(selectors.AdvancedEventSelectors?.[0]?.Name).toBe(
142 "Management events",
143 );
144 const insights = yield* cloudtrail.getInsightSelectors({
145 TrailName: trailArn,
146 });
147 expect(
148 (insights.InsightSelectors ?? []).map((s) => s.InsightType),
149 ).toContain("ApiCallRateInsight");
150
151 // 3. Update in place — stop logging, flip settings, swap tags,
152 // disable Insights (`[]`). Omitted event selectors stay untouched.
153 const { trail: updated } = yield* stack.deploy(
154 make({
155 isLogging: false,
156 includeGlobalServiceEvents: false,
157 s3KeyPrefix: "audit",
158 tags: { team: "security" },
159 insightSelectors: [],
160 }),
161 );
162 expect(updated.trailArn).toBe(trail.trailArn);
163 expect(updated.isLogging).toBe(false);
164
165 const observedAfter = yield* cloudtrail.getTrail({ Name: TRAIL_NAME });
166 expect(observedAfter.Trail?.IncludeGlobalServiceEvents).toBe(false);
167 expect(observedAfter.Trail?.S3KeyPrefix).toBe("audit");
168 const statusAfter = yield* cloudtrail.getTrailStatus({ Name: trailArn });
169 expect(statusAfter.IsLogging).toBe(false);
170 const tagsAfter = yield* cloudtrail.listTags({
171 ResourceIdList: [trailArn],
172 });
173 const tagRecordAfter = Object.fromEntries(
174 (tagsAfter.ResourceTagList?.[0]?.TagsList ?? []).map((t) => [
175 t.Key,
176 t.Value,
177 ]),
178 );
179 expect(tagRecordAfter.team).toBe("security");
180 expect(tagRecordAfter.fixture).toBeUndefined();
181 expect(tagRecordAfter["alchemy::id"]).toBe("Audit");
182
183 // Insights disabled: reads back as the typed InsightNotEnabledException
184 // (or an empty list, depending on propagation).
185 const insightsAfter = yield* cloudtrail
186 .getInsightSelectors({ TrailName: trailArn })
187 .pipe(
188 Effect.map((r) => r.InsightSelectors ?? []),
189 Effect.catchTag("InsightNotEnabledException", () =>
190 Effect.succeed([] as cloudtrail.InsightSelector[]),
191 ),
192 );
193 expect(insightsAfter).toEqual([]);
194 // Omitted event selectors were left untouched by the update.
195 const selectorsAfter = yield* cloudtrail.getEventSelectors({
196 TrailName: trailArn,
197 });
198 expect(selectorsAfter.AdvancedEventSelectors?.[0]?.Name).toBe(
199 "Management events",
200 );
201
202 // 4. Delete — trail deletion is synchronous.
203 yield* stack.destroy();
204 const gone = yield* Effect.flip(
205 cloudtrail.getTrail({ Name: TRAIL_NAME }),
206 );
207 expect(gone._tag).toBe("TrailNotFoundException");
208 }),
209 {
210 tags: [
211 "provider:aws",
212 "provider:aws:cloudtrail",
213 "provider:aws:s3",
214 "live",
215 ],
216 timeout: 240_000,
217 },
218);