| 1 | import * as ACMPCA from "@/AWS/ACMPCA"; |
| 2 | import * as Lambda from "@/AWS/Lambda"; |
| 3 | import * as S3 from "@/AWS/S3"; |
| 4 | import * as Cause from "effect/Cause"; |
| 5 | import * as Duration from "effect/Duration"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as Schedule from "effect/Schedule"; |
| 9 | import * as Stream from "effect/Stream"; |
| 10 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 11 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 12 | import crypto from "node:crypto"; |
| 13 | import path from "pathe"; |
| 14 | |
| 15 | const 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 | */ |
| 23 | export 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 | */ |
| 30 | export const LEAF_CSR = `-----BEGIN CERTIFICATE REQUEST----- |
| 31 | MIICgTCCAWkCAQAwPDEjMCEGA1UEAwwabGVhZi5hbGNoZW15LXRlc3QuaW50ZXJu |
| 32 | YWwxFTATBgNVBAoMDEFsY2hlbXkgVGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEP |
| 33 | ADCCAQoCggEBALGadvvgETlb8XMOiqRBUz/L+C3Ilc2d43TRYROvMXKnbzewzyA2 |
| 34 | EyBhXYUvWvewXH6chrC8GqcgbHGLs4GyHfLE3qO6TAF8mBzdvZo8J0aKGBMZLASA |
| 35 | yq/JwwSawzGuQp818h/Qs85nxqEk8oxOQPMK3e6N7z81JC/nWEV/c9bQa5HGPiGF |
| 36 | 0sMbNik5wPaMxxmt6Njx3P9DwbJWjcDaoslpY44iQrtWON3kiGVIFSE7jPX5B/Na |
| 37 | i3RZJofI/UhMZkS5sHrAboNuZ8p03AS7O+zCkLTvvD948N4FPIWUP0khZocaR+k1 |
| 38 | xWgv/lFU0OBy3+SGifqb9QwcM5aD8ExN1UsCAwEAAaAAMA0GCSqGSIb3DQEBCwUA |
| 39 | A4IBAQAIk42ecyyxlCHTA6xarCx74SgptR3VyCvgFj4qErEJA/ST3l26Emx5uuAC |
| 40 | 59hfZq42szQ/6VZwJTq71ThwUFPP+x3LpXRJnuHAOP2ORZ0x4LJwHlPiEZR2GFnZ |
| 41 | CWuYpynAYgAKUh0ubeFzyp/odXwemlbaFc52JuxA9gUPVuThsiFqIWH780cfA9NM |
| 42 | VIhBWsnInHBw0ffVQDbxcIRqP9qxwOIH9SHGKPXz1F5CfvSII9Z5QqeMuZwdXu7k |
| 43 | Sy1wRo1UPY6ot0Kstqs8TJVA6R220wEk5iUEg/d3Ft1W0T17I60Gx5P6LgxjTJbp |
| 44 | WDtemfBJScvPotEJ+xTh9kn81whi |
| 45 | -----END CERTIFICATE REQUEST-----`; |
| 46 | |
| 47 | export class ACMPCATestFunction extends Lambda.Function<Lambda.Function>()( |
| 48 | "ACMPCATestFunction", |
| 49 | ) {} |
| 50 | |
| 51 | export 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 | ); |