1import * as AWS from "@/AWS";
2import { SecurityGroup, Vpc } from "@/AWS/EC2";
3import {
4 ClientVpnEndpoint,
5 type ClientVpnEndpointProps,
6} from "@/AWS/EC2/ClientVpnEndpoint.ts";
7import { LogGroup, LogStream } from "@/AWS/Logs";
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 assertClientVpnEndpointDeleted,
17 clientVpnEndpointProps,
18 clientVpnTestTimeout,
19 expectClientVpnOwnershipTags,
20 importClientVpnCertificate,
21 readClientVpnEndpoint,
22 waitForClientVpn,
23} from "./fixtures/client-vpn.ts";
24
25const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
26 providers: AWS.providers(),
27});
28const certificate = beforeAll(
29 importClientVpnCertificate("ClientVpnEndpointPrerequisites"),
30);
31const Stack = Alchemy.Stack(
32 "ClientVpnEndpointPrerequisites",
33 { providers: AWS.providers(), state: Alchemy.localState() },
34 Effect.gen(function* () {
35 const certificateArn = yield* certificate;
36 const vpc = yield* Vpc("Vpc", { cidrBlock: "10.171.0.0/16" });
37 const firstGroup = yield* SecurityGroup("FirstGroup", {
38 vpcId: vpc.vpcId,
39 description: "Client VPN first security group",
40 });
41 const secondGroup = yield* SecurityGroup("SecondGroup", {
42 vpcId: vpc.vpcId,
43 description: "Client VPN second security group",
44 });
45 const logs = yield* LogGroup("ConnectionLogs", { retention: "1 day" });
46 const stream = yield* LogStream("ConnectionLogStream", {
47 logGroupName: logs.logGroupName,
48 });
49 return { certificateArn, vpc, firstGroup, secondGroup, logs, stream };
50 }),
51);
52const prerequisites = beforeAll(deploy(Stack), {
53 timeout: clientVpnTestTimeout,
54});
55afterAll(
56 destroy(Stack).pipe(
57 Effect.andThen(
58 certificate.pipe(Effect.flatMap(assertClientVpnCertificateDeleted)),
59 ),
60 ),
61 { timeout: clientVpnTestTimeout },
62);
63
64// Keep replacement generations within the account's Client VPN endpoint quota.
65describe.sequential(
66 "Client VPN endpoints",
67 {
68 tags: [
69 "provider:aws",
70 "provider:aws:acm",
71 "provider:aws:ec2",
72 "provider:aws:logs",
73 "live",
74 ],
75 },
76 () => {
77 test.provider(
78 "creates, lists, updates, removes optional settings, and deletes a Client VPN endpoint",
79 (stack) =>
80 Effect.gen(function* () {
81 yield* stack.destroy();
82 const fixture = yield* prerequisites;
83 const base = {
84 ...clientVpnEndpointProps(fixture.certificateArn),
85 vpcId: fixture.vpc.vpcId,
86 };
87 const deployEndpoint = (props: Partial<ClientVpnEndpointProps>) =>
88 stack.deploy(ClientVpnEndpoint("Endpoint", { ...base, ...props }));
89 const created = yield* deployEndpoint({
90 description: "Client VPN initial description",
91 dnsServers: ["1.1.1.1"],
92 vpnPort: 443,
93 splitTunnel: false,
94 connectionLogOptions: { enabled: false },
95 securityGroupIds: [fixture.firstGroup.groupId],
96 sessionTimeoutHours: 8,
97 clientLoginBannerOptions: {
98 enabled: true,
99 bannerText: "Initial banner",
100 },
101 clientConnectOptions: { enabled: false },
102 selfServicePortal: "disabled",
103 tags: { Environment: "initial", RemoveMe: "yes" },
104 });
105 expect(created.clientVpnEndpointId).toMatch(/^cvpn-endpoint-/);
106 expect(created.dnsName).toBeTruthy();
107 expect(created.status).toBeDefined();
108 const initial = yield* waitForClientVpn(
109 readClientVpnEndpoint(created.clientVpnEndpointId),
110 (endpoint) => endpoint?.Status?.Code === "pending-associate",
111 created.clientVpnEndpointId,
112 );
113 expect(initial).toMatchObject({
114 ClientVpnEndpointId: created.clientVpnEndpointId,
115 ClientCidrBlock: base.clientCidrBlock,
116 ServerCertificateArn: fixture.certificateArn,
117 TransportProtocol: "udp",
118 VpnPort: 443,
119 SplitTunnel: false,
120 VpcId: fixture.vpc.vpcId,
121 SecurityGroupIds: [fixture.firstGroup.groupId],
122 DnsServers: ["1.1.1.1"],
123 SessionTimeoutHours: 8,
124 ConnectionLogOptions: { Enabled: false },
125 ClientLoginBannerOptions: {
126 Enabled: true,
127 BannerText: "Initial banner",
128 },
129 AuthenticationOptions: [
130 {
131 Type: "certificate-authentication",
132 MutualAuthentication: {
133 ClientRootCertificateChain: fixture.certificateArn,
134 },
135 },
136 ],
137 });
138 expectClientVpnOwnershipTags(initial?.Tags, stack, "Endpoint");
139 const provider = yield* Provider.findProvider(ClientVpnEndpoint);
140 const listed = yield* waitForClientVpn(
141 provider.list(),
142 (endpoints) =>
143 endpoints.some(
144 (endpoint) =>
145 endpoint.clientVpnEndpointId === created.clientVpnEndpointId,
146 ),
147 "Client VPN endpoint provider list",
148 );
149 expect(
150 listed.find(
151 (endpoint) =>
152 endpoint.clientVpnEndpointId === created.clientVpnEndpointId,
153 ),
154 ).toMatchObject({ clientCidrBlock: base.clientCidrBlock });
155
156 const updated = yield* deployEndpoint({
157 description: "Client VPN updated description",
158 dnsServers: ["8.8.8.8", "8.8.4.4"],
159 vpnPort: 1194,
160 splitTunnel: true,
161 connectionLogOptions: {
162 enabled: true,
163 cloudwatchLogGroup: fixture.logs.logGroupName,
164 cloudwatchLogStream: fixture.stream.logStreamName,
165 },
166 securityGroupIds: [fixture.secondGroup.groupId],
167 sessionTimeoutHours: 10,
168 clientLoginBannerOptions: {
169 enabled: true,
170 bannerText: "Updated banner",
171 },
172 tags: { Environment: "updated" },
173 });
174 expect(updated.clientVpnEndpointId).toBe(created.clientVpnEndpointId);
175 expect(updated).toMatchObject({
176 description: "Client VPN updated description",
177 dnsServers: ["8.8.8.8", "8.8.4.4"],
178 vpnPort: 1194,
179 splitTunnel: true,
180 securityGroupIds: [fixture.secondGroup.groupId],
181 sessionTimeoutHours: 10,
182 connectionLogOptions: {
183 enabled: true,
184 cloudwatchLogGroup: fixture.logs.logGroupName,
185 cloudwatchLogStream: fixture.stream.logStreamName,
186 },
187 clientLoginBannerOptions: {
188 enabled: true,
189 bannerText: "Updated banner",
190 },
191 });
192 const observed = yield* waitForClientVpn(
193 readClientVpnEndpoint(updated.clientVpnEndpointId),
194 (endpoint) =>
195 endpoint?.Description === "Client VPN updated description" &&
196 endpoint.SessionTimeoutHours === 10 &&
197 endpoint.SecurityGroupIds?.includes(
198 fixture.secondGroup.groupId,
199 ) === true,
200 "updated endpoint settings",
201 );
202 expect(observed).toMatchObject({
203 DnsServers: ["8.8.8.8", "8.8.4.4"],
204 VpnPort: 1194,
205 SplitTunnel: true,
206 ConnectionLogOptions: {
207 Enabled: true,
208 CloudwatchLogGroup: fixture.logs.logGroupName,
209 CloudwatchLogStream: fixture.stream.logStreamName,
210 },
211 ClientLoginBannerOptions: {
212 Enabled: true,
213 BannerText: "Updated banner",
214 },
215 });
216 expect(observed?.Tags).toContainEqual({
217 Key: "Environment",
218 Value: "updated",
219 });
220 expect(observed?.Tags?.some((tag) => tag.Key === "RemoveMe")).toBe(
221 false,
222 );
223 expectClientVpnOwnershipTags(observed?.Tags, stack, "Endpoint");
224
225 const defaults = yield* deployEndpoint({});
226 expect(defaults.clientVpnEndpointId).toBe(
227 created.clientVpnEndpointId,
228 );
229 expect(defaults).toMatchObject({
230 description: "",
231 dnsServers: [],
232 vpnPort: 443,
233 splitTunnel: false,
234 sessionTimeoutHours: 24,
235 connectionLogOptions: { enabled: false },
236 clientLoginBannerOptions: { enabled: false },
237 clientConnectOptions: { enabled: false },
238 });
239 const reset = yield* waitForClientVpn(
240 readClientVpnEndpoint(defaults.clientVpnEndpointId),
241 (endpoint) =>
242 endpoint !== undefined &&
243 !endpoint.Description &&
244 (endpoint.DnsServers ?? []).length === 0 &&
245 endpoint.SessionTimeoutHours === 24 &&
246 endpoint.ClientLoginBannerOptions?.Enabled !== true,
247 "endpoint default restoration",
248 );
249 expect(reset).toMatchObject({
250 VpnPort: 443,
251 SplitTunnel: false,
252 ConnectionLogOptions: { Enabled: false },
253 });
254 expect(reset?.ClientConnectOptions?.Enabled ?? false).toBe(false);
255 expect(reset?.ClientLoginBannerOptions?.Enabled ?? false).toBe(false);
256 expect(reset?.Tags?.some((tag) => tag.Key === "Environment")).toBe(
257 false,
258 );
259 const groups = yield* ec2.describeSecurityGroups({
260 Filters: [
261 { Name: "vpc-id", Values: [fixture.vpc.vpcId] },
262 { Name: "group-name", Values: ["default"] },
263 ],
264 });
265 expect(reset?.SecurityGroupIds).toEqual([
266 groups.SecurityGroups?.[0]?.GroupId,
267 ]);
268 expectClientVpnOwnershipTags(reset?.Tags, stack, "Endpoint");
269 yield* stack.destroy();
270 yield* assertClientVpnEndpointDeleted(created.clientVpnEndpointId);
271 }),
272 { timeout: clientVpnTestTimeout },
273 );
274
275 test.provider(
276 "repairs out-of-band endpoint settings and tags with unchanged desired props",
277 (stack) =>
278 Effect.gen(function* () {
279 yield* stack.destroy();
280 const { certificateArn } = yield* prerequisites;
281 const props: ClientVpnEndpointProps = {
282 ...clientVpnEndpointProps(certificateArn),
283 description: "Managed description",
284 dnsServers: ["1.1.1.1"],
285 splitTunnel: true,
286 tags: { Environment: "managed" },
287 };
288 const program = ClientVpnEndpoint("Endpoint", props);
289 const created = yield* stack.deploy(program);
290 yield* ec2.modifyClientVpnEndpoint({
291 ClientVpnEndpointId: created.clientVpnEndpointId,
292 Description: "Out-of-band description",
293 DnsServers: { CustomDnsServers: ["8.8.8.8"], Enabled: true },
294 SplitTunnel: false,
295 });
296 yield* ec2.createTags({
297 Resources: [created.clientVpnEndpointId],
298 Tags: [
299 { Key: "Environment", Value: "drifted" },
300 { Key: "Unmanaged", Value: "remove" },
301 ],
302 });
303 yield* waitForClientVpn(
304 readClientVpnEndpoint(created.clientVpnEndpointId),
305 (endpoint) => endpoint?.Description === "Out-of-band description",
306 "out-of-band endpoint modification",
307 );
308 const plan = yield* stack.plan(program);
309 expect(plan.resources.Endpoint?.action).toBe("update");
310 const repaired = yield* stack.deploy(program);
311 expect(repaired.clientVpnEndpointId).toBe(
312 created.clientVpnEndpointId,
313 );
314 const observed = yield* waitForClientVpn(
315 readClientVpnEndpoint(created.clientVpnEndpointId),
316 (endpoint) => endpoint?.Description === props.description,
317 "repaired endpoint",
318 );
319 expect(observed).toMatchObject({
320 DnsServers: ["1.1.1.1"],
321 SplitTunnel: true,
322 });
323 expect(observed?.Tags).toContainEqual({
324 Key: "Environment",
325 Value: "managed",
326 });
327 expect(observed?.Tags?.some((tag) => tag.Key === "Unmanaged")).toBe(
328 false,
329 );
330 expectClientVpnOwnershipTags(observed?.Tags, stack, "Endpoint");
331 yield* stack.destroy();
332 yield* assertClientVpnEndpointDeleted(created.clientVpnEndpointId);
333 }),
334 { timeout: clientVpnTestTimeout },
335 );
336
337 test.provider(
338 "enables group-only logging from a newly created dependency without replacing the endpoint",
339 (stack) =>
340 Effect.gen(function* () {
341 yield* stack.destroy();
342 const { certificateArn } = yield* prerequisites;
343 const program = (logging: boolean) =>
344 Effect.gen(function* () {
345 const logs = logging
346 ? yield* LogGroup("NewConnectionLogs", { retention: "1 day" })
347 : undefined;
348 const endpoint = yield* ClientVpnEndpoint("Endpoint", {
349 ...clientVpnEndpointProps(certificateArn),
350 connectionLogOptions: logs
351 ? { enabled: true, cloudwatchLogGroup: logs.logGroupName }
352 : undefined,
353 });
354 return { endpoint, logs };
355 });
356 const created = yield* stack.deploy(program(false));
357 const plan = yield* stack.plan(program(true));
358 expect(plan.resources.Endpoint?.action).toBe("update");
359 expect(plan.resources.NewConnectionLogs?.action).toBe("create");
360 const updated = yield* stack.deploy(program(true));
361 expect(updated.endpoint.clientVpnEndpointId).toBe(
362 created.endpoint.clientVpnEndpointId,
363 );
364 const observed = yield* readClientVpnEndpoint(
365 updated.endpoint.clientVpnEndpointId,
366 );
367 expect(observed?.ConnectionLogOptions).toMatchObject({
368 Enabled: true,
369 CloudwatchLogGroup: updated.logs?.logGroupName,
370 });
371 expect(
372 (yield* stack.plan(program(true))).resources.Endpoint?.action,
373 ).toBe("noop");
374 yield* stack.destroy();
375 yield* assertClientVpnEndpointDeleted(
376 updated.endpoint.clientVpnEndpointId,
377 );
378 }),
379 { timeout: clientVpnTestTimeout },
380 );
381
382 test.provider(
383 "fails with a typed error when the configured VPC has been deleted",
384 (stack) =>
385 Effect.gen(function* () {
386 yield* stack.destroy();
387 const { certificateArn } = yield* prerequisites;
388 const vpc = yield* stack.deploy(
389 Vpc("DeletedVpc", { cidrBlock: "10.175.0.0/16" }),
390 );
391 yield* stack.destroy();
392 const failure = yield* stack
393 .deploy(
394 ClientVpnEndpoint("Endpoint", {
395 ...clientVpnEndpointProps(certificateArn),
396 vpcId: vpc.vpcId,
397 }),
398 )
399 .pipe(Effect.flip);
400 expect(failure).toMatchObject({ _tag: "InvalidVpcID.NotFound" });
401 yield* stack.destroy();
402 }),
403 { timeout: clientVpnTestTimeout },
404 );
405
406 test.provider(
407 "replaces an endpoint for client CIDR and transport protocol changes",
408 (stack) =>
409 Effect.gen(function* () {
410 yield* stack.destroy();
411 const { certificateArn } = yield* prerequisites;
412 const deployEndpoint = (
413 clientCidrBlock: string,
414 transportProtocol: "udp" | "tcp",
415 ) =>
416 stack.deploy(
417 ClientVpnEndpoint("Endpoint", {
418 ...clientVpnEndpointProps(certificateArn),
419 clientCidrBlock,
420 transportProtocol,
421 }),
422 );
423 const created = yield* deployEndpoint("172.20.0.0/22", "udp");
424 const changedCidr = yield* deployEndpoint("172.20.4.0/22", "udp");
425 expect(changedCidr.clientVpnEndpointId).not.toBe(
426 created.clientVpnEndpointId,
427 );
428 expect(
429 yield* readClientVpnEndpoint(changedCidr.clientVpnEndpointId),
430 ).toMatchObject({
431 ClientCidrBlock: "172.20.4.0/22",
432 TransportProtocol: "udp",
433 });
434 yield* assertClientVpnEndpointDeleted(created.clientVpnEndpointId);
435 const changedProtocol = yield* deployEndpoint("172.20.4.0/22", "tcp");
436 expect(changedProtocol.clientVpnEndpointId).not.toBe(
437 changedCidr.clientVpnEndpointId,
438 );
439 expect(
440 yield* readClientVpnEndpoint(changedProtocol.clientVpnEndpointId),
441 ).toMatchObject({
442 ClientCidrBlock: "172.20.4.0/22",
443 TransportProtocol: "tcp",
444 });
445 yield* assertClientVpnEndpointDeleted(
446 changedCidr.clientVpnEndpointId,
447 );
448 yield* stack.destroy();
449 yield* assertClientVpnEndpointDeleted(
450 changedProtocol.clientVpnEndpointId,
451 );
452 }),
453 { timeout: clientVpnTestTimeout },
454 );
455 },
456);