1import * as AWS from "@/AWS";
2import { ClientVpnEndpoint } from "@/AWS/EC2/ClientVpnEndpoint.ts";
3import {
4 ClientVpnRoute,
5 type ClientVpnRouteProps,
6} from "@/AWS/EC2/ClientVpnRoute.ts";
7import { ClientVpnTargetNetworkAssociation } from "@/AWS/EC2/ClientVpnTargetNetworkAssociation.ts";
8import * as Alchemy from "@/index.ts";
9import * as Provider from "@/Provider";
10import * as Test from "@/Test/Alchemy";
11import * as ec2 from "@distilled.cloud/aws/ec2";
12import { describe, expect } from "alchemy-test";
13import * as Effect from "effect/Effect";
14import {
15 assertClientVpnCertificateDeleted,
16 assertClientVpnRouteDeleted,
17 clientVpnAvailabilityZones,
18 clientVpnEndpointProps,
19 clientVpnTestTimeout,
20 clientVpnNetwork,
21 importClientVpnCertificate,
22 readClientVpnRoutes,
23 readClientVpnTargetNetworks,
24 waitForClientVpn,
25} from "./fixtures/client-vpn.ts";
26
27describe(
28 "Client VPN routes",
29 { tags: ["provider:aws", "provider:aws:acm", "provider:aws:ec2", "live"] },
30 () => {
31 const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
32 providers: AWS.providers(),
33 });
34 const certificate = beforeAll(
35 importClientVpnCertificate("ClientVpnRoutePrerequisites"),
36 );
37 const zones = beforeAll(clientVpnAvailabilityZones);
38 const Stack = Alchemy.Stack(
39 "ClientVpnRoutePrerequisites",
40 { providers: AWS.providers(), state: Alchemy.localState() },
41 Effect.gen(function* () {
42 const certificateArn = yield* certificate;
43 const network = yield* clientVpnNetwork(yield* zones);
44 const endpoint = yield* ClientVpnEndpoint("Endpoint", {
45 ...clientVpnEndpointProps(certificateArn),
46 vpcId: network.vpc.vpcId,
47 splitTunnel: true,
48 });
49 const firstAssociation = yield* ClientVpnTargetNetworkAssociation(
50 "FirstAssociation",
51 {
52 clientVpnEndpointId: endpoint.clientVpnEndpointId,
53 subnetId: network.firstSubnet.subnetId,
54 },
55 );
56 const secondAssociation = yield* ClientVpnTargetNetworkAssociation(
57 "SecondAssociation",
58 {
59 clientVpnEndpointId: endpoint.clientVpnEndpointId,
60 subnetId: network.secondSubnet.subnetId,
61 },
62 );
63 return { ...network, endpoint, firstAssociation, secondAssociation };
64 }),
65 );
66 const prerequisites = beforeAll(deploy(Stack), {
67 timeout: clientVpnTestTimeout,
68 });
69 afterAll(
70 destroy(Stack).pipe(
71 Effect.andThen(
72 certificate.pipe(Effect.flatMap(assertClientVpnCertificateDeleted)),
73 ),
74 ),
75 { timeout: clientVpnTestTimeout },
76 );
77
78 test.provider(
79 "creates, lists, replaces, repairs, and deletes Client VPN routes",
80 (stack) =>
81 Effect.gen(function* () {
82 yield* stack.destroy();
83 const { endpoint, firstSubnet, secondSubnet } = yield* prerequisites;
84 const clientVpnEndpointId = endpoint.clientVpnEndpointId;
85 yield* waitForClientVpn(
86 readClientVpnTargetNetworks(clientVpnEndpointId),
87 (networks) =>
88 [firstSubnet.subnetId, secondSubnet.subnetId].every((subnetId) =>
89 networks.some(
90 (network) =>
91 network.TargetNetworkId === subnetId &&
92 network.Status?.Code === "associated",
93 ),
94 ),
95 "route target networks",
96 );
97 const deployRoute = (
98 destinationCidrBlock: string,
99 targetVpcSubnetId: ClientVpnRouteProps["targetVpcSubnetId"],
100 description?: string,
101 ) =>
102 stack.deploy(
103 ClientVpnRoute("Route", {
104 clientVpnEndpointId,
105 destinationCidrBlock,
106 targetVpcSubnetId,
107 description,
108 }),
109 );
110 const created = yield* deployRoute(
111 "192.168.10.0/24",
112 firstSubnet.subnetId,
113 "Initial route",
114 );
115 expect(created.clientVpnEndpointId).toBe(clientVpnEndpointId);
116 expect(created.destinationCidrBlock).toBe("192.168.10.0/24");
117 expect(created.targetVpcSubnetId).toBe(firstSubnet.subnetId);
118 const routes = yield* waitForClientVpn(
119 readClientVpnRoutes(clientVpnEndpointId),
120 (items) =>
121 items.some(
122 (route) =>
123 route.DestinationCidr === "192.168.10.0/24" &&
124 route.TargetSubnet === firstSubnet.subnetId &&
125 route.Status?.Code === "active",
126 ),
127 "active custom route",
128 );
129 expect(routes).toContainEqual(
130 expect.objectContaining({
131 DestinationCidr: "192.168.10.0/24",
132 TargetSubnet: firstSubnet.subnetId,
133 Description: "Initial route",
134 }),
135 );
136 const provider = yield* Provider.findProvider(ClientVpnRoute);
137 const listed = yield* waitForClientVpn(
138 provider.list(),
139 (items) =>
140 items.some(
141 (route) =>
142 route.clientVpnEndpointId === clientVpnEndpointId &&
143 route.destinationCidrBlock === "192.168.10.0/24" &&
144 route.targetVpcSubnetId === firstSubnet.subnetId,
145 ),
146 "route provider list",
147 );
148 expect(listed).toContainEqual(
149 expect.objectContaining({
150 clientVpnEndpointId,
151 destinationCidrBlock: "192.168.10.0/24",
152 targetVpcSubnetId: firstSubnet.subnetId,
153 }),
154 );
155 yield* deployRoute(
156 "192.168.10.0/24",
157 firstSubnet.subnetId,
158 "Initial route",
159 );
160 expect(
161 (yield* readClientVpnRoutes(clientVpnEndpointId)).filter(
162 (route) =>
163 route.DestinationCidr === "192.168.10.0/24" &&
164 route.TargetSubnet === firstSubnet.subnetId &&
165 route.Status?.Code === "active",
166 ),
167 ).toHaveLength(1);
168
169 // AWS has no modify-route API; description changes replace the route at the same identity.
170 yield* deployRoute(
171 "192.168.10.0/24",
172 firstSubnet.subnetId,
173 "Updated route",
174 );
175 yield* waitForClientVpn(
176 readClientVpnRoutes(clientVpnEndpointId),
177 (items) =>
178 items.some(
179 (route) =>
180 route.DestinationCidr === "192.168.10.0/24" &&
181 route.TargetSubnet === firstSubnet.subnetId &&
182 route.Description === "Updated route" &&
183 route.Status?.Code === "active",
184 ),
185 "replaced route description",
186 );
187 yield* deployRoute("192.168.11.0/24", firstSubnet.subnetId);
188 yield* assertClientVpnRouteDeleted(
189 clientVpnEndpointId,
190 "192.168.10.0/24",
191 firstSubnet.subnetId,
192 );
193 const replaced = yield* deployRoute(
194 "192.168.11.0/24",
195 secondSubnet.subnetId,
196 );
197 expect(replaced.targetVpcSubnetId).toBe(secondSubnet.subnetId);
198 yield* assertClientVpnRouteDeleted(
199 clientVpnEndpointId,
200 "192.168.11.0/24",
201 firstSubnet.subnetId,
202 );
203 const replacement = yield* waitForClientVpn(
204 readClientVpnRoutes(clientVpnEndpointId),
205 (items) =>
206 items.some(
207 (route) =>
208 route.DestinationCidr === "192.168.11.0/24" &&
209 route.TargetSubnet === secondSubnet.subnetId &&
210 route.Status?.Code === "active",
211 ),
212 "replacement route target",
213 );
214 expect(
215 replacement.find(
216 (route) =>
217 route.DestinationCidr === "192.168.11.0/24" &&
218 route.TargetSubnet === secondSubnet.subnetId,
219 )?.Description ?? "",
220 ).toBe("");
221
222 yield* ec2.deleteClientVpnRoute({
223 ClientVpnEndpointId: clientVpnEndpointId,
224 DestinationCidrBlock: "192.168.11.0/24",
225 TargetVpcSubnetId: secondSubnet.subnetId,
226 });
227 yield* assertClientVpnRouteDeleted(
228 clientVpnEndpointId,
229 "192.168.11.0/24",
230 secondSubnet.subnetId,
231 );
232 yield* deployRoute("192.168.11.0/24", secondSubnet.subnetId);
233 yield* waitForClientVpn(
234 readClientVpnRoutes(clientVpnEndpointId),
235 (items) =>
236 items.some(
237 (route) =>
238 route.DestinationCidr === "192.168.11.0/24" &&
239 route.TargetSubnet === secondSubnet.subnetId &&
240 route.Status?.Code === "active",
241 ),
242 "recreated out-of-band deleted route",
243 );
244 yield* stack.destroy();
245 yield* assertClientVpnRouteDeleted(
246 clientVpnEndpointId,
247 "192.168.11.0/24",
248 secondSubnet.subnetId,
249 );
250 // Deleting a managed route must retain AWS's association-owned VPC routes.
251 const remaining = yield* readClientVpnRoutes(clientVpnEndpointId);
252 expect(remaining).toContainEqual(
253 expect.objectContaining({
254 DestinationCidr: "10.171.0.0/16",
255 TargetSubnet: firstSubnet.subnetId,
256 Status: expect.objectContaining({ Code: "active" }),
257 }),
258 );
259 expect(remaining).toContainEqual(
260 expect.objectContaining({
261 DestinationCidr: "10.171.0.0/16",
262 TargetSubnet: secondSubnet.subnetId,
263 Status: expect.objectContaining({ Code: "active" }),
264 }),
265 );
266 }),
267 { timeout: clientVpnTestTimeout },
268 );
269 },
270);