t3-code-android-nightly/.repos/alchemy-effect/packages/alchemy/test/AWS/CloudTrail/Trail.test.ts
Julius Marminge 6f9cea00ae
chore(refs): sync Effect and Alchemy references to 4.0.1 and beta.80 (#16170)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 13:22:30 -07:00

218 lines
8 KiB
TypeScript

import * as AWS from "@/AWS";
import {
Trail,
type TrailAdvancedEventSelector,
type TrailInsightSelector,
} from "@/AWS/CloudTrail";
import { AWSEnvironment } from "@/AWS/Environment.ts";
import { Bucket } from "@/AWS/S3/Bucket.ts";
import * as Test from "@/Test/Alchemy";
import * as cloudtrail from "@distilled.cloud/aws/cloudtrail";
import { expect } from "alchemy-test";
import * as Effect from "effect/Effect";
const { test } = Test.make({ providers: AWS.providers() });
// Ungated typed-error probe: prove the distilled error union carries the
// not-found tag this provider's read/delete paths depend on. Runs in every
// CI pass at near-zero cost.
test.provider(
"getTrail on a nonexistent trail fails with TrailNotFoundException",
() =>
Effect.gen(function* () {
const error = yield* Effect.flip(
cloudtrail.getTrail({ Name: "alchemy-nonexistent-trail-probe" }),
);
expect(error._tag).toBe("TrailNotFoundException");
}),
{ tags: ["provider:aws", "provider:aws:cloudtrail", "live"] },
);
const TRAIL_NAME = "alchemy-test-cloudtrail-trail";
test.provider(
"create trail with S3 bucket + policy, update settings and logging, delete",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const { accountId, region } = yield* AWSEnvironment.current;
// Bucket names are globally unique — key ours to the account. The
// trail ARN is deterministic, so the bucket policy can reference it
// before the trail exists (no circular dependency).
const bucketName = `alchemy-test-cloudtrail-logs-${accountId}`;
const trailArn = `arn:aws:cloudtrail:${region}:${accountId}:trail/${TRAIL_NAME}`;
const make = (props: {
isLogging?: boolean;
includeGlobalServiceEvents?: boolean;
s3KeyPrefix?: string;
tags?: Record<string, string>;
advancedEventSelectors?: TrailAdvancedEventSelector[];
insightSelectors?: TrailInsightSelector[];
}) =>
Effect.gen(function* () {
const bucket = yield* Bucket("TrailLogs", {
bucketName,
forceDestroy: true,
policy: [
{
Effect: "Allow",
Principal: { Service: "cloudtrail.amazonaws.com" },
Action: ["s3:GetBucketAcl"],
Resource: `arn:aws:s3:::${bucketName}`,
Condition: { StringEquals: { "aws:SourceArn": trailArn } },
},
{
Effect: "Allow",
Principal: { Service: "cloudtrail.amazonaws.com" },
Action: ["s3:PutObject"],
// Cover both the default `AWSLogs/...` path and the
// `audit/AWSLogs/...` path used after the s3KeyPrefix
// update below. Still constrained by aws:SourceArn.
Resource: [
`arn:aws:s3:::${bucketName}/AWSLogs/${accountId}/*`,
`arn:aws:s3:::${bucketName}/audit/AWSLogs/${accountId}/*`,
],
Condition: {
StringEquals: {
"s3:x-amz-acl": "bucket-owner-full-control",
"aws:SourceArn": trailArn,
},
},
},
],
});
const trail = yield* Trail("Audit", {
trailName: TRAIL_NAME,
s3BucketName: bucket.bucketName,
...props,
});
return { trail };
});
// 1. Create — logging on by default.
const { trail } = yield* stack.deploy(
make({ tags: { fixture: "cloudtrail-trail" } }),
);
expect(trail.trailName).toBe(TRAIL_NAME);
expect(trail.trailArn).toBe(trailArn);
expect(trail.homeRegion).toBe(region);
expect(trail.s3BucketName).toBe(bucketName);
expect(trail.isLogging).toBe(true);
// Out-of-band verification via distilled.
const observed = yield* cloudtrail.getTrail({ Name: TRAIL_NAME });
expect(observed.Trail?.TrailARN).toBe(trailArn);
expect(observed.Trail?.S3BucketName).toBe(bucketName);
expect(observed.Trail?.IncludeGlobalServiceEvents).toBe(true);
expect(observed.Trail?.IsMultiRegionTrail).toBe(false);
const status = yield* cloudtrail.getTrailStatus({ Name: trailArn });
expect(status.IsLogging).toBe(true);
const tags = yield* cloudtrail.listTags({ ResourceIdList: [trailArn] });
const tagRecord = Object.fromEntries(
(tags.ResourceTagList?.[0]?.TagsList ?? []).map((t) => [
t.Key,
t.Value,
]),
);
expect(tagRecord.fixture).toBe("cloudtrail-trail");
expect(tagRecord["alchemy::id"]).toBe("Audit");
// 2. Sync event + Insights selectors while logging is still on.
const { trail: withSelectors } = yield* stack.deploy(
make({
tags: { fixture: "cloudtrail-trail" },
advancedEventSelectors: [
{
name: "Management events",
fieldSelectors: [
{ field: "eventCategory", equals: ["Management"] },
],
},
],
insightSelectors: [{ insightType: "ApiCallRateInsight" }],
}),
);
expect(withSelectors.trailArn).toBe(trailArn);
const selectors = yield* cloudtrail.getEventSelectors({
TrailName: trailArn,
});
expect(selectors.AdvancedEventSelectors?.[0]?.Name).toBe(
"Management events",
);
const insights = yield* cloudtrail.getInsightSelectors({
TrailName: trailArn,
});
expect(
(insights.InsightSelectors ?? []).map((s) => s.InsightType),
).toContain("ApiCallRateInsight");
// 3. Update in place — stop logging, flip settings, swap tags,
// disable Insights (`[]`). Omitted event selectors stay untouched.
const { trail: updated } = yield* stack.deploy(
make({
isLogging: false,
includeGlobalServiceEvents: false,
s3KeyPrefix: "audit",
tags: { team: "security" },
insightSelectors: [],
}),
);
expect(updated.trailArn).toBe(trail.trailArn);
expect(updated.isLogging).toBe(false);
const observedAfter = yield* cloudtrail.getTrail({ Name: TRAIL_NAME });
expect(observedAfter.Trail?.IncludeGlobalServiceEvents).toBe(false);
expect(observedAfter.Trail?.S3KeyPrefix).toBe("audit");
const statusAfter = yield* cloudtrail.getTrailStatus({ Name: trailArn });
expect(statusAfter.IsLogging).toBe(false);
const tagsAfter = yield* cloudtrail.listTags({
ResourceIdList: [trailArn],
});
const tagRecordAfter = Object.fromEntries(
(tagsAfter.ResourceTagList?.[0]?.TagsList ?? []).map((t) => [
t.Key,
t.Value,
]),
);
expect(tagRecordAfter.team).toBe("security");
expect(tagRecordAfter.fixture).toBeUndefined();
expect(tagRecordAfter["alchemy::id"]).toBe("Audit");
// Insights disabled: reads back as the typed InsightNotEnabledException
// (or an empty list, depending on propagation).
const insightsAfter = yield* cloudtrail
.getInsightSelectors({ TrailName: trailArn })
.pipe(
Effect.map((r) => r.InsightSelectors ?? []),
Effect.catchTag("InsightNotEnabledException", () =>
Effect.succeed([] as cloudtrail.InsightSelector[]),
),
);
expect(insightsAfter).toEqual([]);
// Omitted event selectors were left untouched by the update.
const selectorsAfter = yield* cloudtrail.getEventSelectors({
TrailName: trailArn,
});
expect(selectorsAfter.AdvancedEventSelectors?.[0]?.Name).toBe(
"Management events",
);
// 4. Delete — trail deletion is synchronous.
yield* stack.destroy();
const gone = yield* Effect.flip(
cloudtrail.getTrail({ Name: TRAIL_NAME }),
);
expect(gone._tag).toBe("TrailNotFoundException");
}),
{
tags: [
"provider:aws",
"provider:aws:cloudtrail",
"provider:aws:s3",
"live",
],
timeout: 240_000,
},
);