1import * as AWS from "@/AWS";
2import { ClientVpnEndpoint } from "@/AWS/EC2/ClientVpnEndpoint.ts";
3import {
4 ClientVpnTargetNetworkAssociation,
5 type ClientVpnTargetNetworkAssociationProps,
6} from "@/AWS/EC2/ClientVpnTargetNetworkAssociation.ts";
7import * as Alchemy from "@/index.ts";
8import * as Provider from "@/Provider";
9import * as Test from "@/Test/Alchemy";
10import { describe, expect } from "alchemy-test";
11import * as Effect from "effect/Effect";
12import {
13 assertClientVpnAssociationDeleted,
14 assertClientVpnCertificateDeleted,
15 clientVpnAvailabilityZones,
16 clientVpnEndpointProps,
17 clientVpnTestTimeout,
18 clientVpnNetwork,
19 importClientVpnCertificate,
20 readClientVpnTargetNetworks,
21 waitForClientVpn,
22} from "./fixtures/client-vpn.ts";
23
24describe(
25 "Client VPN target networks",
26 { tags: ["provider:aws", "provider:aws:acm", "provider:aws:ec2", "live"] },
27 () => {
28 const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
29 providers: AWS.providers(),
30 });
31 const certificate = beforeAll(
32 importClientVpnCertificate("ClientVpnAssociationPrerequisites"),
33 );
34 const zones = beforeAll(clientVpnAvailabilityZones);
35 const Stack = Alchemy.Stack(
36 "ClientVpnAssociationPrerequisites",
37 { providers: AWS.providers(), state: Alchemy.localState() },
38 Effect.gen(function* () {
39 const certificateArn = yield* certificate;
40 const network = yield* clientVpnNetwork(yield* zones);
41 const endpoint = yield* ClientVpnEndpoint("Endpoint", {
42 ...clientVpnEndpointProps(certificateArn),
43 vpcId: network.vpc.vpcId,
44 splitTunnel: true,
45 });
46 return { ...network, endpoint };
47 }),
48 );
49 const prerequisites = beforeAll(deploy(Stack), {
50 timeout: clientVpnTestTimeout,
51 });
52 afterAll(
53 destroy(Stack).pipe(
54 Effect.andThen(
55 certificate.pipe(Effect.flatMap(assertClientVpnCertificateDeleted)),
56 ),
57 ),
58 { timeout: clientVpnTestTimeout },
59 );
60
61 test.provider(
62 "creates, lists, retains, replaces, and deletes target network associations",
63 (stack) =>
64 Effect.gen(function* () {
65 yield* stack.destroy();
66 const { endpoint, vpc, firstSubnet, secondSubnet } =
67 yield* prerequisites;
68 const clientVpnEndpointId = endpoint.clientVpnEndpointId;
69 const deployAssociation = (
70 subnetId: ClientVpnTargetNetworkAssociationProps["subnetId"],
71 ) =>
72 stack.deploy(
73 ClientVpnTargetNetworkAssociation("Association", {
74 clientVpnEndpointId,
75 subnetId,
76 }),
77 );
78 const created = yield* deployAssociation(firstSubnet.subnetId);
79 expect(created.associationId).toMatch(/^cvpn-assoc-/);
80 expect(created.clientVpnEndpointId).toBe(clientVpnEndpointId);
81 expect(created.subnetId).toBe(firstSubnet.subnetId);
82 const networks = yield* waitForClientVpn(
83 readClientVpnTargetNetworks(clientVpnEndpointId),
84 (items) =>
85 items.some(
86 (network) =>
87 network.AssociationId === created.associationId &&
88 network.Status?.Code === "associated",
89 ),
90 "associated target network",
91 );
92 expect(networks).toContainEqual(
93 expect.objectContaining({
94 AssociationId: created.associationId,
95 ClientVpnEndpointId: clientVpnEndpointId,
96 TargetNetworkId: firstSubnet.subnetId,
97 VpcId: vpc.vpcId,
98 }),
99 );
100 const provider = yield* Provider.findProvider(
101 ClientVpnTargetNetworkAssociation,
102 );
103 const listed = yield* waitForClientVpn(
104 provider.list(),
105 (items) =>
106 items.some(
107 (network) => network.associationId === created.associationId,
108 ),
109 "target network provider list",
110 );
111 expect(listed).toContainEqual(
112 expect.objectContaining({
113 associationId: created.associationId,
114 clientVpnEndpointId,
115 subnetId: firstSubnet.subnetId,
116 }),
117 );
118 const retained = yield* deployAssociation(firstSubnet.subnetId);
119 expect(retained.associationId).toBe(created.associationId);
120 expect(
121 (yield* readClientVpnTargetNetworks(clientVpnEndpointId)).filter(
122 (network) =>
123 network.TargetNetworkId === firstSubnet.subnetId &&
124 network.Status?.Code === "associated",
125 ),
126 ).toHaveLength(1);
127
128 // Both subnet dependencies survive the replacement; they occupy different AZs.
129 const replaced = yield* deployAssociation(secondSubnet.subnetId);
130 expect(replaced.associationId).not.toBe(created.associationId);
131 expect(replaced.subnetId).toBe(secondSubnet.subnetId);
132 yield* waitForClientVpn(
133 readClientVpnTargetNetworks(clientVpnEndpointId),
134 (items) =>
135 items.some(
136 (network) =>
137 network.AssociationId === replaced.associationId &&
138 network.TargetNetworkId === secondSubnet.subnetId &&
139 network.Status?.Code === "associated",
140 ),
141 "replacement target network",
142 );
143 yield* assertClientVpnAssociationDeleted(
144 clientVpnEndpointId,
145 created.associationId,
146 );
147 yield* stack.destroy();
148 yield* assertClientVpnAssociationDeleted(
149 clientVpnEndpointId,
150 replaced.associationId,
151 );
152 }),
153 { timeout: clientVpnTestTimeout },
154 );
155 },
156);