1import * as AWS from "@/AWS";
2import { PublicKey } from "@/AWS/CloudFront";
3import * as Provider from "@/Provider";
4import { isResourceState, State, type ResourceState } from "@/State";
5import * as Test from "@/Test/Alchemy";
6import * as cloudfront from "@distilled.cloud/aws/cloudfront";
7import { describe, expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import * as Redacted from "effect/Redacted";
10import * as Schedule from "effect/Schedule";
11
12const { test } = Test.make({ providers: AWS.providers() });
13
14const TEST_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
15MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvTkfqkMHU8HMmIRKJaMl
16IoD691g60aS15QlaP/DVkpuoeEp8JA8YDs5vQFu6HSIYCTQ7WwFx9oRvN08i7yXB
17EHt3x7uZVpdkp6JBbjR9BGNsAVri6DZ0TJQ11zWeN3keqhnUdFhQjPwT+u4r6oKk
18kNvkl7eU2nFK+UIaPlD+rA+AlYT0m7gSVcd9KaLf/UzBrtSy1dbXYDT4dHChMUVy
194gDsQ6D4u6lRAHY9jcKxlgEIM+O8ODKyzlbergv2EwhANG4P27DBeDhA/off3upM
20TTVTGKZeoABtqM0ZiYq0cDgf8KUn9NPxSdnJ4+cbigLjJBPS93VYWzWX0HXlZpQ3
21HQIDAQAB
22-----END PUBLIC KEY-----
23`;
24
25describe(
26 "AWS.CloudFront.PublicKey",
27 { tags: ["provider:aws", "provider:aws:cloudfront", "live"] },
28 () => {
29 test.provider(
30 "create, update comment, and delete a public key",
31 (stack) =>
32 Effect.gen(function* () {
33 yield* stack.destroy();
34
35 const created = yield* stack.deploy(
36 Effect.gen(function* () {
37 return yield* PublicKey("SignedUrlKey", {
38 encodedKey: TEST_PUBLIC_KEY,
39 comment: "initial",
40 });
41 }),
42 );
43
44 const initial = yield* cloudfront.getPublicKey({
45 Id: created.publicKeyId,
46 });
47 expect(initial.PublicKey?.Id).toEqual(created.publicKeyId);
48 expect(initial.PublicKey?.PublicKeyConfig?.Comment).toEqual(
49 "initial",
50 );
51 // CloudFront normalizes the stored key (trailing newline stripped).
52 expect(
53 initial.PublicKey?.PublicKeyConfig?.EncodedKey?.trim(),
54 ).toEqual(TEST_PUBLIC_KEY.trim());
55
56 // Update with the SAME key wrapped in Redacted — the provider's
57 // `extractValue` must unwrap it so diff sees no key change (update in
58 // place, not replacement) and the wire value round-trips unredacted.
59 const updated = yield* stack.deploy(
60 Effect.gen(function* () {
61 return yield* PublicKey("SignedUrlKey", {
62 encodedKey: Redacted.make(TEST_PUBLIC_KEY),
63 comment: "updated",
64 });
65 }),
66 );
67
68 expect(updated.publicKeyId).toEqual(created.publicKeyId);
69 expect(updated.callerReference).toEqual(created.callerReference);
70
71 // Control-plane reads are eventually consistent — poll until the
72 // update is visible, then assert.
73 const after = yield* cloudfront
74 .getPublicKey({ Id: updated.publicKeyId })
75 .pipe(
76 Effect.repeat({
77 schedule: Schedule.fixed("2 seconds"),
78 until: (r) =>
79 r.PublicKey?.PublicKeyConfig?.Comment === "updated",
80 times: 15,
81 }),
82 );
83 expect(after.PublicKey?.PublicKeyConfig?.Comment).toEqual("updated");
84 // The Redacted-wrapped key reached the wire as the plain PEM.
85 expect(after.PublicKey?.PublicKeyConfig?.EncodedKey?.trim()).toEqual(
86 TEST_PUBLIC_KEY.trim(),
87 );
88
89 yield* stack.destroy();
90 yield* assertPublicKeyDeleted(updated.publicKeyId);
91 }),
92 { timeout: 300_000 },
93 );
94
95 test.provider(
96 "list enumerates the deployed public key",
97 (stack) =>
98 Effect.gen(function* () {
99 yield* stack.destroy();
100
101 const deployed = yield* stack.deploy(
102 Effect.gen(function* () {
103 return yield* PublicKey("ListResource", {
104 encodedKey: TEST_PUBLIC_KEY,
105 comment: "list test",
106 });
107 }),
108 );
109
110 const provider = yield* Provider.findProvider(PublicKey);
111 const all = yield* provider.list();
112
113 expect(all.some((x) => x.publicKeyId === deployed.publicKeyId)).toBe(
114 true,
115 );
116
117 yield* stack.destroy();
118 yield* assertPublicKeyDeleted(deployed.publicKeyId);
119 }),
120 { timeout: 300_000 },
121 );
122
123 test.provider(
124 "recovers a wedged creating-state row that lost its Output-valued encodedKey (#736)",
125 (stack) =>
126 Effect.gen(function* () {
127 yield* stack.destroy();
128
129 const deployKey = () =>
130 stack.deploy(
131 Effect.gen(function* () {
132 return yield* PublicKey("WedgedKey", {
133 encodedKey: TEST_PUBLIC_KEY,
134 comment: "wedged recovery test",
135 });
136 }),
137 );
138
139 const created = yield* deployKey();
140
141 // Rewrite the persisted row into the wedged shape an interrupted
142 // deploy leaves behind: `creating`, no attributes, and the
143 // Output-valued `encodedKey` lost in the round-trip (#736).
144 const state = yield* yield* State;
145 const stage = stack.stage;
146 const fqns = yield* state.list({ stack: stack.name, stage });
147 const rows = yield* Effect.forEach(fqns, (fqn) =>
148 state
149 .get({ stack: stack.name, stage, fqn })
150 .pipe(Effect.map((row) => ({ fqn, row }))),
151 );
152 const wedged = rows.find(
153 (r): r is { fqn: string; row: ResourceState } =>
154 isResourceState(r.row) &&
155 r.row.resourceType === "AWS.CloudFront.PublicKey",
156 );
157 if (!wedged) {
158 return yield* Effect.die(
159 new Error(
160 "no AWS.CloudFront.PublicKey state row found after deploy",
161 ),
162 );
163 }
164 // Delete the public key out-of-band FIRST — while the state row is
165 // still intact — so the recovery `read` misses and the engine falls
166 // through to `diff` with the junk olds. Ordering matters for
167 // orphan-safety: CloudFront assigns the key Id (no user-controlled
168 // name), so if this test were killed after wedging the state row
169 // (attr: undefined) but before this delete, the ensuring
170 // stack.destroy could no longer identify the live key and it would
171 // orphan forever. Deleting before the state rewrite means every
172 // interruption point leaves either an intact row (destroy reclaims
173 // it) or an already-deleted key.
174 const current = yield* cloudfront.getPublicKey({
175 Id: created.publicKeyId,
176 });
177 yield* cloudfront
178 .deletePublicKey({
179 Id: created.publicKeyId,
180 IfMatch: current.ETag,
181 })
182 .pipe(Effect.catchTag("NoSuchPublicKey", () => Effect.void));
183 yield* assertPublicKeyDeleted(created.publicKeyId);
184
185 // Now rewrite the persisted row into the wedged shape.
186 yield* state.set({
187 stack: stack.name,
188 stage,
189 fqn: wedged.fqn,
190 value: {
191 ...wedged.row,
192 status: "creating",
193 attr: undefined,
194 props: {
195 ...wedged.row.props,
196 encodedKey: undefined,
197 },
198 },
199 });
200
201 // Before the fix this crashed in `diff`: `extractValue(undefined)`
202 // called `Redacted.value(undefined)` on the lost `olds.encodedKey`.
203 // After the fix, diff skips the comparison and the engine recreates
204 // the key cleanly.
205 const recovered = yield* deployKey();
206 expect(recovered.publicKeyId).not.toEqual(created.publicKeyId);
207
208 const after = yield* cloudfront.getPublicKey({
209 Id: recovered.publicKeyId,
210 });
211 expect(after.PublicKey?.Id).toEqual(recovered.publicKeyId);
212 // CloudFront normalizes the stored key (trailing newline stripped).
213 expect(after.PublicKey?.PublicKeyConfig?.EncodedKey?.trim()).toEqual(
214 TEST_PUBLIC_KEY.trim(),
215 );
216
217 yield* stack.destroy();
218 yield* assertPublicKeyDeleted(recovered.publicKeyId);
219 }),
220 { timeout: 240_000 },
221 );
222 },
223);
224
225const assertPublicKeyDeleted = (id: string) =>
226 cloudfront.getPublicKey({ Id: id }).pipe(
227 Effect.flatMap(() => Effect.fail(new Error("PublicKeyStillExists"))),
228 Effect.catchTag("NoSuchPublicKey", () => Effect.void),
229 Effect.retry({
230 while: (error) =>
231 error instanceof Error && error.message === "PublicKeyStillExists",
232 schedule: Schedule.max([
233 Schedule.fixed("5 seconds"),
234 Schedule.recurs(24),
235 ]),
236 }),
237 );