| 1 | import * as AWS from "@/AWS"; |
| 2 | import * as Core from "@/Test/Core"; |
| 3 | import * as Test from "@/Test/Alchemy"; |
| 4 | import * as IAM from "@distilled.cloud/aws/iam"; |
| 5 | import * as Lambda from "@distilled.cloud/aws/lambda"; |
| 6 | import * as secretsmanager from "@distilled.cloud/aws/secrets-manager"; |
| 7 | import { describe, expect } from "alchemy-test"; |
| 8 | import * as Data from "effect/Data"; |
| 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as Schedule from "effect/Schedule"; |
| 11 | import * as Schema from "effect/Schema"; |
| 12 | import * as Stream from "effect/Stream"; |
| 13 | import * as HttpClient from "effect/http/HttpClient"; |
| 14 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 15 | import SecretsManagerTestFunctionLive, { |
| 16 | SecretsManagerTestFunction, |
| 17 | } from "./handler"; |
| 18 | import GetSecretOnlyTestFunctionLive, { |
| 19 | GetSecretOnlyTestFunction, |
| 20 | } from "./fixtures/get-secret-only-handler.ts"; |
| 21 | |
| 22 | const testOptions = { providers: AWS.providers() }; |
| 23 | const { test, beforeAll, afterAll } = Test.make(testOptions); |
| 24 | const 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. |
| 33 | const readinessPolicy = Schedule.max([ |
| 34 | Schedule.fixed("2 seconds"), |
| 35 | Schedule.recurs(75), |
| 36 | ]); |
| 37 | |
| 38 | let baseUrl: string; |
| 39 | let getOnlyUrl: string; |
| 40 | let getOnlyFunction: { functionName: string; roleName: string } | undefined; |
| 41 | let getOnlySecret: { secretArn: string; secretName: string } | undefined; |
| 42 | |
| 43 | const 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 | |
| 58 | const 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 | |
| 98 | const getOnlyValue = Schema.Struct({ |
| 99 | arn: Schema.String, |
| 100 | name: Schema.String, |
| 101 | versionId: Schema.String, |
| 102 | secretString: Schema.String, |
| 103 | }); |
| 104 | |
| 105 | const 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 | |
| 116 | class FixtureStillExists extends Data.TaggedError("FixtureStillExists")<{ |
| 117 | readonly resource: string; |
| 118 | }> {} |
| 119 | |
| 120 | const 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 | |
| 158 | class 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). |
| 169 | const 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. |
| 194 | const 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. |
| 199 | describe.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. |
| 613 | class BindingNotConsistent extends Data.TaggedError("BindingNotConsistent") {} |
| 614 | |
| 615 | const 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 | ); |