1import * as AWS from "@/AWS";
2import { Bucket } from "@/AWS/S3";
3import { TrustStore } from "@/AWS/ELBv2";
4import * as Test from "@/Test/Alchemy";
5import * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2";
6import * as s3 from "@distilled.cloud/aws/s3";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import { MinimumLogLevel } from "effect/References";
10
11const { test } = Test.make({ providers: AWS.providers() });
12
13const logLevel = Effect.provideService(
14 MinimumLogLevel,
15 process.env.DEBUG ? "Debug" : "Info",
16);
17
18// A self-signed CA certificate generated once and checked in (never created at
19// test time, per the fixture convention). X.509 v3 with basicConstraints
20// CA:TRUE + keyCertSign — ELBv2 trust stores reject v1 certs
21// ("The certificate version is not supported").
22const CA_BUNDLE_PEM = `-----BEGIN CERTIFICATE-----
23MIIC2jCCAcKgAwIBAgIJAJyM/Dvd55qtMA0GCSqGSIb3DQEBCwUAMBoxGDAWBgNV
24BAMMD2FsY2hlbXktdGVzdC1jYTAeFw0yNjA2MTcwNjA5MDlaFw0zNjA2MTQwNjA5
25MDlaMBoxGDAWBgNVBAMMD2FsY2hlbXktdGVzdC1jYTCCASIwDQYJKoZIhvcNAQEB
26BQADggEPADCCAQoCggEBANgo7XPCQMpyecXg2SCj6Tn6R1snlmhSA1vKGQnHoQBS
27QA11DMpv+iFRT9s1d3izaGA4GEcxfrXOsmUBkzYIHJIYakCWdr6qcUXs6lS2uhnZ
28qcyR0CamDtHTqAxRKEK+QPaISoxyD3BIwQqE0I8yNzV3/6osIE513e+7tp9E+J04
29dBhyG5goSwR3ueqs53gQioYVp/fgLKo4MqFcsA3p7anEE9hyeq1Q/lGAXxQwZmXT
303kQli/JjMoF8OfccpA3aBx9Y2aDTCU8HXTscVYmPSHbnTGkTARGBwnag+Jwq5Uni
31YvM2OeDUPwvszgpi3JgiblZQhZQAy4/MeNhmE8qgIa8CAwEAAaMjMCEwDwYDVR0T
32AQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQELBQADggEBAJnp
33el0xBbL/eQY87evhy0o+ZTHMVCdI9Uc+kDK0XPMi4hc5OfjWNIy8u5/s33kPkNYS
34Y5Jhm5KtGtMb9kXioCWjSi0aREA8zijGrXn1jC+0rksMQmJka63bKsJ4TjFaHMcc
35m/xt25xX1Ssp/gWr9YX3MzbPhcn57Uu9OTtzf13F6CMv1XtRS1RKFYtkLZrhvzBR
36WPdos3xvn3D0Fjd5H5AgVKTkeb2YPhINfN4jyzn3J09teKZpNN/qHTAQewIh2FnO
37MeplcuT3eQVUZNTBelvUE7VKHe11AUc8TkvVMS/XOFeN6OHAJtq08EegbTcjwz9Z
38lyGGetkNMmdhGRV6AlY=
39-----END CERTIFICATE-----
40`;
41
42// Idempotent out-of-band delete for the probe trust store below. The probe
43// create is EXPECTED to fail, but if it ever unexpectedly succeeded the store
44// would live outside the stack — so cleanup must be guaranteed. Not-found is
45// success; any other error is a defect, so the error channel is `never` and
46// this is a valid `Effect.ensuring` finalizer.
47const deleteTrustStoreByNameIdempotent = (name: string) =>
48 elbv2.describeTrustStores({ Names: [name] }).pipe(
49 Effect.flatMap((r) =>
50 Effect.forEach(
51 (r.TrustStores ?? []).flatMap((t) =>
52 t.TrustStoreArn ? [t.TrustStoreArn] : [],
53 ),
54 (arn) => elbv2.deleteTrustStore({ TrustStoreArn: arn }),
55 ),
56 ),
57 Effect.catchTag("TrustStoreNotFoundException", () => Effect.void),
58 Effect.orDie,
59 Effect.asVoid,
60 );
61
62// Fast unconditional probe: createTrustStore against a non-existent bundle
63// must surface a typed error (not an untyped catch-all). Proves both the
64// resource wiring and the distilled typed-error path.
65test.provider(
66 "trust store create with missing bundle returns a typed error",
67 (stack) =>
68 Effect.gen(function* () {
69 yield* stack.destroy();
70
71 const probeName =
72 `alchemy-mtls-probe-${stack.name.replace(/[^a-zA-Z0-9]/g, "")}`.slice(
73 0,
74 32,
75 );
76
77 // Pre-clean: reclaim a probe trust store leaked by a prior crashed run
78 // (a leftover would turn the expected error into DuplicateTrustStoreName).
79 yield* deleteTrustStoreByNameIdempotent(probeName);
80
81 yield* Effect.gen(function* () {
82 const result = yield* elbv2
83 .createTrustStore({
84 Name: probeName,
85 CaCertificatesBundleS3Bucket: "alchemy-no-such-bucket-elbv2-probe",
86 CaCertificatesBundleS3Key: "missing.pem",
87 })
88 .pipe(Effect.flip);
89
90 // AWS rejects a missing/inaccessible bundle with one of these typed tags.
91 expect(
92 [
93 "CaCertificatesBundleNotFoundException",
94 "InvalidCaCertificatesBundleException",
95 ].includes(result._tag),
96 ).toBe(true);
97 }).pipe(
98 // Guaranteed cleanup if the create ever unexpectedly succeeds.
99 Effect.ensuring(deleteTrustStoreByNameIdempotent(probeName)),
100 );
101
102 yield* stack.destroy();
103 }).pipe(logLevel),
104 { tags: ["provider:aws", "provider:aws:elbv2", "live"], timeout: 120_000 },
105);
106
107// Full mTLS-verify lifecycle: upload a CA bundle to a stack-owned bucket,
108// create an ACTIVE trust store, then destroy. Runs ungated — verified live on
109// the standard testing account (completes in a few seconds).
110test.provider(
111 "trust store full lifecycle from an uploaded CA bundle",
112 (stack) =>
113 Effect.gen(function* () {
114 yield* stack.destroy();
115
116 const deployed = yield* stack.deploy(
117 Effect.gen(function* () {
118 const bucket = yield* Bucket("TsBucket", { forceDestroy: true });
119 return { bucketName: bucket.bucketName };
120 }),
121 );
122
123 const key = "ca-bundle.pem";
124 yield* s3.putObject({
125 Bucket: deployed.bucketName,
126 Key: key,
127 Body: CA_BUNDLE_PEM,
128 ContentType: "application/x-pem-file",
129 });
130
131 const ts = yield* stack.deploy(
132 Effect.gen(function* () {
133 const bucket = yield* Bucket("TsBucket", { forceDestroy: true });
134 const trustStore = yield* TrustStore("TsStore", {
135 caCertificatesBundleS3Bucket: bucket.bucketName,
136 caCertificatesBundleS3Key: key,
137 });
138 return { trustStore };
139 }),
140 );
141
142 expect(ts.trustStore.status).toBe("ACTIVE");
143 expect(ts.trustStore.numberOfCaCertificates).toBeGreaterThanOrEqual(1);
144
145 const observed = yield* elbv2
146 .describeTrustStores({
147 TrustStoreArns: [ts.trustStore.trustStoreArn],
148 })
149 .pipe(Effect.map((r) => r.TrustStores?.[0]));
150 expect(observed?.Status).toBe("ACTIVE");
151
152 yield* stack.destroy();
153
154 const after = yield* elbv2
155 .describeTrustStores({
156 TrustStoreArns: [ts.trustStore.trustStoreArn],
157 })
158 .pipe(
159 Effect.map((r) => r.TrustStores?.length ?? 0),
160 Effect.catchTag("TrustStoreNotFoundException", () =>
161 Effect.succeed(0),
162 ),
163 );
164 expect(after).toBe(0);
165 }).pipe(logLevel),
166 {
167 tags: ["provider:aws", "provider:aws:elbv2", "provider:aws:s3", "live"],
168 timeout: 600_000,
169 },
170);