1import * as AWS from "@/AWS";
2import { RuleGroup } from "@/AWS/NetworkFirewall";
3import * as Test from "@/Test/Alchemy";
4import * as nfw from "@distilled.cloud/aws/network-firewall";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8
9const { test } = Test.make({ providers: AWS.providers() });
10
11// Ungated typed-error probe: prove the distilled error union carries the
12// not-found tag this provider's read/delete paths depend on.
13test.provider(
14 "describeRuleGroup on a nonexistent rule group fails with ResourceNotFoundException",
15 () =>
16 Effect.gen(function* () {
17 const error = yield* Effect.flip(
18 nfw.describeRuleGroup({
19 RuleGroupName: "alchemy-nonexistent-nfw-rulegroup-probe",
20 Type: "STATEFUL",
21 }),
22 );
23 expect(error._tag).toBe("ResourceNotFoundException");
24 }),
25 { tags: ["provider:aws", "provider:aws:networkfirewall", "live"] },
26);
27
28const suricataV1 =
29 'pass tcp any any -> any 80 (msg:"allow http"; sid:100001; rev:1;)';
30const suricataV2 =
31 'drop tcp any any -> any 23 (msg:"block telnet"; sid:100002; rev:1;)';
32
33const statelessRuleGroup: nfw.RuleGroup = {
34 RulesSource: {
35 StatelessRulesAndCustomActions: {
36 StatelessRules: [
37 {
38 Priority: 1,
39 RuleDefinition: {
40 Actions: ["aws:pass"],
41 MatchAttributes: {
42 Sources: [{ AddressDefinition: "0.0.0.0/0" }],
43 Destinations: [{ AddressDefinition: "0.0.0.0/0" }],
44 Protocols: [6],
45 DestinationPorts: [{ FromPort: 80, ToPort: 80 }],
46 },
47 },
48 },
49 ],
50 },
51 },
52};
53
54// Deletion transitions through a DELETING status before the rule group
55// disappears — poll (bounded) until describe returns the typed not-found
56// tag. Throttled polls count as "still waiting".
57const assertRuleGroupGone = (arn: string) =>
58 Effect.gen(function* () {
59 const status = yield* nfw.describeRuleGroup({ RuleGroupArn: arn }).pipe(
60 Effect.map(
61 (r) => r.RuleGroupResponse.RuleGroupStatus ?? ("UNKNOWN" as const),
62 ),
63 Effect.catchTag("ResourceNotFoundException", () =>
64 Effect.succeed("gone" as const),
65 ),
66 Effect.catchTag("ThrottlingException", () =>
67 Effect.succeed("THROTTLED" as const),
68 ),
69 );
70 if (status !== "gone" && status !== "DELETING") {
71 yield* Effect.log(`rule group '${arn}' status: ${status}`);
72 }
73 if (status !== "gone") {
74 return yield* Effect.fail(
75 new Error(`rule group '${arn}' still exists (status: ${status})`),
76 );
77 }
78 }).pipe(
79 Effect.retry({
80 schedule: Schedule.max([
81 Schedule.fixed("10 seconds"),
82 Schedule.recurs(18),
83 ]),
84 }),
85 );
86
87test.provider(
88 "create, update, and delete stateless + stateful rule groups",
89 (stack) =>
90 Effect.gen(function* () {
91 yield* stack.destroy();
92
93 const deploy = (rules: string, description: string) =>
94 stack.deploy(
95 Effect.gen(function* () {
96 const stateless = yield* RuleGroup("Stateless", {
97 type: "STATELESS",
98 capacity: 10,
99 ruleGroup: statelessRuleGroup,
100 tags: { fixture: "nfw-rulegroup" },
101 });
102 const stateful = yield* RuleGroup("Stateful", {
103 type: "STATEFUL",
104 capacity: 100,
105 rules,
106 description,
107 });
108 return { stateless, stateful };
109 }),
110 );
111
112 const { stateless, stateful } = yield* deploy(suricataV1, "v1");
113
114 expect(stateless.ruleGroupArn).toContain(":stateless-rulegroup/");
115 expect(stateless.type).toBe("STATELESS");
116 expect(stateless.capacity).toBe(10);
117 expect(stateful.ruleGroupArn).toContain(":stateful-rulegroup/");
118 expect(stateful.type).toBe("STATEFUL");
119
120 // Out-of-band verification via distilled.
121 const observed = yield* nfw.describeRuleGroup({
122 RuleGroupArn: stateful.ruleGroupArn,
123 });
124 expect(observed.RuleGroupResponse.RuleGroupStatus).toBe("ACTIVE");
125 expect(observed.RuleGroup?.RulesSource?.RulesString).toBe(suricataV1);
126 expect(observed.RuleGroupResponse.Description).toBe("v1");
127
128 // Update in place — same ARN, new rules + description.
129 const updated = yield* deploy(suricataV2, "v2");
130 expect(updated.stateful.ruleGroupArn).toBe(stateful.ruleGroupArn);
131 expect(updated.stateless.ruleGroupArn).toBe(stateless.ruleGroupArn);
132 const reobserved = yield* nfw.describeRuleGroup({
133 RuleGroupArn: stateful.ruleGroupArn,
134 });
135 expect(reobserved.RuleGroup?.RulesSource?.RulesString).toBe(suricataV2);
136 expect(reobserved.RuleGroupResponse.Description).toBe("v2");
137
138 // Internal + user tags landed on the cloud resource.
139 const tags = Object.fromEntries(
140 (yield* nfw.listTagsForResource({
141 ResourceArn: stateless.ruleGroupArn,
142 })).Tags?.map((t) => [t.Key, t.Value]) ?? [],
143 );
144 expect(tags.fixture).toBe("nfw-rulegroup");
145
146 yield* stack.destroy();
147 yield* assertRuleGroupGone(stateless.ruleGroupArn);
148 yield* assertRuleGroupGone(stateful.ruleGroupArn);
149 }),
150 {
151 tags: ["provider:aws", "provider:aws:networkfirewall", "live"],
152 timeout: 240_000,
153 },
154);
155
156test.provider(
157 "changing capacity replaces the rule group",
158 (stack) =>
159 Effect.gen(function* () {
160 yield* stack.destroy();
161
162 const deploy = (capacity: number) =>
163 stack.deploy(
164 Effect.gen(function* () {
165 const group = yield* RuleGroup("Replaceable", {
166 type: "STATEFUL",
167 capacity,
168 rules: suricataV1,
169 });
170 return { group };
171 }),
172 );
173
174 const first = yield* deploy(50);
175 const second = yield* deploy(60);
176
177 // Capacity is create-only — the engine must have provisioned a new
178 // physical rule group (new instance-suffixed name, new ARN).
179 expect(second.group.ruleGroupArn).not.toBe(first.group.ruleGroupArn);
180 expect(second.group.capacity).toBe(60);
181
182 const observed = yield* nfw.describeRuleGroup({
183 RuleGroupArn: second.group.ruleGroupArn,
184 });
185 expect(observed.RuleGroupResponse.Capacity).toBe(60);
186
187 yield* stack.destroy();
188 yield* assertRuleGroupGone(second.group.ruleGroupArn);
189 }),
190 {
191 tags: ["provider:aws", "provider:aws:networkfirewall", "live"],
192 timeout: 240_000,
193 },
194);