| 1 | import * as AWS from "@/AWS"; |
| 2 | import * as Test from "@/Test/Alchemy"; |
| 3 | import * as eventbridge from "@distilled.cloud/aws/eventbridge"; |
| 4 | import { expect } from "alchemy-test"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Redacted from "effect/Redacted"; |
| 7 | import * as Schedule from "effect/Schedule"; |
| 8 | |
| 9 | const { test } = Test.make({ providers: AWS.providers() }); |
| 10 | |
| 11 | const CONNECTION_NAME = "alchemy-test-eb-connection"; |
| 12 | const DESTINATION_NAME = "alchemy-test-eb-destination"; |
| 13 | |
| 14 | test.provider( |
| 15 | "connection (Redacted API key) + api destination lifecycle", |
| 16 | (stack) => |
| 17 | Effect.gen(function* () { |
| 18 | yield* stack.destroy(); |
| 19 | |
| 20 | // Create — the API key value is Redacted end-to-end; EventBridge |
| 21 | // stores it in Secrets Manager. |
| 22 | const deployOnce = (options: { |
| 23 | description?: string; |
| 24 | rateLimit?: number; |
| 25 | }) => |
| 26 | stack.deploy( |
| 27 | Effect.gen(function* () { |
| 28 | const connection = yield* AWS.EventBridge.Connection( |
| 29 | "TestConnection", |
| 30 | { |
| 31 | name: CONNECTION_NAME, |
| 32 | description: options.description, |
| 33 | authorizationType: "API_KEY", |
| 34 | authParameters: { |
| 35 | apiKeyAuthParameters: { |
| 36 | apiKeyName: "x-api-key", |
| 37 | apiKeyValue: Redacted.make("alchemy-test-secret-value"), |
| 38 | }, |
| 39 | }, |
| 40 | }, |
| 41 | ); |
| 42 | const destination = yield* AWS.EventBridge.ApiDestination( |
| 43 | "TestDestination", |
| 44 | { |
| 45 | name: DESTINATION_NAME, |
| 46 | connectionArn: connection.connectionArn, |
| 47 | invocationEndpoint: "https://example.com/events", |
| 48 | httpMethod: "POST", |
| 49 | invocationRateLimitPerSecond: options.rateLimit, |
| 50 | }, |
| 51 | ); |
| 52 | return { connection, destination }; |
| 53 | }), |
| 54 | ); |
| 55 | |
| 56 | const { connection, destination } = yield* deployOnce({ rateLimit: 5 }); |
| 57 | |
| 58 | expect(connection.connectionName).toBe(CONNECTION_NAME); |
| 59 | expect(connection.connectionState).toBe("AUTHORIZED"); |
| 60 | expect(connection.secretArn).toBeTruthy(); |
| 61 | expect(destination.apiDestinationName).toBe(DESTINATION_NAME); |
| 62 | expect(destination.apiDestinationState).toBe("ACTIVE"); |
| 63 | |
| 64 | // Out-of-band verify via distilled — the secret never appears in |
| 65 | // attributes or describe output (only its Secrets Manager ARN). |
| 66 | const describedConnection = yield* eventbridge.describeConnection({ |
| 67 | Name: CONNECTION_NAME, |
| 68 | }); |
| 69 | expect(describedConnection.AuthorizationType).toBe("API_KEY"); |
| 70 | expect(describedConnection.SecretArn).toBeTruthy(); |
| 71 | expect( |
| 72 | describedConnection.AuthParameters?.ApiKeyAuthParameters?.ApiKeyName, |
| 73 | ).toBe("x-api-key"); |
| 74 | |
| 75 | const describedDestination = yield* eventbridge.describeApiDestination({ |
| 76 | Name: DESTINATION_NAME, |
| 77 | }); |
| 78 | expect(describedDestination.InvocationEndpoint).toBe( |
| 79 | "https://example.com/events", |
| 80 | ); |
| 81 | expect(describedDestination.InvocationRateLimitPerSecond).toBe(5); |
| 82 | |
| 83 | // Update — description and rate limit sync in place (no replace). |
| 84 | const { connection: updatedConnection, destination: updatedDestination } = |
| 85 | yield* deployOnce({ description: "updated connection", rateLimit: 10 }); |
| 86 | expect(updatedConnection.connectionArn).toBe(connection.connectionArn); |
| 87 | expect(updatedDestination.apiDestinationArn).toBe( |
| 88 | destination.apiDestinationArn, |
| 89 | ); |
| 90 | |
| 91 | const afterUpdate = yield* eventbridge.describeConnection({ |
| 92 | Name: CONNECTION_NAME, |
| 93 | }); |
| 94 | expect(afterUpdate.Description).toBe("updated connection"); |
| 95 | const destinationAfterUpdate = yield* eventbridge.describeApiDestination({ |
| 96 | Name: DESTINATION_NAME, |
| 97 | }); |
| 98 | expect(destinationAfterUpdate.InvocationRateLimitPerSecond).toBe(10); |
| 99 | |
| 100 | yield* stack.destroy(); |
| 101 | |
| 102 | // Typed wait-until-gone — connection deletion is asynchronous |
| 103 | // (DELETING state), so poll until NotFound. |
| 104 | const gone = yield* eventbridge |
| 105 | .describeConnection({ Name: CONNECTION_NAME }) |
| 106 | .pipe( |
| 107 | Effect.map(() => false), |
| 108 | Effect.catchTag("ResourceNotFoundException", () => |
| 109 | Effect.succeed(true), |
| 110 | ), |
| 111 | Effect.repeat({ |
| 112 | schedule: Schedule.spaced("3 seconds"), |
| 113 | until: (isGone): boolean => isGone, |
| 114 | times: 15, |
| 115 | }), |
| 116 | ); |
| 117 | expect(gone).toBe(true); |
| 118 | |
| 119 | const destinationGone = yield* eventbridge |
| 120 | .describeApiDestination({ Name: DESTINATION_NAME }) |
| 121 | .pipe( |
| 122 | Effect.map(() => false), |
| 123 | Effect.catchTag("ResourceNotFoundException", () => |
| 124 | Effect.succeed(true), |
| 125 | ), |
| 126 | ); |
| 127 | expect(destinationGone).toBe(true); |
| 128 | }), |
| 129 | { |
| 130 | tags: ["provider:aws", "provider:aws:eventbridge", "live"], |
| 131 | timeout: 180_000, |
| 132 | }, |
| 133 | ); |