1import * as AWS from "@/AWS";
2import {
3 CertificateAuthority,
4 CertificateAuthorityPolicy,
5 Permission,
6} from "@/AWS/ACMPCA";
7import { AWSEnvironment } from "@/AWS/Environment.ts";
8import * as Test from "@/Test/Alchemy";
9import * as acmpca from "@distilled.cloud/aws/acm-pca";
10import { expect } from "alchemy-test";
11import * as Effect from "effect/Effect";
12import * as Schedule from "effect/Schedule";
13
14const { test } = Test.make({ providers: AWS.providers() });
15
16// Ungated typed-error probes: prove the distilled union carries the tags the
17// provider's read/reconcile/delete paths depend on. Run in every CI pass at
18// near-zero cost, unlike the gated lifecycle below.
19test.provider(
20 "describeCertificateAuthority on a nonexistent CA fails with ResourceNotFoundException",
21 () =>
22 Effect.gen(function* () {
23 const { accountId, region } = yield* AWSEnvironment.current;
24 const error = yield* Effect.flip(
25 acmpca.describeCertificateAuthority({
26 CertificateAuthorityArn: `arn:aws:acm-pca:${region}:${accountId}:certificate-authority/00000000-0000-0000-0000-000000000000`,
27 }),
28 );
29 expect(error._tag).toBe("ResourceNotFoundException");
30 }),
31 { tags: ["provider:aws", "provider:aws:acmpca", "live"] },
32);
33
34test.provider(
35 "getPolicy on a nonexistent CA fails with ResourceNotFoundException",
36 () =>
37 Effect.gen(function* () {
38 const { accountId, region } = yield* AWSEnvironment.current;
39 // Proves the typed tag the CertificateAuthorityPolicy provider's
40 // observe path treats as "no policy".
41 const error = yield* Effect.flip(
42 acmpca.getPolicy({
43 ResourceArn: `arn:aws:acm-pca:${region}:${accountId}:certificate-authority/00000000-0000-0000-0000-000000000000`,
44 }),
45 );
46 expect(error._tag).toBe("ResourceNotFoundException");
47 }),
48 { tags: ["provider:aws", "provider:aws:acmpca", "live"] },
49);
50
51test.provider(
52 "describeCertificateAuthority on a non-CA ARN fails with InvalidArnException",
53 () =>
54 Effect.gen(function* () {
55 const { accountId, region } = yield* AWSEnvironment.current;
56 // A well-formed ARN whose resource type is not certificate-authority.
57 // (A fully malformed string is rejected earlier as ValidationException.)
58 const error = yield* Effect.flip(
59 acmpca.describeCertificateAuthority({
60 CertificateAuthorityArn: `arn:aws:acm-pca:${region}:${accountId}:something/else`,
61 }),
62 );
63 expect(error._tag).toBe("InvalidArnException");
64 }),
65 { tags: ["provider:aws", "provider:aws:acmpca", "live"] },
66);
67
68// Deletion leaves the CA in the DELETED state for its 7-day restoration
69// window; both DELETED and fully-gone count as deleted.
70const assertCaDeleted = (arn: string) =>
71 acmpca.describeCertificateAuthority({ CertificateAuthorityArn: arn }).pipe(
72 Effect.map((r) => r.CertificateAuthority?.Status ?? "gone"),
73 Effect.catchTag("ResourceNotFoundException", () =>
74 Effect.succeed("gone" as const),
75 ),
76 Effect.flatMap((status) =>
77 status === "gone" || status === "DELETED"
78 ? Effect.void
79 : Effect.fail(
80 new Error(`CA '${arn}' still exists (status: ${status})`),
81 ),
82 ),
83 Effect.retry({
84 schedule: Schedule.max([
85 Schedule.fixed("2 seconds"),
86 Schedule.recurs(10),
87 ]),
88 }),
89 );
90
91// A private CA bills a monthly fee (prorated) for as long as it exists, so
92// the live lifecycle is gated behind AWS_TEST_ACMPCA=1. It uses short-lived
93// certificate mode (the cheaper tier), never activates the CA (a
94// PENDING_CERTIFICATE CA deletes without disabling), and always destroys
95// what it created.
96test.provider.skipIf(!process.env.AWS_TEST_ACMPCA)(
97 "create private CA + ACM permission, update, replace, destroy",
98 (stack) =>
99 Effect.gen(function* () {
100 yield* stack.destroy();
101 const { accountId } = yield* AWSEnvironment.current;
102
103 const deployCa = (props: {
104 commonName: string;
105 tags: Record<string, string>;
106 }) =>
107 stack.deploy(
108 Effect.gen(function* () {
109 const ca = yield* CertificateAuthority("TestCA", {
110 subject: { commonName: props.commonName },
111 usageMode: "SHORT_LIVED_CERTIFICATE",
112 permanentDeletionTime: "7 days",
113 tags: props.tags,
114 });
115 const permission = yield* Permission("AcmRenewal", {
116 certificateAuthorityArn: ca.certificateAuthorityArn,
117 });
118 const policy = yield* CertificateAuthorityPolicy("SharePolicy", {
119 certificateAuthorityArn: ca.certificateAuthorityArn,
120 policy: {
121 Version: "2012-10-17",
122 Statement: [
123 {
124 Effect: "Allow",
125 Principal: { AWS: `arn:aws:iam::${accountId}:root` },
126 Action: [
127 "acm-pca:DescribeCertificateAuthority",
128 "acm-pca:GetCertificate",
129 "acm-pca:GetCertificateAuthorityCertificate",
130 "acm-pca:ListPermissions",
131 "acm-pca:IssueCertificate",
132 "acm-pca:RevokeCertificate",
133 ],
134 Resource: ca.certificateAuthorityArn,
135 },
136 ],
137 },
138 });
139 return { ca, permission, policy };
140 }),
141 );
142
143 // 1. CREATE — a fresh CA lands in PENDING_CERTIFICATE.
144 const first = yield* deployCa({
145 commonName: "alchemy-test.internal",
146 tags: { fixture: "acmpca" },
147 });
148 expect(first.ca.certificateAuthorityArn).toContain(
149 ":certificate-authority/",
150 );
151 expect(first.ca.status).toBe("PENDING_CERTIFICATE");
152 expect(first.permission.principal).toBe("acm.amazonaws.com");
153
154 // Out-of-band verification via distilled.
155 const described = yield* acmpca.describeCertificateAuthority({
156 CertificateAuthorityArn: first.ca.certificateAuthorityArn,
157 });
158 expect(described.CertificateAuthority?.Status).toBe(
159 "PENDING_CERTIFICATE",
160 );
161 expect(
162 described.CertificateAuthority?.CertificateAuthorityConfiguration
163 ?.Subject?.CommonName,
164 ).toBe("alchemy-test.internal");
165 expect(described.CertificateAuthority?.UsageMode).toBe(
166 "SHORT_LIVED_CERTIFICATE",
167 );
168
169 const perms = yield* acmpca.listPermissions({
170 CertificateAuthorityArn: first.ca.certificateAuthorityArn,
171 });
172 const acmPerm = perms.Permissions?.find(
173 (p) => p.Principal === "acm.amazonaws.com",
174 );
175 expect(acmPerm).toBeDefined();
176 expect([...(acmPerm?.Actions ?? [])].sort()).toEqual([
177 "GetCertificate",
178 "IssueCertificate",
179 "ListPermissions",
180 ]);
181
182 // The resource-based policy is attached (out-of-band via distilled).
183 expect(first.policy.certificateAuthorityArn).toBe(
184 first.ca.certificateAuthorityArn,
185 );
186 const attachedPolicy = yield* acmpca.getPolicy({
187 ResourceArn: first.ca.certificateAuthorityArn,
188 });
189 expect(attachedPolicy.Policy).toContain(
190 "acm-pca:DescribeCertificateAuthority",
191 );
192
193 // 2. UPDATE — same CA (stable ARN); tags converge in place. (The
194 // permission's action set cannot shrink: AWS requires all three
195 // actions for the ACM principal — a subset fails with
196 // `ValidationException: Permissions must contain all three actions`.)
197 const second = yield* deployCa({
198 commonName: "alchemy-test.internal",
199 tags: { fixture: "acmpca", updated: "true" },
200 });
201 expect(second.ca.certificateAuthorityArn).toBe(
202 first.ca.certificateAuthorityArn,
203 );
204 const tags = yield* acmpca.listTags({
205 CertificateAuthorityArn: second.ca.certificateAuthorityArn,
206 });
207 expect(tags.Tags?.find((t) => t.Key === "updated")?.Value).toBe("true");
208
209 // 3. REPLACE — changing the subject replaces the CA; the permission
210 // follows the new ARN and the old CA is deleted.
211 const third = yield* deployCa({
212 commonName: "alchemy-test-replaced.internal",
213 tags: { fixture: "acmpca" },
214 });
215 expect(third.ca.certificateAuthorityArn).not.toBe(
216 first.ca.certificateAuthorityArn,
217 );
218 expect(third.permission.certificateAuthorityArn).toBe(
219 third.ca.certificateAuthorityArn,
220 );
221 yield* assertCaDeleted(first.ca.certificateAuthorityArn);
222
223 // 4. DESTROY — CA enters its restoration window (DELETED).
224 yield* stack.destroy();
225 yield* assertCaDeleted(third.ca.certificateAuthorityArn);
226 }),
227 { tags: ["provider:aws", "provider:aws:acmpca", "live"], timeout: 300_000 },
228);