1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as sts from "@distilled.cloud/aws/sts";
5import { describe, expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientRequest from "effect/http/HttpClientRequest";
11import Inspector2TestFunctionLive, { Inspector2TestFunction } from "./handler";
12
13const testOptions = { providers: AWS.providers() };
14const { test, beforeAll, afterAll } = Test.make(testOptions);
15const sharedStack = Core.scratchStack(testOptions, "Inspector2Bindings");
16
17// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
18// over 60s on a fresh deploy.
19const readinessPolicy = Schedule.max([
20 Schedule.fixed("2 seconds"),
21 Schedule.recurs(75),
22]);
23
24let baseUrl: string;
25
26class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
27 readonly status: number;
28 readonly body: string;
29}> {}
30
31// The shared Lambda fixture occasionally answers a transient 5xx under load
32// (cold re-init, IAM propagation on the freshly attached policy that the
33// handler's `Effect.orDie` surfaces as a 500). Retry only 5xx; a genuine
34// 4xx/assertion failure surfaces immediately.
35const send = (request: HttpClientRequest.HttpClientRequest) =>
36 HttpClient.execute(request).pipe(
37 Effect.flatMap((response) =>
38 response.status >= 500
39 ? response.text.pipe(
40 Effect.flatMap((body) =>
41 Effect.fail(
42 new TransientUpstream({ status: response.status, body }),
43 ),
44 ),
45 )
46 : Effect.succeed(response),
47 ),
48 Effect.retry({
49 while: (e) => e._tag === "TransientUpstream",
50 schedule: Schedule.max([
51 Schedule.exponential("500 millis"),
52 Schedule.recurs(6),
53 ]),
54 }),
55 );
56
57const getJson = (path: string) =>
58 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
59 Effect.flatMap((r) => r.json),
60 );
61
62// Accounts where Inspector scanning is not enabled answer some data-plane
63// reads with a typed AccessDeniedException — either outcome proves the
64// binding + IAM wiring end-to-end.
65const DISABLED_OK = ["AccessDeniedException", "ValidationException"];
66
67describe.sequential(
68 "Inspector2 Bindings",
69 {
70 tags: [
71 "provider:aws",
72 "provider:aws:batch",
73 "provider:aws:inspector2",
74 "provider:aws:lambda",
75 "live",
76 ],
77 },
78 () => {
79 beforeAll(
80 Effect.gen(function* () {
81 yield* Effect.logInfo(
82 "Inspector2 test setup: destroying previous resources",
83 );
84 yield* sharedStack.destroy();
85
86 yield* Effect.logInfo("Inspector2 test setup: deploying fixture");
87 const attrs = yield* sharedStack.deploy(
88 Effect.gen(function* () {
89 return yield* Inspector2TestFunction;
90 }).pipe(Effect.provide(Inspector2TestFunctionLive)),
91 );
92
93 expect(attrs.functionUrl).toBeTruthy();
94 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
95
96 const readinessUrl = `${baseUrl}/bindings`;
97 yield* Effect.logInfo(
98 `Inspector2 test setup: probing readiness at ${readinessUrl}`,
99 );
100 yield* HttpClient.get(readinessUrl).pipe(
101 Effect.flatMap((response) =>
102 response.status === 200
103 ? Effect.succeed(response)
104 : Effect.fail(
105 new Error(`Function not ready: ${response.status}`),
106 ),
107 ),
108 Effect.tapError((error) =>
109 Effect.logWarning(
110 `Inspector2 test setup: fixture not ready yet (${String(error)})`,
111 ),
112 ),
113 Effect.retry({ schedule: readinessPolicy }),
114 );
115 }),
116 { timeout: 240_000 },
117 );
118
119 afterAll(
120 Effect.gen(function* () {
121 yield* sharedStack.destroy();
122 }),
123 { timeout: 120_000 },
124 );
125
126 describe("binding registration", () => {
127 test.provider("all 44 capabilities initialize in the runtime", (_stack) =>
128 Effect.gen(function* () {
129 const response = (yield* getJson("/bindings")) as { bound: string[] };
130 expect(response.bound).toHaveLength(44);
131 expect(response.bound).toContain("listFindings");
132 expect(response.bound).toContain("searchVulnerabilities");
133 expect(response.bound).toContain("createSbomExport");
134 expect(response.bound).toContain("enableDelegatedAdminAccount");
135 }),
136 );
137 });
138
139 describe("ListFindings", () => {
140 test.provider("lists findings (or typed error when disabled)", (_stack) =>
141 Effect.gen(function* () {
142 const response = (yield* getJson("/findings")) as {
143 count?: number;
144 errorTag?: string;
145 };
146 if (response.errorTag) {
147 expect(DISABLED_OK).toContain(response.errorTag);
148 } else {
149 expect(response.count).toBeGreaterThanOrEqual(0);
150 }
151 }),
152 );
153 });
154
155 describe("ListCoverage", () => {
156 test.provider("lists covered resources", (_stack) =>
157 Effect.gen(function* () {
158 const response = (yield* getJson("/coverage")) as {
159 count?: number;
160 errorTag?: string;
161 };
162 if (response.errorTag) {
163 expect(DISABLED_OK).toContain(response.errorTag);
164 } else {
165 expect(response.count).toBeGreaterThanOrEqual(0);
166 }
167 }),
168 );
169 });
170
171 describe("SearchVulnerabilities", () => {
172 test.provider("looks up log4shell in the vulnerability intel", (_stack) =>
173 Effect.gen(function* () {
174 const response = (yield* getJson(
175 "/vulnerability?id=CVE-2021-44228",
176 )) as { ids?: string[]; errorTag?: string };
177 if (response.errorTag) {
178 expect(DISABLED_OK).toContain(response.errorTag);
179 } else {
180 expect(response.ids).toContain("CVE-2021-44228");
181 }
182 }),
183 );
184 });
185
186 describe("ListUsageTotals", () => {
187 test.provider("answers with usage totals", (_stack) =>
188 Effect.gen(function* () {
189 const response = (yield* getJson("/usage")) as {
190 totals?: number;
191 errorTag?: string;
192 };
193 if (response.errorTag) {
194 expect(DISABLED_OK).toContain(response.errorTag);
195 } else {
196 expect(response.totals).toBeGreaterThanOrEqual(0);
197 }
198 }),
199 );
200 });
201
202 describe("ListAccountPermissions", () => {
203 test.provider("enumerates granted Inspector permissions", (_stack) =>
204 Effect.gen(function* () {
205 const response = (yield* getJson("/account-permissions")) as {
206 permissions?: number;
207 errorTag?: string;
208 };
209 if (response.errorTag) {
210 expect(DISABLED_OK).toContain(response.errorTag);
211 } else {
212 expect(response.permissions).toBeGreaterThanOrEqual(0);
213 }
214 }),
215 );
216 });
217
218 describe("BatchGetFreeTrialInfo", () => {
219 test.provider("reports free-trial status for the account", (_stack) =>
220 Effect.gen(function* () {
221 const { Account } = yield* sts.getCallerIdentity({});
222 const response = (yield* getJson(
223 `/free-trial?account=${Account}`,
224 )) as {
225 accounts?: number;
226 failed?: number;
227 errorTag?: string;
228 };
229 if (response.errorTag) {
230 expect(DISABLED_OK).toContain(response.errorTag);
231 } else {
232 expect((response.accounts ?? 0) + (response.failed ?? 0)).toBe(1);
233 }
234 }),
235 );
236 });
237
238 describe("GetConfiguration", () => {
239 test.provider("reads the account scan configuration", (_stack) =>
240 Effect.gen(function* () {
241 const response = (yield* getJson("/config")) as {
242 hasEcrConfiguration?: boolean;
243 errorTag?: string;
244 };
245 if (response.errorTag) {
246 expect(DISABLED_OK).toContain(response.errorTag);
247 } else {
248 expect(typeof response.hasEcrConfiguration).toBe("boolean");
249 }
250 }),
251 );
252 });
253
254 describe("GetEc2DeepInspectionConfiguration", () => {
255 test.provider("reads deep-inspection status", (_stack) =>
256 Effect.gen(function* () {
257 const response = (yield* getJson("/deep-inspection")) as {
258 status?: string;
259 errorTag?: string;
260 };
261 if (response.errorTag) {
262 expect(DISABLED_OK).toContain(response.errorTag);
263 } else {
264 expect(typeof response.status).toBe("string");
265 }
266 }),
267 );
268 });
269
270 describe("GetEncryptionKey", () => {
271 test.provider("reads the ECR package-scan key", (_stack) =>
272 Effect.gen(function* () {
273 const response = (yield* getJson("/encryption-key")) as {
274 kmsKeyId?: string;
275 errorTag?: string;
276 };
277 if (response.errorTag) {
278 // ResourceNotFoundException = no customer-managed key configured.
279 expect([...DISABLED_OK, "ResourceNotFoundException"]).toContain(
280 response.errorTag,
281 );
282 } else {
283 expect(response.kmsKeyId).toBeTruthy();
284 }
285 }),
286 );
287 });
288
289 describe("ListCisScans", () => {
290 test.provider("enumerates CIS scans", (_stack) =>
291 Effect.gen(function* () {
292 const response = (yield* getJson("/cis-scans")) as {
293 scans?: number;
294 errorTag?: string;
295 };
296 if (response.errorTag) {
297 expect(DISABLED_OK).toContain(response.errorTag);
298 } else {
299 expect(response.scans).toBeGreaterThanOrEqual(0);
300 }
301 }),
302 );
303 });
304
305 describe("ListMembers", () => {
306 test.provider("enumerates organization members", (_stack) =>
307 Effect.gen(function* () {
308 const response = (yield* getJson("/members")) as {
309 members?: number;
310 errorTag?: string;
311 };
312 if (response.errorTag) {
313 expect(DISABLED_OK).toContain(response.errorTag);
314 } else {
315 expect(response.members).toBeGreaterThanOrEqual(0);
316 }
317 }),
318 );
319 });
320
321 describe("DescribeOrganizationConfiguration", () => {
322 test.provider(
323 "answers (or rejects with a typed error for a non-delegated account)",
324 (_stack) =>
325 Effect.gen(function* () {
326 const response = (yield* getJson("/org-config")) as {
327 maxAccountLimitReached?: boolean;
328 errorTag?: string;
329 };
330 if (response.errorTag) {
331 expect(DISABLED_OK).toContain(response.errorTag);
332 } else {
333 expect(typeof response.maxAccountLimitReached).toBe("boolean");
334 }
335 }),
336 );
337 });
338
339 describe("GetDelegatedAdminAccount", () => {
340 test.provider(
341 "answers (or rejects with a typed error outside an organization)",
342 (_stack) =>
343 Effect.gen(function* () {
344 const response = (yield* getJson("/delegated-admin")) as {
345 accountId?: string;
346 errorTag?: string;
347 };
348 if (response.errorTag) {
349 expect([...DISABLED_OK, "ResourceNotFoundException"]).toContain(
350 response.errorTag,
351 );
352 } else {
353 expect(response.accountId === undefined || true).toBe(true);
354 }
355 }),
356 );
357 });
358
359 describe("GetFindingsReportStatus", () => {
360 test.provider(
361 "a nonexistent report id comes back as a typed error",
362 (_stack) =>
363 Effect.gen(function* () {
364 const response = (yield* getJson("/report-status")) as {
365 status?: string;
366 errorTag?: string;
367 };
368 if (response.errorTag) {
369 expect([...DISABLED_OK, "ResourceNotFoundException"]).toContain(
370 response.errorTag,
371 );
372 } else {
373 expect(typeof response.status).toBe("string");
374 }
375 }),
376 );
377 });
378 },
379);