1import * as CloudTrail from "@/AWS/CloudTrail";
2import * as Lambda from "@/AWS/Lambda";
3import * as S3 from "@/AWS/S3";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Result from "effect/Result";
8import * as Stream from "effect/Stream";
9import { HttpServerRequest } from "effect/http/HttpServerRequest";
10import * as HttpServerResponse from "effect/http/HttpServerResponse";
11import path from "pathe";
12
13const main = path.resolve(import.meta.dirname, "handler.ts");
14
15/**
16 * Probe helper: run the bound operation and report either the success
17 * projection or the typed error tag, so the test can assert that operations
18 * rejected by CloudTrail fail with a TYPED tag (never an untyped catch-all).
19 */
20const tagOr = <A, E extends { _tag: string }, R>(
21 effect: Effect.Effect<A, E, R>,
22 onSuccess: (value: A) => Record<string, unknown>,
23) =>
24 Effect.result(effect).pipe(
25 Effect.map((result) =>
26 Result.isSuccess(result)
27 ? onSuccess(result.success)
28 : { errorTag: result.failure._tag },
29 ),
30 );
31
32export class CloudTrailTestFunction extends Lambda.Function<Lambda.Function>()(
33 "CloudTrailTestFunction",
34) {}
35
36export default CloudTrailTestFunction.make(
37 {
38 main,
39 functionUrl: true,
40 // LookupEvents scans + distilled's bounded retries must complete within
41 // the invocation; AWS's 3s default would intermittently time out.
42 timeout: Duration.seconds(30),
43 },
44 Effect.gen(function* () {
45 // Holds the event-source markers (under events/).
46 const bucket = yield* S3.Bucket("CloudTrailEvents", {
47 forceDestroy: true,
48 });
49 const BucketName = yield* bucket.bucketName;
50
51 const putObject = yield* S3.PutObject(bucket);
52 const getObject = yield* S3.GetObject(bucket);
53
54 // --- account-level bindings ---
55 const lookupEvents = yield* CloudTrail.LookupEvents();
56 const listPublicKeys = yield* CloudTrail.ListPublicKeys();
57 const listInsightsMetricData = yield* CloudTrail.ListInsightsMetricData();
58 const listInsightsData = yield* CloudTrail.ListInsightsData();
59
60 // --- event source ---
61 // EventBridge receives every mutating management API call CloudTrail
62 // records; write a marker object per PutBucketTagging call so
63 // /events/check can observe the delivery out-of-band (the event may
64 // arrive on another instance).
65 yield* CloudTrail.consumeApiCallEvents(
66 {
67 eventSources: ["s3.amazonaws.com"],
68 eventNames: ["PutBucketTagging"],
69 },
70 (events) =>
71 Stream.runForEach(events, (event) =>
72 putObject({
73 Key: `events/${event.detail.requestParameters?.bucketName}`,
74 Body: JSON.stringify({
75 eventName: event.detail.eventName,
76 eventSource: event.detail.eventSource,
77 eventID: event.detail.eventID,
78 }),
79 ContentType: "application/json",
80 }).pipe(Effect.orDie, Effect.asVoid),
81 ),
82 );
83
84 return {
85 fetch: Effect.gen(function* () {
86 const request = yield* HttpServerRequest;
87 const url = new URL(request.originalUrl);
88 const pathname = url.pathname;
89 const param = (name: string) => url.searchParams.get(name) ?? "";
90
91 // Report the fixture bucket so the test can tag it out-of-band.
92 if (request.method === "GET" && pathname === "/info") {
93 return yield* HttpServerResponse.json({
94 bucketName: yield* BucketName,
95 });
96 }
97
98 // 90-day management event history — always available, no trail
99 // needed. Mutating calls always exist in an active test account.
100 if (request.method === "GET" && pathname === "/lookup") {
101 const result = yield* lookupEvents({ MaxResults: 5 });
102 return yield* HttpServerResponse.json({
103 count: (result.Events ?? []).length,
104 names: (result.Events ?? []).map((e) => e.EventName),
105 });
106 }
107
108 if (request.method === "GET" && pathname === "/public-keys") {
109 const result = yield* listPublicKeys();
110 return yield* HttpServerResponse.json({
111 fingerprints: (result.PublicKeyList ?? []).map(
112 (k) => k.Fingerprint,
113 ),
114 });
115 }
116
117 // Insights metrics read; empty series when Insights has recorded no
118 // anomalies. A rejection must surface as a TYPED tag.
119 if (request.method === "GET" && pathname === "/insights-metric") {
120 return yield* HttpServerResponse.json(
121 yield* tagOr(
122 listInsightsMetricData({
123 EventSource: "s3.amazonaws.com",
124 EventName: "PutObject",
125 InsightType: "ApiCallRateInsight",
126 MaxResults: 5,
127 }),
128 (result) => ({
129 timestamps: (result.Timestamps ?? []).length,
130 values: (result.Values ?? []).length,
131 }),
132 ),
133 );
134 }
135
136 // Raw Insights events read; empty when Insights has recorded no
137 // anomalies (none are enabled in the test account). A rejection must
138 // surface as a TYPED tag.
139 if (request.method === "GET" && pathname === "/insights-data") {
140 return yield* HttpServerResponse.json(
141 yield* tagOr(
142 listInsightsData({
143 InsightSource: "s3.amazonaws.com",
144 DataType: "InsightsEvents",
145 MaxResults: 5,
146 }),
147 (result) => ({ events: (result.Events ?? []).length }),
148 ),
149 );
150 }
151
152 // Has the API-call event for this bucket been delivered yet?
153 if (request.method === "GET" && pathname === "/events/check") {
154 const seen = yield* getObject({
155 Key: `events/${param("bucket")}`,
156 }).pipe(
157 Effect.map(() => true),
158 Effect.catchTag("NoSuchKey", () => Effect.succeed(false)),
159 );
160 return yield* HttpServerResponse.json({ seen });
161 }
162
163 return yield* HttpServerResponse.json(
164 { error: "Not found", method: request.method, pathname },
165 { status: 404 },
166 );
167 }).pipe(Effect.orDie),
168 };
169 }).pipe(
170 Effect.provide(
171 Layer.mergeAll(
172 Lambda.EventSource,
173 CloudTrail.LookupEventsHttp,
174 CloudTrail.ListPublicKeysHttp,
175 CloudTrail.ListInsightsMetricDataHttp,
176 CloudTrail.ListInsightsDataHttp,
177 S3.PutObjectHttp,
178 S3.GetObjectHttp,
179 ),
180 ),
181 ),
182);