1import * as ACM from "@/AWS/ACM";
2import * as Lambda from "@/AWS/Lambda";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Redacted from "effect/Redacted";
7import * as Result from "effect/Result";
8import * as Stream from "effect/Stream";
9import { HttpServerRequest } from "effect/http/HttpServerRequest";
10import * as HttpServerResponse from "effect/http/HttpServerResponse";
11import path from "pathe";
12import {
13 IMPORT_CERTIFICATE_PEM,
14 IMPORT_PRIVATE_KEY_PEM,
15} from "./fixtures/import-cert.ts";
16
17const main = path.resolve(import.meta.dirname, "handler.ts");
18
19// NOT under example.com: ACM deny-lists high-profile domains — a request for
20// *.example.com fails within seconds with FAILED /
21// ADDITIONAL_VERIFICATION_REQUIRED. An unregistered domain we never validate
22// stays PENDING_VALIDATION for the life of the test.
23export const FIXTURE_DOMAIN = "alchemy-acm-bindings.alchemy-effect-tests.com";
24
25/**
26 * Probe helper: run the bound operation and report either the success
27 * projection or the typed error tag, so the test can assert that gated
28 * operations fail with a TYPED tag (never an untyped catch-all) on the
29 * perpetually-pending fixture certificate.
30 */
31const tagOr = <A, E extends { _tag: string }, R>(
32 effect: Effect.Effect<A, E, R>,
33 onSuccess: (value: A) => Record<string, unknown>,
34) =>
35 Effect.result(effect).pipe(
36 Effect.map((result) =>
37 Result.isSuccess(result)
38 ? onSuccess(result.success)
39 : { errorTag: result.failure._tag },
40 ),
41 );
42
43export class AcmTestFunction extends Lambda.Function<Lambda.Function>()(
44 "AcmTestFunction",
45) {}
46
47export default AcmTestFunction.make(
48 {
49 main,
50 functionUrl: true,
51 // Above the 3s AWS default: distilled auto-retries retryable typed
52 // errors (RequestInProgressException on /get is patched retryable, ~3s
53 // of bounded backoff), which must complete within the invocation so the
54 // typed tag is returned instead of a function-URL 502 timeout.
55 timeout: Duration.seconds(30),
56 },
57 Effect.gen(function* () {
58 // A DNS-validated certificate on a domain we never validate stays
59 // PENDING_VALIDATION forever: describe/list/search succeed against it,
60 // while issuance-gated operations (get/export/renew) fail with typed
61 // errors — both directions prove the binding + IAM wiring.
62 const certificate = yield* ACM.Certificate("BindingsCertificate", {
63 domainName: FIXTURE_DOMAIN,
64 subjectAlternativeNames: [FIXTURE_DOMAIN],
65 });
66
67 const describeCertificate = yield* ACM.DescribeCertificate(certificate);
68 const getCertificate = yield* ACM.GetCertificate(certificate);
69 const exportCertificate = yield* ACM.ExportCertificate(certificate);
70 const renewCertificate = yield* ACM.RenewCertificate(certificate);
71 const resendValidationEmail = yield* ACM.ResendValidationEmail(certificate);
72 const revokeCertificate = yield* ACM.RevokeCertificate(certificate);
73 const listCertificates = yield* ACM.ListCertificates();
74 const searchCertificates = yield* ACM.SearchCertificates();
75 const importCertificate = yield* ACM.ImportCertificate();
76
77 // Expiry event source: ACM emits "ACM Certificate Approaching
78 // Expiration" daily per certificate nearing expiry — untriggerable on
79 // demand, so the deploy itself (EventBridge rule + invoke permission)
80 // is what this fixture verifies.
81 yield* ACM.consumeExpiryEvents({}, (events) =>
82 Stream.runForEach(events, (event) =>
83 Effect.log(
84 `acm expiry event: ${event.detail.CommonName} expires in ${event.detail.DaysToExpiry} days`,
85 ),
86 ),
87 );
88
89 return {
90 fetch: Effect.gen(function* () {
91 const request = yield* HttpServerRequest;
92 const url = new URL(request.originalUrl);
93 const pathname = url.pathname;
94
95 if (request.method === "GET" && pathname === "/describe") {
96 const result = yield* describeCertificate();
97 return yield* HttpServerResponse.json({
98 arn: result.Certificate?.CertificateArn,
99 domainName: result.Certificate?.DomainName,
100 status: result.Certificate?.Status,
101 });
102 }
103
104 if (request.method === "GET" && pathname === "/get") {
105 return yield* HttpServerResponse.json(
106 yield* tagOr(getCertificate(), (result) => ({
107 certificate: result.Certificate,
108 })),
109 );
110 }
111
112 if (request.method === "GET" && pathname === "/list") {
113 const result = yield* listCertificates({
114 CertificateStatuses: ["PENDING_VALIDATION", "ISSUED"],
115 });
116 return yield* HttpServerResponse.json({
117 arns: (result.CertificateSummaryList ?? []).map(
118 (summary) => summary.CertificateArn,
119 ),
120 });
121 }
122
123 if (request.method === "GET" && pathname === "/search") {
124 const described = yield* describeCertificate();
125 const arn = described.Certificate?.CertificateArn;
126 if (!arn) {
127 return HttpServerResponse.text("certificate has no ARN", {
128 status: 500,
129 });
130 }
131 const result = yield* searchCertificates({
132 FilterStatement: { Filter: { CertificateArn: arn } },
133 });
134 return yield* HttpServerResponse.json({
135 arns: (result.Results ?? []).map((r) => r.CertificateArn),
136 });
137 }
138
139 if (request.method === "POST" && pathname === "/export") {
140 const passphrase = yield* Effect.sync(() =>
141 new TextEncoder().encode("alchemy-acm-test-passphrase"),
142 );
143 return yield* HttpServerResponse.json(
144 yield* tagOr(
145 exportCertificate({ Passphrase: Redacted.make(passphrase) }),
146 (result) => ({
147 certificate: result.Certificate,
148 hasPrivateKey: result.PrivateKey !== undefined,
149 }),
150 ),
151 );
152 }
153
154 if (request.method === "POST" && pathname === "/renew") {
155 return yield* HttpServerResponse.json(
156 yield* tagOr(renewCertificate(), () => ({ ok: true })),
157 );
158 }
159
160 if (request.method === "POST" && pathname === "/resend-email") {
161 return yield* HttpServerResponse.json(
162 yield* tagOr(
163 resendValidationEmail({
164 Domain: FIXTURE_DOMAIN,
165 // The registered-domain suffix of the fixture domain — the
166 // ValidationDomain must be the domain itself or a superdomain.
167 ValidationDomain: FIXTURE_DOMAIN.split(".").slice(1).join("."),
168 }),
169 () => ({ ok: true }),
170 ),
171 );
172 }
173
174 if (request.method === "POST" && pathname === "/revoke") {
175 return yield* HttpServerResponse.json(
176 yield* tagOr(
177 revokeCertificate({ RevocationReason: "UNSPECIFIED" }),
178 () => ({ ok: true }),
179 ),
180 );
181 }
182
183 if (request.method === "POST" && pathname === "/import") {
184 const body = (yield* request.json) as unknown as {
185 reimportArn?: string;
186 };
187 const payload = yield* Effect.sync(() => {
188 const encoder = new TextEncoder();
189 return {
190 Certificate: encoder.encode(IMPORT_CERTIFICATE_PEM),
191 PrivateKey: Redacted.make(encoder.encode(IMPORT_PRIVATE_KEY_PEM)),
192 };
193 });
194 const result = yield* importCertificate({
195 CertificateArn: body.reimportArn,
196 ...payload,
197 });
198 return yield* HttpServerResponse.json({
199 arn: result.CertificateArn,
200 });
201 }
202
203 return yield* HttpServerResponse.json(
204 {
205 error: "Not found",
206 method: request.method,
207 pathname,
208 },
209 { status: 404 },
210 );
211 }).pipe(Effect.orDie),
212 };
213 }).pipe(
214 Effect.provide(
215 Layer.mergeAll(
216 Lambda.EventSource,
217 ACM.DescribeCertificateHttp,
218 ACM.GetCertificateHttp,
219 ACM.ExportCertificateHttp,
220 ACM.RenewCertificateHttp,
221 ACM.ResendValidationEmailHttp,
222 ACM.RevokeCertificateHttp,
223 ACM.ListCertificatesHttp,
224 ACM.SearchCertificatesHttp,
225 ACM.ImportCertificateHttp,
226 ),
227 ),
228 ),
229);