1import * as ACMPCA from "@/AWS/ACMPCA";
2import * as Lambda from "@/AWS/Lambda";
3import * as S3 from "@/AWS/S3";
4import * as Cause from "effect/Cause";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Schedule from "effect/Schedule";
9import * as Stream from "effect/Stream";
10import { HttpServerRequest } from "effect/http/HttpServerRequest";
11import * as HttpServerResponse from "effect/http/HttpServerResponse";
12import crypto from "node:crypto";
13import path from "pathe";
14
15const main = path.resolve(import.meta.dirname, "handler.ts");
16
17/**
18 * Deterministic bucket for audit reports (S3 names are global, but the
19 * testing account is fixed). The bucket policy must grant Amazon Web
20 * Services Private CA write access for CreateCertificateAuthorityAuditReport
21 * to succeed.
22 */
23export const AUDIT_BUCKET_NAME = "alchemy-test-acmpca-audit-reports";
24
25/**
26 * A static leaf CSR (checked in — fixtures are never generated at test
27 * time). The private key was discarded; the tests only need a valid CSR
28 * for the CA to sign.
29 */
30export const LEAF_CSR = `-----BEGIN CERTIFICATE REQUEST-----
31MIICgTCCAWkCAQAwPDEjMCEGA1UEAwwabGVhZi5hbGNoZW15LXRlc3QuaW50ZXJu
32YWwxFTATBgNVBAoMDEFsY2hlbXkgVGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEP
33ADCCAQoCggEBALGadvvgETlb8XMOiqRBUz/L+C3Ilc2d43TRYROvMXKnbzewzyA2
34EyBhXYUvWvewXH6chrC8GqcgbHGLs4GyHfLE3qO6TAF8mBzdvZo8J0aKGBMZLASA
35yq/JwwSawzGuQp818h/Qs85nxqEk8oxOQPMK3e6N7z81JC/nWEV/c9bQa5HGPiGF
360sMbNik5wPaMxxmt6Njx3P9DwbJWjcDaoslpY44iQrtWON3kiGVIFSE7jPX5B/Na
37i3RZJofI/UhMZkS5sHrAboNuZ8p03AS7O+zCkLTvvD948N4FPIWUP0khZocaR+k1
38xWgv/lFU0OBy3+SGifqb9QwcM5aD8ExN1UsCAwEAAaAAMA0GCSqGSIb3DQEBCwUA
39A4IBAQAIk42ecyyxlCHTA6xarCx74SgptR3VyCvgFj4qErEJA/ST3l26Emx5uuAC
4059hfZq42szQ/6VZwJTq71ThwUFPP+x3LpXRJnuHAOP2ORZ0x4LJwHlPiEZR2GFnZ
41CWuYpynAYgAKUh0ubeFzyp/odXwemlbaFc52JuxA9gUPVuThsiFqIWH780cfA9NM
42VIhBWsnInHBw0ffVQDbxcIRqP9qxwOIH9SHGKPXz1F5CfvSII9Z5QqeMuZwdXu7k
43Sy1wRo1UPY6ot0Kstqs8TJVA6R220wEk5iUEg/d3Ft1W0T17I60Gx5P6LgxjTJbp
44WDtemfBJScvPotEJ+xTh9kn81whi
45-----END CERTIFICATE REQUEST-----`;
46
47export class ACMPCATestFunction extends Lambda.Function<Lambda.Function>()(
48 "ACMPCATestFunction",
49) {}
50
51export default ACMPCATestFunction.make(
52 {
53 main,
54 functionUrl: true,
55 timeout: Duration.seconds(60),
56 },
57 Effect.gen(function* () {
58 const ca = yield* ACMPCA.CertificateAuthority("BindingsCA", {
59 subject: { commonName: "bindings.alchemy-test.internal" },
60 usageMode: "SHORT_LIVED_CERTIFICATE",
61 permanentDeletionTime: "7 days",
62 tags: { fixture: "acmpca-bindings" },
63 });
64
65 yield* S3.Bucket("AuditReports", {
66 bucketName: AUDIT_BUCKET_NAME,
67 forceDestroy: true,
68 policy: [
69 {
70 Effect: "Allow",
71 Principal: { Service: "acm-pca.amazonaws.com" },
72 Action: ["s3:PutObject", "s3:GetBucketAcl", "s3:GetBucketLocation"],
73 Resource: [
74 `arn:aws:s3:::${AUDIT_BUCKET_NAME}`,
75 `arn:aws:s3:::${AUDIT_BUCKET_NAME}/*`,
76 ],
77 },
78 ],
79 });
80
81 const CaArn = yield* ca.certificateAuthorityArn;
82
83 // Deploy-time: creates the EventBridge rule (default bus, source
84 // aws.acm-pca) targeting this Function. Runtime firing rides on the
85 // certificates the suite issues/revokes; the test verifies the rule
86 // deploys.
87 yield* ACMPCA.consumeCertificateAuthorityEvents(
88 { kinds: ["issuance", "revocation"] },
89 (events) =>
90 Stream.runForEach(events, (event) =>
91 Effect.log(
92 `acm-pca event: ${event["detail-type"]} -> ${event.resources.join(",")}`,
93 ),
94 ),
95 );
96 const getCsr = yield* ACMPCA.GetCertificateAuthorityCsr(ca);
97 const getCaCertificate =
98 yield* ACMPCA.GetCertificateAuthorityCertificate(ca);
99 const issueCertificate = yield* ACMPCA.IssueCertificate(ca);
100 const getCertificate = yield* ACMPCA.GetCertificate(ca);
101 const importCaCertificate =
102 yield* ACMPCA.ImportCertificateAuthorityCertificate(ca);
103 const revokeCertificate = yield* ACMPCA.RevokeCertificate(ca);
104 const createAuditReport =
105 yield* ACMPCA.CreateCertificateAuthorityAuditReport(ca);
106 const describeAuditReport =
107 yield* ACMPCA.DescribeCertificateAuthorityAuditReport(ca);
108
109 // ACM PCA activation is eventually consistent: for a few seconds after
110 // ImportCertificateAuthorityCertificate flips the CA to ACTIVE, other
111 // API nodes still answer with a typed InvalidStateException. Retry that
112 // tag on a bounded schedule (~30s worst case).
113 const retryInvalidState = <A, E extends { readonly _tag: string }, R>(
114 self: Effect.Effect<A, E, R>,
115 ): Effect.Effect<A, E, R> =>
116 Effect.retry(self, {
117 while: (e): boolean => e._tag === "InvalidStateException",
118 schedule: Schedule.max([
119 Schedule.fixed("3 seconds"),
120 Schedule.recurs(10),
121 ]),
122 });
123
124 // Issuance is asynchronous — poll GetCertificate while the typed
125 // in-progress tag (or a stale post-activation state read) is observed
126 // (bounded, ~30s worst case).
127 const waitForCertificate = (CertificateArn: string) =>
128 getCertificate({ CertificateArn }).pipe(
129 Effect.retry({
130 while: (e): boolean =>
131 e._tag === "RequestInProgressException" ||
132 e._tag === "InvalidStateException",
133 schedule: Schedule.max([
134 Schedule.fixed("3 seconds"),
135 Schedule.recurs(10),
136 ]),
137 }),
138 );
139
140 return {
141 fetch: Effect.gen(function* () {
142 const request = yield* HttpServerRequest;
143 const url = new URL(request.originalUrl);
144 const pathname = url.pathname;
145
146 if (request.method === "GET" && pathname === "/csr") {
147 const { Csr } = yield* getCsr();
148 return yield* HttpServerResponse.json({
149 csr: Csr,
150 caArn: yield* CaArn,
151 });
152 }
153
154 if (request.method === "GET" && pathname === "/ca-certificate") {
155 const result = yield* retryInvalidState(getCaCertificate());
156 return yield* HttpServerResponse.json({
157 certificate: result.Certificate,
158 certificateChain: result.CertificateChain,
159 });
160 }
161
162 // Full activation flow: fetch the CA's CSR, self-sign it with the
163 // root template, then install the signed certificate. Exercises
164 // GetCertificateAuthorityCsr + IssueCertificate + GetCertificate +
165 // ImportCertificateAuthorityCertificate in one route.
166 if (request.method === "POST" && pathname === "/activate") {
167 const { Csr } = yield* getCsr();
168 const issued = yield* retryInvalidState(
169 issueCertificate({
170 Csr: yield* Effect.sync(() => new TextEncoder().encode(Csr!)),
171 SigningAlgorithm: "SHA256WITHRSA",
172 TemplateArn: "arn:aws:acm-pca:::template/RootCACertificate/V1",
173 Validity: { Type: "YEARS", Value: 10 },
174 }),
175 );
176 const certificate = yield* waitForCertificate(issued.CertificateArn!);
177 yield* retryInvalidState(
178 importCaCertificate({
179 Certificate: yield* Effect.sync(() =>
180 new TextEncoder().encode(certificate.Certificate!),
181 ),
182 }),
183 );
184 return yield* HttpServerResponse.json({
185 certificateArn: issued.CertificateArn,
186 });
187 }
188
189 // Issue a short-lived end-entity certificate from the checked-in
190 // leaf CSR and return its PEM.
191 if (request.method === "POST" && pathname === "/issue") {
192 const issued = yield* retryInvalidState(
193 issueCertificate({
194 Csr: yield* Effect.sync(() => new TextEncoder().encode(LEAF_CSR)),
195 SigningAlgorithm: "SHA256WITHRSA",
196 Validity: { Type: "DAYS", Value: 5 },
197 }),
198 );
199 const certificate = yield* waitForCertificate(issued.CertificateArn!);
200 return yield* HttpServerResponse.json({
201 certificateArn: issued.CertificateArn,
202 certificate: certificate.Certificate,
203 });
204 }
205
206 if (request.method === "POST" && pathname === "/revoke") {
207 const body = (yield* request.json) as unknown as {
208 certificateArn: string;
209 };
210 const certificate = yield* waitForCertificate(body.certificateArn);
211 const serial = yield* Effect.sync(
212 () =>
213 new crypto.X509Certificate(certificate.Certificate!).serialNumber,
214 );
215 yield* retryInvalidState(
216 revokeCertificate({
217 CertificateSerial: serial,
218 RevocationReason: "CESSATION_OF_OPERATION",
219 }),
220 );
221 return yield* HttpServerResponse.json({ revoked: true, serial });
222 }
223
224 // Generate an audit report into the fixture bucket and poll it out
225 // of the CREATING state (reports land asynchronously in S3).
226 if (request.method === "POST" && pathname === "/audit") {
227 const report = yield* retryInvalidState(
228 createAuditReport({
229 S3BucketName: AUDIT_BUCKET_NAME,
230 AuditReportResponseFormat: "JSON",
231 }),
232 );
233 const status = yield* describeAuditReport({
234 AuditReportId: report.AuditReportId!,
235 }).pipe(
236 Effect.repeat({
237 schedule: Schedule.spaced("2 seconds"),
238 until: (r): boolean => r.AuditReportStatus !== "CREATING",
239 times: 10,
240 }),
241 );
242 return yield* HttpServerResponse.json({
243 auditReportId: report.AuditReportId,
244 s3Key: report.S3Key,
245 status: status.AuditReportStatus,
246 });
247 }
248
249 return yield* HttpServerResponse.json(
250 { error: "Not found", method: request.method, pathname },
251 { status: 404 },
252 );
253 }).pipe(
254 // Surface the failure to the test instead of a generic Lambda 500 —
255 // the suite's `send` helper includes non-200 bodies in its error.
256 Effect.catchCause((cause) =>
257 HttpServerResponse.json(
258 { error: Cause.pretty(cause) },
259 { status: 500 },
260 ),
261 ),
262 ),
263 };
264 }).pipe(
265 Effect.provide(
266 Layer.mergeAll(
267 Lambda.EventSource,
268 ACMPCA.GetCertificateAuthorityCsrHttp,
269 ACMPCA.GetCertificateAuthorityCertificateHttp,
270 ACMPCA.IssueCertificateHttp,
271 ACMPCA.GetCertificateHttp,
272 ACMPCA.ImportCertificateAuthorityCertificateHttp,
273 ACMPCA.RevokeCertificateHttp,
274 ACMPCA.CreateCertificateAuthorityAuditReportHttp,
275 ACMPCA.DescribeCertificateAuthorityAuditReportHttp,
276 ),
277 ),
278 ),
279);