diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a949e3bfc65a..f641643650a6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,84 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [1.97.0](https://github.com/aws/aws-cdk/compare/v1.96.0...v1.97.0) (2021-04-06) + + +### ⚠ BREAKING CHANGES TO EXPERIMENTAL FEATURES + +* **elasticsearch:** `vpcOptions` was removed. Use `vpc`, `vpcSubnets` and `securityGroups` instead. + +### Features + +* **appmesh:** Implement Outlier Detection for Virtual Nodes ([#13952](https://github.com/aws/aws-cdk/issues/13952)) ([965f130](https://github.com/aws/aws-cdk/commit/965f130dbfc4e1943d384b9fbf5acdf3b547fd57)) +* **cx-api:** graduate to stable 🚀 ([#13859](https://github.com/aws/aws-cdk/issues/13859)) ([d99e13d](https://github.com/aws/aws-cdk/commit/d99e13d523ddacf9e13f6b5169d86d5a20569475)) +* **eks:** Support `secretsEncryptionKey` in FargateCluster ([#13866](https://github.com/aws/aws-cdk/issues/13866)) ([56c6f98](https://github.com/aws/aws-cdk/commit/56c6f98dbcfc98740446f699a8985d7d6b44c503)) +* **eks:** Support bootstrap.sh --dns-cluster-ip arg ([#13890](https://github.com/aws/aws-cdk/issues/13890)) ([56cd863](https://github.com/aws/aws-cdk/commit/56cd8635f77d6a5aefb32c6e1224e1f0a6ca3540)) +* **elasticsearch:** graduate to stable 🚀 ([#13900](https://github.com/aws/aws-cdk/issues/13900)) ([767cd31](https://github.com/aws/aws-cdk/commit/767cd31c2b66b48b3b8fed7cd8d408a6846cf1e1)) +* **s3-deployment:** graduate to stable 🚀 ([#13906](https://github.com/aws/aws-cdk/issues/13906)) ([567d64d](https://github.com/aws/aws-cdk/commit/567d64d70f92adbba9ff9981184d88b46fb95652)) +* **ses:** graduate to stable 🚀 ([#13913](https://github.com/aws/aws-cdk/issues/13913)) ([4f9a715](https://github.com/aws/aws-cdk/commit/4f9a7151b99e8455eeb8b0cd364dfd29624da8c5)) +* **ses-actions:** graduate to stable 🚀 ([#13864](https://github.com/aws/aws-cdk/issues/13864)) ([24f8307](https://github.com/aws/aws-cdk/commit/24f8307b7f9013c5ba909cab8c4a3a3bcdf0041c)) + + +### Bug Fixes + +* **aws-rds:** ServerlessCluster.clusterArn is not correct when clusterIdentifier includes upper cases string. ([#13710](https://github.com/aws/aws-cdk/issues/13710)) ([a8f5b6c](https://github.com/aws/aws-cdk/commit/a8f5b6c54371fe966172a9fb36135bfdc4a01b11)), closes [#12795](https://github.com/aws/aws-cdk/issues/12795) +* **cli:** broken java init template ([#13988](https://github.com/aws/aws-cdk/issues/13988)) ([c6ca2ab](https://github.com/aws/aws-cdk/commit/c6ca2aba915ea4f89e3044b7f388acda231e295d)), closes [#13964](https://github.com/aws/aws-cdk/issues/13964) +* **cloudfront:** Cache Policy headers enforce soft limit of 10 ([#13904](https://github.com/aws/aws-cdk/issues/13904)) ([8a66244](https://github.com/aws/aws-cdk/commit/8a6624477854af17f5ad163fac9be1fd6168cfc4)), closes [#13425](https://github.com/aws/aws-cdk/issues/13425) [#13903](https://github.com/aws/aws-cdk/issues/13903) +* **codepipeline-actions:** EcrSourceAction triggers on a push to every tag ([#13822](https://github.com/aws/aws-cdk/issues/13822)) ([c5a2add](https://github.com/aws/aws-cdk/commit/c5a2addcd87ebb810dcac54c659fa60786f9d345)), closes [#13818](https://github.com/aws/aws-cdk/issues/13818) + +## [1.96.0](https://github.com/aws/aws-cdk/compare/v1.95.2...v1.96.0) (2021-04-01) + + +### ⚠ BREAKING CHANGES TO EXPERIMENTAL FEATURES + +* **globalaccelerator:** automatic naming algorithm has been changed: if you have existing Accelerators you will need to pass an +explicit name to prevent them from being replaced. All endpoints are now added by calling `addEndpoint()` with a +target-specific class that can be found in `@aws-cdk/aws-globalaccelerator-endpoints`. The generated Security Group +is now looked up by calling `endpointGroup.connectionsPeer()`. +* **docdb:** `DatabaseClusterProps.instanceProps` was hoisted and all its properties are now available one level up directly in `DatabaseClusterProps`. +* **docdb**: `DatabaseInstanceProps.instanceClass` renamed to `DatabaseInstanceProps.instanceType`. +* **core:** The type of the `image` property in `BundlingOptions` +is changed from `BundlingDockerImage` to `DockerImage`. +* **core:** The return type of the `DockerImage.fromBuild()` API is +changed from `BundlingDockerImage` to `DockerImage`. + +### Features + +* **autoscaling-common:** graduate to stable 🚀 ([#13862](https://github.com/aws/aws-cdk/issues/13862)) ([2d623d0](https://github.com/aws/aws-cdk/commit/2d623d08d8d5d8c356d871ccd69a8cdac9c4170e)) +* **chatbot:** graduate to stable 🚀 ([#13863](https://github.com/aws/aws-cdk/issues/13863)) ([2384cdd](https://github.com/aws/aws-cdk/commit/2384cdd39bae1639bf3e6bfdeb7a08edc6306cac)) +* **cli:** app init template for golang ([#13840](https://github.com/aws/aws-cdk/issues/13840)) ([41fd42b](https://github.com/aws/aws-cdk/commit/41fd42b89f6f9a95c6e736c17bd404d80c4756a7)), closes [aws/jsii#2678](https://github.com/aws/jsii/issues/2678) +* **cloudformation-diff:** graduate to stable 🚀 ([#13857](https://github.com/aws/aws-cdk/issues/13857)) ([294f546](https://github.com/aws/aws-cdk/commit/294f54692c609eaf20257caba0b53ceb9882ff35)) +* **docdb:** graduate to stable 🚀 ([#13875](https://github.com/aws/aws-cdk/issues/13875)) ([169c2fc](https://github.com/aws/aws-cdk/commit/169c2fc55c3de2426380d0a1151d1d33cbcc2190)) +* **ec2:** allow disabling inline security group rules ([#13613](https://github.com/aws/aws-cdk/issues/13613)) ([793230c](https://github.com/aws/aws-cdk/commit/793230c7a6dcaf93408206e680bd26159ece1b7d)) +* **elasticloadbalancingv2:** graduate to stable 🚀 ([#13861](https://github.com/aws/aws-cdk/issues/13861)) ([08fa5ed](https://github.com/aws/aws-cdk/commit/08fa5ede1721f5165fad5fcf402a83fc2496bc46)) +* **fsx:** graduate to stable 🚀 ([#13860](https://github.com/aws/aws-cdk/issues/13860)) ([b2322aa](https://github.com/aws/aws-cdk/commit/b2322aac00dbbf5b171d5887fef2a3c8f3267c73)) +* **globalaccelerator:** graduate to stable 🚀 ([#13843](https://github.com/aws/aws-cdk/issues/13843)) ([8571008](https://github.com/aws/aws-cdk/commit/8571008884df8e048754fc4e0cfdf06ab20f0149)) +* **lambda:** switch bundling images from DockerHub to ECR public gallery ([#13473](https://github.com/aws/aws-cdk/issues/13473)) ([e2e008b](https://github.com/aws/aws-cdk/commit/e2e008bd19c3ff1b08ccb093dba576551ec73240)), closes [#11296](https://github.com/aws/aws-cdk/issues/11296) +* **lambda-event-sources:** support for batching window to sqs event source ([#13406](https://github.com/aws/aws-cdk/issues/13406)) ([6743e3b](https://github.com/aws/aws-cdk/commit/6743e3bb79a8281a4be5677fff018d702c85038d)), closes [#11722](https://github.com/aws/aws-cdk/issues/11722) [#11724](https://github.com/aws/aws-cdk/issues/11724) [#13770](https://github.com/aws/aws-cdk/issues/13770) +* **lambda-event-sources:** tumbling window ([#13412](https://github.com/aws/aws-cdk/issues/13412)) ([e9f2773](https://github.com/aws/aws-cdk/commit/e9f2773aedeb7f01ebf2a05face719be9bb8b0d7)), closes [#13411](https://github.com/aws/aws-cdk/issues/13411) +* **lambda-nodejs:** graduate to stable 🚀 ([#13844](https://github.com/aws/aws-cdk/issues/13844)) ([37a5502](https://github.com/aws/aws-cdk/commit/37a5502ced1bf1b451ac4bd921752746277461bf)) + + +### Bug Fixes + +* **aws-ecs:** broken splunk-logging `tag`-option in fargate platform version 1.4 ([#13882](https://github.com/aws/aws-cdk/issues/13882)) ([e9d9299](https://github.com/aws/aws-cdk/commit/e9d9299b6bcdab489d94c974074e8c796bce00f3)), closes [#13881](https://github.com/aws/aws-cdk/issues/13881) +* **cloudfront:** auto-generated cache policy name might conflict cross-region ([#13737](https://github.com/aws/aws-cdk/issues/13737)) ([4f067cb](https://github.com/aws/aws-cdk/commit/4f067cb90d43d04659f68dec6b866ba77f10642c)), closes [#13629](https://github.com/aws/aws-cdk/issues/13629) +* **cloudfront:** Origin Request Policy headers enforce soft limit of 10 ([#13907](https://github.com/aws/aws-cdk/issues/13907)) ([9b0a6cf](https://github.com/aws/aws-cdk/commit/9b0a6cf0d349ef3ce1c941b25bbe8e630e09c639)), closes [#13410](https://github.com/aws/aws-cdk/issues/13410) [#13903](https://github.com/aws/aws-cdk/issues/13903) +* **codebuild:** allow passing the ARN of the Secret in environment variables ([#13706](https://github.com/aws/aws-cdk/issues/13706)) ([6f6e079](https://github.com/aws/aws-cdk/commit/6f6e079569fcdb7e0631717fbe269e94f8f7b127)), closes [#12703](https://github.com/aws/aws-cdk/issues/12703) +* **codebuild:** take the account & region of an imported Project from its ARN ([#13708](https://github.com/aws/aws-cdk/issues/13708)) ([fb65123](https://github.com/aws/aws-cdk/commit/fb6512314db1b11fc608cd62753090684ad0d3c4)), closes [#13694](https://github.com/aws/aws-cdk/issues/13694) +* **codedeploy:** script installing CodeDeploy agent fails ([#13758](https://github.com/aws/aws-cdk/issues/13758)) ([25e8d04](https://github.com/aws/aws-cdk/commit/25e8d04d7266a2642f11154750bef49a31b1892e)), closes [#13755](https://github.com/aws/aws-cdk/issues/13755) +* **cognito:** imported userpool not retaining environment from arn ([#13715](https://github.com/aws/aws-cdk/issues/13715)) ([aa9fd9c](https://github.com/aws/aws-cdk/commit/aa9fd9cd9bbaea4149927e08d57d29e547933f49)), closes [#13691](https://github.com/aws/aws-cdk/issues/13691) +* **core:** BundlingDockerImage.fromAsset() does not return a BundlingDockerImage ([#13846](https://github.com/aws/aws-cdk/issues/13846)) ([7176a5d](https://github.com/aws/aws-cdk/commit/7176a5d5208da7d727bbf5112bc12533983380ea)) +* **dynamodb:** table with replicas fails to deploy with "Unresolved resource dependencies" error ([#13889](https://github.com/aws/aws-cdk/issues/13889)) ([5c99d0d](https://github.com/aws/aws-cdk/commit/5c99d0d0e0fde00582e469b667265ebc9f5ef330)) +* **iam:** Role import doesn't fail when forgetting the region in the ARN ([#13821](https://github.com/aws/aws-cdk/issues/13821)) ([560a853](https://github.com/aws/aws-cdk/commit/560a8536ffc31f74fe2366b1365681c1e56e33da)), closes [#13812](https://github.com/aws/aws-cdk/issues/13812) +* **rds:** fail with a descriptive error if Cluster's instance count is a deploy-time value ([#13765](https://github.com/aws/aws-cdk/issues/13765)) ([dd22e8f](https://github.com/aws/aws-cdk/commit/dd22e8fc29f1fc33d391d1bb9ae93963bfd82563)), closes [#13558](https://github.com/aws/aws-cdk/issues/13558) +* **yaml-cfn:** do not deserialize year-month-date as strings ([#13745](https://github.com/aws/aws-cdk/issues/13745)) ([ffea818](https://github.com/aws/aws-cdk/commit/ffea818f26a383e7f314dac3505c46f3b4b4348d)), closes [#13709](https://github.com/aws/aws-cdk/issues/13709) + +## [1.95.2](https://github.com/aws/aws-cdk/compare/v1.95.1...v1.95.2) (2021-04-01) + +* Upgrade a downstream dependency([pac-resolver](https://github.com/TooTallNate/node-pac-resolver)) of the aws-cdk (the CDK CLI), to mitigate [CVE-2021-28918](https://github.com/advisories/GHSA-pch5-whg9-qr2r) ([13914](https://github.com/aws/aws-cdk/pull/13914)) ([794c951](https://github.com/aws/aws-cdk/commit/794c951b5da900fd30827e6f7b0b631bf21df979)) + ## [1.95.1](https://github.com/aws/aws-cdk/compare/v1.95.0...v1.95.1) (2021-03-25) diff --git a/design/aws-ecs/aws-ecs-scheduled-ecs-task-construct.md b/design/aws-ecs/aws-ecs-scheduled-ecs-task-construct.md index 921196c3c1bf7..dad04821f9742 100644 --- a/design/aws-ecs/aws-ecs-scheduled-ecs-task-construct.md +++ b/design/aws-ecs/aws-ecs-scheduled-ecs-task-construct.md @@ -112,7 +112,7 @@ export interface ScheduledEc2TaskProps { The `ScheduledEc2Task` construct will use the following existing constructs: * Ec2TaskDefinition - To create a Task Definition for the container to start -* Ec2EventRuleTarget - The target of the aws event +* Ec2EventRuleTarget - The target of the AWS event * EventRule - To describe the event trigger (in this case, a scheduled run) An example use case to create a task that is scheduled to run every minute: diff --git a/package.json b/package.json index 6dad27d90e51f..bcc83bf7f0957 100644 --- a/package.json +++ b/package.json @@ -18,14 +18,14 @@ "fs-extra": "^9.1.0", "graceful-fs": "^4.2.6", "jest-junit": "^12.0.0", - "jsii-diff": "^1.26.0", - "jsii-pacmak": "^1.26.0", - "jsii-rosetta": "^1.26.0", + "jsii-diff": "^1.27.0", + "jsii-pacmak": "^1.27.0", + "jsii-rosetta": "^1.27.0", "lerna": "^4.0.0", - "standard-version": "^9.1.1", + "standard-version": "^9.2.0", "typescript": "~3.9.9" }, - "resolutions-comment": "should be removed or reviewed when nodeunit dependency is dropped or adjusted", + "tap-mocha-reporter-resolutions-comment": "should be removed or reviewed when nodeunit dependency is dropped or adjusted", "resolutions": { "tap-mocha-reporter": "^5.0.1" }, @@ -51,6 +51,8 @@ "nohoist": [ "**/jszip", "**/jszip/**", + "@aws-cdk/aws-codebuild/yaml", + "@aws-cdk/aws-codebuild/yaml/**", "@aws-cdk/aws-codepipeline-actions/case", "@aws-cdk/aws-codepipeline-actions/case/**", "@aws-cdk/aws-cognito/punycode", @@ -63,6 +65,8 @@ "@aws-cdk/cloud-assembly-schema/jsonschema/**", "@aws-cdk/cloud-assembly-schema/semver", "@aws-cdk/cloud-assembly-schema/semver/**", + "@aws-cdk/cloudformation-include/yaml", + "@aws-cdk/cloudformation-include/yaml/**", "@aws-cdk/core/@balena/dockerignore", "@aws-cdk/core/@balena/dockerignore/**", "@aws-cdk/core/fs-extra", diff --git a/packages/@aws-cdk-containers/ecs-service-extensions/package.json b/packages/@aws-cdk-containers/ecs-service-extensions/package.json index 4b75bbbd568f4..5f4683eb92881 100644 --- a/packages/@aws-cdk-containers/ecs-service-extensions/package.json +++ b/packages/@aws-cdk-containers/ecs-service-extensions/package.json @@ -35,14 +35,14 @@ }, "license": "Apache-2.0", "devDependencies": { - "@aws-cdk/assert": "0.0.0", "@types/nodeunit": "^0.0.31", "cdk-build-tools": "0.0.0", "cdk-integ-tools": "0.0.0", "cfn2ts": "0.0.0", "jest": "^26.6.3", "nodeunit": "^0.11.3", - "pkglint": "0.0.0" + "pkglint": "0.0.0", + "@aws-cdk/assert-internal": "0.0.0" }, "dependencies": { "@aws-cdk/aws-applicationautoscaling": "0.0.0", diff --git a/packages/@aws-cdk-containers/ecs-service-extensions/test/test.appmesh.ts b/packages/@aws-cdk-containers/ecs-service-extensions/test/test.appmesh.ts index a221b9d31933e..c38447ac912bb 100644 --- a/packages/@aws-cdk-containers/ecs-service-extensions/test/test.appmesh.ts +++ b/packages/@aws-cdk-containers/ecs-service-extensions/test/test.appmesh.ts @@ -1,4 +1,4 @@ -import { expect, haveResource, haveResourceLike } from '@aws-cdk/assert'; +import { expect, haveResource, haveResourceLike } from '@aws-cdk/assert-internal'; import * as appmesh from '@aws-cdk/aws-appmesh'; import * as ecs from '@aws-cdk/aws-ecs'; import * as cdk from '@aws-cdk/core'; diff --git a/packages/@aws-cdk-containers/ecs-service-extensions/test/test.assign-public-ip.ts b/packages/@aws-cdk-containers/ecs-service-extensions/test/test.assign-public-ip.ts index 92a36dd1d788b..2f4f926e1746b 100644 --- a/packages/@aws-cdk-containers/ecs-service-extensions/test/test.assign-public-ip.ts +++ b/packages/@aws-cdk-containers/ecs-service-extensions/test/test.assign-public-ip.ts @@ -1,4 +1,4 @@ -import { expect, haveResourceLike } from '@aws-cdk/assert'; +import { expect, haveResourceLike } from '@aws-cdk/assert-internal'; import * as ec2 from '@aws-cdk/aws-ec2'; import * as ecs from '@aws-cdk/aws-ecs'; import * as route53 from '@aws-cdk/aws-route53'; diff --git a/packages/@aws-cdk-containers/ecs-service-extensions/test/test.cloudwatch-agent.ts b/packages/@aws-cdk-containers/ecs-service-extensions/test/test.cloudwatch-agent.ts index a8d94c3dc8e25..0b9ad5a09e647 100644 --- a/packages/@aws-cdk-containers/ecs-service-extensions/test/test.cloudwatch-agent.ts +++ b/packages/@aws-cdk-containers/ecs-service-extensions/test/test.cloudwatch-agent.ts @@ -1,4 +1,4 @@ -import { expect, haveResource } from '@aws-cdk/assert'; +import { expect, haveResource } from '@aws-cdk/assert-internal'; import * as ecs from '@aws-cdk/aws-ecs'; import * as cdk from '@aws-cdk/core'; import { Test } from 'nodeunit'; diff --git a/packages/@aws-cdk-containers/ecs-service-extensions/test/test.environment.ts b/packages/@aws-cdk-containers/ecs-service-extensions/test/test.environment.ts index d029f81e34bc6..f1362695ac535 100644 --- a/packages/@aws-cdk-containers/ecs-service-extensions/test/test.environment.ts +++ b/packages/@aws-cdk-containers/ecs-service-extensions/test/test.environment.ts @@ -1,4 +1,4 @@ -import { countResources, expect, haveResource } from '@aws-cdk/assert'; +import { countResources, expect, haveResource } from '@aws-cdk/assert-internal'; import * as ec2 from '@aws-cdk/aws-ec2'; import * as ecs from '@aws-cdk/aws-ecs'; import * as cdk from '@aws-cdk/core'; diff --git a/packages/@aws-cdk-containers/ecs-service-extensions/test/test.firelens.ts b/packages/@aws-cdk-containers/ecs-service-extensions/test/test.firelens.ts index e4ceb68444dd7..a6011a2caabf6 100644 --- a/packages/@aws-cdk-containers/ecs-service-extensions/test/test.firelens.ts +++ b/packages/@aws-cdk-containers/ecs-service-extensions/test/test.firelens.ts @@ -1,4 +1,4 @@ -import { expect, haveResource } from '@aws-cdk/assert'; +import { expect, haveResource } from '@aws-cdk/assert-internal'; import * as ecs from '@aws-cdk/aws-ecs'; import * as cdk from '@aws-cdk/core'; import { Test } from 'nodeunit'; diff --git a/packages/@aws-cdk-containers/ecs-service-extensions/test/test.http-load-balancer.ts b/packages/@aws-cdk-containers/ecs-service-extensions/test/test.http-load-balancer.ts index 2cc26be97ddcf..2e977e19ee889 100644 --- a/packages/@aws-cdk-containers/ecs-service-extensions/test/test.http-load-balancer.ts +++ b/packages/@aws-cdk-containers/ecs-service-extensions/test/test.http-load-balancer.ts @@ -1,4 +1,4 @@ -import { expect, haveResource } from '@aws-cdk/assert'; +import { expect, haveResource } from '@aws-cdk/assert-internal'; import * as ecs from '@aws-cdk/aws-ecs'; import * as cdk from '@aws-cdk/core'; import { Test } from 'nodeunit'; diff --git a/packages/@aws-cdk-containers/ecs-service-extensions/test/test.scale-on-cpu-utilization.ts b/packages/@aws-cdk-containers/ecs-service-extensions/test/test.scale-on-cpu-utilization.ts index 305655129eb1a..6d3e6ce715b1d 100644 --- a/packages/@aws-cdk-containers/ecs-service-extensions/test/test.scale-on-cpu-utilization.ts +++ b/packages/@aws-cdk-containers/ecs-service-extensions/test/test.scale-on-cpu-utilization.ts @@ -1,4 +1,4 @@ -import { expect, haveResource, haveResourceLike } from '@aws-cdk/assert'; +import { expect, haveResource, haveResourceLike } from '@aws-cdk/assert-internal'; import * as ecs from '@aws-cdk/aws-ecs'; import * as cdk from '@aws-cdk/core'; import { Test } from 'nodeunit'; diff --git a/packages/@aws-cdk-containers/ecs-service-extensions/test/test.service.ts b/packages/@aws-cdk-containers/ecs-service-extensions/test/test.service.ts index 23b30f59afe3d..4fa8c80657cd0 100644 --- a/packages/@aws-cdk-containers/ecs-service-extensions/test/test.service.ts +++ b/packages/@aws-cdk-containers/ecs-service-extensions/test/test.service.ts @@ -1,4 +1,4 @@ -import { countResources, expect, haveResource } from '@aws-cdk/assert'; +import { countResources, expect, haveResource } from '@aws-cdk/assert-internal'; import * as ec2 from '@aws-cdk/aws-ec2'; import * as ecs from '@aws-cdk/aws-ecs'; import * as cdk from '@aws-cdk/core'; diff --git a/packages/@aws-cdk-containers/ecs-service-extensions/test/test.xray.ts b/packages/@aws-cdk-containers/ecs-service-extensions/test/test.xray.ts index 36443123e8719..c591b5c6dd012 100644 --- a/packages/@aws-cdk-containers/ecs-service-extensions/test/test.xray.ts +++ b/packages/@aws-cdk-containers/ecs-service-extensions/test/test.xray.ts @@ -1,4 +1,4 @@ -import { expect, haveResource } from '@aws-cdk/assert'; +import { expect, haveResource } from '@aws-cdk/assert-internal'; import * as ecs from '@aws-cdk/aws-ecs'; import * as cdk from '@aws-cdk/core'; import { Test } from 'nodeunit'; diff --git a/packages/@aws-cdk/alexa-ask/package.json b/packages/@aws-cdk/alexa-ask/package.json index 97bad8c492ebc..767c2013c8398 100644 --- a/packages/@aws-cdk/alexa-ask/package.json +++ b/packages/@aws-cdk/alexa-ask/package.json @@ -72,10 +72,10 @@ }, "license": "Apache-2.0", "devDependencies": { - "@aws-cdk/assert": "0.0.0", "cdk-build-tools": "0.0.0", "cfn2ts": "0.0.0", - "pkglint": "0.0.0" + "pkglint": "0.0.0", + "@aws-cdk/assert-internal": "0.0.0" }, "dependencies": { "@aws-cdk/core": "0.0.0", diff --git a/packages/@aws-cdk/alexa-ask/test/ask.test.ts b/packages/@aws-cdk/alexa-ask/test/ask.test.ts index e394ef336bfb4..c4505ad966984 100644 --- a/packages/@aws-cdk/alexa-ask/test/ask.test.ts +++ b/packages/@aws-cdk/alexa-ask/test/ask.test.ts @@ -1,4 +1,4 @@ -import '@aws-cdk/assert/jest'; +import '@aws-cdk/assert-internal/jest'; import {} from '../lib'; test('No tests are specified for this package', () => { diff --git a/packages/@aws-cdk/app-delivery/package.json b/packages/@aws-cdk/app-delivery/package.json index 7569a0e07a1cc..990a4dd376c50 100644 --- a/packages/@aws-cdk/app-delivery/package.json +++ b/packages/@aws-cdk/app-delivery/package.json @@ -58,14 +58,14 @@ "constructs": "^3.3.69" }, "devDependencies": { - "@aws-cdk/assert": "0.0.0", "@aws-cdk/aws-s3": "0.0.0", "@types/nodeunit": "^0.0.31", "cdk-build-tools": "0.0.0", "cdk-integ-tools": "0.0.0", "fast-check": "^2.14.0", "nodeunit": "^0.11.3", - "pkglint": "0.0.0" + "pkglint": "0.0.0", + "@aws-cdk/assert-internal": "0.0.0" }, "repository": { "type": "git", diff --git a/packages/@aws-cdk/app-delivery/test/test.pipeline-deploy-stack-action.ts b/packages/@aws-cdk/app-delivery/test/test.pipeline-deploy-stack-action.ts index d63ddbf6acb50..e4611adf570f6 100644 --- a/packages/@aws-cdk/app-delivery/test/test.pipeline-deploy-stack-action.ts +++ b/packages/@aws-cdk/app-delivery/test/test.pipeline-deploy-stack-action.ts @@ -1,4 +1,4 @@ -import { expect, haveResource, haveResourceLike, isSuperObject } from '@aws-cdk/assert'; +import { expect, haveResource, haveResourceLike, isSuperObject } from '@aws-cdk/assert-internal'; import * as cfn from '@aws-cdk/aws-cloudformation'; import * as codebuild from '@aws-cdk/aws-codebuild'; import * as codepipeline from '@aws-cdk/aws-codepipeline'; diff --git a/packages/@aws-cdk/assert-internal/.eslintrc.js b/packages/@aws-cdk/assert-internal/.eslintrc.js new file mode 100644 index 0000000000000..61dd8dd001f63 --- /dev/null +++ b/packages/@aws-cdk/assert-internal/.eslintrc.js @@ -0,0 +1,3 @@ +const baseConfig = require('cdk-build-tools/config/eslintrc'); +baseConfig.parserOptions.project = __dirname + '/tsconfig.json'; +module.exports = baseConfig; diff --git a/packages/@aws-cdk/assert-internal/.gitignore b/packages/@aws-cdk/assert-internal/.gitignore new file mode 100644 index 0000000000000..c9b9bcc8658a1 --- /dev/null +++ b/packages/@aws-cdk/assert-internal/.gitignore @@ -0,0 +1,16 @@ +*.js +*.js.map +*.d.ts +node_modules +dist + +.LAST_BUILD +.nyc_output +coverage +nyc.config.js +.LAST_PACKAGE +*.snk +!.eslintrc.js +!jest.config.js + +junit.xml \ No newline at end of file diff --git a/packages/@aws-cdk/assert-internal/.npmignore b/packages/@aws-cdk/assert-internal/.npmignore new file mode 100644 index 0000000000000..6f149ce45fddd --- /dev/null +++ b/packages/@aws-cdk/assert-internal/.npmignore @@ -0,0 +1,22 @@ +# Don't include original .ts files when doing `npm pack` +*.ts +!*.d.ts +coverage +.nyc_output +*.tgz + +dist +.LAST_PACKAGE +.LAST_BUILD +*.snk + +*.tsbuildinfo + +tsconfig.json +.eslintrc.js +jest.config.js + +# exclude cdk artifacts +**/cdk.out +junit.xml +test/ \ No newline at end of file diff --git a/packages/@aws-cdk/assert-internal/LICENSE b/packages/@aws-cdk/assert-internal/LICENSE new file mode 100644 index 0000000000000..28e4bdcec77ec --- /dev/null +++ b/packages/@aws-cdk/assert-internal/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2018-2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/@aws-cdk/assert-internal/NOTICE b/packages/@aws-cdk/assert-internal/NOTICE new file mode 100644 index 0000000000000..5fc3826926b5b --- /dev/null +++ b/packages/@aws-cdk/assert-internal/NOTICE @@ -0,0 +1,2 @@ +AWS Cloud Development Kit (AWS CDK) +Copyright 2018-2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/packages/@aws-cdk/assert-internal/README.md b/packages/@aws-cdk/assert-internal/README.md new file mode 100644 index 0000000000000..9256b46d2b154 --- /dev/null +++ b/packages/@aws-cdk/assert-internal/README.md @@ -0,0 +1,227 @@ +# Testing utilities and assertions for CDK libraries + + +--- + +![cdk-constructs: Experimental](https://img.shields.io/badge/cdk--constructs-experimental-important.svg?style=for-the-badge) + +> The APIs of higher level constructs in this module are experimental and under active development. +> They are subject to non-backward compatible changes or removal in any future version. These are +> not subject to the [Semantic Versioning](https://semver.org/) model and breaking changes will be +> announced in the release notes. This means that while you may use them, you may need to update +> your source code when upgrading to a newer version of this package. + +--- + + + +This library contains helpers for writing unit tests and integration tests for CDK libraries + +## Unit tests + +Write your unit tests like this: + +```ts +const stack = new Stack(); + +new MyConstruct(stack, 'MyConstruct', { + ... +}); + +expect(stack).to(someExpectation(...)); +``` + +Here are the expectations you can use: + +## Verify (parts of) a template + +Check that the synthesized stack template looks like the given template, or is a superset of it. These functions match logical IDs and all properties of a resource. + +```ts +matchTemplate(template, matchStyle) +exactlyMatchTemplate(template) +beASupersetOfTemplate(template) +``` + +Example: + +```ts +expect(stack).to(beASupersetOfTemplate({ + Resources: { + HostedZone674DD2B7: { + Type: "AWS::Route53::HostedZone", + Properties: { + Name: "test.private.", + VPCs: [{ + VPCId: { Ref: 'VPC06C5F037' }, + VPCRegion: { Ref: 'AWS::Region' } + }] + } + } + } +})); +``` + + +## Check existence of a resource + +If you only care that a resource of a particular type exists (regardless of its logical identifier), and that *some* of its properties are set to specific values: + +```ts +haveResource(type, subsetOfProperties) +haveResourceLike(type, subsetOfProperties) +``` + +Example: + +```ts +expect(stack).to(haveResource('AWS::CertificateManager::Certificate', { + DomainName: 'test.example.com', + // Note: some properties omitted here + + ShouldNotExist: ABSENT +})); +``` + +The object you give to `haveResource`/`haveResourceLike` like can contain the +following values: + +- **Literal values**: the given property in the resource must match the given value *exactly*. +- `ABSENT`: a magic value to assert that a particular key in an object is *not* set (or set to `undefined`). +- special matchers for inexact matching. You can use these to match values based on more lenient conditions + than the default (such as an array containing at least one element, ignoring the rest, or an inexact string + match). + +The following matchers exist: + +- `objectLike(O)` - the value has to be an object matching at least the keys in `O` (but may contain + more). The nested values must match exactly. +- `deepObjectLike(O)` - as `objectLike`, but nested objects are also treated as partial specifications. +- `exactValue(X)` - must match exactly the given value. Use this to escape from `deepObjectLike`'s leniency + back to exact value matching. +- `arrayWith(E, [F, ...])` - value must be an array containing the given elements (or matchers) in any order. +- `stringLike(S)` - value must be a string matching `S`. `S` may contain `*` as wildcard to match any number + of characters. +- `anything()` - matches any value. +- `notMatching(M)` - any value that does NOT match the given matcher (or exact value) given. +- `encodedJson(M)` - value must be a string which, when decoded as JSON, matches the given matcher or + exact value. + +Slightly more complex example with array matchers: + +```ts +expect(stack).to(haveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Statement: arrayWith(objectLike({ + Action: ['s3:GetObject'], + Resource: ['arn:my:arn'], + }}) + } +})); +``` + +## Capturing values from a match + +Special `Capture` matchers exist to capture values encountered during a match. These can be +used for two typical purposes: + +- Apply additional assertions to the values found during a matching operation. +- Use the value found during a matching operation in a new matching operation. + +`Capture` matchers take an inner matcher as an argument, and will only capture the value +if the inner matcher succeeds in matching the given value. + +Here's an example which asserts that a policy for `RoleA` contains two statements +with *different* ARNs (without caring what those ARNs might be), and that +a policy for `RoleB` *also* has a statement for one of those ARNs (again, without +caring what the ARN might be): + +```ts +const arn1 = Capture.aString(); +const arn2 = Capture.aString(); + +expect(stack).to(haveResourceLike('AWS::IAM::Policy', { + Roles: ['RoleA'], + PolicyDocument: { + Statement: [ + objectLike({ + Resource: [arn1.capture()], + }), + objectLike({ + Resource: [arn2.capture()], + }), + ], + }, +})); + +// Don't care about the values as long as they are not the same +expect(arn1.capturedValue).not.toEqual(arn2.capturedValue); + +expect(stack).to(haveResourceLike('AWS::IAM::Policy', { + Roles: ['RoleB'], + PolicyDocument: { + Statement: [ + objectLike({ + // This ARN must be the same as ARN1 above. + Resource: [arn1.capturedValue] + }), + ], + }, +})); +``` + +NOTE: `Capture` look somewhat like *bindings* in other pattern matching +libraries you might be used to, but they are far simpler and very +deterministic. In particular, they don't do unification: if the same Capture +is either used multiple times in the same structure expression or matches +multiple times, no restarting of the match is done to make them all match the +same value: the last value encountered by the `Capture` (as determined by the +behavior of the matchers around it) is stored into it and will be the one +available after the match has completed. + +## Check number of resources + +If you want to assert that `n` number of resources of a particular type exist, with or without specific properties: + +```ts +countResources(type, count) +countResourcesLike(type, count, props) +``` + +Example: + +```ts +expect(stack).to(countResources('AWS::ApiGateway::Method', 3)); +expect(stack).to(countResourcesLike('AWS::ApiGateway::Method', 1, { + HttpMethod: 'GET', + ResourceId: { + "Ref": "MyResource01234" + } +})); +``` + +## Check existence of an output + +`haveOutput` assertion can be used to check that a stack contains specific output. +Parameters to check against can be: + +- `outputName` +- `outputValue` +- `exportName` + +If `outputValue` is provided, at least one of `outputName`, `exportName` should be provided as well + +Example + +```ts +expect(synthStack).to(haveOutput({ + outputName: 'TestOutputName', + exportName: 'TestOutputExportName', + outputValue: { + 'Fn::GetAtt': [ + 'TestResource', + 'Arn' + ] + } +})); +``` diff --git a/packages/@aws-cdk/assert-internal/jest.config.js b/packages/@aws-cdk/assert-internal/jest.config.js new file mode 100644 index 0000000000000..ac8c47076506a --- /dev/null +++ b/packages/@aws-cdk/assert-internal/jest.config.js @@ -0,0 +1,10 @@ +const baseConfig = require('cdk-build-tools/config/jest.config'); +module.exports = { + ...baseConfig, + coverageThreshold: { + global: { + statements: 75, + branches: 65, + }, + }, +}; diff --git a/packages/@aws-cdk/assert-internal/jest.ts b/packages/@aws-cdk/assert-internal/jest.ts new file mode 100644 index 0000000000000..5c6db5727ed8d --- /dev/null +++ b/packages/@aws-cdk/assert-internal/jest.ts @@ -0,0 +1,107 @@ +import * as core from '@aws-cdk/core'; +import * as cxapi from '@aws-cdk/cx-api'; +import { countResources } from './lib'; +import { JestFriendlyAssertion } from './lib/assertion'; +import { haveOutput, HaveOutputProperties } from './lib/assertions/have-output'; +import { HaveResourceAssertion, ResourcePart } from './lib/assertions/have-resource'; +import { MatchStyle, matchTemplate } from './lib/assertions/match-template'; +import { expect as ourExpect } from './lib/expect'; +import { StackInspector } from './lib/inspector'; + +declare global { + namespace jest { + interface Matchers { + toMatchTemplate( + template: any, + matchStyle?: MatchStyle): R; + + toHaveResource( + resourceType: string, + properties?: any, + comparison?: ResourcePart): R; + + toHaveResourceLike( + resourceType: string, + properties?: any, + comparison?: ResourcePart): R; + + toHaveOutput(props: HaveOutputProperties): R; + + toCountResources(resourceType: string, count: number): R; + } + } +} + +expect.extend({ + toMatchTemplate( + actual: cxapi.CloudFormationStackArtifact | core.Stack, + template: any, + matchStyle?: MatchStyle) { + + const assertion = matchTemplate(template, matchStyle); + const inspector = ourExpect(actual); + const pass = assertion.assertUsing(inspector); + if (pass) { + return { + pass, + message: () => 'Not ' + assertion.description, + }; + } else { + return { + pass, + message: () => assertion.description, + }; + } + }, + + toHaveResource( + actual: cxapi.CloudFormationStackArtifact | core.Stack, + resourceType: string, + properties?: any, + comparison?: ResourcePart) { + + const assertion = new HaveResourceAssertion(resourceType, properties, comparison, false); + return applyAssertion(assertion, actual); + }, + + toHaveResourceLike( + actual: cxapi.CloudFormationStackArtifact | core.Stack, + resourceType: string, + properties?: any, + comparison?: ResourcePart) { + + const assertion = new HaveResourceAssertion(resourceType, properties, comparison, true); + return applyAssertion(assertion, actual); + }, + + toHaveOutput( + actual: cxapi.CloudFormationStackArtifact | core.Stack, + props: HaveOutputProperties) { + + return applyAssertion(haveOutput(props), actual); + }, + + toCountResources( + actual: cxapi.CloudFormationStackArtifact | core.Stack, + resourceType: string, + count = 1) { + + return applyAssertion(countResources(resourceType, count), actual); + }, +}); + +function applyAssertion(assertion: JestFriendlyAssertion, actual: cxapi.CloudFormationStackArtifact | core.Stack) { + const inspector = ourExpect(actual); + const pass = assertion.assertUsing(inspector); + if (pass) { + return { + pass, + message: () => 'Not ' + assertion.generateErrorMessage(), + }; + } else { + return { + pass, + message: () => assertion.generateErrorMessage(), + }; + } +} diff --git a/packages/@aws-cdk/assert-internal/lib/assertion.ts b/packages/@aws-cdk/assert-internal/lib/assertion.ts new file mode 100644 index 0000000000000..376b099f8433f --- /dev/null +++ b/packages/@aws-cdk/assert-internal/lib/assertion.ts @@ -0,0 +1,40 @@ +import { Inspector } from './inspector'; + +export abstract class Assertion { + public abstract readonly description: string; + + public abstract assertUsing(inspector: InspectorClass): boolean; + + /** + * Assert this thing and another thing + */ + public and(assertion: Assertion): Assertion { + // Needs to delegate to a function so that we can import mutually dependent classes in the right order + return and(this, assertion); + } + + public assertOrThrow(inspector: InspectorClass) { + if (!this.assertUsing(inspector)) { + throw new Error(`${JSON.stringify(inspector.value, null, 2)} does not match ${this.description}`); + } + } +} + +export abstract class JestFriendlyAssertion extends Assertion { + /** + * Generates an error message that can be used by Jest. + */ + public abstract generateErrorMessage(): string; +} + +import { AndAssertion } from './assertions/and-assertion'; + +function and(left: Assertion, right: Assertion): Assertion { + return new AndAssertion(left, right); +} + +import { NegatedAssertion } from './assertions/negated-assertion'; + +export function not(assertion: Assertion): Assertion { + return new NegatedAssertion(assertion); +} diff --git a/packages/@aws-cdk/assert-internal/lib/assertions/and-assertion.ts b/packages/@aws-cdk/assert-internal/lib/assertions/and-assertion.ts new file mode 100644 index 0000000000000..737dbaca67e5e --- /dev/null +++ b/packages/@aws-cdk/assert-internal/lib/assertions/and-assertion.ts @@ -0,0 +1,19 @@ +import { Assertion } from '../assertion'; +import { Inspector } from '../inspector'; + +export class AndAssertion extends Assertion { + public description: string = 'Combined assertion'; + + constructor(private readonly first: Assertion, private readonly second: Assertion) { + super(); + } + + public assertUsing(_inspector: InspectorClass): boolean { + throw new Error('This is never called'); + } + + public assertOrThrow(inspector: InspectorClass) { + this.first.assertOrThrow(inspector); + this.second.assertOrThrow(inspector); + } +} diff --git a/packages/@aws-cdk/assert-internal/lib/assertions/count-resources.ts b/packages/@aws-cdk/assert-internal/lib/assertions/count-resources.ts new file mode 100644 index 0000000000000..0827ba1f18306 --- /dev/null +++ b/packages/@aws-cdk/assert-internal/lib/assertions/count-resources.ts @@ -0,0 +1,58 @@ +import { Assertion, JestFriendlyAssertion } from '../assertion'; +import { StackInspector } from '../inspector'; +import { isSuperObject } from './have-resource'; + +/** + * An assertion to check whether a resource of a given type and with the given properties exists, disregarding properties + */ +export function countResources(resourceType: string, count = 1): JestFriendlyAssertion { + return new CountResourcesAssertion(resourceType, count); +} + +/** + * An assertion to check whether a resource of a given type and with the given properties exists, considering properties + */ +export function countResourcesLike(resourceType: string, count = 1, props: any): Assertion { + return new CountResourcesAssertion(resourceType, count, props); +} + +class CountResourcesAssertion extends JestFriendlyAssertion { + private inspected: number = 0; + private readonly props: any; + + constructor( + private readonly resourceType: string, + private readonly count: number, + props: any = null) { + super(); + this.props = props; + } + + public assertUsing(inspector: StackInspector): boolean { + let counted = 0; + for (const logicalId of Object.keys(inspector.value.Resources || {})) { + const resource = inspector.value.Resources[logicalId]; + if (resource.Type === this.resourceType) { + if (this.props) { + if (isSuperObject(resource.Properties, this.props, [], true)) { + counted++; + this.inspected += 1; + } + } else { + counted++; + this.inspected += 1; + } + } + } + + return counted === this.count; + } + + public generateErrorMessage(): string { + return this.description; + } + + public get description(): string { + return `stack only has ${this.inspected} resource of type ${this.resourceType}${this.props ? ' with specified properties' : ''} but we expected to find ${this.count}`; + } +} diff --git a/packages/@aws-cdk/assert-internal/lib/assertions/exist.ts b/packages/@aws-cdk/assert-internal/lib/assertions/exist.ts new file mode 100644 index 0000000000000..3cc62f0444de4 --- /dev/null +++ b/packages/@aws-cdk/assert-internal/lib/assertions/exist.ts @@ -0,0 +1,18 @@ +import { Assertion } from '../assertion'; +import { StackPathInspector } from '../inspector'; + +class ExistingResourceAssertion extends Assertion { + public description: string = 'an existing resource'; + + constructor() { + super(); + } + + public assertUsing(inspector: StackPathInspector): boolean { + return inspector.value !== undefined; + } +} + +export function exist(): Assertion { + return new ExistingResourceAssertion(); +} diff --git a/packages/@aws-cdk/assert-internal/lib/assertions/have-output.ts b/packages/@aws-cdk/assert-internal/lib/assertions/have-output.ts new file mode 100644 index 0000000000000..36f76b3e573a0 --- /dev/null +++ b/packages/@aws-cdk/assert-internal/lib/assertions/have-output.ts @@ -0,0 +1,116 @@ +import { JestFriendlyAssertion } from '../assertion'; +import { StackInspector } from '../inspector'; + +class HaveOutputAssertion extends JestFriendlyAssertion { + private readonly inspected: InspectionFailure[] = []; + + constructor(private readonly outputName?: string, private readonly exportName?: any, private outputValue?: any) { + super(); + if (!this.outputName && !this.exportName) { + throw new Error('At least one of [outputName, exportName] should be provided'); + } + } + + public get description(): string { + const descriptionPartsArray = new Array(); + + if (this.outputName) { + descriptionPartsArray.push(`name '${this.outputName}'`); + } + if (this.exportName) { + descriptionPartsArray.push(`export name ${JSON.stringify(this.exportName)}`); + } + if (this.outputValue) { + descriptionPartsArray.push(`value ${JSON.stringify(this.outputValue)}`); + } + + return 'output with ' + descriptionPartsArray.join(', '); + } + + public assertUsing(inspector: StackInspector): boolean { + if (!('Outputs' in inspector.value)) { + return false; + } + + for (const [name, props] of Object.entries(inspector.value.Outputs as Record)) { + const mismatchedFields = new Array(); + + if (this.outputName && name !== this.outputName) { + mismatchedFields.push('name'); + } + + if (this.exportName && JSON.stringify(this.exportName) !== JSON.stringify(props.Export?.Name)) { + mismatchedFields.push('export name'); + } + + if (this.outputValue && JSON.stringify(this.outputValue) !== JSON.stringify(props.Value)) { + mismatchedFields.push('value'); + } + + if (mismatchedFields.length === 0) { + return true; + } + + this.inspected.push({ + output: { [name]: props }, + failureReason: `mismatched ${mismatchedFields.join(', ')}`, + }); + } + + return false; + } + + public generateErrorMessage() { + const lines = new Array(); + + lines.push(`None of ${this.inspected.length} outputs matches ${this.description}.`); + + for (const inspected of this.inspected) { + lines.push(`- ${inspected.failureReason} in:`); + lines.push(indent(4, JSON.stringify(inspected.output, null, 2))); + } + + return lines.join('\n'); + } +} + +/** + * Interface for haveOutput function properties + * NOTE that at least one of [outputName, exportName] should be provided + */ +export interface HaveOutputProperties { + /** + * Logical ID of the output + * @default - the logical ID of the output will not be checked + */ + outputName?: string; + /** + * Export name of the output, when it's exported for cross-stack referencing + * @default - the export name is not required and will not be checked + */ + exportName?: any; + /** + * Value of the output; + * @default - the value will not be checked + */ + outputValue?: any; +} + +interface InspectionFailure { + output: any; + failureReason: string; +} + +/** + * An assertion to check whether Output with particular properties is present in a stack + * @param props properties of the Output that is being asserted against. + * Check ``HaveOutputProperties`` interface to get full list of available parameters + */ +export function haveOutput(props: HaveOutputProperties): JestFriendlyAssertion { + return new HaveOutputAssertion(props.outputName, props.exportName, props.outputValue); +} + +function indent(n: number, s: string) { + const prefix = ' '.repeat(n); + return prefix + s.replace(/\n/g, '\n' + prefix); +} diff --git a/packages/@aws-cdk/assert-internal/lib/assertions/have-resource-matchers.ts b/packages/@aws-cdk/assert-internal/lib/assertions/have-resource-matchers.ts new file mode 100644 index 0000000000000..deb64b769ff16 --- /dev/null +++ b/packages/@aws-cdk/assert-internal/lib/assertions/have-resource-matchers.ts @@ -0,0 +1,430 @@ +import { ABSENT, InspectionFailure, PropertyMatcher } from './have-resource'; + +/** + * A matcher for an object that contains at least the given fields with the given matchers (or literals) + * + * Only does lenient matching one level deep, at the next level all objects must declare the + * exact expected keys again. + */ +export function objectLike(pattern: A): PropertyMatcher { + return _objectContaining(pattern, false); +} + +/** + * A matcher for an object that contains at least the given fields with the given matchers (or literals) + * + * Switches to "deep" lenient matching. Nested objects also only need to contain declared keys. + */ +export function deepObjectLike(pattern: A): PropertyMatcher { + return _objectContaining(pattern, true); +} + +function _objectContaining(pattern: A, deep: boolean): PropertyMatcher { + const anno = { [deep ? '$deepObjectLike' : '$objectLike']: pattern }; + + return annotateMatcher(anno, (value: any, inspection: InspectionFailure): boolean => { + if (typeof value !== 'object' || !value) { + return failMatcher(inspection, `Expect an object but got '${typeof value}'`); + } + + const errors = new Array(); + + for (const [patternKey, patternValue] of Object.entries(pattern)) { + if (patternValue === ABSENT) { + if (value[patternKey] !== undefined) { errors.push(`Field ${patternKey} present, but shouldn't be`); } + continue; + } + + if (!(patternKey in value)) { + errors.push(`Field ${patternKey} missing`); + continue; + } + + // If we are doing DEEP objectLike, translate object literals in the pattern into + // more `deepObjectLike` matchers, even if they occur in lists. + const matchValue = deep ? deepMatcherFromObjectLiteral(patternValue) : patternValue; + + const innerInspection = { ...inspection, failureReason: '' }; + const valueMatches = match(value[patternKey], matchValue, innerInspection); + if (!valueMatches) { + errors.push(`Field ${patternKey} mismatch: ${innerInspection.failureReason}`); + } + } + + /** + * Transform nested object literals into more deep object matchers, if applicable + * + * Object literals in lists are also transformed. + */ + function deepMatcherFromObjectLiteral(nestedPattern: any): any { + if (isObject(nestedPattern)) { + return deepObjectLike(nestedPattern); + } + if (Array.isArray(nestedPattern)) { + return nestedPattern.map(deepMatcherFromObjectLiteral); + } + return nestedPattern; + } + + if (errors.length > 0) { + return failMatcher(inspection, errors.join(', ')); + } + return true; + }); +} + +/** + * Match exactly the given value + * + * This is the default, you only need this to escape from the deep lenient matching + * of `deepObjectLike`. + */ +export function exactValue(expected: any): PropertyMatcher { + const anno = { $exactValue: expected }; + return annotateMatcher(anno, (value: any, inspection: InspectionFailure): boolean => { + return matchLiteral(value, expected, inspection); + }); +} + +/** + * A matcher for a list that contains all of the given elements in any order + */ +export function arrayWith(...elements: any[]): PropertyMatcher { + if (elements.length === 0) { return anything(); } + + const anno = { $arrayContaining: elements.length === 1 ? elements[0] : elements }; + return annotateMatcher(anno, (value: any, inspection: InspectionFailure): boolean => { + if (!Array.isArray(value)) { + return failMatcher(inspection, `Expect an array but got '${typeof value}'`); + } + + for (const element of elements) { + const failure = longestFailure(value, element); + if (failure) { + return failMatcher(inspection, `Array did not contain expected element, closest match at index ${failure[0]}: ${failure[1]}`); + } + } + + return true; + + /** + * Return 'null' if the matcher matches anywhere in the array, otherwise the longest error and its index + */ + function longestFailure(array: any[], matcher: any): [number, string] | null { + let fail: [number, string] | null = null; + for (let i = 0; i < array.length; i++) { + const innerInspection = { ...inspection, failureReason: '' }; + if (match(array[i], matcher, innerInspection)) { + return null; + } + + if (fail === null || innerInspection.failureReason.length > fail[1].length) { + fail = [i, innerInspection.failureReason]; + } + } + return fail; + } + }); +} + +/** + * Whether a value is an object + */ +function isObject(x: any): x is object { + // Because `typeof null === 'object'`. + return x && typeof x === 'object'; +} + +/** + * Helper function to make matcher failure reporting a little easier + * + * Our protocol is weird (change a string on a passed-in object and return 'false'), + * but I don't want to change that right now. + */ +export function failMatcher(inspection: InspectionFailure, error: string): boolean { + inspection.failureReason = error; + return false; +} + +/** + * Match a given literal value against a matcher + * + * If the matcher is a callable, use that to evaluate the value. Otherwise, the values + * must be literally the same. + */ +export function match(value: any, matcher: any, inspection: InspectionFailure) { + if (isCallable(matcher)) { + // Custom matcher (this mostly looks very weird because our `InspectionFailure` signature is weird) + const innerInspection: InspectionFailure = { ...inspection, failureReason: '' }; + const result = matcher(value, innerInspection); + if (typeof result !== 'boolean') { + return failMatcher(inspection, `Predicate returned non-boolean return value: ${result}`); + } + if (!result && !innerInspection.failureReason) { + // Custom matcher neglected to return an error + return failMatcher(inspection, 'Predicate returned false'); + } + // Propagate inner error in case of failure + if (!result) { inspection.failureReason = innerInspection.failureReason; } + return result; + } + + return matchLiteral(value, matcher, inspection); +} + +/** + * Match a literal value at the top level. + * + * When recursing into arrays or objects, the nested values can be either matchers + * or literals. + */ +function matchLiteral(value: any, pattern: any, inspection: InspectionFailure) { + if (pattern == null) { return true; } + + const errors = new Array(); + + if (Array.isArray(value) !== Array.isArray(pattern)) { + return failMatcher(inspection, 'Array type mismatch'); + } + if (Array.isArray(value)) { + if (pattern.length !== value.length) { + return failMatcher(inspection, 'Array length mismatch'); + } + + // Recurse comparison for individual objects + for (let i = 0; i < pattern.length; i++) { + if (!match(value[i], pattern[i], { ...inspection })) { + errors.push(`Array element ${i} mismatch`); + } + } + + if (errors.length > 0) { + return failMatcher(inspection, errors.join(', ')); + } + return true; + } + if ((typeof value === 'object') !== (typeof pattern === 'object')) { + return failMatcher(inspection, 'Object type mismatch'); + } + if (typeof pattern === 'object') { + // Check that all fields in the pattern have the right value + const innerInspection = { ...inspection, failureReason: '' }; + const matcher = objectLike(pattern)(value, innerInspection); + if (!matcher) { + inspection.failureReason = innerInspection.failureReason; + return false; + } + + // Check no fields uncovered + const realFields = new Set(Object.keys(value)); + for (const key of Object.keys(pattern)) { realFields.delete(key); } + if (realFields.size > 0) { + return failMatcher(inspection, `Unexpected keys present in object: ${Array.from(realFields).join(', ')}`); + } + return true; + } + + if (value !== pattern) { + return failMatcher(inspection, 'Different values'); + } + + return true; +} + +/** + * Whether a value is a callable + */ +function isCallable(x: any): x is ((...args: any[]) => any) { + return x && {}.toString.call(x) === '[object Function]'; +} + +/** + * Do a glob-like pattern match (which only supports *s) + */ +export function stringLike(pattern: string): PropertyMatcher { + // Replace * with .* in the string, escape the rest and brace with ^...$ + const regex = new RegExp(`^${pattern.split('*').map(escapeRegex).join('.*')}$`); + + return annotateMatcher({ $stringContaining: pattern }, (value: any, failure: InspectionFailure) => { + if (typeof value !== 'string') { + failure.failureReason = `Expected a string, but got '${typeof value}'`; + return false; + } + + if (!regex.test(value)) { + failure.failureReason = 'String did not match pattern'; + return false; + } + + return true; + }); +} + +/** + * Matches any value + */ +export function anything(): PropertyMatcher { + return annotateMatcher({ $anything: true }, () => true); +} + +/** + * Negate an inner matcher + */ +export function notMatching(matcher: any): PropertyMatcher { + return annotateMatcher({ $notMatching: matcher }, (value: any, failure: InspectionFailure) => { + const result = matcherFrom(matcher)(value, failure); + if (result) { + failure.failureReason = 'Should not have matched, but did'; + return false; + } + return true; + }); +} + +export type TypeValidator = (x: any) => x is T; + +/** + * Captures a value onto an object if it matches a given inner matcher + * + * @example + * + * const someValue = Capture.aString(); + * expect(stack).toHaveResource({ + * // ... + * Value: someValue.capture(stringMatching('*a*')), + * }); + * console.log(someValue.capturedValue); + */ +export class Capture { + /** + * A Capture object that captures any type + */ + public static anyType(): Capture { + return new Capture(); + } + + /** + * A Capture object that captures a string type + */ + public static aString(): Capture { + return new Capture((x: any): x is string => { + if (typeof x !== 'string') { + throw new Error(`Expected to capture a string, got '${x}'`); + } + return true; + }); + } + + /** + * A Capture object that captures a custom type + */ + // eslint-disable-next-line @typescript-eslint/no-shadow + public static a(validator: TypeValidator): Capture { + return new Capture(validator); + } + + private _value?: T; + private _didCapture = false; + private _wasInvoked = false; + + protected constructor(private readonly typeValidator?: TypeValidator) { + } + + /** + * Capture the value if the inner matcher successfully matches it + * + * If no matcher is given, `anything()` is assumed. + * + * And exception will be thrown if the inner matcher returns `true` and + * the value turns out to be of a different type than the `Capture` object + * is expecting. + */ + public capture(matcher?: any): PropertyMatcher { + if (matcher === undefined) { + matcher = anything(); + } + + return annotateMatcher({ $capture: matcher }, (value: any, failure: InspectionFailure) => { + this._wasInvoked = true; + const result = matcherFrom(matcher)(value, failure); + if (result) { + if (this.typeValidator && !this.typeValidator(value)) { + throw new Error(`Value not of the expected type: ${value}`); + } + this._didCapture = true; + this._value = value; + } + return result; + }); + } + + /** + * Whether a value was successfully captured + */ + public get didCapture() { + return this._didCapture; + } + + /** + * Return the value that was captured + * + * Throws an exception if now value was captured + */ + public get capturedValue(): T { + // When this module is ported to jsii, the type parameter will obviously + // have to be dropped and this will have to turn into an `any`. + if (!this.didCapture) { + throw new Error(`Did not capture a value: ${this._wasInvoked ? 'inner matcher failed' : 'never invoked'}`); + } + return this._value!; + } +} + +/** + * Match on the innards of a JSON string, instead of the complete string + */ +export function encodedJson(matcher: any): PropertyMatcher { + return annotateMatcher({ $encodedJson: matcher }, (value: any, failure: InspectionFailure) => { + if (typeof value !== 'string') { + failure.failureReason = `Expected a string, but got '${typeof value}'`; + return false; + } + + let decoded; + try { + decoded = JSON.parse(value); + } catch (e) { + failure.failureReason = `String is not JSON: ${e}`; + return false; + } + + return matcherFrom(matcher)(decoded, failure); + }); +} + +function escapeRegex(s: string) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +} + +/** + * Make a matcher out of the given argument if it's not a matcher already + * + * If it's not a matcher, it will be treated as a literal. + */ +export function matcherFrom(matcher: any): PropertyMatcher { + return isCallable(matcher) ? matcher : exactValue(matcher); +} + +/** + * Annotate a matcher with toJSON + * + * We will JSON.stringify() values if we have a match failure, but for matchers this + * would show (in traditional JS fashion) something like '[function Function]', or more + * accurately nothing at all since functions cannot be JSONified. + * + * We override to JSON() in order to produce a readadable version of the matcher. + */ +export function annotateMatcher(how: A, matcher: PropertyMatcher): PropertyMatcher { + (matcher as any).toJSON = () => how; + return matcher; +} diff --git a/packages/@aws-cdk/assert-internal/lib/assertions/have-resource.ts b/packages/@aws-cdk/assert-internal/lib/assertions/have-resource.ts new file mode 100644 index 0000000000000..5a977d8252fd4 --- /dev/null +++ b/packages/@aws-cdk/assert-internal/lib/assertions/have-resource.ts @@ -0,0 +1,163 @@ +import { Assertion, JestFriendlyAssertion } from '../assertion'; +import { StackInspector } from '../inspector'; +import { anything, deepObjectLike, match, objectLike } from './have-resource-matchers'; + +/** + * Magic value to signify that a certain key should be absent from the property bag. + * + * The property is either not present or set to `undefined. + * + * NOTE: `ABSENT` only works with the `haveResource()` and `haveResourceLike()` + * assertions. + */ +export const ABSENT = '{{ABSENT}}'; + +/** + * An assertion to check whether a resource of a given type and with the given properties exists, disregarding properties + * + * @param resourceType the type of the resource that is expected to be present. + * @param properties the properties that the resource is expected to have. A function may be provided, in which case + * it will be called with the properties of candidate resources and an ``InspectionFailure`` + * instance on which errors should be appended, and should return a truthy value to denote a match. + * @param comparison the entity that is being asserted against. + * @param allowValueExtension if properties is an object, tells whether values must match exactly, or if they are + * allowed to be supersets of the reference values. Meaningless if properties is a function. + */ +export function haveResource( + resourceType: string, + properties?: any, + comparison?: ResourcePart, + allowValueExtension: boolean = false): Assertion { + return new HaveResourceAssertion(resourceType, properties, comparison, allowValueExtension); +} + +/** + * Sugar for calling ``haveResource`` with ``allowValueExtension`` set to ``true``. + */ +export function haveResourceLike( + resourceType: string, + properties?: any, + comparison?: ResourcePart) { + return haveResource(resourceType, properties, comparison, true); +} + +export type PropertyMatcher = (props: any, inspection: InspectionFailure) => boolean; + +export class HaveResourceAssertion extends JestFriendlyAssertion { + private readonly inspected: InspectionFailure[] = []; + private readonly part: ResourcePart; + private readonly matcher: any; + + constructor( + private readonly resourceType: string, + properties?: any, + part?: ResourcePart, + allowValueExtension: boolean = false) { + super(); + + this.matcher = isCallable(properties) ? properties : + properties === undefined ? anything() : + allowValueExtension ? deepObjectLike(properties) : + objectLike(properties); + this.part = part ?? ResourcePart.Properties; + } + + public assertUsing(inspector: StackInspector): boolean { + for (const logicalId of Object.keys(inspector.value.Resources || {})) { + const resource = inspector.value.Resources[logicalId]; + if (resource.Type === this.resourceType) { + const propsToCheck = this.part === ResourcePart.Properties ? (resource.Properties ?? {}) : resource; + + // Pass inspection object as 2nd argument, initialize failure with default string, + // to maintain backwards compatibility with old predicate API. + const inspection = { resource, failureReason: 'Object did not match predicate' }; + + if (match(propsToCheck, this.matcher, inspection)) { + return true; + } + + this.inspected.push(inspection); + } + } + + return false; + } + + public generateErrorMessage() { + const lines: string[] = []; + lines.push(`None of ${this.inspected.length} resources matches ${this.description}.`); + + for (const inspected of this.inspected) { + lines.push(`- ${inspected.failureReason} in:`); + lines.push(indent(4, JSON.stringify(inspected.resource, null, 2))); + } + + return lines.join('\n'); + } + + public assertOrThrow(inspector: StackInspector) { + if (!this.assertUsing(inspector)) { + throw new Error(this.generateErrorMessage()); + } + } + + public get description(): string { + // eslint-disable-next-line max-len + return `resource '${this.resourceType}' with ${JSON.stringify(this.matcher, undefined, 2)}`; + } +} + +function indent(n: number, s: string) { + const prefix = ' '.repeat(n); + return prefix + s.replace(/\n/g, '\n' + prefix); +} + +export interface InspectionFailure { + resource: any; + failureReason: string; +} + +/** + * What part of the resource to compare + */ +export enum ResourcePart { + /** + * Only compare the resource's properties + */ + Properties, + + /** + * Check the entire CloudFormation config + * + * (including UpdateConfig, DependsOn, etc.) + */ + CompleteDefinition +} + +/** + * Whether a value is a callable + */ +function isCallable(x: any): x is ((...args: any[]) => any) { + return x && {}.toString.call(x) === '[object Function]'; +} + +/** + * Return whether `superObj` is a super-object of `obj`. + * + * A super-object has the same or more property values, recursing into sub properties if ``allowValueExtension`` is true. + * + * At any point in the object, a value may be replaced with a function which will be used to check that particular field. + * The type of a matcher function is expected to be of type PropertyMatcher. + * + * @deprecated - Use `objectLike` or a literal object instead. + */ +export function isSuperObject(superObj: any, pattern: any, errors: string[] = [], allowValueExtension: boolean = false): boolean { + const matcher = allowValueExtension ? deepObjectLike(pattern) : objectLike(pattern); + + const inspection: InspectionFailure = { resource: superObj, failureReason: '' }; + const ret = match(superObj, matcher, inspection); + if (!ret) { + errors.push(inspection.failureReason); + } + return ret; +} diff --git a/packages/@aws-cdk/assert-internal/lib/assertions/have-type.ts b/packages/@aws-cdk/assert-internal/lib/assertions/have-type.ts new file mode 100644 index 0000000000000..a04d8a450a338 --- /dev/null +++ b/packages/@aws-cdk/assert-internal/lib/assertions/have-type.ts @@ -0,0 +1,21 @@ +import { Assertion } from '../assertion'; +import { StackPathInspector } from '../inspector'; + +export function haveType(type: string): Assertion { + return new StackPathHasTypeAssertion(type); +} + +class StackPathHasTypeAssertion extends Assertion { + constructor(private readonly type: string) { + super(); + } + + public assertUsing(inspector: StackPathInspector): boolean { + const resource = inspector.value; + return resource !== undefined && resource.Type === this.type; + } + + public get description(): string { + return `resource of type ${this.type}`; + } +} diff --git a/packages/@aws-cdk/assert-internal/lib/assertions/match-template.ts b/packages/@aws-cdk/assert-internal/lib/assertions/match-template.ts new file mode 100644 index 0000000000000..e668466d12416 --- /dev/null +++ b/packages/@aws-cdk/assert-internal/lib/assertions/match-template.ts @@ -0,0 +1,96 @@ +import * as cfnDiff from '@aws-cdk/cloudformation-diff'; +import { Assertion } from '../assertion'; +import { StackInspector } from '../inspector'; + +export enum MatchStyle { + /** Requires an exact match */ + EXACT = 'exactly', + /** Allows any change that does not cause a resource replacement */ + NO_REPLACES = 'no replaces', + /** Allows additions, but no updates */ + SUPERSET = 'superset' +} + +export function exactlyMatchTemplate(template: { [key: string]: any }) { + return matchTemplate(template, MatchStyle.EXACT); +} + +export function beASupersetOfTemplate(template: { [key: string]: any }) { + return matchTemplate(template, MatchStyle.SUPERSET); +} + +export function matchTemplate( + template: { [key: string]: any }, + matchStyle: MatchStyle = MatchStyle.EXACT): Assertion { + return new StackMatchesTemplateAssertion(template, matchStyle); +} + +class StackMatchesTemplateAssertion extends Assertion { + constructor( + private readonly template: { [key: string]: any }, + private readonly matchStyle: MatchStyle) { + super(); + } + + public assertOrThrow(inspector: StackInspector) { + if (!this.assertUsing(inspector)) { + // The details have already been printed, so don't generate a huge error message + throw new Error('Template comparison produced unacceptable match'); + } + } + + public assertUsing(inspector: StackInspector): boolean { + const diff = cfnDiff.diffTemplate(this.template, inspector.value); + const acceptable = this.isDiffAcceptable(diff); + if (!acceptable) { + // Print the diff + cfnDiff.formatDifferences(process.stderr, diff); + + // Print the actual template + process.stdout.write('--------------------------------------------------------------------------------------\n'); + process.stdout.write(JSON.stringify(inspector.value, undefined, 2) + '\n'); + } + + return acceptable; + } + + private isDiffAcceptable(diff: cfnDiff.TemplateDiff): boolean { + switch (this.matchStyle) { + case MatchStyle.EXACT: + return diff.differenceCount === 0; + case MatchStyle.NO_REPLACES: + for (const change of Object.values(diff.resources.changes)) { + if (change.changeImpact === cfnDiff.ResourceImpact.MAY_REPLACE) { return false; } + if (change.changeImpact === cfnDiff.ResourceImpact.WILL_REPLACE) { return false; } + } + + for (const change of Object.values(diff.parameters.changes)) { + if (change.isUpdate) { return false; } + } + + for (const change of Object.values(diff.outputs.changes)) { + if (change.isUpdate) { return false; } + } + return true; + case MatchStyle.SUPERSET: + for (const change of Object.values(diff.resources.changes)) { + if (change.changeImpact !== cfnDiff.ResourceImpact.WILL_CREATE) { return false; } + } + + for (const change of Object.values(diff.parameters.changes)) { + if (change.isAddition) { return false; } + } + + for (const change of Object.values(diff.outputs.changes)) { + if (change.isAddition || change.isUpdate) { return false; } + } + + return true; + } + throw new Error(`Unsupported match style: ${this.matchStyle}`); + } + + public get description(): string { + return `template (${this.matchStyle}): ${JSON.stringify(this.template, null, 2)}`; + } +} diff --git a/packages/@aws-cdk/assert-internal/lib/assertions/negated-assertion.ts b/packages/@aws-cdk/assert-internal/lib/assertions/negated-assertion.ts new file mode 100644 index 0000000000000..4c62225ee48a9 --- /dev/null +++ b/packages/@aws-cdk/assert-internal/lib/assertions/negated-assertion.ts @@ -0,0 +1,16 @@ +import { Assertion } from '../assertion'; +import { Inspector } from '../inspector'; + +export class NegatedAssertion extends Assertion { + constructor(private readonly negated: Assertion) { + super(); + } + + public assertUsing(inspector: I): boolean { + return !this.negated.assertUsing(inspector); + } + + public get description(): string { + return `not ${this.negated.description}`; + } +} diff --git a/packages/@aws-cdk/assert-internal/lib/canonicalize-assets.ts b/packages/@aws-cdk/assert-internal/lib/canonicalize-assets.ts new file mode 100644 index 0000000000000..9cee3d4742b3c --- /dev/null +++ b/packages/@aws-cdk/assert-internal/lib/canonicalize-assets.ts @@ -0,0 +1,71 @@ +/** + * Reduce template to a normal form where asset references have been normalized + * + * This makes it possible to compare templates if all that's different between + * them is the hashes of the asset values. + * + * Currently only handles parameterized assets, but can (and should) + * be adapted to handle convention-mode assets as well when we start using + * more of those. + */ +export function canonicalizeTemplate(template: any): any { + // For the weird case where we have an array of templates... + if (Array.isArray(template)) { + return template.map(canonicalizeTemplate); + } + + // Find assets via parameters + const stringSubstitutions = new Array<[RegExp, string]>(); + const paramRe = /^AssetParameters([a-zA-Z0-9]{64})(S3Bucket|S3VersionKey|ArtifactHash)([a-zA-Z0-9]{8})$/; + + const assetsSeen = new Set(); + for (const paramName of Object.keys(template?.Parameters || {})) { + const m = paramRe.exec(paramName); + if (!m) { continue; } + if (assetsSeen.has(m[1])) { continue; } + + assetsSeen.add(m[1]); + const ix = assetsSeen.size; + + // Full parameter reference + stringSubstitutions.push([ + new RegExp(`AssetParameters${m[1]}(S3Bucket|S3VersionKey|ArtifactHash)([a-zA-Z0-9]{8})`), + `Asset${ix}$1`, + ]); + // Substring asset hash reference + stringSubstitutions.push([ + new RegExp(`${m[1]}`), + `Asset${ix}Hash`, + ]); + } + + // Substitute them out + return substitute(template); + + function substitute(what: any): any { + if (Array.isArray(what)) { + return what.map(substitute); + } + + if (typeof what === 'object' && what !== null) { + const ret: any = {}; + for (const [k, v] of Object.entries(what)) { + ret[stringSub(k)] = substitute(v); + } + return ret; + } + + if (typeof what === 'string') { + return stringSub(what); + } + + return what; + } + + function stringSub(x: string) { + for (const [re, replacement] of stringSubstitutions) { + x = x.replace(re, replacement); + } + return x; + } +} diff --git a/packages/@aws-cdk/assert-internal/lib/expect.ts b/packages/@aws-cdk/assert-internal/lib/expect.ts new file mode 100644 index 0000000000000..21dd7e011c826 --- /dev/null +++ b/packages/@aws-cdk/assert-internal/lib/expect.ts @@ -0,0 +1,12 @@ +import * as cdk from '@aws-cdk/core'; +import * as api from '@aws-cdk/cx-api'; +import { StackInspector } from './inspector'; +import { SynthUtils } from './synth-utils'; + +export function expect(stack: api.CloudFormationStackArtifact | cdk.Stack | Record, skipValidation = false): StackInspector { + // if this is already a synthesized stack, then just inspect it. + const artifact = stack instanceof api.CloudFormationStackArtifact ? stack + : cdk.Stack.isStack(stack) ? SynthUtils._synthesizeWithNested(stack, { skipValidation }) + : stack; // This is a template already + return new StackInspector(artifact); +} diff --git a/packages/@aws-cdk/assert-internal/lib/index.ts b/packages/@aws-cdk/assert-internal/lib/index.ts new file mode 100644 index 0000000000000..902a5c222f003 --- /dev/null +++ b/packages/@aws-cdk/assert-internal/lib/index.ts @@ -0,0 +1,15 @@ +export * from './assertion'; +export * from './canonicalize-assets'; +export * from './expect'; +export * from './inspector'; +export * from './synth-utils'; + +export * from './assertions/exist'; +export * from './assertions/have-output'; +export * from './assertions/have-resource'; +export * from './assertions/have-resource-matchers'; +export * from './assertions/have-type'; +export * from './assertions/match-template'; +export * from './assertions/and-assertion'; +export * from './assertions/negated-assertion'; +export * from './assertions/count-resources'; diff --git a/packages/@aws-cdk/assert-internal/lib/inspector.ts b/packages/@aws-cdk/assert-internal/lib/inspector.ts new file mode 100644 index 0000000000000..f633de428f4f2 --- /dev/null +++ b/packages/@aws-cdk/assert-internal/lib/inspector.ts @@ -0,0 +1,74 @@ +import * as cxschema from '@aws-cdk/cloud-assembly-schema'; +import * as api from '@aws-cdk/cx-api'; +import { Assertion, not } from './assertion'; +import { MatchStyle, matchTemplate } from './assertions/match-template'; + +export abstract class Inspector { + public aroundAssert?: (cb: () => void) => any; + + constructor() { + this.aroundAssert = undefined; + } + + public to(assertion: Assertion): any { + return this.aroundAssert ? this.aroundAssert(() => this._to(assertion)) + : this._to(assertion); + } + + public notTo(assertion: Assertion): any { + return this.to(not(assertion)); + } + + abstract get value(): any; + + private _to(assertion: Assertion): any { + assertion.assertOrThrow(this); + } +} + +export class StackInspector extends Inspector { + + private readonly template: { [key: string]: any }; + + constructor(public readonly stack: api.CloudFormationStackArtifact | object) { + super(); + + this.template = stack instanceof api.CloudFormationStackArtifact ? stack.template : stack; + } + + public at(path: string | string[]): StackPathInspector { + if (!(this.stack instanceof api.CloudFormationStackArtifact)) { + throw new Error('Cannot use "expect(stack).at(path)" for a raw template, only CloudFormationStackArtifact'); + } + + const strPath = typeof path === 'string' ? path : path.join('/'); + return new StackPathInspector(this.stack, strPath); + } + + public toMatch(template: { [key: string]: any }, matchStyle = MatchStyle.EXACT) { + return this.to(matchTemplate(template, matchStyle)); + } + + public get value(): { [key: string]: any } { + return this.template; + } +} + +export class StackPathInspector extends Inspector { + constructor(public readonly stack: api.CloudFormationStackArtifact, public readonly path: string) { + super(); + } + + public get value(): { [key: string]: any } | undefined { + // The names of paths in metadata in tests are very ill-defined. Try with the full path first, + // then try with the stack name preprended for backwards compat with most tests that happen to give + // their stack an ID that's the same as the stack name. + const metadata = this.stack.manifest.metadata || {}; + const md = metadata[this.path] || metadata[`/${this.stack.id}${this.path}`]; + if (md === undefined) { return undefined; } + const resourceMd = md.find(entry => entry.type === cxschema.ArtifactMetadataEntryType.LOGICAL_ID); + if (resourceMd === undefined) { return undefined; } + const logicalId = resourceMd.data as cxschema.LogMessageMetadataEntry; + return this.stack.template.Resources[logicalId]; + } +} diff --git a/packages/@aws-cdk/assert-internal/lib/synth-utils.ts b/packages/@aws-cdk/assert-internal/lib/synth-utils.ts new file mode 100644 index 0000000000000..bb8d9a437afd9 --- /dev/null +++ b/packages/@aws-cdk/assert-internal/lib/synth-utils.ts @@ -0,0 +1,87 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as core from '@aws-cdk/core'; +import * as cxapi from '@aws-cdk/cx-api'; + +export class SynthUtils { + /** + * Returns the cloud assembly template artifact for a stack. + */ + public static synthesize(stack: core.Stack, options: core.SynthesisOptions = { }): cxapi.CloudFormationStackArtifact { + // always synthesize against the root (be it an App or whatever) so all artifacts will be included + const assembly = synthesizeApp(stack, options); + return assembly.getStackArtifact(stack.artifactId); + } + + /** + * Synthesizes the stack and returns the resulting CloudFormation template. + */ + public static toCloudFormation(stack: core.Stack, options: core.SynthesisOptions = { }): any { + const synth = this._synthesizeWithNested(stack, options); + if (synth instanceof cxapi.CloudFormationStackArtifact) { + return synth.template; + } else { + return synth; + } + } + + /** + * @returns Returns a subset of the synthesized CloudFormation template (only specific resource types). + */ + public static subset(stack: core.Stack, options: SubsetOptions): any { + const template = this.toCloudFormation(stack); + if (template.Resources) { + for (const [key, resource] of Object.entries(template.Resources)) { + if (options.resourceTypes && !options.resourceTypes.includes((resource as any).Type)) { + delete template.Resources[key]; + } + } + } + + return template; + } + + /** + * Synthesizes the stack and returns a `CloudFormationStackArtifact` which can be inspected. + * Supports nested stacks as well as normal stacks. + * + * @return CloudFormationStackArtifact for normal stacks or the actual template for nested stacks + * @internal + */ + public static _synthesizeWithNested(stack: core.Stack, options: core.SynthesisOptions = { }): cxapi.CloudFormationStackArtifact | object { + // always synthesize against the root (be it an App or whatever) so all artifacts will be included + const assembly = synthesizeApp(stack, options); + + // if this is a nested stack (it has a parent), then just read the template as a string + if (stack.nestedStackParent) { + return JSON.parse(fs.readFileSync(path.join(assembly.directory, stack.templateFile)).toString('utf-8')); + } + + return assembly.getStackArtifact(stack.artifactId); + } +} + +/** + * Synthesizes the app in which a stack resides and returns the cloud assembly object. + */ +function synthesizeApp(stack: core.Stack, options: core.SynthesisOptions) { + const root = stack.node.root; + if (!core.Stage.isStage(root)) { + throw new Error('unexpected: all stacks must be part of a Stage or an App'); + } + + // to support incremental assertions (i.e. "expect(stack).toNotContainSomething(); doSomething(); expect(stack).toContainSomthing()") + const force = true; + + return root.synth({ + force, + ...options, + }); +} + +export interface SubsetOptions { + /** + * Match all resources of the given type + */ + resourceTypes?: string[]; +} diff --git a/packages/@aws-cdk/assert-internal/package.json b/packages/@aws-cdk/assert-internal/package.json new file mode 100644 index 0000000000000..7b96667edc773 --- /dev/null +++ b/packages/@aws-cdk/assert-internal/package.json @@ -0,0 +1,67 @@ +{ + "name": "@aws-cdk/assert-internal", + "private": true, + "version": "0.0.0", + "description": "An assertion library for use with CDK Apps", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "scripts": { + "build": "cdk-build", + "watch": "cdk-watch", + "lint": "cdk-lint", + "test": "cdk-test", + "pkglint": "pkglint -f", + "package": "cdk-package", + "build+test+package": "yarn build+test && yarn package", + "build+test": "yarn build && yarn test" + }, + "author": { + "name": "Amazon Web Services", + "url": "https://aws.amazon.com", + "organization": true + }, + "license": "Apache-2.0", + "devDependencies": { + "@types/jest": "^26.0.22", + "cdk-build-tools": "0.0.0", + "jest": "^26.6.3", + "pkglint": "0.0.0", + "ts-jest": "^26.5.4" + }, + "dependencies": { + "@aws-cdk/cloud-assembly-schema": "0.0.0", + "@aws-cdk/cloudformation-diff": "0.0.0", + "@aws-cdk/core": "0.0.0", + "@aws-cdk/cx-api": "0.0.0", + "constructs": "^3.3.69" + }, + "peerDependencies": { + "@aws-cdk/core": "0.0.0", + "constructs": "^3.3.69", + "jest": "^26.6.3" + }, + "repository": { + "url": "https://github.com/aws/aws-cdk.git", + "type": "git", + "directory": "packages/@aws-cdk/assert-internal" + }, + "keywords": [ + "aws", + "cdk" + ], + "homepage": "https://github.com/aws/aws-cdk", + "engines": { + "node": ">= 10.13.0 <13 || >=13.7.0" + }, + "stability": "experimental", + "maturity": "experimental", + "cdk-build": { + "jest": true + }, + "publishConfig": { + "tag": "latest" + }, + "ubergen": { + "exclude": true + } +} diff --git a/packages/@aws-cdk/assert-internal/test/assertions.test.ts b/packages/@aws-cdk/assert-internal/test/assertions.test.ts new file mode 100644 index 0000000000000..bd20d60032d76 --- /dev/null +++ b/packages/@aws-cdk/assert-internal/test/assertions.test.ts @@ -0,0 +1,349 @@ +import * as cdk from '@aws-cdk/core'; +import * as cx from '@aws-cdk/cx-api'; +import * as constructs from 'constructs'; + +import { countResources, countResourcesLike, exist, expect as cdkExpect, haveType, MatchStyle, matchTemplate } from '../lib/index'; + +passingExample('expect at to have ', () => { + const resourceType = 'Test::Resource'; + const synthStack = synthesizedStack(stack => { + new TestResource(stack, 'TestResource', { type: resourceType }); + }); + cdkExpect(synthStack).at('/TestResource').to(haveType(resourceType)); +}); +passingExample('expect non-synthesized stack at to have ', () => { + const resourceType = 'Test::Resource'; + const stack = new cdk.Stack(); + new TestResource(stack, 'TestResource', { type: resourceType }); + cdkExpect(stack).at('/TestResource').to(haveType(resourceType)); +}); +passingExample('expect at *not* to have ', () => { + const resourceType = 'Test::Resource'; + const synthStack = synthesizedStack(stack => { + new TestResource(stack, 'TestResource', { type: resourceType }); + }); + cdkExpect(synthStack).at('/TestResource').notTo(haveType('Foo::Bar')); +}); +passingExample('expect at to exist', () => { + const resourceType = 'Test::Resource'; + const synthStack = synthesizedStack(stack => { + new TestResource(stack, 'TestResource', { type: resourceType }); + }); + cdkExpect(synthStack).at('/TestResource').to(exist()); +}); +passingExample('expect to match (exactly)