1import * as AWS from "@/AWS";
2import { Subnet, Vpc } from "@/AWS/EC2";
3import type { ClientVpnEndpointProps } from "@/AWS/EC2/ClientVpnEndpoint.ts";
4import { createInternalTags, createTagsList } from "@/Tags.ts";
5import { withProviders } from "@/Test/Core.ts";
6import * as acm from "@distilled.cloud/aws/acm";
7import * as ec2 from "@distilled.cloud/aws/ec2";
8import { expect } from "alchemy-test";
9import * as Data from "effect/Data";
10import * as Effect from "effect/Effect";
11import * as Redacted from "effect/Redacted";
12import * as Schedule from "effect/Schedule";
13import * as Stream from "effect/Stream";
14import {
15 CLIENT_VPN_CA_PEM,
16 CLIENT_VPN_CERTIFICATE_PEM,
17 CLIENT_VPN_PRIVATE_KEY_PEM,
18} from "./client-vpn-certificate.ts";
19
20// A lifecycle test performs several create, replacement, and delete operations.
21export const clientVpnTestTimeout =
22 Number(process.env.AWS_CLIENT_VPN_TEST_TIMEOUT_MINUTES ?? 60) * 60_000;
23if (!Number.isFinite(clientVpnTestTimeout) || clientVpnTestTimeout <= 0) {
24 throw new RangeError(
25 "AWS_CLIENT_VPN_TEST_TIMEOUT_MINUTES must be positive and finite",
26 );
27}
28
29class ClientVpnFixtureNotReady extends Data.TaggedError(
30 "ClientVpnFixtureNotReady",
31)<{ readonly resource: string }> {}
32
33// ACM.Certificate only requests public certificates; VPN mutual auth needs an imported CA chain.
34export const importClientVpnCertificate = (stackName: string) =>
35 withProviders(
36 Effect.acquireRelease(
37 Effect.gen(function* () {
38 const tags = yield* createInternalTags("Certificate");
39 const pem = yield* Effect.sync(() => {
40 const encoder = new TextEncoder();
41 return {
42 Certificate: encoder.encode(CLIENT_VPN_CERTIFICATE_PEM),
43 PrivateKey: Redacted.make(
44 encoder.encode(CLIENT_VPN_PRIVATE_KEY_PEM),
45 ),
46 CertificateChain: encoder.encode(CLIENT_VPN_CA_PEM),
47 };
48 });
49 const imported = yield* acm.importCertificate({
50 ...pem,
51 Tags: createTagsList(tags),
52 });
53 if (!imported.CertificateArn) {
54 return yield* Effect.fail(
55 new ClientVpnFixtureNotReady({
56 resource: "ACM certificate import",
57 }),
58 );
59 }
60 return imported.CertificateArn;
61 }),
62 (certificateArn) =>
63 acm.deleteCertificate({ CertificateArn: certificateArn }).pipe(
64 Effect.retry({
65 while: (error) => error._tag === "ResourceInUseException",
66 schedule: Schedule.spaced("5 seconds").pipe(
67 Schedule.upTo({ duration: clientVpnTestTimeout }),
68 ),
69 }),
70 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
71 Effect.orDie,
72 ),
73 ),
74 { providers: AWS.providers() },
75 stackName,
76 );
77
78export const assertClientVpnCertificateDeleted = (certificateArn: string) =>
79 withProviders(
80 acm.describeCertificate({ CertificateArn: certificateArn }).pipe(
81 Effect.flatMap(() =>
82 Effect.fail(new ClientVpnFixtureNotReady({ resource: certificateArn })),
83 ),
84 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
85 ),
86 { providers: AWS.providers() },
87 "ClientVpnCertificateCleanup",
88 );
89
90export const clientVpnEndpointProps = (
91 certificateArn: string,
92): ClientVpnEndpointProps => ({
93 clientCidrBlock: "172.20.0.0/22",
94 serverCertificateArn: certificateArn,
95 authenticationOptions: [
96 {
97 type: "certificate-authentication",
98 mutualAuthentication: {
99 clientRootCertificateChainArn: certificateArn,
100 },
101 },
102 ],
103});
104
105export const clientVpnAvailabilityZones = withProviders(
106 Effect.gen(function* () {
107 const zones = yield* ec2.describeAvailabilityZones({
108 Filters: [
109 { Name: "state", Values: ["available"] },
110 { Name: "zone-type", Values: ["availability-zone"] },
111 ],
112 });
113 const [firstZone, secondZone] = (zones.AvailabilityZones ?? [])
114 .map((zone) => zone.ZoneName)
115 .filter((name): name is string => name !== undefined)
116 .sort();
117 if (!firstZone || !secondZone) {
118 return yield* Effect.fail(
119 new ClientVpnFixtureNotReady({
120 resource: "two AWS availability zones",
121 }),
122 );
123 }
124 return { firstZone, secondZone };
125 }),
126 { providers: AWS.providers() },
127 "ClientVpnNetworkPrerequisites",
128);
129
130export const clientVpnNetwork = ({
131 firstZone,
132 secondZone,
133}: {
134 firstZone: string;
135 secondZone: string;
136}) =>
137 Effect.gen(function* () {
138 const vpc = yield* Vpc("Vpc", {
139 cidrBlock: "10.171.0.0/16",
140 enableDnsSupport: true,
141 enableDnsHostnames: true,
142 });
143 const firstSubnet = yield* Subnet("FirstSubnet", {
144 vpcId: vpc.vpcId,
145 cidrBlock: "10.171.1.0/24",
146 availabilityZone: firstZone,
147 });
148 const secondSubnet = yield* Subnet("SecondSubnet", {
149 vpcId: vpc.vpcId,
150 cidrBlock: "10.171.2.0/24",
151 availabilityZone: secondZone,
152 });
153 return { vpc, firstSubnet, secondSubnet };
154 });
155
156export const waitForClientVpn = <A, E, R>(
157 observed: Effect.Effect<A, E, R>,
158 ready: (value: A) => boolean,
159 resource: string,
160) =>
161 observed.pipe(
162 Effect.flatMap((value) =>
163 ready(value)
164 ? Effect.succeed(value)
165 : Effect.fail(new ClientVpnFixtureNotReady({ resource })),
166 ),
167 Effect.retry({
168 while: (error) => error instanceof ClientVpnFixtureNotReady,
169 schedule: Schedule.spaced("5 seconds").pipe(
170 Schedule.upTo({ duration: clientVpnTestTimeout }),
171 ),
172 }),
173 );
174
175export const readClientVpnEndpoint = (clientVpnEndpointId: string) =>
176 ec2
177 .describeClientVpnEndpoints({
178 ClientVpnEndpointIds: [clientVpnEndpointId],
179 })
180 .pipe(
181 Effect.map((response) =>
182 response.ClientVpnEndpoints?.find(
183 (endpoint) => endpoint.ClientVpnEndpointId === clientVpnEndpointId,
184 ),
185 ),
186 Effect.catchTag("InvalidClientVpnEndpointId.NotFound", () =>
187 Effect.succeed(undefined),
188 ),
189 );
190
191export const assertClientVpnEndpointDeleted = (clientVpnEndpointId: string) =>
192 waitForClientVpn(
193 readClientVpnEndpoint(clientVpnEndpointId),
194 (endpoint) => endpoint === undefined || endpoint.Status?.Code === "deleted",
195 clientVpnEndpointId,
196 );
197
198export const readClientVpnAuthorizationRules = (clientVpnEndpointId: string) =>
199 ec2.describeClientVpnAuthorizationRules
200 .items({ ClientVpnEndpointId: clientVpnEndpointId })
201 .pipe(Stream.runCollect);
202
203export const readClientVpnTargetNetworks = (clientVpnEndpointId: string) =>
204 ec2.describeClientVpnTargetNetworks
205 .items({ ClientVpnEndpointId: clientVpnEndpointId })
206 .pipe(Stream.runCollect);
207
208export const readClientVpnRoutes = (clientVpnEndpointId: string) =>
209 ec2.describeClientVpnRoutes
210 .items({ ClientVpnEndpointId: clientVpnEndpointId })
211 .pipe(Stream.runCollect);
212
213export const assertClientVpnAuthorizationDeleted = (
214 clientVpnEndpointId: string,
215 targetNetworkCidr: string,
216) =>
217 waitForClientVpn(
218 readClientVpnAuthorizationRules(clientVpnEndpointId),
219 (rules) =>
220 !rules.some(
221 (rule) =>
222 rule.DestinationCidr === targetNetworkCidr &&
223 rule.Status?.Code !== "revoked",
224 ),
225 `authorization ${targetNetworkCidr}`,
226 );
227
228export const assertClientVpnAssociationDeleted = (
229 clientVpnEndpointId: string,
230 associationId: string,
231) =>
232 waitForClientVpn(
233 readClientVpnTargetNetworks(clientVpnEndpointId),
234 (networks) =>
235 !networks.some(
236 (network) =>
237 network.AssociationId === associationId &&
238 network.Status?.Code !== "disassociated",
239 ),
240 associationId,
241 );
242
243export const assertClientVpnRouteDeleted = (
244 clientVpnEndpointId: string,
245 destinationCidrBlock: string,
246 targetVpcSubnetId: string,
247) =>
248 waitForClientVpn(
249 readClientVpnRoutes(clientVpnEndpointId),
250 (routes) =>
251 !routes.some(
252 (route) =>
253 route.DestinationCidr === destinationCidrBlock &&
254 route.TargetSubnet === targetVpcSubnetId &&
255 route.Status?.Code !== "deleted",
256 ),
257 `route ${destinationCidrBlock} via ${targetVpcSubnetId}`,
258 );
259
260export const expectClientVpnOwnershipTags = (
261 tags: ec2.Tag[] | undefined,
262 stack: { name: string; stage: string },
263 id: string,
264) => {
265 expect(tags).toEqual(
266 expect.arrayContaining([
267 { Key: "alchemy::stack", Value: stack.name },
268 { Key: "alchemy::stage", Value: stack.stage },
269 { Key: "alchemy::id", Value: id },
270 ]),
271 );
272};