1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import { Region as AwsRegion } from "@distilled.cloud/aws/Region";
5import * as acm from "@distilled.cloud/aws/acm";
6import { describe, expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import * as HttpClientRequest from "effect/http/HttpClientRequest";
12import AcmTestFunctionLive, {
13 AcmTestFunction,
14 FIXTURE_DOMAIN,
15} from "./handler";
16
17const testOptions = { providers: AWS.providers() };
18const { test, beforeAll, afterAll } = Test.make(testOptions);
19const sharedStack = Core.scratchStack(testOptions, "ACMBindings");
20
21// ACM operations (and the bindings) are pinned to us-east-1, matching where
22// the Certificate resource provider requests certificates. Every out-of-band
23// ACM call in this file must target the same region.
24const withUsEast1 = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
25 effect.pipe(Effect.provideService(AwsRegion, Effect.succeed("us-east-1")));
26
27// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
28// over 60s on a fresh deploy under parallel-suite load.
29const readinessPolicy = Schedule.max([
30 Schedule.fixed("2 seconds"),
31 Schedule.recurs(75),
32]);
33
34let baseUrl: string;
35
36class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
37 readonly status: number;
38 readonly body: string;
39}> {}
40
41// Retry transient 5xx from the shared Lambda fixture (cold re-init, IAM
42// propagation on the freshly attached acm policy surfaced as a 500 by the
43// handler's `Effect.orDie`). Genuine 4xx/assertion failures return
44// immediately.
45const send = (request: HttpClientRequest.HttpClientRequest) =>
46 HttpClient.execute(request).pipe(
47 Effect.flatMap((response) =>
48 response.status >= 500
49 ? response.text.pipe(
50 Effect.flatMap((body) =>
51 Effect.fail(
52 new TransientUpstream({ status: response.status, body }),
53 ),
54 ),
55 )
56 : Effect.succeed(response),
57 ),
58 Effect.retry({
59 while: (e) => e._tag === "TransientUpstream",
60 schedule: Schedule.max([
61 Schedule.exponential("500 millis"),
62 Schedule.recurs(6),
63 ]),
64 }),
65 );
66
67// The revoke probe below mutates shared fixture state if it were ever to
68// succeed, and list/search are eventually consistent against the fixture
69// certificate — run the file sequentially.
70describe.sequential(
71 "ACM Bindings",
72 { tags: ["provider:aws", "provider:aws:acm", "provider:aws:lambda", "live"] },
73 () => {
74 beforeAll(
75 Effect.gen(function* () {
76 yield* Effect.logInfo("ACM test setup: destroying previous resources");
77 yield* sharedStack.destroy();
78
79 yield* Effect.logInfo("ACM test setup: deploying fixture");
80 const { functionUrl } = yield* sharedStack.deploy(
81 Effect.gen(function* () {
82 return yield* AcmTestFunction;
83 }).pipe(Effect.provide(AcmTestFunctionLive)),
84 );
85
86 expect(functionUrl).toBeTruthy();
87 baseUrl = functionUrl!.replace(/\/+$/, "");
88 const readinessUrl = `${baseUrl}/describe`;
89
90 yield* Effect.logInfo(
91 `ACM test setup: probing readiness at ${readinessUrl}`,
92 );
93 yield* HttpClient.get(readinessUrl).pipe(
94 Effect.flatMap((response) =>
95 response.status === 200
96 ? Effect.succeed(response)
97 : Effect.fail(
98 new Error(`Function not ready: ${response.status}`),
99 ),
100 ),
101 Effect.retry({ schedule: readinessPolicy }),
102 );
103 }),
104 { timeout: 240_000 },
105 );
106
107 afterAll(sharedStack.destroy(), { timeout: 120_000 });
108
109 describe("DescribeCertificate", () => {
110 test.provider("reads the bound certificate's metadata", (_stack) =>
111 Effect.gen(function* () {
112 const body = (yield* send(
113 HttpClientRequest.get(`${baseUrl}/describe`),
114 ).pipe(Effect.flatMap((r) => r.json))) as any;
115
116 expect(body.arn).toContain("arn:aws:acm:us-east-1:");
117 expect(body.domainName).toBe(FIXTURE_DOMAIN);
118 // example.com is not ours — the DNS-validated fixture certificate
119 // stays pending forever.
120 expect(body.status).toBe("PENDING_VALIDATION");
121 }),
122 );
123 });
124
125 describe("GetCertificate", () => {
126 test.provider(
127 "fails with the typed RequestInProgressException while pending",
128 (_stack) =>
129 Effect.gen(function* () {
130 const body = (yield* send(
131 HttpClientRequest.get(`${baseUrl}/get`),
132 ).pipe(Effect.flatMap((r) => r.json))) as any;
133
134 expect(body.errorTag).toBe("RequestInProgressException");
135 }),
136 );
137 });
138
139 describe("ListCertificates", () => {
140 test.provider("lists the bound certificate", (_stack) =>
141 Effect.gen(function* () {
142 const described = (yield* send(
143 HttpClientRequest.get(`${baseUrl}/describe`),
144 ).pipe(Effect.flatMap((r) => r.json))) as any;
145
146 // ListCertificates is eventually consistent for fresh requests.
147 const body = yield* fetchUntil(
148 send(HttpClientRequest.get(`${baseUrl}/list`)).pipe(
149 Effect.flatMap((r) => r.json),
150 ),
151 (b) => Array.isArray(b?.arns) && b.arns.includes(described.arn),
152 );
153
154 expect((body as any).arns).toContain(described.arn);
155 }),
156 );
157 });
158
159 describe("SearchCertificates", () => {
160 test.provider("finds the bound certificate by ARN filter", (_stack) =>
161 Effect.gen(function* () {
162 const described = (yield* send(
163 HttpClientRequest.get(`${baseUrl}/describe`),
164 ).pipe(Effect.flatMap((r) => r.json))) as any;
165
166 const body = yield* fetchUntil(
167 send(HttpClientRequest.get(`${baseUrl}/search`)).pipe(
168 Effect.flatMap((r) => r.json),
169 ),
170 (b) => Array.isArray(b?.arns) && b.arns.includes(described.arn),
171 );
172
173 expect((body as any).arns).toContain(described.arn);
174 }),
175 );
176 });
177
178 describe("ExportCertificate", () => {
179 test.provider(
180 "fails with a typed error on a non-exportable pending certificate",
181 (_stack) =>
182 Effect.gen(function* () {
183 const body = (yield* send(
184 HttpClientRequest.post(`${baseUrl}/export`),
185 ).pipe(Effect.flatMap((r) => r.json))) as any;
186
187 // The fixture certificate is pending and was not requested with
188 // `export: "ENABLED"` — the export must be rejected with a TYPED
189 // tag (an untyped catch-all would crash the handler into a 500).
190 expect(typeof body.errorTag).toBe("string");
191 expect(body.errorTag.length).toBeGreaterThan(0);
192 }),
193 );
194 });
195
196 describe("RenewCertificate", () => {
197 test.provider(
198 "fails with a typed error on a pending certificate",
199 (_stack) =>
200 Effect.gen(function* () {
201 const body = (yield* send(
202 HttpClientRequest.post(`${baseUrl}/renew`),
203 ).pipe(Effect.flatMap((r) => r.json))) as any;
204
205 expect(typeof body.errorTag).toBe("string");
206 expect(body.errorTag.length).toBeGreaterThan(0);
207 }),
208 );
209 });
210
211 describe("ResendValidationEmail", () => {
212 test.provider(
213 "fails with a typed error on a DNS-validated certificate",
214 (_stack) =>
215 Effect.gen(function* () {
216 const body = (yield* send(
217 HttpClientRequest.post(`${baseUrl}/resend-email`),
218 ).pipe(Effect.flatMap((r) => r.json))) as any;
219
220 // Email validation cannot be (re)sent for a DNS-validated
221 // certificate; ACM reports the invalid state with a typed tag.
222 expect(typeof body.errorTag).toBe("string");
223 expect(body.errorTag.length).toBeGreaterThan(0);
224 }),
225 );
226 });
227
228 describe("RevokeCertificate", () => {
229 test.provider(
230 "fails with a typed error on a never-exported certificate",
231 (_stack) =>
232 Effect.gen(function* () {
233 const body = (yield* send(
234 HttpClientRequest.post(`${baseUrl}/revoke`),
235 ).pipe(Effect.flatMap((r) => r.json))) as any;
236
237 // Only previously exported certificates can be revoked — the
238 // pending fixture must be rejected with a typed tag.
239 expect(typeof body.errorTag).toBe("string");
240 expect(body.errorTag.length).toBeGreaterThan(0);
241 }),
242 );
243 });
244
245 describe("ImportCertificate", () => {
246 test.provider(
247 "imports and rotates an externally issued certificate",
248 (_stack) =>
249 Effect.gen(function* () {
250 const imported = (yield* send(
251 HttpClientRequest.bodyJsonUnsafe(
252 HttpClientRequest.post(`${baseUrl}/import`),
253 {},
254 ),
255 ).pipe(Effect.flatMap((r) => r.json))) as any;
256
257 expect(imported.arn).toContain("arn:aws:acm:us-east-1:");
258 expect(imported.arn).toContain(":certificate/");
259
260 // The imported certificate is created outside IaC management —
261 // reclaim it even if the assertions below fail.
262 yield* Effect.addFinalizer(() =>
263 withUsEast1(
264 acm.deleteCertificate({ CertificateArn: imported.arn }),
265 ).pipe(Effect.ignore),
266 );
267
268 // Rotation path: re-importing over the existing ARN keeps the ARN.
269 const reimported = (yield* send(
270 HttpClientRequest.bodyJsonUnsafe(
271 HttpClientRequest.post(`${baseUrl}/import`),
272 { reimportArn: imported.arn },
273 ),
274 ).pipe(Effect.flatMap((r) => r.json))) as any;
275
276 expect(reimported.arn).toBe(imported.arn);
277
278 // Deleting a freshly imported certificate can hit eventual
279 // consistency; retry the typed conflict, tolerate already-gone.
280 yield* withUsEast1(
281 acm.deleteCertificate({ CertificateArn: imported.arn }).pipe(
282 Effect.retry({
283 while: (e) => e._tag === "ConflictException",
284 schedule: Schedule.max([
285 Schedule.fixed("2 seconds"),
286 Schedule.recurs(10),
287 ]),
288 }),
289 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
290 ),
291 );
292 }),
293 { timeout: 120_000 },
294 );
295 });
296 },
297);
298
299// A request observed not-yet-consistent control-plane state (ListCertificates
300// lags fresh requests by a few seconds). Retry, bounded.
301class BindingNotConsistent extends Data.TaggedError("BindingNotConsistent") {}
302
303const fetchUntil = <A>(
304 fetch: Effect.Effect<unknown, any, HttpClient.HttpClient>,
305 ready: (body: any) => boolean,
306) =>
307 fetch.pipe(
308 Effect.flatMap((body) =>
309 ready(body)
310 ? Effect.succeed(body as A)
311 : Effect.fail(new BindingNotConsistent()),
312 ),
313 Effect.retry({
314 while: (e) => e._tag === "BindingNotConsistent",
315 schedule: Schedule.max([
316 Schedule.fixed("2 seconds"),
317 Schedule.recurs(20),
318 ]),
319 }),
320 );