├── .all-contributorsrc ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── .release-it.json ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── __tests__ └── eventbridge.test.ts ├── babel.config.js ├── img └── logo.png ├── jest.config.js ├── jest.setup.js ├── package-lock.json ├── package.json ├── src ├── assertions │ ├── index.ts │ ├── toContainItemWithValues │ │ └── index.ts │ ├── toContainUser │ │ └── index.ts │ ├── toExistAsS3Bucket │ │ └── index.ts │ ├── toExistInDynamoTable │ │ └── index.ts │ ├── toHaveCompletedExecutionWithStatus │ │ └── index.ts │ ├── toHaveContentEqualTo │ │ └── index.ts │ ├── toHaveContentTypeEqualTo │ │ └── index.ts │ ├── toHaveEvent │ │ └── index.ts │ ├── toHaveEventWithSource │ │ └── index.ts │ ├── toHaveObjectWithNameEqualTo │ │ └── index.ts │ ├── toMatchStateMachineOutput │ │ └── index.ts │ └── utils │ │ ├── errorTypesCheckers.ts │ │ ├── globalTypeChecker.ts │ │ └── index.ts ├── helpers │ ├── cognito.ts │ ├── eventBridge.ts │ ├── general.ts │ ├── index.ts │ ├── stepFunctions.ts │ └── utils │ │ ├── loadArg.ts │ │ └── removeUndefinedMessages.ts ├── index.ts └── utils │ └── testResult.ts ├── tsconfig.json ├── yarn-error.log └── yarn.lock /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "contributors": [ 8 | { 9 | "login": "BenEllerby", 10 | "name": "Ben Ellerby", 11 | "avatar_url": "https://avatars1.githubusercontent.com/u/11080984?v=4", 12 | "profile": "https://medium.com/serverless-transformation", 13 | "contributions": [ 14 | "code", 15 | "doc", 16 | "ideas", 17 | "design", 18 | "review" 19 | ] 20 | }, 21 | { 22 | "login": "hamilton-s", 23 | "name": "Sarah Hamilton", 24 | "avatar_url": "https://avatars.githubusercontent.com/hamilton-s", 25 | "profile": "https://github.com/hamilton-s", 26 | "contributions": [ 27 | "code", 28 | "doc" 29 | ] 30 | }, 31 | { 32 | "login": "aghwi", 33 | "name": "Alexander White", 34 | "avatar_url": "https://avatars.githubusercontent.com/agwhi", 35 | "profile": "https://github.com/agwhi", 36 | "contributions": [ 37 | "code" 38 | ] 39 | }, 40 | { 41 | "login": "joelhamiltondev", 42 | "name": "Joel Hamilton", 43 | "avatar_url": "https://avatars.githubusercontent.com/joelhamiltondev", 44 | "profile": "https://github.com/joelhamiltondev", 45 | "contributions": [ 46 | "code" 47 | ] 48 | } 49 | ], 50 | "contributorsPerLine": 7, 51 | "projectName": "sls-test-tools", 52 | "projectOwner": "BenEllerby", 53 | "repoType": "github", 54 | "repoHost": "https://github.com", 55 | "skipCi": true, 56 | "commitConvention": "none" 57 | } 58 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | "eslint:recommended", 4 | "plugin:import/recommended", 5 | "plugin:prettier/recommended", 6 | ], 7 | rules: { 8 | curly: ["error", "all"], 9 | eqeqeq: ["error", "smart"], 10 | "import/no-duplicates": "off", 11 | "import/no-extraneous-dependencies": [ 12 | "error", 13 | { 14 | devDependencies: true, 15 | optionalDependencies: false, 16 | peerDependencies: false, 17 | }, 18 | ], 19 | "no-shadow": [ 20 | "error", 21 | { 22 | hoist: "all", 23 | }, 24 | ], 25 | "prefer-const": "error", 26 | "sort-imports": [ 27 | "error", 28 | { 29 | ignoreCase: true, 30 | ignoreDeclarationSort: true, 31 | ignoreMemberSort: false, 32 | memberSyntaxSortOrder: ["none", "all", "multiple", "single"], 33 | }, 34 | ], 35 | "padding-line-between-statements": [ 36 | "error", 37 | { 38 | blankLine: "always", 39 | prev: "*", 40 | next: "return", 41 | }, 42 | ], 43 | complexity: ["error", 8], 44 | "max-lines": ["error", 300], 45 | "max-depth": ["error", 3], 46 | "max-params": ["error", 2], 47 | }, 48 | 49 | root: true, 50 | plugins: ["import"], 51 | env: { 52 | es6: true, 53 | node: true, 54 | }, 55 | parserOptions: { ecmaVersion: 2021 }, 56 | overrides: [ 57 | { 58 | files: ["**/*.ts?(x)"], 59 | settings: { "import/resolver": { typescript: {} } }, 60 | extends: [ 61 | "plugin:@typescript-eslint/recommended", 62 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 63 | "plugin:import/typescript", 64 | "plugin:prettier/recommended", 65 | ], 66 | parser: "@typescript-eslint/parser", 67 | parserOptions: { 68 | project: "tsconfig.json", 69 | }, 70 | rules: { 71 | "@typescript-eslint/explicit-module-boundary-types": "error", 72 | "@typescript-eslint/prefer-optional-chain": "error", 73 | "no-shadow": "off", 74 | "@typescript-eslint/no-shadow": "error", 75 | "@typescript-eslint/prefer-nullish-coalescing": "error", 76 | "@typescript-eslint/strict-boolean-expressions": "error", 77 | "@typescript-eslint/no-unnecessary-boolean-literal-compare": "error", 78 | "@typescript-eslint/no-unnecessary-condition": "error", 79 | "@typescript-eslint/no-unnecessary-type-arguments": "error", 80 | "no-unused-vars": "off", 81 | "@typescript-eslint/no-unused-vars": [ 82 | "error", 83 | { argsIgnorePattern: "^_$", varsIgnorePattern: "^_$" }, 84 | ], 85 | "@typescript-eslint/prefer-string-starts-ends-with": "error", 86 | "@typescript-eslint/switch-exhaustiveness-check": "error", 87 | "@typescript-eslint/ban-ts-comment": [ 88 | "error", 89 | { 90 | "ts-ignore": "allow-with-description", 91 | minimumDescriptionLength: 10, 92 | }, 93 | ], 94 | }, 95 | }, 96 | ], 97 | }; 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | build -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "auto" 3 | } 4 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "github": { 3 | "release": true 4 | }, 5 | "hooks": { 6 | "after:my-plugin:bump": "./bin/my-script.sh", 7 | "after:bump": "yarn build", 8 | "after:git:release": "echo After git push, before github release", 9 | "after:release": "echo Successfully released ${name} v${version} to ${repo.repository}." 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll.eslint": true 5 | }, 6 | "eslint.validate": ["javascript"] 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Theodo UK 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

sls-test-tools

3 | 4 | 5 | Custom Jest Assertions for Serverless Projects 6 | 7 |
8 | 9 |
10 | 11 | `sls-test-tools` provides a range of utilities, setup, teardown and assertions to make it easier to write effective and high quality integration tests for Serverless Architectures on AWS. 12 | 13 | **🚧 This is in an alpha state while we trial a few initial assertions and get feedback on the approach and structure. 🚧** 14 | 15 | **⚠️ AWS resources will be created (SQS Queue, EventBridge Rule...) if the EventBridge module is used. Although there is clear setup and teardown we do not advise running this on production environments currently. ⚠️** 16 | 17 | ## Installation 18 | 19 | With npm: 20 | 21 | ```sh 22 | npm install --save-dev sls-test-tools 23 | ``` 24 | 25 | With yarn: 26 | 27 | ```sh 28 | yarn add -D sls-test-tools 29 | ``` 30 | 31 | ## Maintenance 32 | 33 | sls-test-tools is currently being actively maintained, yet is in alpha. Your feedback is very welcome. 34 | 35 | ## Assertions: 36 | 37 | ### EventBridge 38 | 39 | ``` 40 | expect(eventBridgeEvents).toHaveEvent(); 41 | 42 | expect(eventBridgeEvents).toHaveEventWithSource("order.created"); 43 | ``` 44 | 45 | ### S3 46 | 47 | Note: these async assertions require "await" 48 | 49 | ``` 50 | await expect("BUCKET NAME").toHaveS3ObjectWithNameEqualTo("FILE NAME"); 51 | ``` 52 | 53 | ``` 54 | await expect("BUCKET NAME").toExistAsS3Bucket(); 55 | ``` 56 | 57 | ``` 58 | await expect({ 59 | bucketName: "BUCKET_NAME", 60 | objectName: "FILE NAME", 61 | }).toHaveContentTypeEqualTo("CONTENT_TYPE");; 62 | ``` 63 | 64 | where CONTENT_TYPE are [standards MIME types](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types) 65 | 66 | ``` 67 | await expect({ 68 | bucketName: "BUCKET_NAME", 69 | objectName: "FILE NAME", 70 | }).toHaveContentEqualTo("CONTENT"); 71 | ``` 72 | 73 | ### Step Functions 74 | 75 | Note: these assertions also require "await" 76 | 77 | ``` 78 | await expect("STATE_MACHINE_NAME").toHaveCompletedExecutionWithStatus("STATUS"); 79 | await expect("STATE_MACHINE_NAME").toMatchStateMachineOutput({EXPECTED_OUTPUT})); 80 | ``` 81 | 82 | ### DynamoDB 83 | 84 | Note: these assertions also require "await" 85 | 86 | ``` 87 | await expect("TABLENAME").toContainItemWithValues({[field]: value}); 88 | await expect({PK: pk, 89 | SK: sk, 90 | }).toExistInDynamoTable('TABLENAME'); 91 | ``` 92 | 93 | ### Cognito 94 | 95 | Note: this assertion also requires "await" 96 | 97 | ``` 98 | await expect('USER_POOL_ID').toContainUser('USERNAME'); 99 | ``` 100 | 101 | ## Helpers 102 | 103 | ### General 104 | 105 | AWSClient - An AWS client with credentials set up 106 | 107 | ``` 108 | getStackResources(stackName) - get information about a stack 109 | getOptions() - get options for making requests to AWS 110 | ``` 111 | 112 | ### EventBridge 113 | 114 | An interface to the deployed EventBridge, allowing events to be injected and intercepted via an SQS queue and EventBridge rule. 115 | 116 | #### Static 117 | 118 | ``` 119 | EventBridge.build(busName) - create a EventBridge instance to allow events to be injected and intercepted 120 | ``` 121 | 122 | #### Instance 123 | 124 | ``` 125 | eventBridge.publishEvent(source, detailType, detail, clear?) - publish an event to the bus 126 | eventBridge.getEvents(clear?) - get the events that have been sent to the bus 127 | eventBridge.clear() - clear old messages 128 | eventBridge.destroy() - remove infastructure used to track events 129 | ``` 130 | 131 | ### Step Functions 132 | 133 | An interface to a deployed Step Function, with a function to execute a Step Function until its completion. 134 | 135 | #### Static 136 | 137 | ``` 138 | StepFunctions.build() // create a Step Functions Client for executing existing state machines 139 | ``` 140 | 141 | #### Instance 142 | 143 | ``` 144 | stepFunctions.runExecution(stateMachineName, input) // executes state machine until completion 145 | ``` 146 | 147 | ### Cognito 148 | 149 | ``` 150 | await createAuthenticatedUser({ 151 | clientId: "CLIENT_ID", 152 | userPoolId: "USER_POOL_ID", 153 | standardAttributes: ["email", "middle_name"], // works for all cognito standard user attributes 154 | customAttributes: [{hello: "string"}], // only works for customAttributes which have been explicitly defined in the user pool schema 155 | }); 156 | 157 | await createUnauthenticatedUser({ 158 | clientId: "CLIENT_ID", 159 | userPoolId: "USER_POOL_ID", 160 | confirmed: true, 161 | standardAttributes: ["email", "middle_name", "address", "birthdate"], // works for all cognito standard user attributes 162 | customAttributes: [{hello: "string"}], // only works for customAttributes which have been explicitly defined in the user pool schema 163 | }); 164 | ``` 165 | 166 | ## Running with `jest` 167 | 168 | ### Arguments 169 | 170 | - When running tests with `jest` using `sls-test-tools` matchers there are certain parameters needed for `sls-test-tools` to make assertions. 171 | - These are passed either as command line arguments, using quotation to match `jests` convention on test arguments, or by using environment variables. CLI arguments override environment variables. 172 | 173 | **Required** 174 | 175 | - `'--stack=my-service-dev'` or `process.env.CFN_STACK_NAME` - the CloudFormation stack name of the stack under test. 176 | 177 | **Optional** 178 | 179 | - `'--profile=[PROFILE NAME]'` or `process.env.AWS_PROFILE` (will default to `default`) 180 | - `'--region=[AWS Region]'` or `process.env.AWS_REGION` (will default to `eu-west-2`) 181 | - `'--keep=true'` or `process.env.SLS_TEST_TOOLS_KEEP` (will default to `false`) - keeps testing resources up to avoid creation throttles (e.g. SQS Queue created for EventBridge assertions) 182 | - `'--event-rule-name=[Event Bus Rule Name]'` - Custom Event bridge Rule name (will deafult to `test-${eventBridgeName}-rule`) 183 | - `'--queue-name=[SQS Queue Name]'` - Custom SQS Queue name (will deafult to `${eventBridgeName}-testing-queue`) 184 | 185 | - To avoid issues we recommend `--runInBand` 186 | 187 | ``` 188 | import { AWSClient, EventBridge } from "sls-test-tools"; 189 | 190 | const lambda = new AWSClient.Lambda() 191 | let eventBridge; 192 | const s3 = new AWSClient.S3() 193 | 194 | describe("Integration Testing Event Bridge", () => { 195 | beforeAll(async () => { 196 | eventBridge = await EventBridge.build("event-bridge") 197 | }); 198 | 199 | afterAll(async () => { 200 | await eventBridge.destroy() 201 | }); 202 | 203 | it("correctly publishes an event to the event bus when the lambda is invoked", async () => { 204 | const event = { 205 | body: JSON.stringify({ 206 | filename: filename, 207 | }), 208 | }; 209 | 210 | // Invoke Lambda Function 211 | const params = { 212 | FunctionName: "event-bridge-example-dev-service1", 213 | Payload: JSON.stringify(event), 214 | }; 215 | await lambda.invoke(params).promise(); 216 | 217 | const eventBridgeEvents = await eventBridge.getEvents() 218 | expect(eventBridgeEvents).toHaveEvent(); 219 | expect(eventBridgeEvents).toHaveEventWithSource("order.created"); 220 | }); 221 | 222 | it("correctly generates a PDF when an order is created", async () => { 223 | const bucketName = example-bucket 224 | await eventBridge 225 | .publishEvent("order.created", "example", JSON.stringify({ filename: filename })); 226 | 227 | await sleep(5000); // wait 5 seconds to allow event to pass 228 | 229 | const params = { 230 | Bucket: bucketName, 231 | Key: filename, 232 | }; 233 | 234 | // Assert that file was added to the S3 bucket 235 | await expect("example-dev-thumbnails-bucket").toHaveS3ObjectWithNameEqualTo( 236 | filename 237 | ); 238 | }); 239 | ``` 240 | 241 | ## Contributors ✨ 242 | 243 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 |

Ben Ellerby

💻 📖 🤔 🎨 👀

Sarah Hamilton

💻 📖

Alexander White

💻

Joel Hamilton

💻
256 | 257 | 258 | 259 | 260 | 261 | 262 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 263 | 264 |
265 |

sls-test-tools

266 | 267 | 268 | Custom Jest Assertions for Serverless Projects 269 | 270 |
271 | 272 |
273 | 274 | `sls-test-tools` provides a range of utilities, setup, teardown and assertions to make it easier to write effective and high quality integration tests for Serverless Architectures on AWS. 275 | 276 | **🚧 This is in an alpha state while we trial a few initial assertions and get feedback on the approach and structure. 🚧** 277 | 278 | **⚠️ AWS resources will be created (SQS Queue, EventBridge Rule...) if the EventBridge module is used. Although there is clear setup and teardown we do not advise running this on production environments currently. ⚠️** 279 | 280 | ## Installation 281 | 282 | With npm: 283 | 284 | ```sh 285 | npm install --save-dev sls-test-tools 286 | ``` 287 | 288 | With yarn: 289 | 290 | ```sh 291 | yarn add -D sls-test-tools 292 | ``` 293 | 294 | ## Maintenance 295 | 296 | sls-test-tools is currently being actively maintained, yet is in alpha. Your feedback is very welcome. 297 | 298 | ## Assertions: 299 | 300 | ### EventBridge 301 | 302 | ``` 303 | expect(eventBridgeEvents).toHaveEvent(); 304 | 305 | expect(eventBridgeEvents).toHaveEventWithSource("order.created"); 306 | ``` 307 | 308 | ### S3 309 | 310 | Note: these async assertions require "await" 311 | 312 | ``` 313 | await expect("BUCKET NAME").toHaveS3ObjectWithNameEqualTo("FILE NAME"); 314 | ``` 315 | 316 | ``` 317 | await expect("BUCKET NAME").toExistAsS3Bucket(); 318 | ``` 319 | 320 | ``` 321 | await expect({ 322 | bucketName: "BUCKET_NAME", 323 | objectName: "FILE NAME", 324 | }).toHaveContentTypeEqualTo("CONTENT_TYPE");; 325 | ``` 326 | 327 | where CONTENT_TYPE are [standards MIME types](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types) 328 | 329 | ``` 330 | await expect({ 331 | bucketName: "BUCKET_NAME", 332 | objectName: "FILE NAME", 333 | }).toHaveContentEqualTo("CONTENT"); 334 | ``` 335 | 336 | ### Step Functions 337 | 338 | Note: these assertions also require "await" 339 | 340 | ``` 341 | await expect("STATE_MACHINE_NAME").toHaveCompletedExecutionWithStatus("STATUS"); 342 | await expect("STATE_MACHINE_NAME").toMatchStateMachineOutput({EXPECTED_OUTPUT})); 343 | ``` 344 | 345 | ### DynamoDB 346 | 347 | Note: these assertions also require "await" 348 | 349 | ``` 350 | await expect("TABLENAME").toContainItemWithValues({[field]: value}); 351 | await expect({PK: pk, 352 | SK: sk, 353 | }).toExistInDynamoTable('TABLENAME'); 354 | ``` 355 | 356 | ### Cognito 357 | 358 | Note: this assertion also requires "await" 359 | 360 | ``` 361 | await expect('USER_POOL_ID').toContainUser('USERNAME'); 362 | ``` 363 | 364 | ## Helpers 365 | 366 | ### General 367 | 368 | AWSClient - An AWS client with credentials set up 369 | 370 | ``` 371 | getStackResources(stackName) - get information about a stack 372 | getOptions() - get options for making requests to AWS 373 | ``` 374 | 375 | ### EventBridge 376 | 377 | An interface to the deployed EventBridge, allowing events to be injected and intercepted via an SQS queue and EventBridge rule. 378 | 379 | #### Static 380 | 381 | ``` 382 | EventBridge.build(busName) - create a EventBridge instance to allow events to be injected and intercepted 383 | ``` 384 | 385 | #### Instance 386 | 387 | ``` 388 | eventBridge.publishEvent(source, detailType, detail) - publish an event to the bus 389 | eventBridge.getEvents() - get the events that have been sent to the bus 390 | eventBridge.clear() - clear old messages 391 | eventBridge.destroy() - remove infastructure used to track events 392 | ``` 393 | 394 | ### Step Functions 395 | 396 | An interface to a deployed Step Function, with a function to execute a Step Function until its completion. 397 | 398 | #### Static 399 | 400 | ``` 401 | StepFunctions.build() // create a Step Functions Client for executing existing state machines 402 | ``` 403 | 404 | #### Instance 405 | 406 | ``` 407 | stepFunctions.runExecution(stateMachineName, input) // executes state machine until completion 408 | ``` 409 | 410 | ### Cognito 411 | 412 | ``` 413 | await createAuthenticatedUser({ 414 | clientId: "CLIENT_ID", 415 | userPoolId: "USER_POOL_ID", 416 | standardAttributes: ["email", "middle_name"], // works for all cognito standard user attributes 417 | customAttributes: ["hello"], // only works for customAttributes which have been explicitly defined in the user pool schema 418 | }); 419 | 420 | await createUnauthenticatedUser({ 421 | clientId: "CLIENT_ID", 422 | userPoolId: "USER_POOL_ID", 423 | confirmed: true, 424 | standardAttributes: ["email", "middle_name", "address", "birthdate"], // works for all cognito standard user attributes 425 | }); 426 | 427 | 428 | ``` 429 | 430 | ## Running with `jest` 431 | 432 | ### Arguments 433 | 434 | - When running tests with `jest` using `sls-test-tools` matchers there are certain parameters needed for `sls-test-tools` to make assertions. 435 | - These are passed either as command line arguments, using quotation to match `jests` convention on test arguments, or by using environment variables. CLI arguments override environment variables. 436 | 437 | **Required** 438 | 439 | - `'--stack=my-service-dev'` or `process.env.CFN_STACK_NAME` - the CloudFormation stack name of the stack under test. 440 | 441 | **Optional** 442 | 443 | - `'--profile=[PROFILE NAME]'` or `process.env.AWS_PROFILE` (will default to `default`) 444 | - `'--region=[AWS Region]'` or `process.env.AWS_REGION` (will default to `eu-west-2`) 445 | - `'--keep=true'` - keeps testing resources up to avoid creation throttles (e.g. SQS Queue created for EventBridge assertions) 446 | 447 | - To avoid issues we recommend `--runInBand` 448 | 449 | ``` 450 | import { AWSClient, EventBridge } from "sls-test-tools"; 451 | 452 | const lambda = new AWSClient.Lambda() 453 | let eventBridge; 454 | const s3 = new AWSClient.S3() 455 | 456 | describe("Integration Testing Event Bridge", () => { 457 | beforeAll(async () => { 458 | eventBridge = await EventBridge.build("event-bridge") 459 | }); 460 | 461 | afterAll(async () => { 462 | await eventBridge.destroy() 463 | }); 464 | 465 | it("correctly publishes an event to the event bus when the lambda is invoked", async () => { 466 | const event = { 467 | body: JSON.stringify({ 468 | filename: filename, 469 | }), 470 | }; 471 | 472 | // Invoke Lambda Function 473 | const params = { 474 | FunctionName: "event-bridge-example-dev-service1", 475 | Payload: JSON.stringify(event), 476 | }; 477 | await lambda.invoke(params).promise(); 478 | 479 | const eventBridgeEvents = await eventBridge.getEvents() 480 | expect(eventBridgeEvents).toHaveEvent(); 481 | expect(eventBridgeEvents).toHaveEventWithSource("order.created"); 482 | }); 483 | 484 | it("correctly generates a PDF when an order is created", async () => { 485 | const bucketName = example-bucket 486 | await eventBridge 487 | .publishEvent("order.created", "example", JSON.stringify({ filename: filename })); 488 | 489 | await sleep(5000); // wait 5 seconds to allow event to pass 490 | 491 | const params = { 492 | Bucket: bucketName, 493 | Key: filename, 494 | }; 495 | 496 | // Assert that file was added to the S3 bucket 497 | await expect("example-dev-thumbnails-bucket").toHaveS3ObjectWithNameEqualTo( 498 | filename 499 | ); 500 | }); 501 | ``` 502 | 503 | ## Contributors ✨ 504 | 505 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 506 | 507 | 508 | 509 | 510 | 511 | 512 | 513 | 514 | 515 | 516 |

Ben Ellerby

💻 🖋 📖 🤔 🎨 📢 👀

Sarah Hamilton

💻 🖋 📖 🤔

Alex White

💻📖
517 | 518 | 519 | 520 | 521 | 522 | 523 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 524 | -------------------------------------------------------------------------------- /__tests__/eventbridge.test.ts: -------------------------------------------------------------------------------- 1 | import { EventBridge as AWSEventBridge, SQS } from "aws-sdk"; 2 | import EventBridge from "../src/helpers/eventBridge"; 3 | 4 | describe("EventBridge assertions", () => { 5 | let awsEventBridgeClient: AWSEventBridge; 6 | let slsEventBridgeClient: EventBridge; 7 | 8 | beforeAll(async () => { 9 | awsEventBridgeClient = new AWSEventBridge({ 10 | region: "eu-west-2", 11 | }); 12 | await awsEventBridgeClient 13 | .createEventBus({ 14 | Name: "TestEventBus", 15 | }) 16 | .promise(); 17 | slsEventBridgeClient = await EventBridge.build("TestEventBus"); 18 | }); 19 | 20 | test("toHaveEvent should pass if event is created", async () => { 21 | await slsEventBridgeClient.publishEvent( 22 | "TestSource", 23 | "TestDetailType", 24 | JSON.stringify({ Detail: "TestDetail" }), 25 | false 26 | ); 27 | const events = await slsEventBridgeClient.getEvents(); 28 | expect(events).toHaveEvent(); 29 | }); 30 | 31 | test("toHaveEvent should fail if event is not present", async () => { 32 | await slsEventBridgeClient.publishEvent( 33 | "TestSource", 34 | "TestDetailType", 35 | JSON.stringify({ Detail: "TestDetail" }), 36 | false 37 | ); 38 | await new Promise((resolve) => setTimeout(resolve, 3000)); 39 | await slsEventBridgeClient.clear(); 40 | await new Promise((resolve) => setTimeout(resolve, 60000)); 41 | const events = await slsEventBridgeClient.getEvents(); 42 | expect(events).not.toHaveEvent(); 43 | }); 44 | 45 | test("toHaveEventWithSource should pass if event is created with correct source", async () => { 46 | await slsEventBridgeClient.publishEvent( 47 | "TestSource", 48 | "TestDetailType", 49 | JSON.stringify({ Detail: "TestDetail" }), 50 | false 51 | ); 52 | const events = await slsEventBridgeClient.getEvents(); 53 | expect(events).toHaveEventWithSource("TestSource"); 54 | }); 55 | 56 | test("toHaveEventWithSource should pass if event is created with wrong source", async () => { 57 | await slsEventBridgeClient.publishEvent( 58 | "TestSource1", 59 | "TestDetailType", 60 | JSON.stringify({ Detail: "TestDetail" }), 61 | false 62 | ); 63 | const events = await slsEventBridgeClient.getEvents(); 64 | expect(events).not.toHaveEventWithSource("TestSource"); 65 | }); 66 | 67 | test("toHaveEventWithSource should fail if event is not created", async () => { 68 | await slsEventBridgeClient.clear(); 69 | await new Promise((resolve) => setTimeout(resolve, 60000)); 70 | const events = await slsEventBridgeClient.getEvents(); 71 | expect(events).not.toHaveEventWithSource("TestSource"); 72 | }); 73 | 74 | test("clear helper should delete events off queue", async () => { 75 | await slsEventBridgeClient.publishEvent( 76 | "TestSource1", 77 | "TestDetailType1", 78 | JSON.stringify({ Detail: "TestDetail1" }), 79 | false 80 | ); 81 | await slsEventBridgeClient.publishEvent( 82 | "TestSource2", 83 | "TestDetailType2", 84 | JSON.stringify({ Detail: "TestDetail2" }), 85 | false 86 | ); 87 | await new Promise((resolve) => setTimeout(resolve, 3000)); 88 | await slsEventBridgeClient.clear(); 89 | await new Promise((resolve) => setTimeout(resolve, 60000)); 90 | const events = await slsEventBridgeClient.getEvents(); 91 | expect(events?.Messages?.length).toBe(undefined); 92 | }); 93 | 94 | test("destroy helper should delete queue, and eventbus rules & targets.", async () => { 95 | let rules; 96 | let targets; 97 | const busName = "destroyTestingEventBus"; 98 | const testingAwsEventBridgeClient = new AWSEventBridge({ 99 | region: "eu-west-2", 100 | }); 101 | // creating event bus 102 | await testingAwsEventBridgeClient 103 | .createEventBus({ 104 | Name: busName, 105 | }) 106 | .promise(); 107 | const testingSlsEventBridgeClient = await EventBridge.build(busName); 108 | // get rules and check that rule has been created 109 | rules = await testingSlsEventBridgeClient.eventBridgeClient 110 | ?.listRules({ 111 | EventBusName: busName, 112 | }) 113 | .promise(); 114 | expect(rules?.Rules?.length).toBeGreaterThan(0); 115 | // get rule targets and see that target has been assigned to rule 116 | targets = await testingSlsEventBridgeClient.eventBridgeClient 117 | ?.listTargetsByRule({ 118 | EventBusName: busName, 119 | Rule: testingSlsEventBridgeClient.ruleName || `test-${busName}-rule`, 120 | }) 121 | .promise(); 122 | expect(targets?.Targets?.length).toBeGreaterThan(0); 123 | const testingSqsClient = new SQS(); 124 | let queueExists = true; 125 | // check that queue exists 126 | let attributes = ( 127 | await testingSqsClient 128 | .getQueueAttributes({ 129 | QueueUrl: slsEventBridgeClient.QueueUrl || "", 130 | }) 131 | .promise() 132 | ).Attributes; 133 | attributes === undefined ? (queueExists = true) : (queueExists = false); 134 | expect(queueExists).toBe(true); 135 | // call destroy 136 | await testingSlsEventBridgeClient.destroy(); 137 | 138 | // check that there are no more rules 139 | rules = await testingSlsEventBridgeClient.eventBridgeClient 140 | ?.listRules({ 141 | EventBusName: busName, 142 | }) 143 | .promise(); 144 | expect(rules?.Rules?.length).toBe(0); 145 | // check that the queue no longer exists 146 | attributes = ( 147 | await testingSqsClient 148 | .getQueueAttributes({ 149 | QueueUrl: slsEventBridgeClient.QueueUrl || "", 150 | }) 151 | .promise() 152 | ).Attributes; 153 | attributes === undefined ? (queueExists = false) : (queueExists = true); 154 | expect(queueExists).toBe(false); 155 | await testingAwsEventBridgeClient 156 | .deleteEventBus({ Name: busName }) 157 | .promise(); 158 | }); 159 | 160 | afterAll(async () => { 161 | await awsEventBridgeClient 162 | .removeTargets({ 163 | EventBusName: "TestEventBus", 164 | Rule: slsEventBridgeClient.ruleName || `test-TestEventBus-rule`, 165 | Ids: ["1"], 166 | }) 167 | .promise(); 168 | await awsEventBridgeClient 169 | .deleteRule({ 170 | EventBusName: "TestEventBus", 171 | Name: slsEventBridgeClient.ruleName || `test-TestEventBus-rule`, 172 | }) 173 | .promise(); 174 | const sqsClient = new SQS(); 175 | if (slsEventBridgeClient.QueueUrl === undefined) { 176 | throw new Error("QueueUrl is undefined"); 177 | } else { 178 | await sqsClient 179 | .deleteQueue({ 180 | QueueUrl: slsEventBridgeClient.QueueUrl || "", 181 | }) 182 | .promise(); 183 | } 184 | await awsEventBridgeClient 185 | .deleteEventBus({ Name: "TestEventBus" }) 186 | .promise(); 187 | }); 188 | }); 189 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | const defaultPresets = [ 2 | ["@babel/preset-typescript", { allowNamespaces: true }], 3 | ]; 4 | 5 | const defaultIgnores = [ 6 | /.*\/(.*\.|)test\.tsx?/, 7 | /bundle\.ts/, 8 | /node_modules/, 9 | /lib/, 10 | ]; 11 | 12 | const presetsForESM = [ 13 | [ 14 | "@babel/preset-env", 15 | { 16 | modules: false, 17 | }, 18 | ], 19 | ...defaultPresets, 20 | ]; 21 | const presetsForCJS = [ 22 | [ 23 | "@babel/preset-env", 24 | { 25 | modules: "cjs", 26 | }, 27 | ], 28 | ...defaultPresets, 29 | ]; 30 | const plugins = [ 31 | [ 32 | "module-resolver", 33 | { 34 | root: ["./src"], 35 | extensions: [".ts"], 36 | }, 37 | ], 38 | "@babel/plugin-transform-runtime", 39 | ]; 40 | 41 | module.exports = { 42 | env: { 43 | cjs: { 44 | presets: presetsForCJS, 45 | }, 46 | esm: { 47 | presets: presetsForESM, 48 | }, 49 | }, 50 | ignore: defaultIgnores, 51 | plugins, 52 | }; 53 | -------------------------------------------------------------------------------- /img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aleios-cloud/sls-test-tools/bcfd81153d4957ff1f16edc7b0a61e4cae68b81d/img/logo.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFilesAfterEnv: ["./jest.setup.js", "sls-test-tools"], 3 | preset: "ts-jest", 4 | }; 5 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | jest.setTimeout(190000); 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sls-test-tools", 3 | "version": "1.0.7", 4 | "description": "Custom Jest Assertions for Serverless Projects", 5 | "main": "lib/cjs/index.js", 6 | "module": "lib/esm/index.js", 7 | "types": "lib/types/index.d.ts", 8 | "directories": { 9 | "lib": "lib" 10 | }, 11 | "repository": "git@github.com:aleios-cloud/sls-test-tools.git", 12 | "bugs": { 13 | "url": "https://github.com/aleios-cloud/sls-test-tools/issues" 14 | }, 15 | "homepage": "https://github.com/aleios-cloud/sls-test-tools#readme", 16 | "author": "Aleios", 17 | "license": "MIT", 18 | "scripts": { 19 | "build": "rm -rf lib && yarn package:cjs && yarn package:esm && yarn package:types", 20 | "contributors:add": "all-contributors add", 21 | "contributors:generate": "all-contributors generate", 22 | "dev": "rm -rf lib && concurrently 'yarn:package:* --watch'", 23 | "lint": "eslint ./src", 24 | "fix": "yarn lint --fix", 25 | "release": "release-it", 26 | "package:cjs": "NODE_ENV=cjs yarn transpile --out-dir lib/cjs --source-maps", 27 | "package:esm": "NODE_ENV=esm yarn transpile --out-dir lib/esm --source-maps", 28 | "package:types": "ttsc", 29 | "transpile": "babel src --extensions .ts" 30 | }, 31 | "dependencies": { 32 | "@types/chance": "^1.1.3", 33 | "aws-sdk": "^2.711.0", 34 | "chance": "^1.1.8", 35 | "import-all.macro": "^3.1.0", 36 | "json-schema-faker": "^0.5.0-rcv.42", 37 | "uuid": "^8.3.2", 38 | "yargs": "^17.3.1" 39 | }, 40 | "devDependencies": { 41 | "@babel/cli": "^7.16.0", 42 | "@babel/core": "^7.16.0", 43 | "@babel/plugin-transform-runtime": "^7.16.0", 44 | "@babel/preset-env": "^7.16.0", 45 | "@babel/preset-typescript": "^7.16.0", 46 | "@types/jest": "^28.1.4", 47 | "@types/yargs": "^17.0.8", 48 | "@typescript-eslint/eslint-plugin": "^5.6.0", 49 | "@typescript-eslint/parser": "^5.6.0", 50 | "@zerollup/ts-transform-paths": "^1.7.18", 51 | "all-contributors-cli": "^6.20.0", 52 | "babel-plugin-macros": "^3.1.0", 53 | "babel-plugin-module-resolver": "^4.1.0", 54 | "concurrently": "^6.0.0", 55 | "eslint": "^8.4.1", 56 | "eslint-config-airbnb-base": "^14.2.1", 57 | "eslint-config-prettier": "^8.3.0", 58 | "eslint-import-resolver-typescript": "^2.5.0", 59 | "eslint-plugin-import": "^2.25.3", 60 | "eslint-plugin-prettier": "^4.0.0", 61 | "jest": "^28.1.2", 62 | "prettier": "^2.2.1", 63 | "release-it": "^14.12.4", 64 | "ts-jest": "^28.0.5", 65 | "ts-migrate": "^0.1.27", 66 | "ts-node": "^10.4.0", 67 | "ttypescript": "^1.5.12", 68 | "typescript": "^4.7.4" 69 | }, 70 | "babel": { 71 | "presets": [ 72 | [ 73 | "@babel/preset-env", 74 | { 75 | "targets": { 76 | "node": true 77 | } 78 | } 79 | ] 80 | ], 81 | "plugins": [ 82 | "macros" 83 | ] 84 | }, 85 | "files": [ 86 | "lib/**/*.js", 87 | "lib/**/*.ts" 88 | ], 89 | "keywords": [ 90 | "aws", 91 | "dev", 92 | "EventBridge", 93 | "serverless", 94 | "sls", 95 | "testing", 96 | "tools", 97 | "theodo" 98 | ] 99 | } 100 | -------------------------------------------------------------------------------- /src/assertions/index.ts: -------------------------------------------------------------------------------- 1 | import toExistAsS3Bucket from "./toExistAsS3Bucket"; 2 | import toHaveContentEqualTo from "./toHaveContentEqualTo"; 3 | import toHaveContentTypeEqualTo from "./toHaveContentTypeEqualTo"; 4 | import toHaveEvent from "./toHaveEvent"; 5 | import toHaveEventWithSource from "./toHaveEventWithSource"; 6 | import toHaveObjectWithNameEqualTo from "./toHaveObjectWithNameEqualTo"; 7 | import toExistInDynamoTable from "./toExistInDynamoTable"; 8 | import toHaveCompletedExecutionWithStatus from "./toHaveCompletedExecutionWithStatus"; 9 | import toContainItemWithValues from "./toContainItemWithValues"; 10 | import toMatchStateMachineOutput from "./toMatchStateMachineOutput"; 11 | import toContainUser from "./toContainUser"; 12 | 13 | export default { 14 | ...toExistAsS3Bucket, 15 | ...toHaveContentEqualTo, 16 | ...toHaveContentTypeEqualTo, 17 | ...toHaveEvent, 18 | ...toHaveEventWithSource, 19 | ...toHaveObjectWithNameEqualTo, 20 | ...toExistInDynamoTable, 21 | ...toHaveCompletedExecutionWithStatus, 22 | ...toContainItemWithValues, 23 | ...toMatchStateMachineOutput, 24 | ...toContainUser, 25 | }; 26 | -------------------------------------------------------------------------------- /src/assertions/toContainItemWithValues/index.ts: -------------------------------------------------------------------------------- 1 | import { testResult, TestResultOutput } from "utils/testResult"; 2 | import { AWSClient } from "helpers/general"; 3 | import { region } from "../../helpers/general"; 4 | 5 | export default { 6 | async toContainItemWithValues( 7 | tableName: string, 8 | values: { [key: string]: unknown } 9 | ): Promise { 10 | const docClient = new AWSClient.DynamoDB.DocumentClient({ 11 | region: region, 12 | }); 13 | const keys: { pk: unknown; sk?: unknown } = { pk: values["PK"] }; 14 | if (values["SK"] !== undefined) { 15 | keys.sk = values["SK"]; 16 | } 17 | const queryParams = { 18 | Key: keys, 19 | TableName: tableName, 20 | }; 21 | let allMatched = true; 22 | let itemExists = true; 23 | try { 24 | const result = await docClient.get(queryParams).promise(); 25 | Object.entries(values).forEach(([key, val]) => { 26 | if (result.Item !== undefined) { 27 | if (key in result.Item) { 28 | if (result.Item[key] !== val) { 29 | allMatched = false; 30 | } 31 | } 32 | } else { 33 | itemExists = false; 34 | } 35 | }); 36 | if (!itemExists) { 37 | return testResult(`Item does not exist.`, false); 38 | } else if (!allMatched) { 39 | return testResult(`Some values do not match as expected.`, false); 40 | } else { 41 | return testResult("Item exists with expected values", true); 42 | } 43 | } catch (e: any) { 44 | console.log(e); 45 | 46 | return testResult("Item with specified keys does not exist.", false); 47 | } 48 | }, 49 | }; 50 | -------------------------------------------------------------------------------- /src/assertions/toContainUser/index.ts: -------------------------------------------------------------------------------- 1 | import { AWSClient } from "helpers/general"; 2 | import { CognitoIdentityServiceProvider } from "aws-sdk"; 3 | import { testResult, TestResultOutput } from "utils/testResult"; 4 | 5 | export default { 6 | async toContainUser( 7 | userPoolId: string, 8 | username: string 9 | ): Promise { 10 | const cognitoClient: CognitoIdentityServiceProvider = new AWSClient.CognitoIdentityServiceProvider(); 11 | try { 12 | await cognitoClient 13 | .adminGetUser({ 14 | UserPoolId: userPoolId, 15 | Username: username, 16 | }) 17 | .promise(); 18 | 19 | return testResult( 20 | `User with username ${username} exists in User Pool with Id ${userPoolId}`, 21 | true 22 | ); 23 | } catch (e) { 24 | console.log(e); 25 | 26 | return testResult( 27 | `User does not exist in User Pool with Id ${userPoolId}`, 28 | false 29 | ); 30 | } 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /src/assertions/toExistAsS3Bucket/index.ts: -------------------------------------------------------------------------------- 1 | import { AWSClient } from "helpers/general"; 2 | import { testResult, TestResultOutput } from "utils/testResult"; 3 | import { is404Error } from "../utils"; 4 | 5 | export default { 6 | async toExistAsS3Bucket(bucketName: string): Promise { 7 | const s3 = new AWSClient.S3(); 8 | const params = { 9 | Bucket: bucketName, 10 | }; 11 | 12 | let message; 13 | try { 14 | await s3.headBucket(params).promise(); 15 | message = `expected S3 bucket to exist with name ${bucketName}`; 16 | 17 | return testResult(message, true); 18 | } catch (error) { 19 | if (is404Error(error)) { 20 | message = `expected S3 bucket to exist with name ${bucketName} - not found`; 21 | 22 | return testResult(message, false); 23 | } 24 | throw error; 25 | } 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /src/assertions/toExistInDynamoTable/index.ts: -------------------------------------------------------------------------------- 1 | import { TestResultOutput } from "utils/testResult"; 2 | import { AWSClient } from "helpers/general"; 3 | 4 | export default { 5 | async toExistInDynamoTable( 6 | { PK, SK }: { PK: string; SK?: string }, 7 | tableName: string 8 | ): Promise { 9 | const docClient = new AWSClient.DynamoDB.DocumentClient(); 10 | if (SK === undefined) { 11 | const queryParams = { 12 | TableName: tableName, 13 | KeyConditionExpression: "#pk = :pk", 14 | ExpressionAttributeNames: { 15 | "#pk": "PK", 16 | }, 17 | ExpressionAttributeValues: { 18 | ":pk": "PK", 19 | }, 20 | Limit: 1, 21 | }; 22 | const result = await docClient.query(queryParams).promise(); 23 | 24 | return { 25 | message: () => `expected to find ${PK} in ${tableName}`, 26 | pass: result.Count === 1, 27 | }; 28 | } 29 | const getParams = { 30 | TableName: tableName, 31 | Key: { 32 | PK, 33 | SK, 34 | }, 35 | }; 36 | const result = await docClient.get(getParams).promise(); 37 | 38 | return { 39 | message: () => `expected to find ${PK} in ${tableName}`, 40 | pass: result.Item !== undefined, 41 | }; 42 | }, 43 | }; 44 | -------------------------------------------------------------------------------- /src/assertions/toHaveCompletedExecutionWithStatus/index.ts: -------------------------------------------------------------------------------- 1 | import { testResult, TestResultOutput } from "../../utils/testResult"; 2 | import StepFunctions from "../../helpers/stepFunctions"; 3 | 4 | import { AWSClient } from "../../helpers/general"; 5 | 6 | export default { 7 | async toHaveCompletedExecutionWithStatus( 8 | stateMachineName: string, 9 | expectedStatus: string 10 | ): Promise { 11 | const stepFunctions = new AWSClient.StepFunctions(); 12 | const stepFunctionsObject = await StepFunctions.build(); 13 | // Helper to get stateMachine ARN from stateMachine name 14 | const smArn = await stepFunctionsObject.obtainStateMachineArn( 15 | stateMachineName 16 | ); 17 | 18 | const listExecutionsParams = { stateMachineArn: smArn }; 19 | // Get all executions of specified state machine 20 | const smExecutions = await stepFunctions 21 | .listExecutions(listExecutionsParams) 22 | .promise(); 23 | // Get the latest execution (list ordered in reverse chronological) 24 | const latestExecution = smExecutions.executions[0]; 25 | if (latestExecution.status === expectedStatus) { 26 | return testResult( 27 | `Execution status is ${expectedStatus}, as expected.`, 28 | true 29 | ); 30 | } 31 | 32 | return testResult( 33 | `Execution status was ${latestExecution.status}, where it was expected to be ${expectedStatus}`, 34 | false 35 | ); 36 | }, 37 | }; 38 | -------------------------------------------------------------------------------- /src/assertions/toHaveContentEqualTo/index.ts: -------------------------------------------------------------------------------- 1 | import { AWSClient } from "helpers/general"; 2 | import { testResult, TestResultOutput } from "utils/testResult"; 3 | import { isNoSuchBucketError, isNoSuchKeyError } from "../utils"; 4 | 5 | export default { 6 | // Import & use s3 type ? 7 | async toHaveContentEqualTo( 8 | { bucketName, objectName }: { bucketName: string; objectName: string }, 9 | content: Record | string 10 | ): Promise { 11 | const s3 = new AWSClient.S3(); 12 | const params = { 13 | Bucket: bucketName, 14 | Key: objectName, 15 | }; 16 | 17 | let message; 18 | try { 19 | const object = await s3.getObject(params).promise(); 20 | if (JSON.stringify(object.Body) === JSON.stringify(content)) { 21 | message = `expected ${objectName} to have content ${JSON.stringify( 22 | content 23 | )}`; 24 | 25 | return testResult(message, true); 26 | } 27 | const stringifiedObjectBody = object.Body?.toString(); 28 | if (stringifiedObjectBody === undefined) { 29 | message = `expected ${objectName} to have content ${JSON.stringify( 30 | content 31 | )}, but content found was undefined`; 32 | 33 | return testResult(message, false); 34 | } 35 | 36 | message = `expected ${objectName} to have content ${JSON.stringify( 37 | content 38 | )}, but content found was ${stringifiedObjectBody}`; 39 | 40 | return testResult(message, false); 41 | } catch (error) { 42 | if (isNoSuchKeyError(error)) { 43 | message = `expected ${bucketName} to have object with name ${objectName} - not found`; 44 | 45 | return testResult(message, false); 46 | } 47 | if (isNoSuchBucketError(error)) { 48 | message = `expected ${bucketName} to exist - not found`; 49 | 50 | return testResult(message, false); 51 | } 52 | throw error; 53 | } 54 | }, 55 | }; 56 | -------------------------------------------------------------------------------- /src/assertions/toHaveContentTypeEqualTo/index.ts: -------------------------------------------------------------------------------- 1 | import { AWSClient } from "helpers/general"; 2 | import { testResult, TestResultOutput } from "utils/testResult"; 3 | import { isNoSuchBucketError, isNoSuchKeyError } from "../utils"; 4 | 5 | export default { 6 | async toHaveContentTypeEqualTo( 7 | { bucketName, objectName }: { bucketName: string; objectName: string }, 8 | contentType: string 9 | ): Promise { 10 | const s3 = new AWSClient.S3(); 11 | const params = { 12 | Bucket: bucketName, 13 | Key: objectName, 14 | }; 15 | 16 | let message; 17 | try { 18 | const object = await s3.getObject(params).promise(); 19 | if (object.ContentType === contentType) { 20 | message = `expected ${objectName} to have content type ${contentType}`; 21 | 22 | return testResult(message, true); 23 | } 24 | message = `expected ${objectName} to have content type ${contentType}, but content type found was ${ 25 | object.ContentType ?? "undefined" 26 | }`; 27 | 28 | return testResult(message, false); 29 | } catch (error) { 30 | if (isNoSuchKeyError(error)) { 31 | message = `expected ${bucketName} to have object with name ${objectName} - not found`; 32 | 33 | return testResult(message, false); 34 | } 35 | if (isNoSuchBucketError(error)) { 36 | message = `expected ${bucketName} to exist - not found`; 37 | 38 | return testResult(message, false); 39 | } 40 | throw error; 41 | } 42 | }, 43 | }; 44 | -------------------------------------------------------------------------------- /src/assertions/toHaveEvent/index.ts: -------------------------------------------------------------------------------- 1 | import { SQS } from "aws-sdk"; 2 | import { testResult, TestResultOutput } from "utils/testResult"; 3 | 4 | export default { 5 | toHaveEvent(eventBridgeEvents?: SQS.ReceiveMessageResult): TestResultOutput { 6 | if ( 7 | eventBridgeEvents === undefined || 8 | eventBridgeEvents.Messages === undefined || 9 | eventBridgeEvents.Messages.length === 0 10 | ) { 11 | return testResult("no message intercepted from EventBridge Bus", false); 12 | } 13 | 14 | return testResult("expected to have message in EventBridge Bus", true); 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /src/assertions/toHaveEventWithSource/index.ts: -------------------------------------------------------------------------------- 1 | import { testResult, TestResultOutput } from "utils/testResult"; 2 | import { SQS } from "aws-sdk"; 3 | 4 | export default { 5 | toHaveEventWithSource( 6 | Events: SQS.ReceiveMessageResult, 7 | expectedSourceName: string 8 | ): TestResultOutput { 9 | let message; 10 | 11 | if (Events.Messages === undefined || Events.Messages.length < 1) { 12 | return testResult("There are no events present.", false); 13 | } 14 | 15 | const parsedBody = JSON.parse(Events.Messages[0].Body) as { 16 | source?: string; 17 | }; 18 | 19 | if (parsedBody.source === expectedSourceName) { 20 | message = `expected sent event to have source ${expectedSourceName}`; 21 | 22 | return testResult(message, true); 23 | } 24 | message = `sent event source "${ 25 | parsedBody.source ?? "undefined" 26 | }" does not match expected source "${expectedSourceName}"`; 27 | 28 | return testResult(message, false); 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /src/assertions/toHaveObjectWithNameEqualTo/index.ts: -------------------------------------------------------------------------------- 1 | import { AWSClient } from "helpers/general"; 2 | import { testResult, TestResultOutput } from "utils/testResult"; 3 | import { isNoSuchKeyError } from "../utils"; 4 | 5 | export default { 6 | async toHaveS3ObjectWithNameEqualTo( 7 | bucketName: string, 8 | objectName: string 9 | ): Promise { 10 | const s3 = new AWSClient.S3(); 11 | const params = { 12 | Bucket: bucketName, 13 | Key: objectName, 14 | }; 15 | 16 | let message; 17 | try { 18 | await s3.getObject(params).promise(); 19 | message = `expected ${bucketName} to have object with name ${objectName}`; 20 | 21 | return testResult(message, true); 22 | } catch (error) { 23 | if (isNoSuchKeyError(error)) { 24 | message = `expected ${bucketName} to have object with name ${objectName} - not found`; 25 | 26 | return testResult(message, false); 27 | } 28 | throw error; 29 | } 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /src/assertions/toMatchStateMachineOutput/index.ts: -------------------------------------------------------------------------------- 1 | import { testResult, TestResultOutput } from "../../utils/testResult"; 2 | import { StepFunctions as AWSStepFunctions } from "aws-sdk"; 3 | import StepFunctions from "../../helpers/stepFunctions"; 4 | 5 | export default { 6 | async toMatchStateMachineOutput( 7 | stateMachineName: string, 8 | expectedOutput: unknown 9 | ): Promise { 10 | const stepFunctions = new AWSStepFunctions(); 11 | const stepFunctionObject = await StepFunctions.build(); 12 | // Helper to get stateMachine ARN from stateMachine name 13 | const smArn = await stepFunctionObject.obtainStateMachineArn( 14 | stateMachineName 15 | ); 16 | // Helper to get latest execution ARN for given stateMachine 17 | const exArn = await stepFunctionObject.obtainExecutionArn(smArn); 18 | 19 | const executionResult = await stepFunctions 20 | .describeExecution({ 21 | executionArn: exArn, 22 | }) 23 | .promise(); 24 | 25 | if (executionResult.status === "SUCCEEDED") { 26 | if (executionResult.output === expectedOutput) { 27 | return testResult( 28 | `Output is ${JSON.stringify(executionResult.output)} as expected`, 29 | true 30 | ); 31 | } else { 32 | return testResult( 33 | `Expected output was ${JSON.stringify( 34 | expectedOutput 35 | )}, but output received was ${JSON.stringify( 36 | executionResult.output 37 | )}`, 38 | false 39 | ); 40 | } 41 | } 42 | 43 | return testResult( 44 | "Step Function execution failed. Cannot verify output for failed executions.", 45 | false 46 | ); 47 | }, 48 | }; 49 | -------------------------------------------------------------------------------- /src/assertions/utils/errorTypesCheckers.ts: -------------------------------------------------------------------------------- 1 | type Error404 = { statusCode: 404 }; 2 | export const is404Error = (error: unknown): error is Error404 => 3 | (error as { statusCode?: number }).statusCode === 404; 4 | 5 | type ErrorNoSuchKey = { code: "NoSuchKey" }; 6 | export const isNoSuchKeyError = (error: unknown): error is ErrorNoSuchKey => 7 | (error as { code?: string }).code === "NoSuchKey"; 8 | 9 | type ErrorNoSuchBucket = { code: "NoSuchBucket" }; 10 | export const isNoSuchBucketError = ( 11 | error: unknown 12 | ): error is ErrorNoSuchBucket => 13 | (error as { code?: string }).code === "NoSuchBucket"; 14 | -------------------------------------------------------------------------------- /src/assertions/utils/globalTypeChecker.ts: -------------------------------------------------------------------------------- 1 | type GlobalWithExpectKey = { expect: any }; 2 | export const isGlobalWithExpectKey = ( 3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 4 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 5 | global: any 6 | ): global is GlobalWithExpectKey => "expect" in global; 7 | -------------------------------------------------------------------------------- /src/assertions/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./errorTypesCheckers"; 2 | -------------------------------------------------------------------------------- /src/helpers/cognito.ts: -------------------------------------------------------------------------------- 1 | import { CognitoIdentityServiceProvider } from "aws-sdk"; 2 | import { AWSClient } from "./general"; 3 | import { Chance } from "chance"; 4 | import { AttributeType } from "aws-sdk/clients/cognitoidentityserviceprovider"; 5 | import jsf from "json-schema-faker"; 6 | 7 | interface User { 8 | username: string; 9 | password: string; 10 | confirmed?: boolean | undefined; 11 | standardAttributes?: StandardAttributes; 12 | customAttributes?: { [attribute: string]: string }; 13 | } 14 | 15 | interface CreateUserInput { 16 | clientId: string; 17 | userPoolId: string; 18 | confirmed: boolean; 19 | standardAttributes?: Array; 20 | customAttributes?: { [key: string]: unknown }; 21 | } 22 | 23 | interface ConfirmUserInput { 24 | userPoolId: string; 25 | username: string; 26 | password: string; 27 | } 28 | 29 | interface Address { 30 | formatted: string; 31 | street_address: string; 32 | locality: string; 33 | region: string; 34 | postal_code: string; 35 | country: string; 36 | } 37 | 38 | interface StandardAttributes { 39 | address: string; 40 | birthdate: string; 41 | email: string; 42 | family_name: string; 43 | gender: string; 44 | given_name: string; 45 | locale: string; 46 | middle_name: string; 47 | name: string; 48 | nickname: string; 49 | phone_number: string; 50 | picture: string; 51 | preferred_username: string; 52 | profile: string; 53 | updated_at: string; 54 | website: string; 55 | zoneinfo: string; 56 | } 57 | 58 | const createUser = async ( 59 | createUserInput: CreateUserInput, 60 | username: string 61 | ): Promise => { 62 | const cognitoClient: CognitoIdentityServiceProvider = 63 | new AWSClient.CognitoIdentityServiceProvider(); 64 | const chance = new Chance(); 65 | const password: string = chance.string({ length: 8 }); 66 | 67 | const givenName = chance.first(); 68 | const middleName = chance.first(); 69 | const familyName = chance.last(); 70 | const name = givenName + " " + middleName + " " + familyName; 71 | 72 | const country = chance.country(); 73 | const streetAddress = chance.street(); 74 | const locality = chance.city(); 75 | const region = chance.province(); 76 | const postalCode = chance.postcode(); 77 | 78 | const formatted = [streetAddress, locality, region, postalCode, country].join( 79 | "\r\n" 80 | ); 81 | 82 | const address: Address = { 83 | formatted: formatted, 84 | street_address: streetAddress, 85 | locality: locality, 86 | region: region, 87 | postal_code: postalCode, 88 | country: country, 89 | }; 90 | 91 | const allStandardAttributes: StandardAttributes = { 92 | email: chance.email(), 93 | birthdate: chance.date().toISOString().split("T")[0], 94 | family_name: familyName, 95 | gender: chance.gender(), 96 | given_name: givenName, 97 | locale: chance.locale(), 98 | middle_name: middleName, 99 | name: name, 100 | nickname: chance.string(), 101 | phone_number: chance.phone(), 102 | picture: chance.url(), 103 | preferred_username: chance.string(), 104 | profile: chance.url(), 105 | website: chance.url(), 106 | zoneinfo: chance.string(), 107 | address: JSON.stringify(address), 108 | updated_at: String(chance.timestamp()), 109 | }; 110 | 111 | const attributesArg: AttributeType[] = []; 112 | jsf.extend("chance", () => new Chance()); 113 | if (createUserInput.customAttributes !== undefined) { 114 | Object.entries(createUserInput.customAttributes).forEach(([key, val]) => { 115 | attributesArg.push({ 116 | Name: "custom:" + key, 117 | Value: jsf.generate({ type: val }), 118 | }); 119 | }); 120 | } 121 | 122 | createUserInput.standardAttributes?.forEach( 123 | (attribute: keyof StandardAttributes) => { 124 | attributesArg.push({ 125 | Name: attribute, 126 | Value: allStandardAttributes[attribute], 127 | }); 128 | } 129 | ); 130 | 131 | try { 132 | const signUpParams: CognitoIdentityServiceProvider.Types.SignUpRequest = { 133 | ClientId: createUserInput.clientId, 134 | Username: username, 135 | Password: password, 136 | UserAttributes: attributesArg, 137 | }; 138 | await cognitoClient.signUp(signUpParams).promise(); 139 | } catch (e) { 140 | console.log(e); 141 | console.error( 142 | "Failed to create user. Please make sure the clientId is correct, and that the username is valid." 143 | ); 144 | } 145 | 146 | return { 147 | username, 148 | password, 149 | }; 150 | }; 151 | 152 | const confirmUser = async (input: ConfirmUserInput): Promise => { 153 | const cognitoClient: CognitoIdentityServiceProvider = 154 | new AWSClient.CognitoIdentityServiceProvider(); 155 | 156 | try { 157 | await cognitoClient 158 | .adminConfirmSignUp({ 159 | UserPoolId: input.userPoolId, 160 | Username: input.username, 161 | }) 162 | .promise(); 163 | } catch (e) { 164 | console.error( 165 | "Failed to confirm sign up. Please make sure the user exists." 166 | ); 167 | throw e; 168 | } 169 | 170 | return { 171 | username: input.username, 172 | password: input.password, 173 | confirmed: true, 174 | }; 175 | }; 176 | 177 | export const createUnauthenticatedUser = async ( 178 | input: CreateUserInput 179 | ): Promise => { 180 | const chance = new Chance(); 181 | const username: string = chance.email(); 182 | const user: User = await createUser(input, username); 183 | 184 | if (input.confirmed) { 185 | return await confirmUser({ 186 | userPoolId: input.userPoolId, 187 | username: username, 188 | password: user.password, 189 | }); 190 | } 191 | 192 | return { 193 | username: username, 194 | password: user.password, 195 | confirmed: input.confirmed, 196 | }; 197 | }; 198 | 199 | export const createAuthenticatedUser = async ( 200 | input: CreateUserInput 201 | ): Promise => { 202 | const cognitoClient: CognitoIdentityServiceProvider = 203 | new AWSClient.CognitoIdentityServiceProvider(); 204 | const chance = new Chance(); 205 | const username: string = chance.email(); 206 | 207 | const user: User = await createUser(input, username); 208 | 209 | await confirmUser({ 210 | userPoolId: input.userPoolId, 211 | username: username, 212 | password: user.password, 213 | }); 214 | 215 | try { 216 | const auth: CognitoIdentityServiceProvider.InitiateAuthResponse = 217 | await cognitoClient 218 | .initiateAuth({ 219 | AuthFlow: "USER_PASSWORD_AUTH", 220 | ClientId: input.clientId, 221 | AuthParameters: { 222 | USERNAME: user.username, 223 | PASSWORD: user.password, 224 | }, 225 | }) 226 | .promise(); 227 | 228 | return { 229 | username, 230 | password: user.password, 231 | idToken: auth.AuthenticationResult?.IdToken, 232 | accessToken: auth.AuthenticationResult?.AccessToken, 233 | }; 234 | } catch (e) { 235 | console.error( 236 | "Failed to authorize user - please make sure you're using the correct AuthFlow and that the user exists, and is confirmed." 237 | ); 238 | 239 | throw e; 240 | } 241 | }; 242 | -------------------------------------------------------------------------------- /src/helpers/eventBridge.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-lines */ 2 | import { AWSError, EventBridge as AWSEventBridge, SQS } from "aws-sdk"; 3 | import { PromiseResult } from "aws-sdk/lib/request"; 4 | import { AWSClient, region } from "./general"; 5 | import { removeUndefinedMessages } from "./utils/removeUndefinedMessages"; 6 | 7 | export default class EventBridge { 8 | QueueUrl: string | undefined; 9 | eventBridgeClient: AWSEventBridge | undefined; 10 | eventBridgeName: string | undefined; 11 | keep: boolean | undefined; 12 | ruleName: string | undefined; 13 | sqsClient: SQS | undefined; 14 | targetId: string | undefined; 15 | 16 | async init(eventBridgeName: string): Promise { 17 | this.eventBridgeClient = new AWSClient.EventBridge(); 18 | this.eventBridgeName = eventBridgeName; 19 | this.ruleName = `test-${eventBridgeName}-rule`; 20 | this.targetId = "1"; 21 | 22 | const keepArg = process.argv.filter((x) => x.startsWith("--keep="))[0]; 23 | const keepArgEnabled = keepArg ? keepArg.split("=")[1] === "true" : false; 24 | const keepEnvVarEnabled = !!process.env.SLS_TEST_TOOLS_KEEP; 25 | this.keep = keepArgEnabled || keepEnvVarEnabled; 26 | const ruleNameArg = process.argv.filter((x) => x.startsWith("--event-rule-name="))[0]; 27 | this.ruleName = ruleNameArg ? ruleNameArg.split("=")[1] : `test-${eventBridgeName}-rule`; 28 | const queueNameArg = process.argv.filter((x) => x.startsWith("--queue-name="))[0]; 29 | const queueName = queueNameArg ? queueNameArg.split("=")[1] : `${eventBridgeName}-testing-queue`; 30 | 31 | this.sqsClient = new AWSClient.SQS(); 32 | if (!this.keep) { 33 | console.info( 34 | "If running repeatedly add '--keep=true' to keep testing resources up to avoid creation throttles" 35 | ); 36 | } 37 | 38 | const queueResult = await this.sqsClient 39 | .createQueue({ 40 | QueueName: queueName, 41 | }) 42 | .promise(); 43 | 44 | this.QueueUrl = queueResult.QueueUrl; 45 | 46 | if (this.QueueUrl === undefined) { 47 | throw new Error("QueueUrl is undefined"); 48 | } 49 | const accountId = this.QueueUrl.split("/")[3]; 50 | const sqsArn = `arn:aws:sqs:${region}:${accountId}:${queueName}`; 51 | const pattern = { 52 | account: [`${accountId}`], 53 | }; 54 | 55 | await this.eventBridgeClient 56 | .putRule({ 57 | Name: this.ruleName, 58 | EventBusName: eventBridgeName, 59 | EventPattern: JSON.stringify(pattern), 60 | State: "ENABLED", 61 | }) 62 | .promise(); 63 | 64 | await this.eventBridgeClient 65 | .putTargets({ 66 | EventBusName: eventBridgeName, 67 | Rule: this.ruleName, 68 | Targets: [ 69 | { 70 | Arn: sqsArn, 71 | Id: this.targetId, 72 | }, 73 | ], 74 | }) 75 | .promise(); 76 | 77 | const policy = { 78 | Version: "2008-10-17", 79 | Statement: [ 80 | { 81 | Effect: "Allow", 82 | Principal: { 83 | Service: "events.amazonaws.com", 84 | }, 85 | Action: "SQS:SendMessage", 86 | Resource: sqsArn, 87 | }, 88 | ], 89 | }; 90 | 91 | await this.sqsClient 92 | .setQueueAttributes({ 93 | Attributes: { 94 | Policy: JSON.stringify(policy), 95 | }, 96 | QueueUrl: this.QueueUrl, 97 | }) 98 | .promise(); 99 | } 100 | 101 | static async build(eventBridgeName: string): Promise { 102 | const eventBridge = new EventBridge(); 103 | await eventBridge.init(eventBridgeName); 104 | 105 | return eventBridge; 106 | } 107 | 108 | // eslint-disable-next-line max-params 109 | async publishEvent( 110 | source: string | undefined, 111 | detailType: string | undefined, 112 | detail: string | undefined, 113 | clear?: boolean 114 | ): Promise> { 115 | if (this.eventBridgeClient === undefined) { 116 | throw new Error( 117 | "EventBridgeClient is undefined. You might have forgotten to use init()" 118 | ); 119 | } 120 | const result = await this.eventBridgeClient 121 | .putEvents({ 122 | Entries: [ 123 | { 124 | EventBusName: this.eventBridgeName, 125 | Source: source, 126 | DetailType: detailType, 127 | Detail: detail, 128 | }, 129 | ], 130 | }) 131 | .promise(); 132 | 133 | if (clear === undefined) { 134 | clear = true; 135 | } 136 | if (!clear) { 137 | return result; 138 | } 139 | await this.getEvents(); // need to clear this manual published event from the SQS observer queue. 140 | 141 | return result; 142 | } 143 | 144 | async getEvents( 145 | clear?: boolean | undefined 146 | ): Promise { 147 | if (this.QueueUrl === undefined) { 148 | throw new Error("QueueUrl is undefined"); 149 | } 150 | // Long poll SQS queue 151 | const queueParams = { 152 | QueueUrl: this.QueueUrl, 153 | WaitTimeSeconds: 5, 154 | }; 155 | if (this.sqsClient === undefined) { 156 | throw new Error( 157 | "SQSClient is undefined. You might have forgotten to use init()" 158 | ); 159 | } 160 | const result = await this.sqsClient.receiveMessage(queueParams).promise(); 161 | 162 | if (clear === undefined) { 163 | clear = true; 164 | } 165 | 166 | if (!clear) { 167 | return result; 168 | } 169 | 170 | const messageHandlers = removeUndefinedMessages( 171 | result.Messages?.map((message: SQS.Message) => ({ 172 | Id: message.MessageId, 173 | ReceiptHandle: message.ReceiptHandle, 174 | })) 175 | ); 176 | 177 | if (messageHandlers !== undefined && messageHandlers.length > 0) { 178 | await this.sqsClient 179 | .deleteMessageBatch({ 180 | Entries: messageHandlers, 181 | QueueUrl: this.QueueUrl, 182 | }) 183 | .promise(); 184 | } 185 | 186 | return result; 187 | } 188 | 189 | async clear(): Promise { 190 | if (this.sqsClient === undefined) { 191 | throw new Error( 192 | "SQSClient is undefined. You might have forgotten to use init()" 193 | ); 194 | } 195 | if (this.QueueUrl === undefined) { 196 | throw new Error("QueueUrl is undefined"); 197 | } 198 | const result = await this.sqsClient 199 | .purgeQueue({ 200 | QueueUrl: this.QueueUrl, 201 | }) 202 | .promise(); 203 | 204 | return result; 205 | } 206 | 207 | async destroy(): Promise { 208 | if (this.keep === undefined) { 209 | throw new Error( 210 | "keep is undefined. You might have forgotten to use init()" 211 | ); 212 | } 213 | if (!this.keep) { 214 | if (this.sqsClient === undefined) { 215 | throw new Error( 216 | "SQSClient is undefined. You might have forgotten to use init()" 217 | ); 218 | } 219 | if (this.QueueUrl === undefined) { 220 | throw new Error("QueueUrl is undefined"); 221 | } 222 | 223 | await this.sqsClient 224 | .deleteQueue({ 225 | QueueUrl: this.QueueUrl, 226 | }) 227 | .promise(); 228 | 229 | if (this.eventBridgeClient === undefined) { 230 | throw new Error( 231 | "EventBridgeClient is undefined. You might have forgotten to use init()" 232 | ); 233 | } 234 | 235 | if (this.targetId === undefined) { 236 | throw new Error( 237 | "targetId is undefined. You might have forgotten to use init()" 238 | ); 239 | } 240 | if (this.ruleName === undefined) { 241 | throw new Error( 242 | "ruleName is undefined. You might have forgotten to use init()" 243 | ); 244 | } 245 | await this.eventBridgeClient 246 | .removeTargets({ 247 | Ids: [this.targetId], 248 | Rule: this.ruleName, 249 | EventBusName: this.eventBridgeName, 250 | }) 251 | .promise(); 252 | 253 | await this.eventBridgeClient 254 | .deleteRule({ 255 | Name: this.ruleName, 256 | EventBusName: this.eventBridgeName, 257 | }) 258 | .promise(); 259 | } else { 260 | await this.clear(); 261 | } 262 | 263 | return true; 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /src/helpers/general.ts: -------------------------------------------------------------------------------- 1 | import AWS, { AWSError } from "aws-sdk"; 2 | import { DescribeStacksOutput } from "aws-sdk/clients/cloudformation"; 3 | import { PromiseResult } from "aws-sdk/lib/request"; 4 | import { loadArg } from "./utils/loadArg"; 5 | 6 | export const stackName = loadArg({ 7 | cliArg: "stack", 8 | processEnvName: "CFN_STACK_NAME", 9 | }); 10 | 11 | const profile = loadArg({ 12 | cliArg: "profile", 13 | processEnvName: "AWS_PROFILE", 14 | defaultValue: "default", 15 | }); 16 | 17 | export const region = loadArg({ 18 | cliArg: "region", 19 | processEnvName: "AWS_REGION", 20 | defaultValue: "eu-west-2", 21 | }); 22 | 23 | let creds; 24 | 25 | if ( 26 | process.env.AWS_ACCESS_KEY_ID !== undefined && 27 | process.env.AWS_SECRET_ACCESS_KEY !== undefined 28 | ) { 29 | creds = new AWS.Credentials({ 30 | accessKeyId: process.env.AWS_ACCESS_KEY_ID, 31 | secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, 32 | sessionToken: process.env.AWS_SESSION_TOKEN, 33 | }); 34 | } else { 35 | creds = new AWS.SharedIniFileCredentials({ 36 | profile, 37 | callback: (err) => { 38 | if (err) { 39 | console.error(`SharedIniFileCreds Error: ${err.name} - ${err.message}`); 40 | } 41 | }, 42 | }); 43 | } 44 | 45 | AWS.config.credentials = creds; 46 | AWS.config.region = region; 47 | 48 | export const AWSClient = AWS; 49 | 50 | const cloudformation = new AWSClient.CloudFormation(); 51 | 52 | export const getStackResources = ( 53 | stack: string | undefined 54 | ): Promise> => 55 | cloudformation 56 | .describeStacks({ StackName: stack }) 57 | .promise() 58 | .catch((error) => { 59 | console.error(error); 60 | }); 61 | 62 | const apigateway = new AWSClient.APIGateway(); 63 | let apiKey: string | null = null; 64 | 65 | interface GetOptionsOutput { 66 | method: string; 67 | headers: { "x-api-key": string | null; "Content-Type": string }; 68 | } 69 | 70 | export const getOptions = async (): Promise => { 71 | if (apiKey === null) { 72 | const resources = await cloudformation 73 | .listStackResources({ StackName: stackName }) 74 | .promise(); 75 | 76 | const stackResourceSummaries = resources.StackResourceSummaries; 77 | if (stackResourceSummaries === undefined) { 78 | return; 79 | } 80 | 81 | const stackResourceSummary = stackResourceSummaries.find( 82 | (r) => r.ResourceType === "AWS::ApiGateway::ApiKey" 83 | ); 84 | 85 | if (stackResourceSummary === undefined) { 86 | return; 87 | } 88 | 89 | const id = stackResourceSummary.PhysicalResourceId; 90 | 91 | if (id === undefined) { 92 | return; 93 | } 94 | const params = { 95 | apiKey: id, 96 | includeValue: true, 97 | }; 98 | 99 | const data = await apigateway.getApiKey(params).promise(); 100 | apiKey = data.value !== undefined ? data.value : null; 101 | } 102 | 103 | return { 104 | method: "POST", 105 | headers: { 106 | "x-api-key": apiKey, 107 | "Content-Type": "application/json", 108 | }, 109 | }; 110 | }; 111 | -------------------------------------------------------------------------------- /src/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export { default as EventBridge } from "./eventBridge"; 2 | export { default as StepFunctions } from "./stepFunctions"; 3 | export * from "./general"; 4 | export * from "./cognito"; 5 | -------------------------------------------------------------------------------- /src/helpers/stepFunctions.ts: -------------------------------------------------------------------------------- 1 | import { StepFunctions as AWSStepFunctions } from "aws-sdk"; 2 | import { AWSClient } from "./general"; 3 | 4 | const API_POLLING_DELAY_MS = 1000; 5 | 6 | export default class StepFunctions { 7 | stepFunctions: AWSStepFunctions | undefined; 8 | allStateMachines: AWSStepFunctions.ListStateMachinesOutput | undefined; 9 | 10 | async init(): Promise { 11 | this.stepFunctions = new AWSClient.StepFunctions(); 12 | this.allStateMachines = await this.stepFunctions 13 | .listStateMachines() 14 | .promise(); 15 | } 16 | 17 | static async build(): Promise { 18 | const stepFunction = new StepFunctions(); 19 | await stepFunction.init(); 20 | 21 | return stepFunction; 22 | } 23 | 24 | async runExecution( 25 | stateMachineName: string, 26 | input: unknown 27 | ): Promise { 28 | if (this.allStateMachines === undefined) { 29 | throw new Error( 30 | "The list of state machines is undefined. You might have forgotten to run build()." 31 | ); 32 | } 33 | const smList = this.allStateMachines.stateMachines.filter( 34 | (stateMachine: AWSStepFunctions.StateMachineListItem) => 35 | stateMachine.name === stateMachineName 36 | ); 37 | const stateMachineArn = smList[0].stateMachineArn; 38 | const executionParams = { 39 | stateMachineArn: stateMachineArn, 40 | input: JSON.stringify(input), 41 | }; 42 | if (this.stepFunctions === undefined) { 43 | throw new Error( 44 | "The Step Functions client is undefined. You might have forgotten to run build()." 45 | ); 46 | } 47 | const execution: AWSStepFunctions.StartExecutionOutput = 48 | await this.stepFunctions.startExecution(executionParams).promise(); 49 | const listExecParams = { stateMachineArn: stateMachineArn }; 50 | let executionList = await this.stepFunctions 51 | .listExecutions(listExecParams) 52 | .promise(); 53 | // Poll until the given execution is no longer running 54 | while ( 55 | executionList.executions.filter( 56 | (exec: AWSStepFunctions.ExecutionListItem) => 57 | exec.executionArn === execution.executionArn && 58 | exec.status === "RUNNING" 59 | ).length !== 0 60 | ) { 61 | executionList = await this.stepFunctions 62 | .listExecutions(listExecParams) 63 | .promise(); 64 | 65 | // Wait before retrying to avoid throttle limits 66 | await new Promise((resolve) => setTimeout(resolve, API_POLLING_DELAY_MS)); 67 | } 68 | 69 | return await this.stepFunctions 70 | .describeExecution({ executionArn: execution.executionArn }) 71 | .promise(); 72 | } 73 | 74 | async obtainStateMachineArn(stateMachineName: string): Promise { 75 | const listStateMachineParams = {}; 76 | // Get all state machines 77 | if (this.stepFunctions === undefined) { 78 | throw new Error( 79 | "The Step Functions client is undefined. You might have forgotten to run build()." 80 | ); 81 | } 82 | const allStateMachines = await this.stepFunctions 83 | .listStateMachines(listStateMachineParams) 84 | .promise(); 85 | // Find state machine with specified name and get its arn 86 | const smList = allStateMachines.stateMachines.find( 87 | (stateMachine: AWSStepFunctions.StateMachineListItem) => 88 | stateMachine.name === stateMachineName 89 | ); 90 | if (smList == null) throw new Error("No matching state machine. "); 91 | 92 | return smList.stateMachineArn; 93 | } 94 | 95 | async obtainExecutionArn(StateMachineArn: string): Promise { 96 | const listExecParams = { stateMachineArn: StateMachineArn }; 97 | if (this.stepFunctions == null) { 98 | throw new Error( 99 | "The Step Functions client is undefined. You might have forgotten to run build()." 100 | ); 101 | } 102 | 103 | // Get all executions for this stateMachine 104 | const executionList = await this.stepFunctions 105 | .listExecutions(listExecParams) 106 | .promise(); 107 | 108 | return executionList.executions[0].executionArn; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/helpers/utils/loadArg.ts: -------------------------------------------------------------------------------- 1 | import yargs from "yargs"; 2 | 3 | const argv = yargs(process.argv).argv as Record; 4 | 5 | const isNonEmptyString = (arg: unknown): arg is string => 6 | typeof arg === "string" && arg !== ""; 7 | 8 | export const loadArg = ({ 9 | cliArg, 10 | processEnvName, 11 | defaultValue, 12 | }: { 13 | cliArg: string; 14 | processEnvName: string; 15 | defaultValue?: string; 16 | }): string => { 17 | let arg = argv[cliArg]; 18 | 19 | if (isNonEmptyString(arg)) { 20 | return arg; 21 | } 22 | 23 | arg = process.env[processEnvName]; 24 | 25 | if (isNonEmptyString(arg)) { 26 | return arg; 27 | } 28 | 29 | if (defaultValue === undefined) { 30 | throw new Error( 31 | `--${cliArg} CLI argument or ${processEnvName} env var required.` 32 | ); 33 | } 34 | 35 | return defaultValue; 36 | }; 37 | -------------------------------------------------------------------------------- /src/helpers/utils/removeUndefinedMessages.ts: -------------------------------------------------------------------------------- 1 | interface MessageHandler { 2 | Id: string | undefined; 3 | ReceiptHandle: string | undefined; 4 | } 5 | interface DefinedMessageHandler { 6 | Id: string; 7 | ReceiptHandle: string; 8 | } 9 | 10 | export const removeUndefinedMessages = ( 11 | messageHandlers: MessageHandler[] | undefined 12 | ): DefinedMessageHandler[] | undefined => 13 | messageHandlers?.filter( 14 | (messageHandler): messageHandler is DefinedMessageHandler => 15 | messageHandler.Id !== undefined && 16 | messageHandler.ReceiptHandle !== undefined 17 | ); 18 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | /* eslint-disable @typescript-eslint/no-namespace */ 3 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 4 | /* eslint-disable @typescript-eslint/no-unsafe-call */ 5 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 6 | import matchers from "./assertions"; 7 | import { isGlobalWithExpectKey } from "./assertions/utils/globalTypeChecker"; 8 | 9 | if (isGlobalWithExpectKey(global)) { 10 | const jestExpect = global.expect; 11 | 12 | if (jestExpect !== undefined) { 13 | jestExpect.extend(matchers); 14 | } else { 15 | console.error("Unable to find Jest's global expect."); 16 | } 17 | } 18 | 19 | export * from "./helpers"; 20 | 21 | declare global { 22 | namespace jest { 23 | interface Matchers { 24 | toExistAsS3Bucket(): Promise; 25 | toExistInDynamoTable(table: string): Promise; 26 | toHaveContentEqualTo( 27 | content: Record | string 28 | ): Promise; 29 | toHaveContentTypeEqualTo(contentType: string): Promise; 30 | toHaveEvent(): R; 31 | toHaveEventWithSource(expectedSourceName: string): R; 32 | toHaveS3ObjectWithNameEqualTo(objectName: string): Promise; 33 | toContainItemWithValues(values:{ [key: string]: unknown }): Promise; 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/utils/testResult.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/prefer-default-export 2 | 3 | export interface TestResultOutput { 4 | message: () => string; 5 | pass: boolean; 6 | } 7 | 8 | export const testResult = ( 9 | message: string, 10 | pass: boolean 11 | ): TestResultOutput => ({ 12 | message: () => message, 13 | pass, 14 | }); 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ESNext"], 4 | "skipLibCheck": true, 5 | "moduleResolution": "node", 6 | "noUnusedLocals": true, 7 | "noUnusedParameters": true, 8 | "removeComments": true, 9 | "sourceMap": true, 10 | "target": "ES2020", 11 | "strict": true, 12 | "baseUrl": "src", 13 | "composite": true, 14 | "plugins": [{ "transform": "@zerollup/ts-transform-paths" }], 15 | "emitDeclarationOnly": true, 16 | "outDir": "./lib/types", 17 | "rootDir": "./src", 18 | "esModuleInterop": true 19 | }, 20 | "include": ["./src/**/*.ts"] 21 | } 22 | --------------------------------------------------------------------------------