1import * as AWS from "@/AWS";
2import { Assessment, Control, Framework } from "@/AWS/AuditManager";
3import * as Output from "@/Output";
4import * as Test from "@/Test/Alchemy";
5import * as auditmanager from "@distilled.cloud/aws/auditmanager";
6import { describe, expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9
10const { test } = Test.make({ providers: AWS.providers() });
11
12// Audit Manager must be registered per account (RegisterAccount) before any
13// control/framework/assessment operation succeeds — registration is a
14// account-level side effect, so the full lifecycle is gated behind
15// AWS_TEST_AUDITMANAGER=1. The ungated probes below prove the distilled
16// wiring and the typed error union at near-zero cost in every CI pass.
17describe(
18 "AWS.AuditManager",
19 { tags: ["provider:aws", "provider:aws:auditmanager", "live"] },
20 () => {
21 test.provider(
22 "getAccountStatus returns a typed account status",
23 (_stack) =>
24 Effect.gen(function* () {
25 const response = yield* auditmanager.getAccountStatus({});
26 expect(["ACTIVE", "INACTIVE", "PENDING_ACTIVATION"]).toContain(
27 response.status,
28 );
29 }),
30 { timeout: 60_000 },
31 );
32
33 test.provider(
34 "getControl on a nonexistent id yields a typed error tag",
35 (_stack) =>
36 Effect.gen(function* () {
37 const error = yield* auditmanager
38 .getControl({ controlId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" })
39 .pipe(Effect.flip);
40 // Registered accounts answer ResourceNotFoundException; unregistered
41 // accounts reject every control op with AccessDeniedException. Both
42 // are typed tags in the distilled union — the provider's read path
43 // depends on the former.
44 expect([
45 "ResourceNotFoundException",
46 "AccessDeniedException",
47 ]).toContain(error._tag);
48 }),
49 { timeout: 60_000 },
50 );
51
52 test.provider(
53 "registerAccount is typed: idempotent on registered accounts, AuditManagerMaintenanceMode otherwise",
54 (_stack) =>
55 Effect.gen(function* () {
56 const { status } = yield* auditmanager.getAccountStatus({});
57 if (status === "ACTIVE") {
58 // Already registered — re-registering is an idempotent no-op.
59 const response = yield* auditmanager.registerAccount({});
60 expect(response.status).toBe("ACTIVE");
61 } else {
62 // Audit Manager entered maintenance mode on 2026-04-30: new
63 // accounts can no longer be registered. The rejection surfaces as
64 // the typed tag patched into distilled (patches/auditmanager.json).
65 const error = yield* Effect.flip(auditmanager.registerAccount({}));
66 expect(error._tag).toBe("AuditManagerMaintenanceMode");
67 }
68 }),
69 { timeout: 60_000 },
70 );
71
72 test.provider.skipIf(!process.env.AWS_TEST_AUDITMANAGER)(
73 "control + framework + assessment lifecycle: create, update, destroy",
74 (stack) =>
75 Effect.gen(function* () {
76 yield* stack.destroy();
77
78 // Ensure the account is registered and wait for ACTIVE. On accounts
79 // that never enabled Audit Manager this fails with the typed
80 // AuditManagerMaintenanceMode tag — the service is in maintenance
81 // mode and new registrations are permanently rejected.
82 const { status } = yield* auditmanager.getAccountStatus({});
83 if (status !== "ACTIVE") {
84 yield* auditmanager.registerAccount({});
85 yield* Effect.gen(function* () {
86 const current = yield* auditmanager.getAccountStatus({});
87 if (current.status !== "ACTIVE") {
88 return yield* Effect.fail({
89 _tag: "NotActive" as const,
90 status: current.status,
91 });
92 }
93 }).pipe(
94 Effect.retry({
95 while: (e) => e._tag === "NotActive",
96 schedule: Schedule.max([
97 Schedule.spaced("5 seconds"),
98 Schedule.recurs(12),
99 ]),
100 }),
101 );
102 }
103
104 const deploy = (props: {
105 controlDescription?: string;
106 frameworkDescription?: string;
107 assessmentDescription?: string;
108 }) =>
109 stack.deploy(
110 Effect.gen(function* () {
111 const control = yield* Control("EvidenceControl", {
112 description: props.controlDescription,
113 controlMappingSources: [
114 {
115 sourceName: "manual-evidence",
116 sourceDescription: "Manually uploaded evidence",
117 sourceSetUpOption: "Procedural_Controls_Mapping",
118 sourceType: "MANUAL",
119 },
120 ],
121 tags: { fixture: "auditmanager" },
122 });
123
124 const framework = yield* Framework("ComplianceFramework", {
125 description: props.frameworkDescription,
126 complianceType: "Internal",
127 controlSets: [
128 {
129 name: "Operations",
130 controls: [{ id: control.controlId }],
131 },
132 ],
133 tags: { fixture: "auditmanager" },
134 });
135
136 const reports = yield* AWS.S3.Bucket("AuditReports", {
137 forceDestroy: true,
138 });
139
140 const owner = yield* AWS.IAM.Role("AuditOwner", {
141 assumeRolePolicyDocument: {
142 Version: "2012-10-17",
143 Statement: [
144 {
145 Effect: "Allow",
146 Principal: { Service: ["auditmanager.amazonaws.com"] },
147 Action: ["sts:AssumeRole"],
148 },
149 ],
150 },
151 });
152
153 const assessment = yield* Assessment("Quarterly", {
154 description: props.assessmentDescription,
155 frameworkId: framework.frameworkId,
156 assessmentReportsDestination: {
157 destination: Output.interpolate`s3://${reports.bucketName}`,
158 },
159 roles: [
160 { roleType: "PROCESS_OWNER", roleArn: owner.roleArn },
161 ],
162 tags: { fixture: "auditmanager" },
163 });
164
165 return { control, framework, assessment };
166 }),
167 );
168
169 // Create.
170 const { control, framework, assessment } = yield* deploy({
171 controlDescription: "initial control",
172 frameworkDescription: "initial framework",
173 assessmentDescription: "initial assessment",
174 });
175 expect(control.controlId).toBeDefined();
176 expect(control.arn).toContain(":control/");
177 expect(control.type).toBe("Custom");
178 expect(framework.frameworkId).toBeDefined();
179 expect(framework.type).toBe("Custom");
180 expect(assessment.assessmentId).toBeDefined();
181 expect(assessment.status).toBe("ACTIVE");
182 expect(assessment.frameworkId).toBe(framework.frameworkId);
183
184 // Out-of-band verification via distilled.
185 const observedControl = yield* auditmanager.getControl({
186 controlId: control.controlId,
187 });
188 expect(observedControl.control?.name).toBe(control.name);
189 const observedFramework = yield* auditmanager.getAssessmentFramework({
190 frameworkId: framework.frameworkId,
191 });
192 expect(
193 observedFramework.framework?.controlSets?.[0]?.controls?.[0]?.id,
194 ).toBe(control.controlId);
195 const observedAssessment = yield* auditmanager.getAssessment({
196 assessmentId: assessment.assessmentId,
197 });
198 expect(observedAssessment.assessment?.framework?.id).toBe(
199 framework.frameworkId,
200 );
201
202 // Update in place — descriptions flow through the update APIs and
203 // ids are stable.
204 const updated = yield* deploy({
205 controlDescription: "updated control",
206 frameworkDescription: "updated framework",
207 assessmentDescription: "updated assessment",
208 });
209 expect(updated.control.controlId).toBe(control.controlId);
210 expect(updated.framework.frameworkId).toBe(framework.frameworkId);
211 expect(updated.assessment.assessmentId).toBe(assessment.assessmentId);
212 const reobservedFramework =
213 yield* auditmanager.getAssessmentFramework({
214 frameworkId: framework.frameworkId,
215 });
216 expect(reobservedFramework.framework?.description).toBe(
217 "updated framework",
218 );
219
220 yield* stack.destroy();
221
222 // Typed wait-until-gone for each resource.
223 const assertGone = Effect.gen(function* () {
224 const controlGone = yield* auditmanager
225 .getControl({ controlId: control.controlId })
226 .pipe(
227 Effect.map(() => false),
228 Effect.catchTag("ResourceNotFoundException", () =>
229 Effect.succeed(true),
230 ),
231 );
232 const frameworkGone = yield* auditmanager
233 .getAssessmentFramework({ frameworkId: framework.frameworkId })
234 .pipe(
235 Effect.map(() => false),
236 Effect.catchTag("ResourceNotFoundException", () =>
237 Effect.succeed(true),
238 ),
239 );
240 const assessmentGone = yield* auditmanager
241 .getAssessment({ assessmentId: assessment.assessmentId })
242 .pipe(
243 Effect.map(() => false),
244 Effect.catchTag("ResourceNotFoundException", () =>
245 Effect.succeed(true),
246 ),
247 );
248 if (!controlGone || !frameworkGone || !assessmentGone) {
249 return yield* Effect.fail({ _tag: "StillExists" as const });
250 }
251 }).pipe(
252 Effect.retry({
253 while: (e: { _tag: string }) => e._tag === "StillExists",
254 schedule: Schedule.max([
255 Schedule.spaced("5 seconds"),
256 Schedule.recurs(10),
257 ]),
258 }),
259 );
260 yield* assertGone;
261 }),
262 { tags: ["provider:aws:iam", "provider:aws:s3"], timeout: 600_000 },
263 );
264 },
265);