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 Effect from "effect/Effect";
6import * as Schedule from "effect/Schedule";
7import * as HttpClient from "effect/http/HttpClient";
8import NetworkFirewallFirewallBindingsFunctionLive, {
9 NetworkFirewallFirewallBindingsFunction,
10} from "./firewall-handler";
11
12// A firewall takes ~5-10 minutes to provision its endpoints (and a similar
13// window to deprovision), so the firewall-scoped bindings fixture is gated
14// behind AWS_TEST_NETWORKFIREWALL=1 — same gate as the Firewall lifecycle
15// test.
16const gated = !process.env.AWS_TEST_NETWORKFIREWALL;
17
18const testOptions = { providers: AWS.providers() };
19const { test, beforeAll, afterAll } = Test.make(testOptions);
20const sharedStack = Core.scratchStack(
21 testOptions,
22 "NetworkFirewallFirewallBindings",
23);
24
25const readinessPolicy = Schedule.max([
26 Schedule.fixed("2 seconds"),
27 Schedule.recurs(75),
28]);
29
30let baseUrl: string;
31
32const getJson = (path: string) =>
33 HttpClient.get(`${baseUrl}${path}`).pipe(Effect.flatMap((r) => r.json));
34
35describe.sequential(
36 "NetworkFirewall Firewall Bindings",
37 {
38 tags: [
39 "provider:aws",
40 "provider:aws:ec2",
41 "provider:aws:lambda",
42 "provider:aws:networkfirewall",
43 "live",
44 ],
45 },
46 () => {
47 beforeAll(
48 gated
49 ? Effect.void
50 : Effect.gen(function* () {
51 yield* Effect.logInfo(
52 "NetworkFirewall firewall-bindings setup: destroying previous resources",
53 );
54 yield* sharedStack.destroy();
55
56 yield* Effect.logInfo(
57 "NetworkFirewall firewall-bindings setup: deploying fixture (firewall provisioning takes 5-10 minutes)",
58 );
59 const attrs = yield* sharedStack.deploy(
60 Effect.gen(function* () {
61 return yield* NetworkFirewallFirewallBindingsFunction;
62 }).pipe(
63 Effect.provide(NetworkFirewallFirewallBindingsFunctionLive),
64 ),
65 );
66
67 expect(attrs.functionUrl).toBeTruthy();
68 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
69
70 yield* HttpClient.get(`${baseUrl}/bindings`).pipe(
71 Effect.flatMap((response) =>
72 response.status === 200
73 ? Effect.succeed(response)
74 : Effect.fail(
75 new Error(`Function not ready: ${response.status}`),
76 ),
77 ),
78 Effect.retry({ schedule: readinessPolicy }),
79 );
80 }),
81 // firewall create (~10 min) + Lambda deploy.
82 { timeout: 1_500_000 },
83 );
84
85 afterAll(
86 gated || process.env.NO_DESTROY ? Effect.void : sharedStack.destroy(),
87 // firewall delete waits for endpoint deprovisioning (~10 min).
88 { timeout: 1_500_000 },
89 );
90
91 describe("binding registration", () => {
92 test.provider.skipIf(gated)(
93 "all 9 firewall-scoped capabilities initialize in the runtime",
94 (_stack) =>
95 Effect.gen(function* () {
96 const response = (yield* getJson("/bindings")) as {
97 bound: string[];
98 };
99 expect(response.bound).toHaveLength(9);
100 expect(response.bound).toContain("describeFirewall");
101 expect(response.bound).toContain("startFlowCapture");
102 expect(response.bound).toContain("startAnalysisReport");
103 }),
104 );
105 });
106
107 describe("DescribeFirewall", () => {
108 test.provider.skipIf(gated)(
109 "reads the bound firewall's status (ARN injected)",
110 (_stack) =>
111 Effect.gen(function* () {
112 const response = (yield* getJson("/firewall")) as {
113 status: string;
114 endpointCount: number;
115 };
116 expect(response.status).toBe("READY");
117 expect(response.endpointCount).toBeGreaterThanOrEqual(1);
118 }),
119 );
120 });
121
122 describe("StartFlowCapture / DescribeFlowOperation / ListFlowOperations / ListFlowOperationResults", () => {
123 test.provider.skipIf(gated)(
124 "runs a flow capture end-to-end on the bound firewall",
125 (_stack) =>
126 Effect.gen(function* () {
127 type FlowResponse = {
128 step: string;
129 tag?: string;
130 error?: string;
131 flowOperationId?: string;
132 status?: string;
133 operations?: number;
134 flows?: number;
135 };
136 // The role policy is attached moments before this test — IAM
137 // propagation can lag, surfacing as AccessDeniedException from a
138 // step. Retry (bounded) until the grant lands.
139 const response = yield* getJson("/flow").pipe(
140 Effect.map((r) => r as FlowResponse),
141 Effect.repeat({
142 schedule: Schedule.spaced("5 seconds"),
143 until: (r): boolean => r.tag !== "AccessDeniedException",
144 times: 10,
145 }),
146 );
147 yield* Effect.logInfo(
148 `/flow response: ${JSON.stringify(response)}`,
149 );
150 expect(response).toMatchObject({ step: "ok" });
151 expect(response.flowOperationId).toBeTruthy();
152 expect(["COMPLETED", "COMPLETED_WITH_ERRORS", "FAILED"]).toContain(
153 response.status,
154 );
155 expect(response.operations).toBeGreaterThanOrEqual(1);
156 expect(response.flows).toBeGreaterThanOrEqual(0);
157 }),
158 { timeout: 180_000 },
159 );
160 });
161
162 describe("StartFlowFlush", () => {
163 test.provider.skipIf(gated)(
164 "starts a flow flush (grant proven by success or typed rejection)",
165 (_stack) =>
166 Effect.gen(function* () {
167 const response = (yield* getJson("/flush")) as { tag: string };
168 expect(response.tag).not.toBe("AccessDeniedException");
169 }),
170 );
171 });
172
173 describe("StartAnalysisReport / ListAnalysisReports / GetAnalysisReportResults", () => {
174 test.provider.skipIf(gated)(
175 "exercises the analysis-report interface (typed rejections prove grants)",
176 (_stack) =>
177 Effect.gen(function* () {
178 const response = (yield* getJson("/analysis")) as {
179 step: string;
180 startTag: string;
181 reports: number;
182 getTag: string;
183 };
184 expect(response.step).toBe("ok");
185 // The fixture firewall has no analysis types enabled — any typed
186 // tag except AccessDenied proves the grant round-tripped.
187 expect(response.startTag).not.toBe("AccessDeniedException");
188 expect(response.reports).toBeGreaterThanOrEqual(0);
189 expect(response.getTag).not.toBe("AccessDeniedException");
190 }),
191 );
192 });
193 },
194);