1import * as AWS from "@/AWS";
2import { AllowList } from "@/AWS/Macie2/AllowList.ts";
3import { CustomDataIdentifier } from "@/AWS/Macie2/CustomDataIdentifier.ts";
4import { FindingsFilter } from "@/AWS/Macie2/FindingsFilter.ts";
5import { Session } from "@/AWS/Macie2/Session.ts";
6import * as Test from "@/Test/Alchemy";
7import * as macie2 from "@distilled.cloud/aws/macie2";
8import { expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import { makeMacie2TestLease } from "./TestLease.ts";
11
12const { test, beforeAll, afterAll } = Test.make({ providers: AWS.providers() });
13const testLease = makeMacie2TestLease();
14
15// Lease acquisition may queue behind the complete lifecycle of every other
16// Macie file. This does not widen any cloud-operation polling budget.
17beforeAll(testLease.acquire, { timeout: 3_600_000 });
18afterAll(testLease.release);
19
20// `getMacieSession` throws `AccessDeniedException` ("Macie is not enabled")
21// when the account has no session.
22const getSession = macie2.getMacieSession({}).pipe(
23 Effect.map((s) => s as macie2.GetMacieSessionResponse | undefined),
24 Effect.catchTag("AccessDeniedException", () => Effect.succeed(undefined)),
25 Effect.catchTag("ResourceNotFoundException", () => Effect.succeed(undefined)),
26);
27
28// AllowList, CustomDataIdentifier, and FindingsFilter all require Macie to be
29// enabled. To avoid touching a Macie session the user already operates
30// (capture-and-restore safety), the test only runs when Macie is not already
31// enabled — it enables Macie itself and disables it again on destroy.
32test.provider(
33 "lifecycle: allow list + custom data identifier + findings filter",
34 (stack) =>
35 Effect.gen(function* () {
36 const preexisting = yield* getSession;
37 if (preexisting) {
38 yield* Effect.logInfo(
39 "Macie already enabled — skipping Macie2 resources lifecycle test",
40 );
41 return;
42 }
43
44 yield* stack.destroy();
45
46 // Phase 1 — enable Macie and create all three resources.
47 const created = yield* stack.deploy(
48 Effect.gen(function* () {
49 yield* Session("Macie", { status: "ENABLED" });
50 const allowList = yield* AllowList("Ignore", {
51 description: "internal ticket ids",
52 criteria: { regex: "TICKET-[0-9]{6}" },
53 tags: { env: "test" },
54 });
55 const identifier = yield* CustomDataIdentifier("EmployeeId", {
56 regex: "EMP-[0-9]{8}",
57 description: "internal employee id",
58 tags: { env: "test" },
59 });
60 const filter = yield* FindingsFilter("LowSeverity", {
61 action: "ARCHIVE",
62 position: 1,
63 findingCriteria: {
64 criterion: { "severity.description": { eq: ["Low"] } },
65 },
66 tags: { env: "test" },
67 });
68 return {
69 allowListId: allowList.id,
70 allowListArn: allowList.arn,
71 identifierId: identifier.id,
72 identifierArn: identifier.arn,
73 filterId: filter.id,
74 filterAction: filter.action,
75 };
76 }),
77 );
78 expect(created.allowListId).toBeTruthy();
79 expect(created.allowListArn).toContain(":allow-list/");
80 expect(created.identifierArn).toContain(":custom-data-identifier/");
81 expect(created.filterAction).toBe("ARCHIVE");
82
83 // Out-of-band verification (including Alchemy ownership tags).
84 const liveAllow = yield* macie2.getAllowList({
85 id: created.allowListId,
86 });
87 expect(liveAllow.criteria?.regex).toBe("TICKET-[0-9]{6}");
88 expect(liveAllow.tags?.["env"]).toBe("test");
89 expect(liveAllow.tags?.["alchemy::id"]).toBe("Ignore");
90
91 const liveIdentifier = yield* macie2.getCustomDataIdentifier({
92 id: created.identifierId,
93 });
94 expect(liveIdentifier.regex).toBe("EMP-[0-9]{8}");
95 expect(liveIdentifier.tags?.["alchemy::id"]).toBe("EmployeeId");
96
97 const liveFilter = yield* macie2.getFindingsFilter({
98 id: created.filterId,
99 });
100 expect(liveFilter.action).toBe("ARCHIVE");
101 expect(
102 liveFilter.findingCriteria?.criterion?.["severity.description"]?.eq,
103 ).toEqual(["Low"]);
104
105 // Phase 2 — in-place updates (allow list criteria + filter action/tags)
106 // and a replacement (custom data identifiers are immutable, so a regex
107 // change swaps the physical identifier).
108 const updated = yield* stack.deploy(
109 Effect.gen(function* () {
110 yield* Session("Macie", { status: "ENABLED" });
111 const allowList = yield* AllowList("Ignore", {
112 description: "internal ticket ids v2",
113 criteria: { regex: "TICKET-[0-9]{7}" },
114 tags: { env: "test" },
115 });
116 const identifier = yield* CustomDataIdentifier("EmployeeId", {
117 regex: "EMP-[0-9]{9}",
118 description: "internal employee id",
119 tags: { env: "test" },
120 });
121 const filter = yield* FindingsFilter("LowSeverity", {
122 action: "NOOP",
123 position: 1,
124 findingCriteria: {
125 criterion: { "severity.description": { eq: ["Low"] } },
126 },
127 tags: { env: "test", phase: "two" },
128 });
129 return {
130 allowListId: allowList.id,
131 identifierId: identifier.id,
132 filterId: filter.id,
133 };
134 }),
135 );
136
137 // Allow list + filter update in place; the identifier is replaced.
138 expect(updated.allowListId).toBe(created.allowListId);
139 expect(updated.filterId).toBe(created.filterId);
140 expect(updated.identifierId).not.toBe(created.identifierId);
141
142 const updatedAllow = yield* macie2.getAllowList({
143 id: updated.allowListId,
144 });
145 expect(updatedAllow.criteria?.regex).toBe("TICKET-[0-9]{7}");
146 expect(updatedAllow.description).toBe("internal ticket ids v2");
147
148 const updatedFilter = yield* macie2.getFindingsFilter({
149 id: updated.filterId,
150 });
151 expect(updatedFilter.action).toBe("NOOP");
152 expect(updatedFilter.tags?.["phase"]).toBe("two");
153
154 const updatedIdentifier = yield* macie2.getCustomDataIdentifier({
155 id: updated.identifierId,
156 });
157 expect(updatedIdentifier.regex).toBe("EMP-[0-9]{9}");
158 // The replaced identifier is soft-deleted.
159 const replaced = yield* macie2.getCustomDataIdentifier({
160 id: created.identifierId,
161 });
162 expect(replaced.deleted).toBe(true);
163
164 // Destroy — every resource is removed and Macie is disabled (which
165 // also proves delete-idempotence via the AccessDenied catch: the
166 // session delete may land before the sub-resource deletes re-check).
167 yield* stack.destroy();
168 const after = yield* getSession;
169 expect(after).toBeUndefined();
170 }),
171 { tags: ["provider:aws", "provider:aws:macie2", "live"], timeout: 300_000 },
172);