1import * as Lambda from "@/AWS/Lambda";
2import * as Macie2 from "@/AWS/Macie2";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import { HttpServerRequest } from "effect/http/HttpServerRequest";
7import * as HttpServerResponse from "effect/http/HttpServerResponse";
8import path from "pathe";
9
10const main = path.resolve(import.meta.dirname, "handler.ts");
11
12export class Macie2TestFunction extends Lambda.Function<Lambda.Function>()(
13 "Macie2TestFunction",
14) {}
15
16/**
17 * Routes answer `{ …fields }` on success or `{ errorTag }` when the operation
18 * fails with a TYPED error — the test asserts the tag is in a route-specific
19 * allowlist, which proves both the binding wiring and the IAM grant. An
20 * untyped error crashes into a 500 instead.
21 */
22const errorTagged = <A, E extends { _tag: string }, R>(
23 effect: Effect.Effect<A, E, R>,
24): Effect.Effect<A | { errorTag: string; errorMessage?: string }, never, R> =>
25 effect.pipe(
26 Effect.map((a): A | { errorTag: string; errorMessage?: string } => a),
27 Effect.catch((e) =>
28 Effect.succeed({
29 errorTag: e._tag,
30 errorMessage:
31 (e as { Message?: string }).Message ??
32 (e as { message?: string }).message,
33 }),
34 ),
35 );
36
37// One binding per exercised Macie capability. Every Macie2 binding shares the
38// single account-level scaffolding shape (the request passes through as-is),
39// so a representative spread across the thematic groups proves the scaffold;
40// org/member writes with cross-account side effects (CreateMember,
41// EnableOrganizationAdminAccount, …) use the same builder and are not
42// re-proven here.
43export default Macie2TestFunction.make(
44 {
45 main,
46 functionUrl: true,
47 timeout: Duration.seconds(30),
48 },
49 Effect.gen(function* () {
50 // Macie must be enabled for every runtime call — the session is the
51 // account/region singleton the whole fixture leans on. The Bindings test
52 // only runs when the account had no session (capture-and-restore safety).
53 yield* Macie2.Session("BindingsSession", {
54 status: "ENABLED",
55 findingPublishingFrequency: "FIFTEEN_MINUTES",
56 });
57
58 // Findings triage loop
59 const createSampleFindings = yield* Macie2.CreateSampleFindings();
60 const listFindings = yield* Macie2.ListFindings();
61 const getFindings = yield* Macie2.GetFindings();
62 const getFindingStatistics = yield* Macie2.GetFindingStatistics();
63
64 // S3 bucket inventory
65 const getBucketStatistics = yield* Macie2.GetBucketStatistics();
66 const searchResources = yield* Macie2.SearchResources();
67
68 // Identifiers & lists
69 const listManagedDataIdentifiers =
70 yield* Macie2.ListManagedDataIdentifiers();
71 const testCustomDataIdentifier = yield* Macie2.TestCustomDataIdentifier();
72 const listAllowLists = yield* Macie2.ListAllowLists();
73 const listFindingsFilters = yield* Macie2.ListFindingsFilters();
74
75 // Jobs, export & usage
76 const listClassificationJobs = yield* Macie2.ListClassificationJobs();
77 const getClassificationExportConfiguration =
78 yield* Macie2.GetClassificationExportConfiguration();
79 const getUsageTotals = yield* Macie2.GetUsageTotals();
80
81 // Automated discovery & reveal
82 const getAutomatedDiscoveryConfiguration =
83 yield* Macie2.GetAutomatedDiscoveryConfiguration();
84 const listClassificationScopes = yield* Macie2.ListClassificationScopes();
85 const getRevealConfiguration = yield* Macie2.GetRevealConfiguration();
86
87 // Administrator, invitations & organization
88 const getAdministratorAccount = yield* Macie2.GetAdministratorAccount();
89 const getInvitationsCount = yield* Macie2.GetInvitationsCount();
90 const listInvitations = yield* Macie2.ListInvitations();
91 const listMembers = yield* Macie2.ListMembers();
92 const listOrganizationAdminAccounts =
93 yield* Macie2.ListOrganizationAdminAccounts();
94 const describeOrganizationConfiguration =
95 yield* Macie2.DescribeOrganizationConfiguration();
96
97 const bound = {
98 createSampleFindings,
99 listFindings,
100 getFindings,
101 getFindingStatistics,
102 getBucketStatistics,
103 searchResources,
104 listManagedDataIdentifiers,
105 testCustomDataIdentifier,
106 listAllowLists,
107 listFindingsFilters,
108 listClassificationJobs,
109 getClassificationExportConfiguration,
110 getUsageTotals,
111 getAutomatedDiscoveryConfiguration,
112 listClassificationScopes,
113 getRevealConfiguration,
114 getAdministratorAccount,
115 getInvitationsCount,
116 listInvitations,
117 listMembers,
118 listOrganizationAdminAccounts,
119 describeOrganizationConfiguration,
120 };
121
122 return {
123 fetch: Effect.gen(function* () {
124 const request = yield* HttpServerRequest;
125 const url = new URL(request.originalUrl);
126 const pathname = url.pathname;
127
128 if (request.method === "GET" && pathname === "/bindings") {
129 return yield* HttpServerResponse.json({ bound: Object.keys(bound) });
130 }
131
132 // Generates sample findings — the documented way to exercise a
133 // findings pipeline without staging real sensitive data.
134 if (request.method === "POST" && pathname === "/sample") {
135 yield* createSampleFindings({});
136 return yield* HttpServerResponse.json({ ok: true });
137 }
138
139 if (request.method === "GET" && pathname === "/findings") {
140 const { findingIds } = yield* listFindings();
141 return yield* HttpServerResponse.json({
142 count: (findingIds ?? []).length,
143 ids: (findingIds ?? []).slice(0, 5),
144 });
145 }
146
147 if (request.method === "GET" && pathname === "/finding-detail") {
148 const id = url.searchParams.get("id");
149 if (!id) {
150 return yield* HttpServerResponse.json(
151 { error: "missing id" },
152 { status: 400 },
153 );
154 }
155 const { findings } = yield* getFindings({ findingIds: [id] });
156 return yield* HttpServerResponse.json({
157 count: (findings ?? []).length,
158 type: findings?.[0]?.type,
159 sample: findings?.[0]?.sample ?? false,
160 });
161 }
162
163 if (request.method === "GET" && pathname === "/finding-stats") {
164 const { countsByGroup } = yield* getFindingStatistics({
165 groupBy: "severity.description",
166 });
167 return yield* HttpServerResponse.json({
168 groups: (countsByGroup ?? []).length,
169 });
170 }
171
172 if (request.method === "GET" && pathname === "/buckets") {
173 const stats = yield* getBucketStatistics({});
174 return yield* HttpServerResponse.json({
175 bucketCount: Number(stats.bucketCount ?? 0),
176 });
177 }
178
179 if (request.method === "GET" && pathname === "/search") {
180 // SearchResources requires bucket criteria — exclude a bucket that
181 // cannot exist, i.e. match everything.
182 const { matchingResources } = yield* searchResources({
183 bucketCriteria: {
184 excludes: {
185 and: [
186 {
187 simpleCriterion: {
188 comparator: "EQ",
189 key: "S3_BUCKET_NAME",
190 values: ["macie2-bindings-nonexistent-bucket"],
191 },
192 },
193 ],
194 },
195 },
196 });
197 return yield* HttpServerResponse.json({
198 matches: (matchingResources ?? []).length,
199 });
200 }
201
202 if (request.method === "GET" && pathname === "/managed-identifiers") {
203 const { items } = yield* listManagedDataIdentifiers();
204 return yield* HttpServerResponse.json({
205 count: (items ?? []).length,
206 });
207 }
208
209 // A real detection round-trip that needs no cloud state at all.
210 if (request.method === "POST" && pathname === "/test-identifier") {
211 const { matchCount } = yield* testCustomDataIdentifier({
212 regex: "EMP-[0-9]{8}",
213 sampleText: "ids EMP-12345678 and EMP-87654321 but not EMP-123",
214 });
215 return yield* HttpServerResponse.json({ matchCount });
216 }
217
218 if (request.method === "GET" && pathname === "/allow-lists") {
219 const { allowLists } = yield* listAllowLists();
220 return yield* HttpServerResponse.json({
221 count: (allowLists ?? []).length,
222 });
223 }
224
225 if (request.method === "GET" && pathname === "/findings-filters") {
226 const { findingsFilterListItems } = yield* listFindingsFilters();
227 return yield* HttpServerResponse.json({
228 count: (findingsFilterListItems ?? []).length,
229 });
230 }
231
232 if (request.method === "GET" && pathname === "/jobs") {
233 const { items } = yield* listClassificationJobs();
234 return yield* HttpServerResponse.json({
235 count: (items ?? []).length,
236 });
237 }
238
239 // A fresh session has no export configuration — an empty document.
240 if (request.method === "GET" && pathname === "/export-config") {
241 const { configuration } =
242 yield* getClassificationExportConfiguration();
243 return yield* HttpServerResponse.json({
244 configured: configuration?.s3Destination !== undefined,
245 });
246 }
247
248 if (request.method === "GET" && pathname === "/usage") {
249 const { usageTotals } = yield* getUsageTotals();
250 return yield* HttpServerResponse.json({
251 totals: (usageTotals ?? []).length,
252 });
253 }
254
255 if (request.method === "GET" && pathname === "/auto-discovery") {
256 const { status } = yield* getAutomatedDiscoveryConfiguration();
257 return yield* HttpServerResponse.json({ status: status ?? null });
258 }
259
260 if (request.method === "GET" && pathname === "/scopes") {
261 const { classificationScopes } = yield* listClassificationScopes();
262 return yield* HttpServerResponse.json({
263 count: (classificationScopes ?? []).length,
264 });
265 }
266
267 // A fresh session rejects the reveal-configuration read with a typed
268 // AccessDeniedException until sample retrieval is set up.
269 if (request.method === "GET" && pathname === "/reveal") {
270 const result = yield* errorTagged(
271 getRevealConfiguration().pipe(
272 Effect.map((r) => ({
273 status: r.configuration?.status ?? null,
274 })),
275 ),
276 );
277 return yield* HttpServerResponse.json(result);
278 }
279
280 // A standalone account has no administrator — Macie answers with an
281 // empty document or a typed error.
282 if (request.method === "GET" && pathname === "/admin") {
283 const result = yield* errorTagged(
284 getAdministratorAccount().pipe(
285 Effect.map((r) => ({
286 administrator: r.administrator?.accountId ?? null,
287 })),
288 ),
289 );
290 return yield* HttpServerResponse.json(result);
291 }
292
293 if (request.method === "GET" && pathname === "/invitations-count") {
294 const { invitationsCount } = yield* getInvitationsCount();
295 return yield* HttpServerResponse.json({
296 count: Number(invitationsCount ?? 0),
297 });
298 }
299
300 if (request.method === "GET" && pathname === "/invitations") {
301 const { invitations } = yield* listInvitations();
302 return yield* HttpServerResponse.json({
303 count: (invitations ?? []).length,
304 });
305 }
306
307 if (request.method === "GET" && pathname === "/members") {
308 const { members } = yield* listMembers();
309 return yield* HttpServerResponse.json({
310 count: (members ?? []).length,
311 });
312 }
313
314 // Organization administration: for a standalone (non-management)
315 // account Macie answers with a typed error — asserting the tag proves
316 // the binding + IAM wiring end-to-end.
317 if (request.method === "GET" && pathname === "/org-admins") {
318 const result = yield* errorTagged(
319 listOrganizationAdminAccounts().pipe(
320 Effect.map((r) => ({ admins: (r.adminAccounts ?? []).length })),
321 ),
322 );
323 return yield* HttpServerResponse.json(result);
324 }
325
326 if (request.method === "GET" && pathname === "/org-config") {
327 const result = yield* errorTagged(
328 describeOrganizationConfiguration().pipe(
329 Effect.map((r) => ({ autoEnable: r.autoEnable ?? false })),
330 ),
331 );
332 return yield* HttpServerResponse.json(result);
333 }
334
335 return yield* HttpServerResponse.json(
336 { error: "Not found", method: request.method, pathname },
337 { status: 404 },
338 );
339 }).pipe(
340 // Surface typed operation errors as JSON so live-test failures carry
341 // the tag + message instead of an opaque 500 (routes that expect a
342 // typed error use `errorTagged` above and never reach this).
343 Effect.catch((e) =>
344 HttpServerResponse.json(
345 {
346 errorTag: (e as { _tag?: string })._tag ?? "UnknownError",
347 errorMessage:
348 (e as { message?: string }).message ??
349 (e as { Message?: string }).Message,
350 },
351 { status: 500 },
352 ),
353 ),
354 Effect.orDie,
355 ),
356 };
357 }).pipe(
358 Effect.provide(
359 Layer.mergeAll(
360 Macie2.CreateSampleFindingsHttp,
361 Macie2.DescribeOrganizationConfigurationHttp,
362 Macie2.GetAdministratorAccountHttp,
363 Macie2.GetAutomatedDiscoveryConfigurationHttp,
364 Macie2.GetBucketStatisticsHttp,
365 Macie2.GetClassificationExportConfigurationHttp,
366 Macie2.GetFindingStatisticsHttp,
367 Macie2.GetFindingsHttp,
368 Macie2.GetInvitationsCountHttp,
369 Macie2.GetRevealConfigurationHttp,
370 Macie2.GetUsageTotalsHttp,
371 Macie2.ListAllowListsHttp,
372 Macie2.ListClassificationJobsHttp,
373 Macie2.ListClassificationScopesHttp,
374 Macie2.ListFindingsFiltersHttp,
375 Macie2.ListFindingsHttp,
376 Macie2.ListInvitationsHttp,
377 Macie2.ListManagedDataIdentifiersHttp,
378 Macie2.ListMembersHttp,
379 Macie2.ListOrganizationAdminAccountsHttp,
380 Macie2.SearchResourcesHttp,
381 Macie2.TestCustomDataIdentifierHttp,
382 ),
383 ),
384 ),
385);