1import * as AWS from "@/AWS";
2import { Certificate, waitForRoute53Change } from "@/AWS/ACM/Certificate.ts";
3import { HostedZone } from "@/AWS/Route53";
4import * as Provider from "@/Provider";
5import { isResourceState, State, type ResourceState } from "@/State";
6import * as Test from "@/Test/Alchemy";
7import { Region as AwsRegion } from "@distilled.cloud/aws/Region";
8import * as acm from "@distilled.cloud/aws/acm";
9import * as route53 from "@distilled.cloud/aws/route-53";
10import { expect } from "alchemy-test";
11import * as Data from "effect/Data";
12import * as Effect from "effect/Effect";
13import * as Schedule from "effect/Schedule";
14
15// ACM certificates for CloudFront are provider-pinned to us-east-1; every
16// out-of-band ACM call in this file must target the same region.
17const withUsEast1 = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
18 effect.pipe(Effect.provideService(AwsRegion, Effect.succeed("us-east-1")));
19
20const { test } = Test.make({ providers: AWS.providers() });
21
22test.provider.skipIf(!!process.env.FAST)(
23 "polls Route53 validation changes returned with resource-path IDs",
24 (stack) =>
25 Effect.gen(function* () {
26 yield* stack.destroy();
27
28 const zone = yield* stack.deploy(
29 HostedZone("CertificateValidationZone", {
30 name: "alchemy-certificate-change-id.alchemy.",
31 forceDestroy: true,
32 }),
33 );
34
35 const change = yield* route53.changeResourceRecordSets({
36 HostedZoneId: zone.id.replace(/^\/hostedzone\//, ""),
37 ChangeBatch: {
38 Comment: "ACM Route53 change ID regression test",
39 Changes: [
40 {
41 Action: "UPSERT",
42 ResourceRecordSet: {
43 Name: `_validation.${zone.name}`,
44 Type: "TXT",
45 TTL: 60,
46 ResourceRecords: [{ Value: '"alchemy"' }],
47 },
48 },
49 ],
50 },
51 });
52
53 expect(change.ChangeInfo.Id).toMatch(/^\/change\//);
54 const completed = yield* waitForRoute53Change(change.ChangeInfo.Id);
55 expect(completed.Status).toBe("INSYNC");
56
57 yield* stack.destroy();
58 }),
59 {
60 tags: ["provider:aws", "provider:aws:acm", "provider:aws:route53", "live"],
61 timeout: 180_000,
62 },
63);
64
65// Canonical `list()` test (AWS account/region-scoped collection): request a
66// real ACM certificate (no DNS validation, stays PENDING_VALIDATION), resolve
67// the provider from context via the typed `findProvider` helper, call `list()`,
68// and assert the deployed certificate ARN appears in the exhaustively-paginated
69// result. A PENDING certificate is fully enumerable and deletable.
70test.provider.skipIf(!!process.env.FAST)(
71 "list enumerates the deployed certificate",
72 (stack) =>
73 Effect.gen(function* () {
74 yield* stack.destroy();
75
76 const cert = yield* stack.deploy(
77 Effect.gen(function* () {
78 return yield* Certificate("ListCertificate", {
79 domainName: "alchemy-acm-list-test.example.com",
80 });
81 }),
82 );
83
84 expect(cert.certificateArn).toBeDefined();
85
86 const provider = yield* Provider.findProvider(Certificate);
87 // ACM `ListCertificates` is eventually consistent — a freshly requested
88 // certificate can take a few seconds to surface in the paginated
89 // listing. Poll until our ARN appears, bounded.
90 const all = yield* Effect.gen(function* () {
91 const result = yield* provider.list();
92 if (!result.some((c) => c.certificateArn === cert.certificateArn)) {
93 return yield* Effect.fail(new CertificateNotListed());
94 }
95 return result;
96 }).pipe(
97 Effect.retry({
98 while: (e) => e._tag === "CertificateNotListed",
99 schedule: Schedule.max([
100 Schedule.fixed("3 seconds"),
101 Schedule.recurs(20),
102 ]),
103 }),
104 );
105
106 expect(all.some((c) => c.certificateArn === cert.certificateArn)).toBe(
107 true,
108 );
109
110 yield* stack.destroy();
111 }),
112 { tags: ["provider:aws", "provider:aws:acm", "live"], timeout: 120_000 },
113);
114
115class CertificateNotListed extends Data.TaggedError("CertificateNotListed") {}
116
117// Exportability is fixed at request time — ACM rejects
118// `UpdateCertificateOptions` for the export option ("Export option for
119// certificates cannot be updated"), so changing `export` must REPLACE the
120// certificate with a freshly requested one.
121test.provider.skipIf(!!process.env.FAST)(
122 "replaces the certificate when the export option changes",
123 (stack) =>
124 Effect.gen(function* () {
125 yield* stack.destroy();
126
127 const domainName = "alchemy-acm-options-test.example.com";
128 const deployWith = (exportOption: "ENABLED" | "DISABLED") =>
129 stack.deploy(
130 Certificate("OptionsCertificate", {
131 domainName,
132 export: exportOption,
133 }),
134 );
135
136 const created = yield* deployWith("ENABLED");
137 expect(created.certificateArn).toBeDefined();
138 expect(created.export).toBe("ENABLED");
139
140 const updated = yield* deployWith("DISABLED");
141 expect(updated.certificateArn).not.toEqual(created.certificateArn);
142 expect(updated.export).toBe("DISABLED");
143
144 yield* stack.destroy();
145 }),
146 { tags: ["provider:aws", "provider:aws:acm", "live"], timeout: 180_000 },
147);
148
149// Regression test for https://github.com/alchemy-run/alchemy/issues/736.
150//
151// A `creating` state row persisted before upstream Outputs resolve cannot
152// round-trip Output-valued props (`domainName` from a HostedZone Output,
153// `hostedZoneId`) — they deserialize as `undefined`. In the worst case the
154// row's props are junk and the engine's deletion-planning branch (which every
155// `destroy` and every deploy that drops the resource runs) calls
156// `provider.read` with those junk `olds`. Before the fix, `read` dereferenced
157// `olds!` unconditionally (`findManagedCertificate` reads
158// `props.keyAlgorithm` / `props.domainName`), so planning crashed with a
159// TypeError and wedged the stack — plan/deploy/destroy all build a plan, so
160// there was no CLI escape hatch.
161//
162// Simulate exactly that row after a real deploy and assert the guarded `read`
163// reports "not found" instead of crashing: destroy succeeds (skipping the
164// physical delete, since no attr could be recovered), and a follow-up deploy
165// of the same program converges on the SAME leaked certificate (found by
166// domain + SAN + alchemy ownership tags) — no duplicate is requested.
167test.provider.skipIf(!!process.env.FAST)(
168 "recovers a wedged creating-state certificate whose props were lost (#736)",
169 (stack) =>
170 Effect.gen(function* () {
171 yield* stack.destroy();
172
173 const domainName = "alchemy-acm-recovery-736.example.com";
174 const deployCertificate = () =>
175 stack.deploy(
176 Certificate("RecoveryCertificate", {
177 domainName,
178 // List the primary domain explicitly: ACM's DescribeCertificate
179 // reports it in SubjectAlternativeNames, and recovery-by-search
180 // (`findManagedCertificate`) compares that list against the
181 // props' SAN list — aligning them is what lets the redeploy
182 // below converge on the leaked certificate.
183 subjectAlternativeNames: [domainName],
184 }),
185 );
186
187 const created = yield* deployCertificate();
188 expect(created.certificateArn).toBeDefined();
189
190 // Safety net: reclaim the certificate on scope close even if the body
191 // fails mid-way (e.g. during the pre-fix crash verification).
192 yield* Effect.addFinalizer(() =>
193 withUsEast1(
194 acm.deleteCertificate({ CertificateArn: created.certificateArn }),
195 ).pipe(Effect.ignore),
196 );
197
198 // `ListCertificates` is eventually consistent; the recovery redeploy
199 // below finds the certificate through it, so wait (bounded) until the
200 // fresh ARN is visible before wedging the state.
201 yield* Effect.gen(function* () {
202 const listed = yield* withUsEast1(acm.listCertificates({}));
203 if (
204 !listed.CertificateSummaryList?.some(
205 (summary) => summary.CertificateArn === created.certificateArn,
206 )
207 ) {
208 return yield* Effect.fail(new CertificateNotListed());
209 }
210 }).pipe(
211 Effect.retry({
212 while: (e) => e._tag === "CertificateNotListed",
213 schedule: Schedule.max([
214 Schedule.fixed("3 seconds"),
215 Schedule.recurs(18),
216 ]),
217 }),
218 );
219
220 // Rewrite the certificate's persisted row into the wedged shape an
221 // interrupted deploy leaves behind: `creating`, no attributes, and the
222 // props lost in the Output round-trip.
223 const state = yield* yield* State;
224 const stage = stack.stage;
225 const fqns = yield* state.list({ stack: stack.name, stage });
226 const rows = yield* Effect.forEach(fqns, (fqn) =>
227 state
228 .get({ stack: stack.name, stage, fqn })
229 .pipe(Effect.map((row) => ({ fqn, row }))),
230 );
231 const wedged = rows.find(
232 (r): r is { fqn: string; row: ResourceState } =>
233 isResourceState(r.row) &&
234 r.row.resourceType === "AWS.ACM.Certificate",
235 );
236 if (!wedged) {
237 return yield* Effect.die(
238 new Error("no AWS.ACM.Certificate state row found after deploy"),
239 );
240 }
241 yield* state.set({
242 stack: stack.name,
243 stage,
244 fqn: wedged.fqn,
245 value: {
246 ...wedged.row,
247 status: "creating",
248 attr: undefined,
249 props: undefined as never,
250 },
251 });
252
253 // Deletion planning reads the wedged row with `olds: undefined`.
254 // Before the fix this crashed with
255 // `TypeError: undefined is not an object (evaluating 'props.keyAlgorithm')`;
256 // after it, `read` reports "not found", the physical delete is skipped
257 // (no attr could be recovered) and the row is reaped.
258 yield* stack.destroy();
259
260 // The certificate itself leaked (the wedged row had no recoverable
261 // attr) — redeploying the same program must converge on it via
262 // domain + SAN + ownership tags instead of requesting a duplicate.
263 const recovered = yield* deployCertificate();
264 expect(recovered.certificateArn).toEqual(created.certificateArn);
265
266 yield* stack.destroy();
267 }),
268 { tags: ["provider:aws", "provider:aws:acm", "live"], timeout: 240_000 },
269);