1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as IAM from "@distilled.cloud/aws/iam";
5import * as Lambda from "@distilled.cloud/aws/lambda";
6import * as secretsmanager from "@distilled.cloud/aws/secrets-manager";
7import { describe, expect } from "alchemy-test";
8import * as Data from "effect/Data";
9import * as Effect from "effect/Effect";
10import * as Schedule from "effect/Schedule";
11import * as Schema from "effect/Schema";
12import * as Stream from "effect/Stream";
13import * as HttpClient from "effect/http/HttpClient";
14import * as HttpClientRequest from "effect/http/HttpClientRequest";
15import SecretsManagerTestFunctionLive, {
16 SecretsManagerTestFunction,
17} from "./handler";
18import GetSecretOnlyTestFunctionLive, {
19 GetSecretOnlyTestFunction,
20} from "./fixtures/get-secret-only-handler.ts";
21
22const testOptions = { providers: AWS.providers() };
23const { test, beforeAll, afterAll } = Test.make(testOptions);
24const sharedStack = Core.scratchStack(
25 testOptions,
26 "SecretsManagerBindings",
27 "test/AWS/SecretsManager/Bindings.test.ts",
28);
29
30// Lambda function URL cold-start (DNS, IAM propagation, init) can take
31// well over 60s on a fresh deploy under parallel-suite load. Budget ~150s
32// of readiness polling so we don't fail the whole suite on a slow init.
33const readinessPolicy = Schedule.max([
34 Schedule.fixed("2 seconds"),
35 Schedule.recurs(75),
36]);
37
38let baseUrl: string;
39let getOnlyUrl: string;
40let getOnlyFunction: { functionName: string; roleName: string } | undefined;
41let getOnlySecret: { secretArn: string; secretName: string } | undefined;
42
43const policyDocument = Schema.fromJsonString(
44 Schema.Struct({
45 Version: Schema.optional(Schema.String),
46 Id: Schema.optional(Schema.String),
47 Statement: Schema.Array(
48 Schema.Struct({
49 Sid: Schema.optional(Schema.String),
50 Effect: Schema.String,
51 Action: Schema.Union([Schema.String, Schema.Array(Schema.String)]),
52 Resource: Schema.Union([Schema.String, Schema.Array(Schema.String)]),
53 }),
54 ),
55 }),
56);
57
58const secretRolePermissions = Effect.fn(function* (roleName: string) {
59 const pages = yield* IAM.listRolePolicies
60 .pages({ RoleName: roleName })
61 .pipe(Stream.runCollect);
62 const policies = yield* Effect.forEach(
63 pages.flatMap((page) => page.PolicyNames),
64 Effect.fn(function* (PolicyName) {
65 const policy = yield* IAM.getRolePolicy({
66 RoleName: roleName,
67 PolicyName,
68 });
69 const decoded = yield* Effect.try(() =>
70 decodeURIComponent(policy.PolicyDocument),
71 );
72 return yield* Schema.decodeUnknownEffect(policyDocument, {
73 onExcessProperty: "error",
74 })(decoded);
75 }),
76 );
77 return policies.flatMap((policy) =>
78 policy.Statement.flatMap((statement) => {
79 const actions =
80 typeof statement.Action === "string"
81 ? [statement.Action]
82 : statement.Action;
83 const resources =
84 typeof statement.Resource === "string"
85 ? [statement.Resource]
86 : statement.Resource;
87 return actions.flatMap((action) =>
88 resources.map((resource) => ({
89 effect: statement.Effect,
90 action,
91 resource,
92 })),
93 );
94 }),
95 );
96});
97
98const getOnlyValue = Schema.Struct({
99 arn: Schema.String,
100 name: Schema.String,
101 versionId: Schema.String,
102 secretString: Schema.String,
103});
104
105const readGetOnlyValue = (query = "") =>
106 HttpClient.get(`${getOnlyUrl}/get-value${query}`).pipe(
107 Effect.flatMap((response) =>
108 response.status === 200
109 ? response.json
110 : Effect.fail(new Error(`GetSecretValue failed: ${response.status}`)),
111 ),
112 Effect.flatMap(Schema.decodeUnknownEffect(getOnlyValue)),
113 Effect.retry({ schedule: Schedule.spaced("2 seconds"), times: 10 }),
114 );
115
116class FixtureStillExists extends Data.TaggedError("FixtureStillExists")<{
117 readonly resource: string;
118}> {}
119
120const assertGetOnlyResourcesDeleted = Effect.gen(function* () {
121 yield* Effect.all(
122 [
123 getOnlySecret
124 ? secretsmanager
125 .describeSecret({ SecretId: getOnlySecret.secretArn })
126 .pipe(
127 Effect.flatMap(() =>
128 Effect.fail(new FixtureStillExists({ resource: "secret" })),
129 ),
130 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
131 Effect.retry({
132 while: (error) => error._tag === "FixtureStillExists",
133 schedule: Schedule.spaced("2 seconds"),
134 times: 10,
135 }),
136 )
137 : Effect.void,
138 getOnlyFunction
139 ? Lambda.getFunction({
140 FunctionName: getOnlyFunction.functionName,
141 }).pipe(
142 Effect.flatMap(() =>
143 Effect.fail(new FixtureStillExists({ resource: "function" })),
144 ),
145 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
146 Effect.retry({
147 while: (error) => error._tag === "FixtureStillExists",
148 schedule: Schedule.spaced("2 seconds"),
149 times: 10,
150 }),
151 )
152 : Effect.void,
153 ],
154 { concurrency: "unbounded" },
155 );
156});
157
158class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
159 readonly status: number;
160 readonly body: string;
161}> {}
162
163// The shared Lambda fixture occasionally answers a transient 5xx under
164// full-suite parallel load (cold re-init, IAM propagation on the freshly
165// attached secretsmanager policy that the handler's `Effect.orDie`
166// surfaces as a 500). Those are not assertion failures: retry the request
167// a few times before surfacing it. A genuine 4xx/assertion failure is
168// returned immediately (only 5xx is retried).
169const send = (request: HttpClientRequest.HttpClientRequest) =>
170 HttpClient.execute(request).pipe(
171 Effect.flatMap((response) =>
172 response.status >= 500
173 ? response.text.pipe(
174 Effect.flatMap((body) =>
175 Effect.fail(
176 new TransientUpstream({ status: response.status, body }),
177 ),
178 ),
179 )
180 : Effect.succeed(response),
181 ),
182 Effect.retry({
183 while: (e) => e._tag === "TransientUpstream",
184 schedule: Schedule.max([
185 Schedule.exponential("500 millis"),
186 Schedule.recurs(6),
187 ]),
188 }),
189 );
190
191// Deterministic binary payload (checked-in constant — never generated at
192// test time): base64 of bytes [0,1,2,3,250,251,252,253,254,255,42,7],
193// exercising non-UTF8 bytes through the base64 transport.
194const BINARY_BASE64 = "AAECA/r7/P3+/yoH";
195
196// GetSecretValue's round-trip and PutSecretValue both act on ONE shared
197// fixture secret; the global vitest `sequence: { concurrent: true }` would
198// let the put race the read and fail it with BindingNotConsistent.
199describe.sequential(
200 "SecretsManager Bindings",
201 {
202 tags: [
203 "provider:aws",
204 "provider:aws:batch",
205 "provider:aws:lambda",
206 "provider:aws:secretsmanager",
207 "live",
208 ],
209 },
210 () => {
211 beforeAll(
212 Effect.gen(function* () {
213 yield* Effect.logInfo(
214 "SecretsManager test setup: destroying previous resources",
215 );
216 yield* sharedStack.destroy();
217
218 yield* Effect.logInfo("SecretsManager test setup: deploying fixture");
219 const { shared, getOnly } = yield* sharedStack.deploy(
220 Effect.gen(function* () {
221 return {
222 shared: yield* SecretsManagerTestFunction,
223 getOnly: yield* GetSecretOnlyTestFunction,
224 };
225 }).pipe(
226 Effect.provide(SecretsManagerTestFunctionLive),
227 Effect.provide(GetSecretOnlyTestFunctionLive),
228 ),
229 );
230
231 getOnlyFunction = getOnly;
232 expect(getOnly.functionUrl).toBeTruthy();
233 getOnlyUrl = getOnly.functionUrl!.replace(/\/+$/, "");
234 getOnlySecret = yield* HttpClient.get(`${getOnlyUrl}/info`).pipe(
235 Effect.flatMap((response) =>
236 response.status === 200
237 ? response.json
238 : Effect.fail(
239 new Error(`Function not ready: ${response.status}`),
240 ),
241 ),
242 Effect.flatMap(
243 Schema.decodeUnknownEffect(
244 Schema.Struct({
245 secretArn: Schema.String,
246 secretName: Schema.String,
247 }),
248 ),
249 ),
250 Effect.retry({ schedule: Schedule.spaced("2 seconds"), times: 10 }),
251 );
252
253 expect(shared.functionUrl).toBeTruthy();
254 baseUrl = shared.functionUrl!.replace(/\/+$/, "");
255 const readinessUrl = `${baseUrl}/describe`;
256
257 yield* Effect.logInfo(
258 `SecretsManager test setup: probing readiness at ${readinessUrl}`,
259 );
260
261 yield* HttpClient.get(readinessUrl).pipe(
262 Effect.flatMap((response) =>
263 response.status === 200
264 ? Effect.succeed(response)
265 : Effect.fail(
266 new Error(`Function not ready: ${response.status}`),
267 ),
268 ),
269 Effect.tap(() =>
270 Effect.logInfo(
271 "SecretsManager test setup: fixture responded successfully",
272 ),
273 ),
274 Effect.tapError((error) =>
275 Effect.logWarning(
276 `SecretsManager test setup: fixture not ready yet (${String(error)})`,
277 ),
278 ),
279 Effect.retry({ schedule: readinessPolicy }),
280 );
281 }),
282 { timeout: 240_000 },
283 );
284
285 afterAll.skipIf(!!process.env.NO_DESTROY)(
286 sharedStack
287 .destroy()
288 .pipe(
289 Effect.andThen(
290 Core.withProviders(
291 assertGetOnlyResourcesDeleted,
292 testOptions,
293 "SecretsManagerBindings",
294 ),
295 ),
296 ),
297 { timeout: 120_000 },
298 );
299
300 describe("GetSecretValue", () => {
301 test.provider(
302 "reads a string with only the GetSecretValue binding",
303 (_stack) =>
304 Effect.gen(function* () {
305 const value = yield* readGetOnlyValue();
306 expect(value.secretString).toBe("alchemy-sm-get-only-value");
307 expect(value.arn).toBe(getOnlySecret!.secretArn);
308 expect(value.name).toBe(getOnlySecret!.secretName);
309 expect(value.versionId).toBeTruthy();
310
311 const query = yield* Effect.sync(() =>
312 new URLSearchParams({
313 versionId: value.versionId,
314 versionStage: "AWSCURRENT",
315 }).toString(),
316 );
317 const versioned = yield* readGetOnlyValue(`?${query}`);
318 expect(versioned).toEqual(value);
319 }),
320 { timeout: 120_000 },
321 );
322
323 test.provider(
324 "grants exactly GetSecretValue on the isolated secret ARN",
325 (_stack) =>
326 Effect.gen(function* () {
327 const roleName = getOnlyFunction!.roleName;
328 const permissions = yield* secretRolePermissions(roleName);
329 expect(permissions).toEqual([
330 {
331 effect: "Allow",
332 action: "secretsmanager:GetSecretValue",
333 resource: getOnlySecret!.secretArn,
334 },
335 ]);
336 const attached = yield* IAM.listAttachedRolePolicies
337 .pages({ RoleName: roleName })
338 .pipe(Stream.runCollect);
339 expect(
340 attached.flatMap((page) =>
341 (page.AttachedPolicies ?? []).map((policy) => policy.PolicyArn),
342 ),
343 ).toEqual([
344 "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole",
345 ]);
346 }),
347 { tags: ["provider:aws:iam"], timeout: 120_000 },
348 );
349
350 test.provider(
351 "denies raw DescribeSecret after an authorized GetSecretValue read",
352 (_stack) =>
353 Effect.gen(function* () {
354 const value = yield* readGetOnlyValue();
355 expect(value.secretString).toBe("alchemy-sm-get-only-value");
356 expect(value.arn).toBe(getOnlySecret!.secretArn);
357
358 const response = yield* HttpClient.get(
359 `${getOnlyUrl}/describe-denied`,
360 );
361 expect(response.status).toBe(200);
362 const denied = yield* response.json.pipe(
363 Effect.flatMap(
364 Schema.decodeUnknownEffect(
365 Schema.Struct({
366 tag: Schema.Literal("AccessDeniedException"),
367 }),
368 ),
369 ),
370 );
371 expect(denied.tag).toBe("AccessDeniedException");
372 }),
373 { timeout: 120_000 },
374 );
375
376 test.provider("reads the string secret value round-trip", (_stack) =>
377 Effect.gen(function* () {
378 // GetSecretValue is eventually consistent right after the fixture
379 // secret is (re)created; poll until the fixture value is observed.
380 const response = yield* fetchUntil(
381 send(HttpClientRequest.get(`${baseUrl}/string-value`)).pipe(
382 Effect.flatMap((r) => r.json),
383 ),
384 (body) => body?.secretString === "alchemy-sm-fixture-value",
385 );
386
387 expect((response as any).secretString).toBe(
388 "alchemy-sm-fixture-value",
389 );
390 expect((response as any).arn).toContain("arn:aws:secretsmanager:");
391 expect((response as any).versionId).toBeTruthy();
392 }),
393 );
394 });
395
396 describe("PutSecretValue", () => {
397 test.provider("rotates the string secret value", (_stack) =>
398 Effect.gen(function* () {
399 const put = yield* send(
400 HttpClientRequest.bodyJsonUnsafe(
401 HttpClientRequest.post(`${baseUrl}/put-string`),
402 { value: "alchemy-sm-rotated-value" },
403 ),
404 ).pipe(Effect.flatMap((r) => r.json));
405
406 expect((put as any).versionId).toBeTruthy();
407
408 // Read-after-write on a fresh version is eventually consistent;
409 // poll until the new version is served as AWSCURRENT.
410 const got = yield* fetchUntil(
411 send(HttpClientRequest.get(`${baseUrl}/string-value`)).pipe(
412 Effect.flatMap((r) => r.json),
413 ),
414 (body) => body?.versionId === (put as any).versionId,
415 );
416
417 expect((got as any).secretString).toBe("alchemy-sm-rotated-value");
418 expect((got as any).versionId).toBe((put as any).versionId);
419 }),
420 );
421
422 test.provider("writes and reads back a binary secret value", (_stack) =>
423 Effect.gen(function* () {
424 const put = yield* send(
425 HttpClientRequest.bodyJsonUnsafe(
426 HttpClientRequest.post(`${baseUrl}/put-binary`),
427 { base64: BINARY_BASE64 },
428 ),
429 ).pipe(Effect.flatMap((r) => r.json));
430
431 expect((put as any).versionId).toBeTruthy();
432
433 // Read-after-write on a fresh version is eventually consistent;
434 // poll until the new version is served as AWSCURRENT.
435 const got = yield* fetchUntil(
436 send(HttpClientRequest.get(`${baseUrl}/binary-value`)).pipe(
437 Effect.flatMap((r) => r.json),
438 ),
439 (body) => body?.versionId === (put as any).versionId,
440 );
441
442 expect((got as any).base64).toBe(BINARY_BASE64);
443 // A binary version carries no SecretString.
444 expect((got as any).secretString).toBeUndefined();
445 expect((got as any).versionId).toBe((put as any).versionId);
446 }),
447 );
448 });
449
450 describe("DescribeSecret", () => {
451 test.provider("describes the bound secret", (_stack) =>
452 Effect.gen(function* () {
453 const response = yield* send(
454 HttpClientRequest.get(`${baseUrl}/describe`),
455 ).pipe(Effect.flatMap((r) => r.json));
456
457 expect((response as any).arn).toContain("arn:aws:secretsmanager:");
458 expect((response as any).name).toBeTruthy();
459 expect((response as any).description).toBe(
460 "alchemy binding fixture (string value)",
461 );
462 }),
463 );
464 });
465
466 describe("GetRandomPassword", () => {
467 test.provider("generates a password of the requested length", (_stack) =>
468 Effect.gen(function* () {
469 const response = yield* send(
470 HttpClientRequest.get(`${baseUrl}/random-password?length=24`),
471 ).pipe(Effect.flatMap((r) => r.json));
472
473 expect(typeof (response as any).password).toBe("string");
474 expect((response as any).password).toHaveLength(24);
475 }),
476 );
477 });
478
479 describe("ListSecrets", () => {
480 test.provider("lists the bound secret by name filter", (_stack) =>
481 Effect.gen(function* () {
482 const described = yield* send(
483 HttpClientRequest.get(`${baseUrl}/describe`),
484 ).pipe(Effect.flatMap((r) => r.json));
485 const name = (described as any).name as string;
486
487 // ListSecrets is eventually consistent; poll until the freshly
488 // created secret surfaces in the filtered listing.
489 const response = yield* fetchUntil(
490 send(
491 HttpClientRequest.get(
492 `${baseUrl}/list?name=${encodeURIComponent(name)}`,
493 ),
494 ).pipe(Effect.flatMap((r) => r.json)),
495 (body) => Array.isArray(body?.names) && body.names.includes(name),
496 );
497
498 expect((response as any).names).toContain(name);
499 }),
500 );
501 });
502
503 describe("ListSecretVersionIds", () => {
504 test.provider(
505 "lists the string secret's versions with stages",
506 (_stack) =>
507 Effect.gen(function* () {
508 const response = yield* fetchUntil(
509 send(HttpClientRequest.get(`${baseUrl}/versions`)).pipe(
510 Effect.flatMap((r) => r.json),
511 ),
512 (body) =>
513 Array.isArray(body?.versions) &&
514 body.versions.some((version: any) =>
515 version.stages?.includes("AWSCURRENT"),
516 ),
517 );
518
519 const current = (response as any).versions.find((version: any) =>
520 version.stages.includes("AWSCURRENT"),
521 );
522 expect(current.versionId).toBeTruthy();
523 }),
524 );
525 });
526
527 describe("BatchGetSecretValue", () => {
528 test.provider("reads both bound secrets in one call", (_stack) =>
529 Effect.gen(function* () {
530 // BatchGetSecretValue is eventually consistent right after the
531 // fixture secrets are created; poll until both values are served.
532 const response = yield* fetchUntil(
533 send(HttpClientRequest.get(`${baseUrl}/batch`)).pipe(
534 Effect.flatMap((r) => r.json),
535 ),
536 (body) =>
537 Array.isArray(body?.values) &&
538 body.values.length === 2 &&
539 body.values.every(
540 (entry: any) =>
541 typeof entry.secretString === "string" &&
542 entry.secretString.length > 0,
543 ),
544 );
545
546 expect((response as any).values).toHaveLength(2);
547 expect((response as any).errors).toHaveLength(0);
548 }),
549 );
550 });
551
552 // The RotationEventSource wires this same fixture Lambda as the rotation
553 // function: RotateSecret kicks off the 4-step protocol
554 // (createSecret -> setSecret -> testSecret -> finishSecret), which the
555 // handler implements via the GetRandomPassword / PutSecretValue /
556 // DescribeSecret / UpdateSecretVersionStage bindings. Runs LAST — it
557 // permanently changes the rotation secret's value.
558 describe("RotationEventSource", () => {
559 test.provider("rotation is configured on the secret", (_stack) =>
560 Effect.gen(function* () {
561 const status = yield* send(
562 HttpClientRequest.get(`${baseUrl}/rotation-status`),
563 ).pipe(Effect.flatMap((r) => r.json));
564
565 expect((status as any).rotationEnabled).toBe(true);
566 }),
567 );
568
569 test.provider(
570 "RotateSecret triggers the rotation protocol end-to-end",
571 (_stack) =>
572 Effect.gen(function* () {
573 const before = yield* fetchUntil(
574 send(HttpClientRequest.get(`${baseUrl}/rotation-value`)).pipe(
575 Effect.flatMap((r) => r.json),
576 ),
577 (body) => typeof body?.secretString === "string",
578 );
579
580 const rotate = yield* send(
581 HttpClientRequest.post(`${baseUrl}/rotate`),
582 ).pipe(Effect.flatMap((r) => r.json));
583 // The fixture surfaces typed RotateSecret failures as
584 // `{ error, message }` — assert none so failures are readable.
585 expect(rotate).not.toHaveProperty("error");
586 expect((rotate as any).versionId).toBeTruthy();
587
588 // Secrets Manager drives the protocol asynchronously — poll until
589 // the handler's finishSecret promoted the new version.
590 const after = yield* fetchUntil(
591 send(HttpClientRequest.get(`${baseUrl}/rotation-value`)).pipe(
592 Effect.flatMap((r) => r.json),
593 ),
594 (body) =>
595 typeof body?.secretString === "string" &&
596 body.secretString.startsWith("alchemy-sm-rotated-"),
597 45,
598 );
599
600 expect((after as any).secretString).toMatch(/^alchemy-sm-rotated-/);
601 expect((after as any).versionId).not.toBe(
602 (before as any).versionId,
603 );
604 }),
605 { timeout: 150_000 },
606 );
607 });
608 },
609);
610
611// A request hit a Lambda instance observing not-yet-consistent control-plane
612// state (ListSecrets lags fresh creates by a few seconds). Retry.
613class BindingNotConsistent extends Data.TaggedError("BindingNotConsistent") {}
614
615const fetchUntil = <A>(
616 fetch: Effect.Effect<unknown, any, HttpClient.HttpClient>,
617 ready: (body: any) => boolean,
618 attempts = 20,
619) =>
620 fetch.pipe(
621 Effect.flatMap((body) =>
622 ready(body)
623 ? Effect.succeed(body as A)
624 : Effect.fail(new BindingNotConsistent()),
625 ),
626 Effect.retry({
627 while: (e) => e._tag === "BindingNotConsistent",
628 schedule: Schedule.max([
629 Schedule.fixed("2 seconds"),
630 Schedule.recurs(attempts),
631 ]),
632 }),
633 );