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 AccessAnalyzerTestFunctionLive, {
11 AccessAnalyzerTestFunction,
12} from "./handler";
13import { makeAccessAnalyzerTestLease } from "./TestLease.ts";
14
15const testOptions = { providers: AWS.providers() };
16const { test, beforeAll, afterAll } = Test.make(testOptions);
17const sharedStack = Core.scratchStack(testOptions, "AccessAnalyzerBindings");
18const testLease = makeAccessAnalyzerTestLease();
19
20beforeAll(testLease.acquire, { timeout: 240_000 });
21afterAll(testLease.release);
22
23// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
24// over 60s on a fresh deploy under parallel-suite load.
25const readinessPolicy = Schedule.max([
26 Schedule.fixed("2 seconds"),
27 Schedule.recurs(75),
28]);
29
30let baseUrl: string;
31let functionRoleArn: string;
32
33class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
34 readonly status: number;
35 readonly body: string;
36}> {}
37
38// The shared Lambda fixture occasionally answers a transient 5xx under
39// full-suite parallel load (cold re-init, IAM propagation on the freshly
40// attached access-analyzer policy that the handler's `Effect.orDie` surfaces
41// as a 500). Retry only 5xx; a genuine 4xx/assertion failure surfaces
42// immediately.
43const send = (request: HttpClientRequest.HttpClientRequest) =>
44 HttpClient.execute(request).pipe(
45 Effect.flatMap((response) =>
46 response.status >= 500
47 ? response.text.pipe(
48 Effect.flatMap((body) =>
49 Effect.fail(
50 new TransientUpstream({ status: response.status, body }),
51 ),
52 ),
53 )
54 : Effect.succeed(response),
55 ),
56 Effect.retry({
57 while: (e) => e._tag === "TransientUpstream",
58 schedule: Schedule.max([
59 Schedule.exponential("500 millis"),
60 Schedule.recurs(6),
61 ]),
62 }),
63 );
64
65describe.sequential(
66 "AccessAnalyzer Bindings",
67 {
68 tags: [
69 "provider:aws",
70 "provider:aws:accessanalyzer",
71 "provider:aws:lambda",
72 "live",
73 ],
74 },
75 () => {
76 beforeAll(
77 Effect.gen(function* () {
78 yield* Effect.logInfo(
79 "AccessAnalyzer test setup: destroying previous resources",
80 );
81 yield* sharedStack.destroy();
82
83 yield* Effect.logInfo("AccessAnalyzer test setup: deploying fixture");
84 const { functionUrl, roleArn } = yield* sharedStack.deploy(
85 Effect.gen(function* () {
86 return yield* AccessAnalyzerTestFunction;
87 }).pipe(Effect.provide(AccessAnalyzerTestFunctionLive)),
88 );
89
90 expect(functionUrl).toBeTruthy();
91 baseUrl = functionUrl!.replace(/\/+$/, "");
92 functionRoleArn = roleArn;
93
94 const readinessUrl = `${baseUrl}/bindings`;
95 yield* Effect.logInfo(
96 `AccessAnalyzer test setup: probing readiness at ${readinessUrl}`,
97 );
98 yield* HttpClient.get(readinessUrl).pipe(
99 Effect.flatMap((response) =>
100 response.status === 200
101 ? Effect.succeed(response)
102 : Effect.fail(
103 new Error(`Function not ready: ${response.status}`),
104 ),
105 ),
106 Effect.tapError((error) =>
107 Effect.logWarning(
108 `AccessAnalyzer test setup: fixture not ready yet (${String(error)})`,
109 ),
110 ),
111 Effect.retry({ schedule: readinessPolicy }),
112 );
113 }),
114 { timeout: 240_000 },
115 );
116
117 afterAll(sharedStack.destroy(), { timeout: 120_000 });
118
119 describe("binding registration", () => {
120 test.provider("all 24 capabilities initialize in the runtime", (_stack) =>
121 Effect.gen(function* () {
122 const response = yield* send(
123 HttpClientRequest.get(`${baseUrl}/bindings`),
124 ).pipe(Effect.flatMap((r) => r.json));
125 expect((response as any).bound).toHaveLength(24);
126 }),
127 );
128 });
129
130 describe("ValidatePolicy", () => {
131 test.provider("validates a well-formed identity policy", (_stack) =>
132 Effect.gen(function* () {
133 const response = yield* send(
134 HttpClientRequest.post(`${baseUrl}/validate-policy`),
135 ).pipe(Effect.flatMap((r) => r.json));
136 // The sample policy is syntactically valid — no ERROR findings.
137 expect((response as any).findingTypes).not.toContain("ERROR");
138 }),
139 );
140 });
141
142 describe("CheckNoNewAccess", () => {
143 test.provider(
144 "passes when the new policy is a subset of the existing one",
145 (_stack) =>
146 Effect.gen(function* () {
147 const response = yield* send(
148 HttpClientRequest.post(`${baseUrl}/check-no-new-access`),
149 ).pipe(Effect.flatMap((r) => r.json));
150 expect((response as any).result).toBe("PASS");
151 }),
152 );
153 });
154
155 describe("CheckAccessNotGranted", () => {
156 test.provider(
157 "passes when the policy does not grant the checked action",
158 (_stack) =>
159 Effect.gen(function* () {
160 const response = yield* send(
161 HttpClientRequest.post(`${baseUrl}/check-access-not-granted`),
162 ).pipe(Effect.flatMap((r) => r.json));
163 expect((response as any).result).toBe("PASS");
164 }),
165 );
166 });
167
168 describe("CheckNoPublicAccess", () => {
169 test.provider(
170 "passes for a bucket policy scoped to a single account",
171 (_stack) =>
172 Effect.gen(function* () {
173 const response = yield* send(
174 HttpClientRequest.post(`${baseUrl}/check-no-public-access`),
175 ).pipe(Effect.flatMap((r) => r.json));
176 expect((response as any).result).toBe("PASS");
177 }),
178 );
179 });
180
181 describe("ListFindingsV2", () => {
182 test.provider("lists the fixture analyzer's findings", (_stack) =>
183 Effect.gen(function* () {
184 const response = yield* send(
185 HttpClientRequest.get(`${baseUrl}/list-findings`),
186 ).pipe(Effect.flatMap((r) => r.json));
187 // A fresh unused-access analyzer typically has zero findings —
188 // assert the call round-trips with a well-formed page.
189 expect(typeof (response as any).count).toBe("number");
190 }),
191 );
192 });
193
194 describe("GetFindingV2", () => {
195 test.provider(
196 "returns the typed not-found error for an unknown finding id",
197 (_stack) =>
198 Effect.gen(function* () {
199 const response = yield* send(
200 HttpClientRequest.get(`${baseUrl}/get-finding-not-found`),
201 ).pipe(Effect.flatMap((r) => r.json));
202 expect((response as any).found).toBe(false);
203 }),
204 );
205 });
206
207 describe("ListAnalyzedResources", () => {
208 test.provider("lists the analyzer's analyzed resources", (_stack) =>
209 Effect.gen(function* () {
210 const response = yield* send(
211 HttpClientRequest.get(`${baseUrl}/list-analyzed-resources`),
212 ).pipe(Effect.flatMap((r) => r.json));
213 expect(typeof (response as any).count).toBe("number");
214 }),
215 );
216 });
217
218 describe("GetFindingsStatistics", () => {
219 test.provider("returns the analyzer's statistics", (_stack) =>
220 Effect.gen(function* () {
221 const response = yield* send(
222 HttpClientRequest.get(`${baseUrl}/findings-statistics`),
223 ).pipe(Effect.flatMap((r) => r.json));
224 expect(Array.isArray((response as any).statistics)).toBe(true);
225 }),
226 );
227 });
228
229 describe("ApplyArchiveRule", () => {
230 test.provider("applies the fixture archive rule", (_stack) =>
231 Effect.gen(function* () {
232 const response = yield* send(
233 HttpClientRequest.post(`${baseUrl}/apply-archive-rule`),
234 ).pipe(Effect.flatMap((r) => r.json));
235 expect((response as any).applied).toBe(true);
236 }),
237 );
238 });
239
240 describe("ListPolicyGenerations", () => {
241 test.provider("lists the account's policy generations", (_stack) =>
242 Effect.gen(function* () {
243 const response = yield* send(
244 HttpClientRequest.get(`${baseUrl}/list-policy-generations`),
245 ).pipe(Effect.flatMap((r) => r.json));
246 expect(typeof (response as any).count).toBe("number");
247 }),
248 );
249 });
250
251 describe("StartPolicyGeneration / GetGeneratedPolicy / CancelPolicyGeneration", () => {
252 // A real policy generation needs a CloudTrail trail + access role (the
253 // live API rejects trail-less starts with ValidationException "Missing
254 // cloudTrailDetails"). The fixture drives all three bindings through
255 // their typed ValidationException paths — an IAM gap would surface
256 // AccessDeniedException instead, so these tags prove the grants and the
257 // typed error unions end-to-end.
258 test.provider(
259 "surfaces the typed ValidationException path for all three bindings",
260 (_stack) =>
261 Effect.gen(function* () {
262 const response = yield* send(
263 HttpClientRequest.bodyJsonUnsafe(
264 HttpClientRequest.post(`${baseUrl}/policy-generation`),
265 { principalArn: functionRoleArn },
266 ),
267 ).pipe(Effect.flatMap((r) => r.json));
268 expect(["Started", "ValidationException"]).toContain(
269 (response as any).startTag,
270 );
271 expect((response as any).getTag).toBe("ValidationException");
272 expect((response as any).cancelTag).toBe("ValidationException");
273 }),
274 );
275 });
276 },
277);