| 1 | import * as AWS from "@/AWS"; |
| 2 | import { |
| 3 | Trail, |
| 4 | type TrailAdvancedEventSelector, |
| 5 | type TrailInsightSelector, |
| 6 | } from "@/AWS/CloudTrail"; |
| 7 | import { AWSEnvironment } from "@/AWS/Environment.ts"; |
| 8 | import { Bucket } from "@/AWS/S3/Bucket.ts"; |
| 9 | import * as Test from "@/Test/Alchemy"; |
| 10 | import * as cloudtrail from "@distilled.cloud/aws/cloudtrail"; |
| 11 | import { expect } from "alchemy-test"; |
| 12 | import * as Effect from "effect/Effect"; |
| 13 | |
| 14 | const { 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. |
| 19 | test.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 | |
| 31 | const TRAIL_NAME = "alchemy-test-cloudtrail-trail"; |
| 32 | |
| 33 | test.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 | ); |