1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import { describe, expect } from "alchemy-test";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import AuditManagerTestFunctionLive, {
11 AuditManagerTestFunction,
12} from "./handler";
13import AuditManagerAssessmentTestFunctionLive, {
14 AuditManagerAssessmentTestFunction,
15} from "./handler-assessment";
16
17const testOptions = { providers: AWS.providers() };
18const { test, beforeAll, afterAll } = Test.make(testOptions);
19const sharedStack = Core.scratchStack(testOptions, "AuditManagerBindings");
20const assessmentStack = Core.scratchStack(
21 testOptions,
22 "AuditManagerAssessmentBindings",
23);
24
25// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
26// over 60s on a fresh deploy.
27const readinessPolicy = Schedule.max([
28 Schedule.fixed("2 seconds"),
29 Schedule.recurs(75),
30]);
31
32let baseUrl: string;
33
34class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
35 readonly status: number;
36 readonly body: string;
37}> {}
38
39// The shared Lambda fixture occasionally answers a transient 5xx under load
40// (cold re-init, IAM propagation on the freshly attached policy). Retry only
41// 5xx; a genuine 4xx/assertion failure surfaces immediately.
42const send = (request: HttpClientRequest.HttpClientRequest) =>
43 HttpClient.execute(request).pipe(
44 Effect.flatMap((response) =>
45 response.status >= 500
46 ? response.text.pipe(
47 Effect.flatMap((body) =>
48 Effect.fail(
49 new TransientUpstream({ status: response.status, body }),
50 ),
51 ),
52 )
53 : Effect.succeed(response),
54 ),
55 Effect.retry({
56 while: (e) => e._tag === "TransientUpstream",
57 schedule: Schedule.max([
58 Schedule.exponential("500 millis"),
59 Schedule.recurs(6),
60 ]),
61 }),
62 );
63
64const getJson = (path: string) =>
65 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
66 Effect.flatMap((r) => r.json),
67 );
68
69// The testing account is NOT registered with Audit Manager, and the service
70// entered maintenance mode on 2026-04-30 so it can never be registered. On
71// unregistered accounts every data-plane operation answers the typed
72// AccessDeniedException; a registered account (AWS_TEST_AUDITMANAGER=1)
73// answers real data. Both prove the binding + IAM grant + typed union
74// end-to-end.
75const expectOkOrAccessDenied = (
76 response: unknown,
77 extraTags: string[] = [],
78) => {
79 const r = response as { ok: boolean; tag?: string };
80 if (!r.ok) {
81 expect(["AccessDeniedException", ...extraTags]).toContain(r.tag);
82 }
83};
84
85describe.sequential(
86 "AuditManager Bindings",
87 {
88 tags: [
89 "provider:aws",
90 "provider:aws:auditmanager",
91 "provider:aws:lambda",
92 "live",
93 ],
94 },
95 () => {
96 beforeAll(
97 Effect.gen(function* () {
98 yield* Effect.logInfo(
99 "AuditManager test setup: destroying previous resources",
100 );
101 yield* sharedStack.destroy();
102
103 yield* Effect.logInfo("AuditManager test setup: deploying fixture");
104 const { functionUrl } = yield* sharedStack.deploy(
105 Effect.gen(function* () {
106 return yield* AuditManagerTestFunction;
107 }).pipe(Effect.provide(AuditManagerTestFunctionLive)),
108 );
109
110 expect(functionUrl).toBeTruthy();
111 baseUrl = functionUrl!.replace(/\/+$/, "");
112
113 const readinessUrl = `${baseUrl}/bindings`;
114 yield* Effect.logInfo(
115 `AuditManager test setup: probing readiness at ${readinessUrl}`,
116 );
117 yield* HttpClient.get(readinessUrl).pipe(
118 Effect.flatMap((response) =>
119 response.status === 200
120 ? Effect.succeed(response)
121 : Effect.fail(
122 new Error(`Function not ready: ${response.status}`),
123 ),
124 ),
125 Effect.tapError((error) =>
126 Effect.logWarning(
127 `AuditManager test setup: fixture not ready yet (${String(error)})`,
128 ),
129 ),
130 Effect.retry({ schedule: readinessPolicy }),
131 );
132 }),
133 { timeout: 240_000 },
134 );
135
136 afterAll(sharedStack.destroy(), { timeout: 120_000 });
137
138 describe("binding registration", () => {
139 test.provider("all 11 account-level capabilities initialize", (_stack) =>
140 Effect.gen(function* () {
141 const response = yield* getJson("/bindings");
142 expect((response as any).bound).toHaveLength(11);
143 }),
144 );
145 });
146
147 describe("GetAccountStatus", () => {
148 test.provider(
149 "succeeds end-to-end through the binding's IAM grant",
150 (_stack) =>
151 Effect.gen(function* () {
152 // GetAccountStatus answers even on unregistered accounts — this is
153 // a REAL data-plane success through the attached policy.
154 const response = (yield* getJson("/account-status")) as {
155 ok: boolean;
156 status: string;
157 };
158 expect(response.ok).toBe(true);
159 expect(["ACTIVE", "INACTIVE", "PENDING_ACTIVATION"]).toContain(
160 response.status,
161 );
162 }),
163 );
164 });
165
166 describe("GetServicesInScope", () => {
167 test.provider("answers data or the typed access tag", (_stack) =>
168 Effect.gen(function* () {
169 expectOkOrAccessDenied(yield* getJson("/services-in-scope"));
170 }),
171 );
172 });
173
174 describe("GetInsights", () => {
175 test.provider("answers data or the typed access tag", (_stack) =>
176 Effect.gen(function* () {
177 expectOkOrAccessDenied(yield* getJson("/insights"));
178 }),
179 );
180 });
181
182 describe("ListControlDomainInsights", () => {
183 test.provider("answers data or the typed access tag", (_stack) =>
184 Effect.gen(function* () {
185 expectOkOrAccessDenied(yield* getJson("/control-domain-insights"));
186 }),
187 );
188 });
189
190 describe("ListControlInsightsByControlDomain", () => {
191 test.provider(
192 "answers the typed tag for a nonexistent domain",
193 (_stack) =>
194 Effect.gen(function* () {
195 expectOkOrAccessDenied(yield* getJson("/control-insights"), [
196 "ResourceNotFoundException",
197 "ValidationException",
198 ]);
199 }),
200 );
201 });
202
203 describe("GetDelegations", () => {
204 test.provider("answers data or the typed access tag", (_stack) =>
205 Effect.gen(function* () {
206 expectOkOrAccessDenied(yield* getJson("/delegations"));
207 }),
208 );
209 });
210
211 describe("GetEvidenceFileUploadUrl", () => {
212 test.provider("answers a presigned URL or the typed tag", (_stack) =>
213 Effect.gen(function* () {
214 const response = (yield* getJson("/upload-url")) as {
215 ok: boolean;
216 hasUrl?: boolean;
217 tag?: string;
218 };
219 if (response.ok) {
220 expect(response.hasUrl).toBe(true);
221 } else {
222 expect(["AccessDeniedException"]).toContain(response.tag);
223 }
224 }),
225 );
226 });
227
228 describe("ListAssessmentReports", () => {
229 test.provider("answers data or the typed access tag", (_stack) =>
230 Effect.gen(function* () {
231 expectOkOrAccessDenied(yield* getJson("/reports"));
232 }),
233 );
234 });
235
236 describe("ValidateAssessmentReportIntegrity", () => {
237 test.provider(
238 "answers the typed tag for a nonexistent report path",
239 (_stack) =>
240 Effect.gen(function* () {
241 const response = (yield* getJson("/validate-report")) as {
242 ok: boolean;
243 tag?: string;
244 };
245 // Never ok — the S3 path doesn't exist; on unregistered accounts
246 // the request is rejected before it is even looked at.
247 expect(response.ok).toBe(false);
248 expect([
249 "AccessDeniedException",
250 "ResourceNotFoundException",
251 "ValidationException",
252 ]).toContain(response.tag);
253 }),
254 );
255 });
256
257 describe("ListKeywordsForDataSource", () => {
258 test.provider("answers keywords or the typed access tag", (_stack) =>
259 Effect.gen(function* () {
260 expectOkOrAccessDenied(yield* getJson("/keywords"));
261 }),
262 );
263 });
264
265 describe("ListNotifications", () => {
266 test.provider("answers data or the typed access tag", (_stack) =>
267 Effect.gen(function* () {
268 expectOkOrAccessDenied(yield* getJson("/notifications"));
269 }),
270 );
271 });
272
273 // Assessment-scoped bindings need an ACTIVE Audit Manager registration to
274 // deploy an Assessment. The testing account can never register (service in
275 // maintenance mode since 2026-04-30) — every deploy attempt fails with the
276 // typed AccessDeniedException on CreateControl. Run on a registered
277 // account with AWS_TEST_AUDITMANAGER=1.
278 describe(
279 "assessment-scoped bindings",
280 { tags: ["provider:aws:batch", "provider:aws:iam", "provider:aws:s3"] },
281 () => {
282 test.provider.skipIf(!process.env.AWS_TEST_AUDITMANAGER)(
283 "all 22 capabilities bind and the reads answer live data",
284 (_stack) =>
285 Effect.gen(function* () {
286 yield* assessmentStack.destroy();
287 const cleanup = assessmentStack
288 .destroy()
289 .pipe(Effect.catchCause(() => Effect.void));
290
291 yield* Effect.gen(function* () {
292 const { functionUrl } = yield* assessmentStack.deploy(
293 Effect.gen(function* () {
294 return yield* AuditManagerAssessmentTestFunction;
295 }).pipe(
296 Effect.provide(AuditManagerAssessmentTestFunctionLive),
297 ),
298 );
299 const assessmentBaseUrl = functionUrl!.replace(/\/+$/, "");
300
301 const ready = yield* HttpClient.get(
302 `${assessmentBaseUrl}/bindings`,
303 ).pipe(
304 Effect.flatMap((response) =>
305 response.status === 200
306 ? response.json
307 : Effect.fail(
308 new Error(`Function not ready: ${response.status}`),
309 ),
310 ),
311 Effect.retry({ schedule: readinessPolicy }),
312 );
313 expect((ready as any).bound).toHaveLength(22);
314
315 const folders = (yield* send(
316 HttpClientRequest.get(
317 `${assessmentBaseUrl}/evidence-folders`,
318 ),
319 ).pipe(Effect.flatMap((r) => r.json))) as { count: number };
320 expect(folders.count).toBeGreaterThanOrEqual(0);
321
322 const insights = (yield* send(
323 HttpClientRequest.get(`${assessmentBaseUrl}/insights`),
324 ).pipe(Effect.flatMap((r) => r.json))) as {
325 totalAssessmentControlsCount: number;
326 };
327 expect(
328 insights.totalAssessmentControlsCount,
329 ).toBeGreaterThanOrEqual(0);
330
331 const changelogs = (yield* send(
332 HttpClientRequest.get(`${assessmentBaseUrl}/changelogs`),
333 ).pipe(Effect.flatMap((r) => r.json))) as { count: number };
334 expect(changelogs.count).toBeGreaterThanOrEqual(0);
335 }).pipe(Effect.ensuring(cleanup));
336 }),
337 { timeout: 600_000 },
338 );
339 },
340 );
341 },
342);