1import * as AWS from "@/AWS";
2import { AWSEnvironment } from "@/AWS/Environment.ts";
3import type { PolicyDocument } from "@/AWS/IAM/Policy.ts";
4import { normalizePolicyDocument } from "@/AWS/IAM/Policy.ts";
5import { Secret } from "@/AWS/SecretsManager/Secret.ts";
6import * as Provider from "@/Provider";
7import * as Test from "@/Test/Alchemy";
8import * as secretsmanager from "@distilled.cloud/aws/secrets-manager";
9import { expect } from "alchemy-test";
10import * as Data from "effect/Data";
11import * as Effect from "effect/Effect";
12import * as Redacted from "effect/Redacted";
13import * as Schedule from "effect/Schedule";
14
15const { test } = Test.make({ providers: AWS.providers() });
16
17class SecretNotListed extends Data.TaggedError("SecretNotListed") {}
18
19class ResourcePolicyNotAttached extends Data.TaggedError(
20 "ResourcePolicyNotAttached",
21) {}
22
23class SecretStillExists extends Data.TaggedError("SecretStillExists") {}
24
25class SecretVersionNotVisible extends Data.TaggedError(
26 "SecretVersionNotVisible",
27)<{
28 readonly expectedVersionId: string;
29 readonly observedVersionId: string | undefined;
30 readonly descriptionMatches: boolean;
31}> {}
32
33// Read APIs propagate independently and can return older snapshots on later calls.
34const readCurrentSecret = Effect.fn(function* (
35 secretArn: string,
36 versionId: string,
37 description: string,
38) {
39 return yield* Effect.retry(
40 Effect.gen(function* () {
41 const value = yield* secretsmanager.getSecretValue({
42 SecretId: secretArn,
43 });
44 const described = yield* secretsmanager.describeSecret({
45 SecretId: secretArn,
46 });
47 if (
48 value.VersionId !== versionId ||
49 !value.VersionStages?.includes("AWSCURRENT") ||
50 described.Description !== description ||
51 !described.VersionIdsToStages?.[versionId]?.includes("AWSCURRENT")
52 ) {
53 return yield* Effect.fail(
54 new SecretVersionNotVisible({
55 expectedVersionId: versionId,
56 observedVersionId: value.VersionId,
57 descriptionMatches: described.Description === description,
58 }),
59 );
60 }
61 return { value, described };
62 }).pipe(
63 Effect.tapError((error) =>
64 error._tag === "SecretVersionNotVisible"
65 ? Effect.logInfo("Waiting for secret version visibility", error)
66 : Effect.void,
67 ),
68 ),
69 {
70 while: (error) =>
71 error._tag === "SecretVersionNotVisible" ||
72 error._tag === "ResourceNotFoundException",
73 schedule: Schedule.fixed("2 seconds"),
74 times: 10,
75 },
76 );
77});
78
79// Secrets Manager marks values as sensitive, so the distilled client can hand
80// them back either raw or wrapped in `Redacted` — unwrap for assertions.
81const unwrapString = (
82 value: string | Redacted.Redacted<string> | undefined,
83): string | undefined =>
84 value === undefined
85 ? undefined
86 : typeof value === "string"
87 ? value
88 : Redacted.value(value);
89
90const unwrapBinary = (
91 value: Uint8Array | Redacted.Redacted<Uint8Array> | undefined,
92): Uint8Array | undefined =>
93 value === undefined
94 ? undefined
95 : value instanceof Uint8Array
96 ? value
97 : Redacted.value(value);
98
99// Typed wait-until-gone: the provider deletes with
100// `ForceDeleteWithoutRecovery`, which completes asynchronously — poll
101// `describeSecret` (bounded) until it fails with the typed
102// `ResourceNotFoundException`.
103const assertSecretDeleted = (secretArn: string) =>
104 secretsmanager.describeSecret({ SecretId: secretArn }).pipe(
105 Effect.flatMap(() => Effect.fail(new SecretStillExists())),
106 Effect.retry({
107 while: (e) => e._tag === "SecretStillExists",
108 schedule: Schedule.max([
109 Schedule.fixed("2 seconds"),
110 Schedule.recurs(10),
111 ]),
112 }),
113 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
114 );
115
116test.provider(
117 "create, update value, destroy",
118 (stack) =>
119 Effect.gen(function* () {
120 yield* stack.destroy();
121
122 const secret = yield* stack.deploy(
123 Effect.gen(function* () {
124 return yield* Secret("LifecycleSecret", {
125 description: "lifecycle v1",
126 secretString: Redacted.make("initial-value"),
127 tags: { Environment: "test" },
128 });
129 }),
130 );
131
132 expect(secret.secretArn).toContain("arn:aws:secretsmanager:");
133 expect(secret.secretName).toBeTruthy();
134 expect(secret.versionId).toBeTruthy();
135
136 // Out-of-band verification via distilled.
137 const { value: v1 } = yield* readCurrentSecret(
138 secret.secretArn,
139 secret.versionId!,
140 "lifecycle v1",
141 );
142 expect(v1.VersionId).toBe(secret.versionId);
143 expect(v1.VersionStages).toContain("AWSCURRENT");
144 expect(unwrapString(v1.SecretString)).toBe("initial-value");
145
146 // Update the value + description in place (no replacement).
147 const updated = yield* stack.deploy(
148 Effect.gen(function* () {
149 return yield* Secret("LifecycleSecret", {
150 description: "lifecycle v2",
151 secretString: Redacted.make("updated-value"),
152 tags: { Environment: "test" },
153 });
154 }),
155 );
156
157 expect(updated.secretArn).toBe(secret.secretArn);
158 expect(updated.versionId).toBeTruthy();
159 expect(updated.versionId).not.toBe(secret.versionId);
160
161 const { value: v2, described } = yield* readCurrentSecret(
162 secret.secretArn,
163 updated.versionId!,
164 "lifecycle v2",
165 );
166 expect(v2.VersionId).toBe(updated.versionId);
167 expect(v2.VersionStages).toContain("AWSCURRENT");
168 expect(unwrapString(v2.SecretString)).toBe("updated-value");
169
170 expect(described.Description).toBe("lifecycle v2");
171 expect(updated.description).toBe(described.Description);
172 expect(described.VersionIdsToStages?.[updated.versionId!]).toContain(
173 "AWSCURRENT",
174 );
175
176 yield* stack.destroy();
177
178 yield* assertSecretDeleted(secret.secretArn);
179 }),
180 { tags: ["provider:aws", "provider:aws:secretsmanager", "live"] },
181);
182
183// Audit: `secretBinary` is declared as `Redacted.Redacted<Uint8Array>` — this
184// exercises the Redacted conversion end-to-end at deploy time: create with a
185// binary value, verify the exact bytes on the wire out-of-band via distilled,
186// rotate the binary value in place, and verify the new bytes.
187// Deterministic checked-in constants (never generated at test time),
188// exercising non-UTF8 bytes through the base64 transport.
189const BINARY_V1 = new Uint8Array([0, 1, 2, 3, 250, 251, 252, 253, 254, 255]);
190const BINARY_V2 = new Uint8Array([42, 7, 128, 129, 130, 0, 255]);
191
192test.provider(
193 "binary secret value round-trips (Redacted prop)",
194 (stack) =>
195 Effect.gen(function* () {
196 yield* stack.destroy();
197
198 const secret = yield* stack.deploy(
199 Effect.gen(function* () {
200 return yield* Secret("BinaryLifecycleSecret", {
201 description: "binary lifecycle v1",
202 secretBinary: Redacted.make(BINARY_V1),
203 });
204 }),
205 );
206
207 expect(secret.secretArn).toContain("arn:aws:secretsmanager:");
208 expect(secret.versionId).toBeTruthy();
209
210 // Out-of-band verification via distilled: exact bytes on the wire.
211 const { value: v1 } = yield* readCurrentSecret(
212 secret.secretArn,
213 secret.versionId!,
214 "binary lifecycle v1",
215 );
216 expect(v1.VersionId).toBe(secret.versionId);
217 expect(v1.VersionStages).toContain("AWSCURRENT");
218 expect(Array.from(unwrapBinary(v1.SecretBinary)!)).toEqual(
219 Array.from(BINARY_V1),
220 );
221 expect(unwrapString(v1.SecretString)).toBeUndefined();
222
223 // Rotate the binary value in place (no replacement).
224 const updated = yield* stack.deploy(
225 Effect.gen(function* () {
226 return yield* Secret("BinaryLifecycleSecret", {
227 description: "binary lifecycle v2",
228 secretBinary: Redacted.make(BINARY_V2),
229 });
230 }),
231 );
232 expect(updated.secretArn).toBe(secret.secretArn);
233 expect(updated.versionId).toBeTruthy();
234 expect(updated.versionId).not.toBe(secret.versionId);
235
236 const { value: v2, described } = yield* readCurrentSecret(
237 secret.secretArn,
238 updated.versionId!,
239 "binary lifecycle v2",
240 );
241 expect(v2.VersionId).toBe(updated.versionId);
242 expect(v2.VersionStages).toContain("AWSCURRENT");
243 expect(Array.from(unwrapBinary(v2.SecretBinary)!)).toEqual(
244 Array.from(BINARY_V2),
245 );
246 expect(unwrapString(v2.SecretString)).toBeUndefined();
247
248 expect(described.Description).toBe("binary lifecycle v2");
249 expect(updated.description).toBe(described.Description);
250 expect(described.VersionIdsToStages?.[updated.versionId!]).toContain(
251 "AWSCURRENT",
252 );
253
254 yield* stack.destroy();
255 yield* assertSecretDeleted(secret.secretArn);
256 }),
257 { tags: ["provider:aws", "provider:aws:secretsmanager", "live"] },
258);
259
260// PolicyDocument adoption: a typed `resourcePolicy` deploys, the attached
261// policy round-trips (normalized comparison), and re-deploying the identical
262// document is clean — reconcile diffs `normalizePolicyDocument(observed)`
263// against `normalizePolicyDocument(desired)` and skips `PutResourcePolicy`
264// on equivalence. Removing the prop detaches the policy.
265test.provider(
266 "resource policy deploys and re-deploys clean",
267 (stack) =>
268 Effect.gen(function* () {
269 const { accountId } = yield* AWSEnvironment.current;
270 const resourcePolicy: PolicyDocument = {
271 Version: "2012-10-17",
272 Statement: [
273 {
274 Sid: "AllowAccountRead",
275 Effect: "Allow",
276 Principal: { AWS: `arn:aws:iam::${accountId}:root` },
277 Action: ["secretsmanager:GetSecretValue"],
278 Resource: "*",
279 },
280 ],
281 };
282
283 const secret = yield* stack.deploy(
284 Effect.gen(function* () {
285 return yield* Secret("PolicySecret", {
286 description: "resource policy round-trip",
287 secretString: Redacted.make("policy-value"),
288 resourcePolicy,
289 });
290 }),
291 );
292
293 // Out-of-band verification via distilled: the attached policy is
294 // equivalent to the typed document (bounded retry through propagation).
295 const attached = yield* secretsmanager
296 .getResourcePolicy({ SecretId: secret.secretArn })
297 .pipe(
298 Effect.flatMap((response) =>
299 response.ResourcePolicy === undefined
300 ? Effect.fail(new ResourcePolicyNotAttached())
301 : Effect.succeed(response.ResourcePolicy),
302 ),
303 Effect.retry({
304 while: (e) => e._tag === "ResourcePolicyNotAttached",
305 schedule: Schedule.max([
306 Schedule.fixed("2 seconds"),
307 Schedule.recurs(5),
308 ]),
309 }),
310 );
311 expect(normalizePolicyDocument(attached)).toBe(
312 normalizePolicyDocument(resourcePolicy),
313 );
314
315 // Re-deploy the identical PolicyDocument — must be a clean no-op.
316 const redeployed = yield* stack.deploy(
317 Effect.gen(function* () {
318 return yield* Secret("PolicySecret", {
319 description: "resource policy round-trip",
320 secretString: Redacted.make("policy-value"),
321 resourcePolicy,
322 });
323 }),
324 );
325 expect(redeployed.secretArn).toBe(secret.secretArn);
326
327 const afterRedeploy = yield* secretsmanager.getResourcePolicy({
328 SecretId: secret.secretArn,
329 });
330 expect(afterRedeploy.ResourcePolicy).toBeTruthy();
331 expect(normalizePolicyDocument(afterRedeploy.ResourcePolicy ?? "")).toBe(
332 normalizePolicyDocument(resourcePolicy),
333 );
334
335 // Removing the prop detaches the policy.
336 yield* stack.deploy(
337 Effect.gen(function* () {
338 return yield* Secret("PolicySecret", {
339 description: "resource policy round-trip",
340 secretString: Redacted.make("policy-value"),
341 });
342 }),
343 );
344 const removed = yield* secretsmanager.getResourcePolicy({
345 SecretId: secret.secretArn,
346 });
347 expect(removed.ResourcePolicy).toBeUndefined();
348
349 yield* stack.destroy();
350 yield* assertSecretDeleted(secret.secretArn);
351 }),
352 {
353 tags: [
354 "provider:aws",
355 "provider:aws:iam",
356 "provider:aws:secretsmanager",
357 "live",
358 ],
359 },
360);
361
362// Canonical `list()` test (AWS account/region-scoped collection): deploy a real
363// secret, resolve the provider from context via the typed `findProvider`, call
364// `list()`, and assert the deployed secret appears in the exhaustively-paginated
365// result. `listSecrets` is eventually consistent, so the assertion retries with
366// a bounded schedule until the new secret surfaces.
367test.provider(
368 "list enumerates the deployed secret",
369 (stack) =>
370 Effect.gen(function* () {
371 yield* stack.destroy();
372
373 const secret = yield* stack.deploy(
374 Effect.gen(function* () {
375 return yield* Secret("ListSecret", {
376 description: "list lifecycle op coverage",
377 secretString: Redacted.make("super-secret-value"),
378 tags: { Environment: "test" },
379 });
380 }),
381 );
382
383 const provider = yield* Provider.findProvider(Secret);
384
385 yield* Effect.gen(function* () {
386 const all = yield* provider.list();
387 const found = all.find((s) => s.secretArn === secret.secretArn);
388 if (!found) {
389 return yield* Effect.fail(new SecretNotListed());
390 }
391 // `list` hydrates the exact `read` Attributes shape (no plaintext value).
392 expect(found.secretName).toBe(secret.secretName);
393 expect(found.versionId).toBeUndefined();
394 expect(found.tags.Environment).toBe("test");
395 }).pipe(
396 Effect.retry({
397 while: (e) => e._tag === "SecretNotListed",
398 schedule: Schedule.max([
399 Schedule.exponential(500),
400 Schedule.recurs(8),
401 ]),
402 }),
403 );
404
405 yield* stack.destroy();
406 }),
407 { tags: ["provider:aws", "provider:aws:secretsmanager", "live"] },
408);