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 ControlTowerTestFunctionLive, {
11 ControlTowerTestFunction,
12} from "./handler";
13
14const testOptions = { providers: AWS.providers() };
15const { test, beforeAll, afterAll } = Test.make(testOptions);
16const sharedStack = Core.scratchStack(testOptions, "ControlTowerBindings");
17
18// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
19// over 60s on a fresh deploy.
20const readinessPolicy = Schedule.max([
21 Schedule.fixed("2 seconds"),
22 Schedule.recurs(75),
23]);
24
25let baseUrl: string;
26
27class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
28 readonly status: number;
29 readonly body: string;
30}> {}
31
32// The shared Lambda fixture occasionally answers a transient 5xx under load
33// (cold re-init, IAM propagation on the freshly attached policy that the
34// handler's `Effect.orDie` surfaces as a 500). Retry only 5xx; a genuine
35// 4xx/assertion failure surfaces immediately.
36const send = (request: HttpClientRequest.HttpClientRequest) =>
37 HttpClient.execute(request).pipe(
38 Effect.flatMap((response) =>
39 response.status >= 500
40 ? response.text.pipe(
41 Effect.flatMap((body) =>
42 Effect.fail(
43 new TransientUpstream({ status: response.status, body }),
44 ),
45 ),
46 )
47 : Effect.succeed(response),
48 ),
49 Effect.retry({
50 while: (e) => e._tag === "TransientUpstream",
51 schedule: Schedule.max([
52 Schedule.exponential("500 millis"),
53 Schedule.recurs(6),
54 ]),
55 }),
56 );
57
58// The freshly attached IAM policy can take ~10-30s to propagate to the
59// Lambda's role; until it does, granted calls surface a transient
60// AccessDeniedException. Poll (bounded) until the response is no longer
61// access-denied so grant-proof assertions see steady-state behavior.
62const getJson = (path: string) =>
63 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
64 Effect.flatMap((r) => r.json),
65 Effect.repeat({
66 schedule: Schedule.spaced("3 seconds"),
67 until: (response): boolean =>
68 (response as { tag?: string }).tag !== "AccessDeniedException",
69 times: 10,
70 }),
71 );
72
73// The testing account has no Control Tower landing zone — the account-level
74// bindings still prove the IAM grant + typed error unions end-to-end: an IAM
75// gap would surface AccessDeniedException, while a granted call returns
76// either real data (the baseline catalog is served without a landing zone)
77// or the landing-zone-gated typed tag.
78describe.sequential(
79 "ControlTower Bindings",
80 {
81 tags: [
82 "provider:aws",
83 "provider:aws:controltower",
84 "provider:aws:lambda",
85 "live",
86 ],
87 },
88 () => {
89 beforeAll(
90 Effect.gen(function* () {
91 yield* Effect.logInfo(
92 "ControlTower test setup: destroying previous resources",
93 );
94 yield* sharedStack.destroy();
95
96 yield* Effect.logInfo("ControlTower test setup: deploying fixture");
97 const { functionUrl } = yield* sharedStack.deploy(
98 Effect.gen(function* () {
99 return yield* ControlTowerTestFunction;
100 }).pipe(Effect.provide(ControlTowerTestFunctionLive)),
101 );
102
103 expect(functionUrl).toBeTruthy();
104 baseUrl = functionUrl!.replace(/\/+$/, "");
105
106 const readinessUrl = `${baseUrl}/bindings`;
107 yield* Effect.logInfo(
108 `ControlTower test setup: probing readiness at ${readinessUrl}`,
109 );
110 yield* HttpClient.get(readinessUrl).pipe(
111 Effect.flatMap((response) =>
112 response.status === 200
113 ? Effect.succeed(response)
114 : Effect.fail(
115 new Error(`Function not ready: ${response.status}`),
116 ),
117 ),
118 Effect.tapError((error) =>
119 Effect.logWarning(
120 `ControlTower test setup: fixture not ready yet (${String(error)})`,
121 ),
122 ),
123 Effect.retry({ schedule: readinessPolicy }),
124 );
125 }),
126 { timeout: 240_000 },
127 );
128
129 afterAll(sharedStack.destroy(), { timeout: 120_000 });
130
131 describe("binding registration", () => {
132 test.provider("all 11 capabilities initialize in the runtime", (_stack) =>
133 Effect.gen(function* () {
134 const response = (yield* getJson("/bindings")) as {
135 bound: string[];
136 };
137 expect(response.bound).toHaveLength(11);
138 }),
139 );
140 });
141
142 describe("ListBaselines", () => {
143 test.provider("lists the baseline catalog", (_stack) =>
144 Effect.gen(function* () {
145 const response = (yield* getJson("/baselines")) as
146 | { ok: true; names: (string | null)[] }
147 | { ok: false; tag: string };
148 if (response.ok) {
149 expect(response.names).toContain("AWSControlTowerBaseline");
150 } else {
151 expect([
152 "AccessDeniedException",
153 "UnauthorizedException",
154 ]).toContain(response.tag);
155 }
156 }),
157 );
158 });
159
160 describe("GetBaseline", () => {
161 test.provider("reads a catalog baseline discovered via list", (_stack) =>
162 Effect.gen(function* () {
163 const response = (yield* getJson("/baseline")) as
164 | { ok: true; name: string }
165 | { ok: false; tag: string };
166 if (response.ok) {
167 expect(response.name).toBe("AWSControlTowerBaseline");
168 } else {
169 expect([
170 "AccessDeniedException",
171 "UnauthorizedException",
172 "NoCatalog",
173 ]).toContain(response.tag);
174 }
175 }),
176 );
177 });
178
179 describe("ListEnabledBaselines", () => {
180 test.provider(
181 "yields a count or the landing-zone-gated typed tag",
182 (_stack) =>
183 Effect.gen(function* () {
184 const response = (yield* getJson("/enabled-baselines")) as
185 | { ok: true; count: number }
186 | { ok: false; tag: string };
187 if (response.ok) {
188 expect(response.count).toBeGreaterThanOrEqual(0);
189 } else {
190 expect([
191 "AccessDeniedException",
192 "UnauthorizedException",
193 "ValidationException",
194 ]).toContain(response.tag);
195 }
196 }),
197 );
198 });
199
200 describe("ListEnabledControls", () => {
201 test.provider(
202 "yields a count or the landing-zone-gated typed tag",
203 (_stack) =>
204 Effect.gen(function* () {
205 const response = (yield* getJson("/enabled-controls")) as
206 | { ok: true; count: number }
207 | { ok: false; tag: string };
208 if (response.ok) {
209 expect(response.count).toBeGreaterThanOrEqual(0);
210 } else {
211 expect([
212 "AccessDeniedException",
213 "ResourceNotFoundException",
214 "ValidationException",
215 ]).toContain(response.tag);
216 }
217 }),
218 );
219 });
220
221 describe("ListControlOperations", () => {
222 test.provider(
223 "yields a count or the landing-zone-gated typed tag",
224 (_stack) =>
225 Effect.gen(function* () {
226 const response = (yield* getJson("/control-operations")) as
227 | { ok: true; count: number }
228 | { ok: false; tag: string };
229 if (response.ok) {
230 expect(response.count).toBeGreaterThanOrEqual(0);
231 } else {
232 expect([
233 "AccessDeniedException",
234 "ValidationException",
235 ]).toContain(response.tag);
236 }
237 }),
238 );
239 });
240
241 describe("ListLandingZones", () => {
242 test.provider("lists the account's landing zones (none)", (_stack) =>
243 Effect.gen(function* () {
244 const response = (yield* getJson("/landing-zones")) as
245 | { ok: true; count: number }
246 | { ok: false; tag: string };
247 if (response.ok) {
248 // No landing zone on the testing account — an empty (or
249 // singleton) list, never a crash.
250 expect(response.count).toBeLessThanOrEqual(1);
251 } else {
252 expect([
253 "AccessDeniedException",
254 "UnauthorizedException",
255 ]).toContain(response.tag);
256 }
257 }),
258 );
259 });
260
261 describe("ListLandingZoneOperations", () => {
262 test.provider(
263 "yields a count or the landing-zone-gated typed tag",
264 (_stack) =>
265 Effect.gen(function* () {
266 const response = (yield* getJson("/landing-zone-operations")) as
267 | { ok: true; count: number }
268 | { ok: false; tag: string };
269 if (response.ok) {
270 expect(response.count).toBeGreaterThanOrEqual(0);
271 } else {
272 expect([
273 "AccessDeniedException",
274 "UnauthorizedException",
275 "ValidationException",
276 ]).toContain(response.tag);
277 }
278 }),
279 );
280 });
281
282 describe("GetLandingZone", () => {
283 test.provider(
284 "surfaces a typed error for a nonexistent landing zone (proving the grant)",
285 (_stack) =>
286 Effect.gen(function* () {
287 const response = (yield* getJson("/landing-zone-not-found")) as {
288 tag: string;
289 };
290 expect([
291 "ResourceNotFoundException",
292 "ValidationException",
293 "UnauthorizedException",
294 ]).toContain(response.tag);
295 }),
296 );
297 });
298
299 describe("GetControlOperation", () => {
300 test.provider(
301 "surfaces a typed error for a nonexistent operation (proving the grant)",
302 (_stack) =>
303 Effect.gen(function* () {
304 const response = (yield* getJson(
305 "/control-operation-not-found",
306 )) as {
307 tag: string;
308 };
309 expect([
310 "ResourceNotFoundException",
311 "ValidationException",
312 ]).toContain(response.tag);
313 }),
314 );
315 });
316
317 describe("GetBaselineOperation", () => {
318 test.provider(
319 "surfaces a typed error for a nonexistent operation (proving the grant)",
320 (_stack) =>
321 Effect.gen(function* () {
322 const response = (yield* getJson(
323 "/baseline-operation-not-found",
324 )) as { tag: string; message?: string };
325 expect([
326 "ResourceNotFoundException",
327 "ValidationException",
328 "UnauthorizedException",
329 ]).toContain(response.tag);
330 }),
331 );
332 });
333
334 describe("GetLandingZoneOperation", () => {
335 test.provider(
336 "surfaces a typed error for a nonexistent operation (proving the grant)",
337 (_stack) =>
338 Effect.gen(function* () {
339 const response = (yield* getJson(
340 "/landing-zone-operation-not-found",
341 )) as { tag: string; message?: string };
342 expect([
343 "ResourceNotFoundException",
344 "ValidationException",
345 "UnauthorizedException",
346 ]).toContain(response.tag);
347 }),
348 );
349 });
350 },
351);