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 FMSTestFunctionLive, { FMSTestFunction } from "./handler";
11
12const testOptions = { providers: AWS.providers() };
13const { test, beforeAll, afterAll } = Test.make(testOptions);
14const sharedStack = Core.scratchStack(testOptions, "FMSBindings");
15
16// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
17// over 60s on a fresh deploy.
18const readinessPolicy = Schedule.max([
19 Schedule.fixed("2 seconds"),
20 Schedule.recurs(75),
21]);
22
23let baseUrl: string;
24
25class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
26 readonly status: number;
27 readonly body: string;
28}> {}
29
30// The shared Lambda fixture occasionally answers a transient 5xx under load
31// (cold re-init, IAM propagation on the freshly attached policy that the
32// handler's `Effect.orDie` surfaces as a 500). Retry only 5xx; a genuine
33// 4xx/assertion failure surfaces immediately.
34const send = (request: HttpClientRequest.HttpClientRequest) =>
35 HttpClient.execute(request).pipe(
36 Effect.flatMap((response) =>
37 response.status >= 500
38 ? response.text.pipe(
39 Effect.flatMap((body) =>
40 Effect.fail(
41 new TransientUpstream({ status: response.status, body }),
42 ),
43 ),
44 )
45 : Effect.succeed(response),
46 ),
47 Effect.retry({
48 while: (e) => e._tag === "TransientUpstream",
49 schedule: Schedule.max([
50 Schedule.exponential("500 millis"),
51 Schedule.recurs(6),
52 ]),
53 }),
54 );
55
56const getJson = (path: string) =>
57 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
58 Effect.flatMap((r) => r.json),
59 );
60
61// The testing organization is deliberately NOT onboarded to Firewall Manager
62// (see AdminAccount.test.ts — offboarding is blocked for >10 minutes after
63// onboarding, so the AdminAccount lifecycle is gated behind AWS_TEST_FMS).
64// Each route therefore proves its binding + IAM grant by observing either
65// real data (an FMS-onboarded account) or the exact typed
66// not-an-FMS-admin rejection captured by live probe. A genuine IAM gap
67// surfaces as an uncaught "not authorized to perform" AccessDeniedException
68// that 500s the route and fails the test.
69describe.sequential(
70 "FMS Bindings",
71 {
72 tags: [
73 "provider:aws",
74 "provider:aws:batch",
75 "provider:aws:fms",
76 "provider:aws:lambda",
77 "live",
78 ],
79 },
80 () => {
81 beforeAll(
82 Effect.gen(function* () {
83 yield* Effect.logInfo("FMS test setup: destroying previous resources");
84 yield* sharedStack.destroy();
85
86 yield* Effect.logInfo("FMS test setup: deploying fixture");
87 const { functionUrl } = yield* sharedStack.deploy(
88 Effect.gen(function* () {
89 return yield* FMSTestFunction;
90 }).pipe(Effect.provide(FMSTestFunctionLive)),
91 );
92
93 expect(functionUrl).toBeTruthy();
94 baseUrl = functionUrl!.replace(/\/+$/, "");
95
96 const readinessUrl = `${baseUrl}/bindings`;
97 yield* Effect.logInfo(
98 `FMS 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 `FMS 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(sharedStack.destroy(), { timeout: 120_000 });
120
121 describe("binding registration", () => {
122 test.provider("all 35 capabilities initialize in the runtime", (_stack) =>
123 Effect.gen(function* () {
124 const response = yield* getJson("/bindings");
125 expect((response as any).bound).toHaveLength(35);
126 }),
127 );
128 });
129
130 describe("ListAdminsManagingAccount", () => {
131 test.provider(
132 "lists the admins managing this account (typed ResourceNotFoundException when none exist)",
133 (_stack) =>
134 Effect.gen(function* () {
135 const response = (yield* getJson(
136 "/admins-managing-account",
137 )) as any;
138 expect(["Ok", "ResourceNotFoundException"]).toContain(response.tag);
139 expect(response.count).toBeGreaterThanOrEqual(0);
140 }),
141 { timeout: 60_000 },
142 );
143 });
144
145 describe("ListAdminAccountsForOrganization", () => {
146 test.provider(
147 "lists the organization's FMS admins (typed InvalidOperationException when none designated)",
148 (_stack) =>
149 Effect.gen(function* () {
150 const response = (yield* getJson(
151 "/admin-accounts-for-organization",
152 )) as any;
153 expect([
154 "Ok",
155 "InvalidOperationException",
156 "ResourceNotFoundException",
157 ]).toContain(response.tag);
158 }),
159 { timeout: 60_000 },
160 );
161 });
162
163 describe("ListPolicies", () => {
164 test.provider(
165 "lists FMS policies (typed AccessDeniedException on a non-admin account)",
166 (_stack) =>
167 Effect.gen(function* () {
168 const response = (yield* getJson("/policies")) as any;
169 expect([
170 "Ok",
171 "AccessDeniedException",
172 "InvalidOperationException",
173 ]).toContain(response.tag);
174 expect(response.count).toBeGreaterThanOrEqual(0);
175 }),
176 { timeout: 60_000 },
177 );
178 });
179
180 describe("ListResourceSets", () => {
181 test.provider(
182 "lists resource sets (typed AccessDeniedException on a non-admin account)",
183 (_stack) =>
184 Effect.gen(function* () {
185 const response = (yield* getJson("/resource-sets")) as any;
186 expect([
187 "Ok",
188 "AccessDeniedException",
189 "InvalidOperationException",
190 ]).toContain(response.tag);
191 }),
192 { timeout: 60_000 },
193 );
194 });
195
196 describe("ListMemberAccounts", () => {
197 test.provider(
198 "lists member accounts (typed AccessDeniedException on a non-admin account)",
199 (_stack) =>
200 Effect.gen(function* () {
201 const response = (yield* getJson("/member-accounts")) as any;
202 expect([
203 "Ok",
204 "AccessDeniedException",
205 "ResourceNotFoundException",
206 ]).toContain(response.tag);
207 }),
208 { timeout: 60_000 },
209 );
210 });
211
212 describe("GetNotificationChannel", () => {
213 test.provider(
214 "reads the notification channel (typed AccessDeniedException on a non-admin account)",
215 (_stack) =>
216 Effect.gen(function* () {
217 const response = (yield* getJson("/notification-channel")) as any;
218 expect([
219 "Ok",
220 "AccessDeniedException",
221 "InvalidOperationException",
222 "ResourceNotFoundException",
223 ]).toContain(response.tag);
224 }),
225 { timeout: 60_000 },
226 );
227 });
228
229 describe("ListAppsLists", () => {
230 test.provider(
231 "lists applications lists (typed AccessDeniedException on a non-admin account)",
232 (_stack) =>
233 Effect.gen(function* () {
234 const response = (yield* getJson("/apps-lists")) as any;
235 expect([
236 "Ok",
237 "AccessDeniedException",
238 "InvalidOperationException",
239 "ResourceNotFoundException",
240 ]).toContain(response.tag);
241 }),
242 { timeout: 60_000 },
243 );
244 });
245
246 describe("ListProtocolsLists", () => {
247 test.provider(
248 "lists protocols lists (typed AccessDeniedException on a non-admin account)",
249 (_stack) =>
250 Effect.gen(function* () {
251 const response = (yield* getJson("/protocols-lists")) as any;
252 expect([
253 "Ok",
254 "AccessDeniedException",
255 "InvalidOperationException",
256 "ResourceNotFoundException",
257 ]).toContain(response.tag);
258 }),
259 { timeout: 60_000 },
260 );
261 });
262
263 describe("GetThirdPartyFirewallAssociationStatus", () => {
264 test.provider(
265 "reads third-party firewall onboarding status (typed rejection on a non-onboarded account)",
266 (_stack) =>
267 Effect.gen(function* () {
268 const response = (yield* getJson(
269 "/third-party-firewall-status",
270 )) as any;
271 expect([
272 "Ok",
273 "AccessDeniedException",
274 "InvalidOperationException",
275 "InvalidInputException",
276 "ResourceNotFoundException",
277 ]).toContain(response.tag);
278 }),
279 { timeout: 60_000 },
280 );
281 });
282 },
283);