1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as acmpca from "@distilled.cloud/aws/acm-pca";
5import { describe, expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import ACMPCATestFunctionLive, { ACMPCATestFunction } from "./fixtures/handler";
11
12const testOptions = { providers: AWS.providers() };
13const { test, beforeAll, afterAll } = Test.make(testOptions);
14const sharedStack = Core.scratchStack(testOptions, "ACMPCABindings");
15
16// Lambda function URL cold-start (DNS, IAM propagation, init) can take
17// well over 60s on a fresh deploy under parallel-suite load.
18const readinessPolicy = Schedule.max([
19 Schedule.fixed("2 seconds"),
20 Schedule.recurs(75),
21]);
22
23let baseUrl: string;
24let caArn: string;
25let caCsr: string;
26let caCertificateArn: string;
27
28const send = (request: HttpClientRequest.HttpClientRequest) =>
29 HttpClient.execute(request).pipe(
30 Effect.flatMap((response) =>
31 response.status === 200
32 ? response.json
33 : response.text.pipe(
34 Effect.flatMap((body) =>
35 Effect.fail(
36 new Error(`Fixture answered ${response.status}: ${body}`),
37 ),
38 ),
39 ),
40 ),
41 );
42
43// A private CA bills a monthly fee (prorated) for as long as it exists, and
44// this suite ACTIVATES its CA (self-signed root, short-lived certificate
45// mode — the cheaper tier), so the whole suite is gated behind
46// AWS_TEST_ACMPCA=1.
47//
48// vitest runs the tests of a file CONCURRENTLY (sequence.concurrent), so all
49// state-ordered steps live in `beforeAll`: the CSR is captured while the CA
50// is still PENDING_CERTIFICATE (GetCertificateAuthorityCsr only answers in
51// that state), then the CA is activated. Every test below is
52// order-independent against the ACTIVE CA; the issue→revoke chain runs
53// inside a single test.
54describe.skipIf(!process.env.AWS_TEST_ACMPCA)(
55 "ACMPCA Bindings",
56 {
57 tags: [
58 "provider:aws",
59 "provider:aws:acmpca",
60 "provider:aws:lambda",
61 "provider:aws:s3",
62 "live",
63 ],
64 },
65 () => {
66 beforeAll(
67 Effect.gen(function* () {
68 yield* sharedStack.destroy();
69 const { functionUrl } = yield* sharedStack.deploy(
70 Effect.gen(function* () {
71 return yield* ACMPCATestFunction;
72 }).pipe(Effect.provide(ACMPCATestFunctionLive)),
73 );
74
75 expect(functionUrl).toBeTruthy();
76 baseUrl = functionUrl!.replace(/\/+$/, "");
77
78 // /csr works while the CA is still PENDING_CERTIFICATE, so it doubles
79 // as the readiness probe. It also reports the CA's ARN for the
80 // out-of-band assertions below.
81 const ready = yield* HttpClient.get(`${baseUrl}/csr`).pipe(
82 Effect.flatMap((response) =>
83 response.status === 200
84 ? response.json
85 : Effect.fail(
86 new Error(`Function not ready: ${response.status}`),
87 ),
88 ),
89 Effect.retry({ schedule: readinessPolicy }),
90 );
91 caArn = (ready as { caArn: string }).caArn;
92 caCsr = (ready as { csr: string }).csr;
93 expect(caArn).toContain(":certificate-authority/");
94
95 // Activate the CA: self-sign its CSR with the root template and
96 // install the result (GetCertificateAuthorityCsr + IssueCertificate +
97 // GetCertificate + ImportCertificateAuthorityCertificate).
98 const activated = (yield* send(
99 HttpClientRequest.post(`${baseUrl}/activate`),
100 ).pipe(
101 Effect.retry({
102 schedule: Schedule.spaced("3 seconds"),
103 times: 3,
104 }),
105 )) as { certificateArn: string };
106 caCertificateArn = activated.certificateArn;
107 }),
108 { timeout: 240_000 },
109 );
110
111 afterAll(sharedStack.destroy(), { timeout: 120_000 });
112
113 describe("GetCertificateAuthorityCsr", () => {
114 test.provider(
115 "returned the CA's PEM CSR while it was pending",
116 () =>
117 Effect.gen(function* () {
118 expect(caCsr).toContain("BEGIN CERTIFICATE REQUEST");
119 }),
120 { timeout: 60_000 },
121 );
122 });
123
124 describe("IssueCertificate + GetCertificate + ImportCertificateAuthorityCertificate", () => {
125 test.provider(
126 "activated the CA by self-signing its CSR with the root template",
127 () =>
128 Effect.gen(function* () {
129 expect(caCertificateArn).toContain(caArn);
130
131 // Out-of-band verification via distilled: the CA is now ACTIVE.
132 const described = yield* acmpca.describeCertificateAuthority({
133 CertificateAuthorityArn: caArn,
134 });
135 expect(described.CertificateAuthority?.Status).toBe("ACTIVE");
136 }),
137 { timeout: 120_000 },
138 );
139 });
140
141 describe("GetCertificateAuthorityCertificate", () => {
142 test.provider(
143 "returns the installed CA certificate",
144 () =>
145 Effect.gen(function* () {
146 const body = (yield* send(
147 HttpClientRequest.get(`${baseUrl}/ca-certificate`),
148 )) as { certificate: string };
149 expect(body.certificate).toContain("BEGIN CERTIFICATE");
150 }),
151 { timeout: 60_000 },
152 );
153 });
154
155 describe("IssueCertificate + RevokeCertificate", () => {
156 test.provider(
157 "issues a short-lived end-entity certificate, then revokes it by serial",
158 () =>
159 Effect.gen(function* () {
160 const issued = (yield* send(
161 HttpClientRequest.post(`${baseUrl}/issue`),
162 )) as { certificateArn: string; certificate: string };
163 expect(issued.certificateArn).toContain("/certificate/");
164 expect(issued.certificate).toContain("BEGIN CERTIFICATE");
165
166 const revoked = (yield* send(
167 HttpClientRequest.bodyJsonUnsafe(
168 HttpClientRequest.post(`${baseUrl}/revoke`),
169 { certificateArn: issued.certificateArn },
170 ),
171 )) as { revoked: boolean; serial: string };
172 expect(revoked.revoked).toBe(true);
173 expect(revoked.serial).toBeTruthy();
174 }),
175 { timeout: 120_000 },
176 );
177 });
178
179 describe("CreateCertificateAuthorityAuditReport + DescribeCertificateAuthorityAuditReport", () => {
180 test.provider(
181 "generates an audit report into S3 and polls it",
182 () =>
183 Effect.gen(function* () {
184 const body = (yield* send(
185 HttpClientRequest.post(`${baseUrl}/audit`),
186 )) as { auditReportId: string; s3Key: string; status: string };
187 expect(body.auditReportId).toBeTruthy();
188 expect(body.status).toBe("SUCCESS");
189 }),
190 { timeout: 120_000 },
191 );
192 });
193 },
194);