diff --git a/packages/@aws-cdk/integ-tests/lib/assertions/assertions.ts b/packages/@aws-cdk/integ-tests/lib/assertions/assertions.ts new file mode 100644 index 0000000000000..1b3ba0f14f7bf --- /dev/null +++ b/packages/@aws-cdk/integ-tests/lib/assertions/assertions.ts @@ -0,0 +1,53 @@ +import { CustomResource } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { IAssertion } from './deploy-assert'; +import { AssertionRequest, AssertionsProvider, ASSERT_RESOURCE_TYPE, AssertionType } from './providers'; +// +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct as CoreConstruct } from '@aws-cdk/core'; + +/** + * Options for an EqualsAssertion + */ +export interface EqualsAssertionProps { + /** + * The CustomResource that continains the "actual" results + */ + readonly inputResource: CustomResource; + + /** + * The CustomResource attribute that continains the "actual" results + */ + readonly inputResourceAtt: string; + + /** + * The expected result to assert + */ + readonly expected: any; +} + +/** + * Construct that creates a CustomResource to assert that two + * values are equal + */ +export class EqualsAssertion extends CoreConstruct implements IAssertion { + public readonly result: string; + + constructor(scope: Construct, id: string, props: EqualsAssertionProps) { + super(scope, id); + + const assertionProvider = new AssertionsProvider(this, 'AssertionProvider'); + const properties: AssertionRequest = { + actual: props.inputResource.getAttString(props.inputResourceAtt), + expected: props.expected, + assertionType: AssertionType.EQUALS, + }; + const resource = new CustomResource(this, 'Default', { + serviceToken: assertionProvider.serviceToken, + properties, + resourceType: ASSERT_RESOURCE_TYPE, + }); + this.result = resource.getAttString('data'); + } +} diff --git a/packages/@aws-cdk/integ-tests/lib/assertions/deploy-assert.ts b/packages/@aws-cdk/integ-tests/lib/assertions/deploy-assert.ts new file mode 100644 index 0000000000000..8ef74b5ce56a5 --- /dev/null +++ b/packages/@aws-cdk/integ-tests/lib/assertions/deploy-assert.ts @@ -0,0 +1,95 @@ +import { CfnOutput, CustomResource, Lazy } from '@aws-cdk/core'; +import { Construct, IConstruct, Node } from 'constructs'; +import { md5hash } from './private/hash'; +import { RESULTS_RESOURCE_TYPE, AssertionsProvider } from './providers'; +import { SdkQuery, SdkQueryOptions } from './sdk'; + +const DEPLOY_ASSERT_SYMBOL = Symbol.for('@aws-cdk/integ-tests.DeployAssert'); + +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct as CoreConstruct } from '@aws-cdk/core'; + +/** + * Represents a deploy time assertion + */ +export interface IAssertion { + /** + * The result of the assertion + */ + readonly result: string; +} + +/** + * Options for DeployAssert + */ +export interface DeployAssertProps { } + +/** + * Construct that allows for registering a list of assertions + * that should be performed on a construct + */ +export class DeployAssert extends CoreConstruct { + + /** + * Returns whether the construct is a DeployAssert construct + */ + public static isDeployAssert(x: any): x is DeployAssert { + return x !== null && typeof(x) === 'object' && DEPLOY_ASSERT_SYMBOL in x; + } + + /** + * Finds a DeployAssert construct in the given scope + */ + public static of(construct: IConstruct): DeployAssert { + const scopes = Node.of(construct).scopes.reverse(); + const deployAssert = scopes.find(s => DeployAssert.isDeployAssert(s)); + if (!deployAssert) { + throw new Error('No DeployAssert construct found in scopes'); + } + return deployAssert as DeployAssert; + } + + /** @internal */ + public readonly _assertions: IAssertion[]; + + constructor(scope: Construct) { + super(scope, 'DeployAssert'); + + Object.defineProperty(this, DEPLOY_ASSERT_SYMBOL, { value: true }); + this._assertions = []; + + const provider = new AssertionsProvider(this, 'ResultsProvider'); + + const resource = new CustomResource(this, 'ResultsCollection', { + serviceToken: provider.serviceToken, + properties: { + assertionResults: Lazy.list({ + produce: () => this._assertions.map(a => a.result), + }), + }, + resourceType: RESULTS_RESOURCE_TYPE, + }); + + // TODO: need to show/store this information + new CfnOutput(this, 'Results', { + value: `\n${resource.getAttString('message')}`, + }).overrideLogicalId('Results'); + } + + /** + * Query AWS using JavaScript SDK V2 API calls + */ + public queryAws(options: SdkQueryOptions): SdkQuery { + const id = md5hash(options); + return new SdkQuery(this, `SdkQuery${id}`, options); + } + + /** + * Register an assertion that should be run as part of the + * deployment + */ + public registerAssertion(assertion: IAssertion) { + this._assertions.push(assertion); + } +} diff --git a/packages/@aws-cdk/integ-tests/lib/assertions/index.ts b/packages/@aws-cdk/integ-tests/lib/assertions/index.ts new file mode 100644 index 0000000000000..f1f833d9f78a4 --- /dev/null +++ b/packages/@aws-cdk/integ-tests/lib/assertions/index.ts @@ -0,0 +1,4 @@ +export * from './assertions'; +export * from './sdk'; +export * from './deploy-assert'; +export * from './providers'; diff --git a/packages/@aws-cdk/integ-tests/lib/assertions/private/hash.ts b/packages/@aws-cdk/integ-tests/lib/assertions/private/hash.ts new file mode 100644 index 0000000000000..38649bbea4473 --- /dev/null +++ b/packages/@aws-cdk/integ-tests/lib/assertions/private/hash.ts @@ -0,0 +1,10 @@ +import * as crypto from 'crypto'; + +export function md5hash(obj: any): string { + if (!obj || (typeof(obj) === 'object' && Object.keys(obj).length === 0)) { + throw new Error('Cannot compute md5 hash for falsy object'); + } + const hash = crypto.createHash('md5'); + hash.update(JSON.stringify(obj)); + return hash.digest('hex'); +} diff --git a/packages/@aws-cdk/integ-tests/lib/assertions/providers/index.ts b/packages/@aws-cdk/integ-tests/lib/assertions/providers/index.ts new file mode 100644 index 0000000000000..1c21f87ecd74a --- /dev/null +++ b/packages/@aws-cdk/integ-tests/lib/assertions/providers/index.ts @@ -0,0 +1,2 @@ +export * from './lambda-handler/types'; +export * from './provider'; diff --git a/packages/@aws-cdk/integ-tests/lib/assertions/providers/lambda-handler/assertion.ts b/packages/@aws-cdk/integ-tests/lib/assertions/providers/lambda-handler/assertion.ts new file mode 100644 index 0000000000000..8efd972d5f98e --- /dev/null +++ b/packages/@aws-cdk/integ-tests/lib/assertions/providers/lambda-handler/assertion.ts @@ -0,0 +1,34 @@ +/* eslint-disable no-console */ +import * as assert from 'assert'; +import { CustomResourceHandler } from './base'; +import { AssertionRequest, AssertionResult } from './types'; + +export class AssertionHandler extends CustomResourceHandler { + protected async processEvent(request: AssertionRequest): Promise { + let result: AssertionResult; + switch (request.assertionType) { + case 'equals': + console.log(`Testing equality between ${JSON.stringify(request.actual)} and ${JSON.stringify(request.expected)}`); + try { + assert.deepStrictEqual(request.actual, request.expected); + result = { data: { status: 'pass' } }; + } catch (e) { + if (e instanceof assert.AssertionError) { + result = { + data: { + status: 'fail', + message: e.message, + }, + }; + } else { + throw e; + } + } + break; + default: + throw new Error(`Unsupported query type ${request.assertionType}`); + } + + return result; + } +} diff --git a/packages/@aws-cdk/integ-tests/lib/assertions/providers/lambda-handler/base.ts b/packages/@aws-cdk/integ-tests/lib/assertions/providers/lambda-handler/base.ts new file mode 100644 index 0000000000000..829574f80d8eb --- /dev/null +++ b/packages/@aws-cdk/integ-tests/lib/assertions/providers/lambda-handler/base.ts @@ -0,0 +1,100 @@ +/* eslint-disable no-console */ +import * as https from 'https'; +import * as url from 'url'; + +interface HandlerResponse { + readonly status: 'SUCCESS' | 'FAILED'; + readonly reason: 'OK' | string; + readonly data?: any; +} + +// eslint-disable-next-line @typescript-eslint/ban-types +export abstract class CustomResourceHandler { + public readonly physicalResourceId: string; + private readonly timeout: NodeJS.Timeout; + private timedOut = false; + + constructor(protected readonly event: AWSLambda.CloudFormationCustomResourceEvent, protected readonly context: AWSLambda.Context) { + this.timeout = setTimeout(async () => { + await this.respond({ + status: 'FAILED', + reason: 'Lambda Function Timeout', + data: this.context.logStreamName, + }); + this.timedOut = true; + }, context.getRemainingTimeInMillis() - 1200); + this.event = event; + this.physicalResourceId = extractPhysicalResourceId(event); + } + + public async handle(): Promise { + try { + console.log(`Event: ${JSON.stringify(this.event)}`); + const response = await this.processEvent(this.event.ResourceProperties as unknown as Request); + console.log(`Event output : ${JSON.stringify(response)}`); + await this.respond({ + status: 'SUCCESS', + reason: 'OK', + data: response, + }); + } catch (e) { + console.log(e); + await this.respond({ + status: 'FAILED', + reason: e.message ?? 'Internal Error', + }); + } finally { + clearTimeout(this.timeout); + } + } + + protected abstract processEvent(request: Request): Promise; + + private respond(response: HandlerResponse) { + if (this.timedOut) { + return; + } + const cfResponse: AWSLambda.CloudFormationCustomResourceResponse = { + Status: response.status, + Reason: response.reason, + PhysicalResourceId: this.physicalResourceId, + StackId: this.event.StackId, + RequestId: this.event.RequestId, + LogicalResourceId: this.event.LogicalResourceId, + NoEcho: false, + Data: response.data, + }; + const responseBody = JSON.stringify(cfResponse); + + console.log('Responding to CloudFormation', responseBody); + + const parsedUrl = url.parse(this.event.ResponseURL); + const requestOptions = { + hostname: parsedUrl.hostname, + path: parsedUrl.path, + method: 'PUT', + headers: { 'content-type': '', 'content-length': responseBody.length }, + }; + + return new Promise((resolve, reject) => { + try { + const request = https.request(requestOptions, resolve); + request.on('error', reject); + request.write(responseBody); + request.end(); + } catch (e) { + reject(e); + } + }); + } +} + +function extractPhysicalResourceId(event: AWSLambda.CloudFormationCustomResourceEvent): string { + switch (event.RequestType) { + case 'Create': + return event.LogicalResourceId; + case 'Update': + case 'Delete': + return event.PhysicalResourceId; + } +} diff --git a/packages/@aws-cdk/integ-tests/lib/assertions/providers/lambda-handler/index.ts b/packages/@aws-cdk/integ-tests/lib/assertions/providers/lambda-handler/index.ts new file mode 100644 index 0000000000000..07a1911efe4dd --- /dev/null +++ b/packages/@aws-cdk/integ-tests/lib/assertions/providers/lambda-handler/index.ts @@ -0,0 +1,21 @@ +import { AssertionHandler } from './assertion'; +import { ResultsCollectionHandler } from './results'; +import { SdkHandler } from './sdk'; +import * as types from './types'; + +export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent, context: AWSLambda.Context) { + const provider = createResourceHandler(event, context); + await provider.handle(); +} + +function createResourceHandler(event: AWSLambda.CloudFormationCustomResourceEvent, context: AWSLambda.Context) { + if (event.ResourceType.startsWith(types.SDK_RESOURCE_TYPE_PREFIX)) { + return new SdkHandler(event, context); + } + switch (event.ResourceType) { + case types.ASSERT_RESOURCE_TYPE: return new AssertionHandler(event, context); + case types.RESULTS_RESOURCE_TYPE: return new ResultsCollectionHandler(event, context); + default: + throw new Error(`Unsupported resource type "${event.ResourceType}`); + } +} diff --git a/packages/@aws-cdk/integ-tests/lib/assertions/providers/lambda-handler/results.ts b/packages/@aws-cdk/integ-tests/lib/assertions/providers/lambda-handler/results.ts new file mode 100644 index 0000000000000..784ff68a05ab6 --- /dev/null +++ b/packages/@aws-cdk/integ-tests/lib/assertions/providers/lambda-handler/results.ts @@ -0,0 +1,12 @@ +import { CustomResourceHandler } from './base'; +import { ResultsCollectionRequest, ResultsCollectionResult } from './types'; + +export class ResultsCollectionHandler extends CustomResourceHandler { + protected async processEvent(request: ResultsCollectionRequest): Promise { + const reduced: string = request.assertionResults.reduce((agg, result, idx) => { + const msg = result.status === 'pass' ? 'pass' : `fail - ${result.message}`; + return `${agg}\nTest${idx}: ${msg}`; + }, '').trim(); + return { message: reduced }; + } +} diff --git a/packages/@aws-cdk/integ-tests/lib/assertions/providers/lambda-handler/sdk.ts b/packages/@aws-cdk/integ-tests/lib/assertions/providers/lambda-handler/sdk.ts new file mode 100644 index 0000000000000..fed1174d3fb27 --- /dev/null +++ b/packages/@aws-cdk/integ-tests/lib/assertions/providers/lambda-handler/sdk.ts @@ -0,0 +1,59 @@ +/* eslint-disable no-console */ +import { CustomResourceHandler } from './base'; +import { SdkRequest, SdkResult } from './types'; + +/** + * Flattens a nested object + * + * @param object the object to be flattened + * @returns a flat object with path as keys + */ +export function flatten(object: object): { [key: string]: any } { + return Object.assign( + {}, + ...function _flatten(child: any, path: string[] = []): any { + return [].concat(...Object.keys(child) + .map(key => { + const childKey = Buffer.isBuffer(child[key]) ? child[key].toString('utf8') : child[key]; + return typeof childKey === 'object' && childKey !== null + ? _flatten(childKey, path.concat([key])) + : ({ [path.concat([key]).join('.')]: childKey }); + })); + }(object), + ); +} + + +export class SdkHandler extends CustomResourceHandler { + protected async processEvent(request: SdkRequest): Promise { + // eslint-disable-next-line + const AWS: any = require('aws-sdk'); + console.log(`AWS SDK VERSION: ${AWS.VERSION}`); + + const service = new AWS[request.service](); + const response = await service[request.api](request.parameters && decode(request.parameters)).promise(); + console.log(`SDK response received ${JSON.stringify(response)}`); + delete response.ResponseMetadata; + const respond = { + apiCallResponse: response, + }; + const flatData: { [key: string]: string } = { + ...flatten(respond), + }; + + return request.flattenResponse === 'true' ? flatData : respond; + } +} + +function decode(object: Record) { + return JSON.parse(JSON.stringify(object), (_k, v) => { + switch (v) { + case 'TRUE:BOOLEAN': + return true; + case 'FALSE:BOOLEAN': + return false; + default: + return v; + } + }); +} diff --git a/packages/@aws-cdk/integ-tests/lib/assertions/providers/lambda-handler/types.ts b/packages/@aws-cdk/integ-tests/lib/assertions/providers/lambda-handler/types.ts new file mode 100644 index 0000000000000..f0ff05507ae61 --- /dev/null +++ b/packages/@aws-cdk/integ-tests/lib/assertions/providers/lambda-handler/types.ts @@ -0,0 +1,138 @@ +// This file contains the input and output types for the providers. +// Kept in a separate file for sharing between the handler and the provider constructs. + +export const ASSERT_RESOURCE_TYPE = 'Custom::DeployAssert@AssertEquals'; +export const RESULTS_RESOURCE_TYPE = 'Custom::DeployAssert@ResultsCollection'; +export const SDK_RESOURCE_TYPE_PREFIX = 'Custom::DeployAssert@SdkCall'; + +/** + * A AWS JavaScript SDK V2 request + */ +export interface SdkRequest { + /** + * The AWS service i.e. S3 + */ + readonly service: string; + + /** + * The AWS api call to make i.e. getBucketLifecycle + */ + readonly api: string; + + /** + * Any parameters to pass to the api call + * + * @default - no parameters + */ + readonly parameters?: any; + + /** + * Whether or not to flatten the response from the api call + * + * Valid values are 'true' or 'false' as strings + * + * Typically when using an SdkRequest you will be passing it as the + * `actual` value to an assertion provider so this would be set + * to 'false' (you want the actual response). + * + * If you are using the SdkRequest to perform more of a query to return + * a single value to use, then this should be set to 'true'. For example, + * you could make a StepFunctions.startExecution api call and retreive the + * `executionArn` from the response. + * + * @default 'false' + */ + readonly flattenResponse?: string; +} + +/** + * The result from a SdkQuery + */ +export interface SdkResult { + /** + * The full api response + */ + readonly apiCallResponse: any; +} + +/** + * The type of assertion to perform + */ +export enum AssertionType { + /** + * Assert that two values are equal + */ + EQUALS = 'equals', +} + +/** + * A request to make an assertion that the + * actual value matches the expected + */ +export interface AssertionRequest { + /** + * The type of assertion to perform + */ + readonly assertionType: AssertionType; + + /** + * The expected value to assert + */ + readonly expected: any; + + /** + * The actual value received + */ + readonly actual: any; +} +/** + * The result of an Assertion + * wrapping the actual result data in another struct. + * Needed to access the whole message via getAtt() on the custom resource. + */ +export interface AssertionResult { +/** + * The result of an assertion + */ + readonly data: AssertionResultData; +} + +/** + * The result of an assertion + */ +export interface AssertionResultData { + /** + * The status of the assertion, i.e. + * pass or fail + */ + readonly status: 'pass' | 'fail' + + /** + * Any message returned with the assertion result + * typically this will be the diff if there is any + * + * @default - none + */ + readonly message?: string; +} + +/** + * Represents a collection of assertion request results + */ +export interface ResultsCollectionRequest { + /** + * The results of all the assertions that have been + * registered + */ + readonly assertionResults: AssertionResultData[]; +} + +/** + * The result of a results request + */ +export interface ResultsCollectionResult { + /** + * A message containing the results of the assertion + */ + readonly message: string; +} diff --git a/packages/@aws-cdk/integ-tests/lib/assertions/providers/provider.ts b/packages/@aws-cdk/integ-tests/lib/assertions/providers/provider.ts new file mode 100644 index 0000000000000..155996452713c --- /dev/null +++ b/packages/@aws-cdk/integ-tests/lib/assertions/providers/provider.ts @@ -0,0 +1,68 @@ +import * as path from 'path'; +import * as iam from '@aws-cdk/aws-iam'; +import * as lambda from '@aws-cdk/aws-lambda'; +import { Duration } from '@aws-cdk/core'; +import { Construct } from 'constructs'; + +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct as CoreConstruct } from '@aws-cdk/core'; +let SDK_METADATA: any = undefined; + +/** + * Represents an assertions provider. The creates a singletone + * Lambda Function that will create a single function per stack + * that serves as the custom resource provider for the various + * assertion providers + */ +export class AssertionsProvider extends CoreConstruct { + public readonly serviceToken: string; + private readonly grantPrincipal: iam.IPrincipal; + + constructor(scope: Construct, id: string) { + super(scope, id); + + const handler = new lambda.SingletonFunction(this, 'AssertionsProvider', { + code: lambda.Code.fromAsset(path.join(__dirname, 'lambda-handler')), + runtime: lambda.Runtime.NODEJS_14_X, + handler: 'index.handler', + uuid: '1488541a-7b23-4664-81b6-9b4408076b81', + timeout: Duration.minutes(2), + }); + + this.grantPrincipal = handler.grantPrincipal; + this.serviceToken = handler.functionArn; + } + + public encode(obj: any): any { + if (!obj) { + return obj; + } + return JSON.parse(JSON.stringify(obj), (_k, v) => { + switch (v) { + case true: + return 'TRUE:BOOLEAN'; + case false: + return 'FALSE:BOOLEAN'; + default: + return v; + } + }); + } + + public addPolicyStatementFromSdkCall(service: string, api: string, resources?: string[]): iam.PolicyStatement { + if (SDK_METADATA === undefined) { + // eslint-disable-next-line + SDK_METADATA = require('./sdk-api-metadata.json'); + } + const srv = service.toLowerCase(); + const iamService = (SDK_METADATA[srv] && SDK_METADATA[srv].prefix) || srv; + const iamAction = api.charAt(0).toUpperCase() + api.slice(1); + const statement = new iam.PolicyStatement({ + actions: [`${iamService}:${iamAction}`], + resources: resources || ['*'], + }); + this.grantPrincipal.addToPolicy(statement); + return statement; + } +} diff --git a/packages/@aws-cdk/integ-tests/lib/assertions/providers/sdk-api-metadata.json b/packages/@aws-cdk/integ-tests/lib/assertions/providers/sdk-api-metadata.json new file mode 100644 index 0000000000000..dbd7fbff66522 --- /dev/null +++ b/packages/@aws-cdk/integ-tests/lib/assertions/providers/sdk-api-metadata.json @@ -0,0 +1,1156 @@ +{ + "acm": { + "name": "ACM", + "cors": true + }, + "apigateway": { + "name": "APIGateway", + "cors": true + }, + "applicationautoscaling": { + "prefix": "application-autoscaling", + "name": "ApplicationAutoScaling", + "cors": true + }, + "appstream": { + "name": "AppStream" + }, + "autoscaling": { + "name": "AutoScaling", + "cors": true + }, + "batch": { + "name": "Batch" + }, + "budgets": { + "name": "Budgets" + }, + "clouddirectory": { + "name": "CloudDirectory", + "versions": [ + "2016-05-10*" + ] + }, + "cloudformation": { + "name": "CloudFormation", + "cors": true + }, + "cloudfront": { + "name": "CloudFront", + "versions": [ + "2013-05-12*", + "2013-11-11*", + "2014-05-31*", + "2014-10-21*", + "2014-11-06*", + "2015-04-17*", + "2015-07-27*", + "2015-09-17*", + "2016-01-13*", + "2016-01-28*", + "2016-08-01*", + "2016-08-20*", + "2016-09-07*", + "2016-09-29*", + "2016-11-25*", + "2017-03-25*", + "2017-10-30*", + "2018-06-18*", + "2018-11-05*", + "2019-03-26*" + ], + "cors": true + }, + "cloudhsm": { + "name": "CloudHSM", + "cors": true + }, + "cloudsearch": { + "name": "CloudSearch" + }, + "cloudsearchdomain": { + "name": "CloudSearchDomain" + }, + "cloudtrail": { + "name": "CloudTrail", + "cors": true + }, + "cloudwatch": { + "prefix": "monitoring", + "name": "CloudWatch", + "cors": true + }, + "cloudwatchevents": { + "prefix": "events", + "name": "CloudWatchEvents", + "versions": [ + "2014-02-03*" + ], + "cors": true + }, + "cloudwatchlogs": { + "prefix": "logs", + "name": "CloudWatchLogs", + "cors": true + }, + "codebuild": { + "name": "CodeBuild", + "cors": true + }, + "codecommit": { + "name": "CodeCommit", + "cors": true + }, + "codedeploy": { + "name": "CodeDeploy", + "cors": true + }, + "codepipeline": { + "name": "CodePipeline", + "cors": true + }, + "cognitoidentity": { + "prefix": "cognito-identity", + "name": "CognitoIdentity", + "cors": true + }, + "cognitoidentityserviceprovider": { + "prefix": "cognito-idp", + "name": "CognitoIdentityServiceProvider", + "cors": true + }, + "cognitosync": { + "prefix": "cognito-sync", + "name": "CognitoSync", + "cors": true + }, + "configservice": { + "prefix": "config", + "name": "ConfigService", + "cors": true + }, + "cur": { + "name": "CUR", + "cors": true + }, + "datapipeline": { + "name": "DataPipeline" + }, + "devicefarm": { + "name": "DeviceFarm", + "cors": true + }, + "directconnect": { + "name": "DirectConnect", + "cors": true + }, + "directoryservice": { + "prefix": "ds", + "name": "DirectoryService" + }, + "discovery": { + "name": "Discovery" + }, + "dms": { + "name": "DMS" + }, + "dynamodb": { + "name": "DynamoDB", + "cors": true + }, + "dynamodbstreams": { + "prefix": "streams.dynamodb", + "name": "DynamoDBStreams", + "cors": true + }, + "ec2": { + "name": "EC2", + "versions": [ + "2013-06-15*", + "2013-10-15*", + "2014-02-01*", + "2014-05-01*", + "2014-06-15*", + "2014-09-01*", + "2014-10-01*", + "2015-03-01*", + "2015-04-15*", + "2015-10-01*", + "2016-04-01*", + "2016-09-15*" + ], + "cors": true + }, + "ecr": { + "name": "ECR", + "cors": true + }, + "ecs": { + "name": "ECS", + "cors": true + }, + "efs": { + "prefix": "elasticfilesystem", + "name": "EFS", + "cors": true + }, + "elasticache": { + "name": "ElastiCache", + "versions": [ + "2012-11-15*", + "2014-03-24*", + "2014-07-15*", + "2014-09-30*" + ], + "cors": true + }, + "elasticbeanstalk": { + "name": "ElasticBeanstalk", + "cors": true + }, + "elb": { + "prefix": "elasticloadbalancing", + "name": "ELB", + "cors": true + }, + "elbv2": { + "prefix": "elasticloadbalancingv2", + "name": "ELBv2", + "cors": true + }, + "emr": { + "prefix": "elasticmapreduce", + "name": "EMR", + "cors": true + }, + "es": { + "name": "ES" + }, + "elastictranscoder": { + "name": "ElasticTranscoder", + "cors": true + }, + "firehose": { + "name": "Firehose", + "cors": true + }, + "gamelift": { + "name": "GameLift", + "cors": true + }, + "glacier": { + "name": "Glacier" + }, + "health": { + "name": "Health" + }, + "iam": { + "name": "IAM", + "cors": true + }, + "importexport": { + "name": "ImportExport" + }, + "inspector": { + "name": "Inspector", + "versions": [ + "2015-08-18*" + ], + "cors": true + }, + "iot": { + "name": "Iot", + "cors": true + }, + "iotdata": { + "prefix": "iot-data", + "name": "IotData", + "cors": true + }, + "kinesis": { + "name": "Kinesis", + "cors": true + }, + "kinesisanalytics": { + "name": "KinesisAnalytics" + }, + "kms": { + "name": "KMS", + "cors": true + }, + "lambda": { + "name": "Lambda", + "cors": true + }, + "lexruntime": { + "prefix": "runtime.lex", + "name": "LexRuntime", + "cors": true + }, + "lightsail": { + "name": "Lightsail" + }, + "machinelearning": { + "name": "MachineLearning", + "cors": true + }, + "marketplacecommerceanalytics": { + "name": "MarketplaceCommerceAnalytics", + "cors": true + }, + "marketplacemetering": { + "prefix": "meteringmarketplace", + "name": "MarketplaceMetering" + }, + "mturk": { + "prefix": "mturk-requester", + "name": "MTurk", + "cors": true + }, + "mobileanalytics": { + "name": "MobileAnalytics", + "cors": true + }, + "opsworks": { + "name": "OpsWorks", + "cors": true + }, + "opsworkscm": { + "name": "OpsWorksCM" + }, + "organizations": { + "name": "Organizations" + }, + "pinpoint": { + "name": "Pinpoint" + }, + "polly": { + "name": "Polly", + "cors": true + }, + "rds": { + "name": "RDS", + "versions": [ + "2014-09-01*" + ], + "cors": true + }, + "redshift": { + "name": "Redshift", + "cors": true + }, + "rekognition": { + "name": "Rekognition", + "cors": true + }, + "resourcegroupstaggingapi": { + "name": "ResourceGroupsTaggingAPI" + }, + "route53": { + "name": "Route53", + "cors": true + }, + "route53domains": { + "name": "Route53Domains", + "cors": true + }, + "s3": { + "name": "S3", + "dualstackAvailable": true, + "cors": true + }, + "s3control": { + "name": "S3Control", + "dualstackAvailable": true, + "xmlNoDefaultLists": true + }, + "servicecatalog": { + "name": "ServiceCatalog", + "cors": true + }, + "ses": { + "prefix": "email", + "name": "SES", + "cors": true + }, + "shield": { + "name": "Shield" + }, + "simpledb": { + "prefix": "sdb", + "name": "SimpleDB" + }, + "sms": { + "name": "SMS" + }, + "snowball": { + "name": "Snowball" + }, + "sns": { + "name": "SNS", + "cors": true + }, + "sqs": { + "name": "SQS", + "cors": true + }, + "ssm": { + "name": "SSM", + "cors": true + }, + "storagegateway": { + "name": "StorageGateway", + "cors": true + }, + "stepfunctions": { + "prefix": "states", + "name": "StepFunctions" + }, + "sts": { + "name": "STS", + "cors": true + }, + "support": { + "name": "Support" + }, + "swf": { + "name": "SWF" + }, + "xray": { + "name": "XRay", + "cors": true + }, + "waf": { + "name": "WAF", + "cors": true + }, + "wafregional": { + "prefix": "waf-regional", + "name": "WAFRegional" + }, + "workdocs": { + "name": "WorkDocs", + "cors": true + }, + "workspaces": { + "name": "WorkSpaces" + }, + "codestar": { + "name": "CodeStar" + }, + "lexmodelbuildingservice": { + "prefix": "lex-models", + "name": "LexModelBuildingService", + "cors": true + }, + "marketplaceentitlementservice": { + "prefix": "entitlement.marketplace", + "name": "MarketplaceEntitlementService" + }, + "athena": { + "name": "Athena", + "cors": true + }, + "greengrass": { + "name": "Greengrass" + }, + "dax": { + "name": "DAX" + }, + "migrationhub": { + "prefix": "AWSMigrationHub", + "name": "MigrationHub" + }, + "cloudhsmv2": { + "name": "CloudHSMV2", + "cors": true + }, + "glue": { + "name": "Glue" + }, + "mobile": { + "name": "Mobile" + }, + "pricing": { + "name": "Pricing", + "cors": true + }, + "costexplorer": { + "prefix": "ce", + "name": "CostExplorer", + "cors": true + }, + "mediaconvert": { + "name": "MediaConvert" + }, + "medialive": { + "name": "MediaLive" + }, + "mediapackage": { + "name": "MediaPackage" + }, + "mediastore": { + "name": "MediaStore" + }, + "mediastoredata": { + "prefix": "mediastore-data", + "name": "MediaStoreData", + "cors": true + }, + "appsync": { + "name": "AppSync" + }, + "guardduty": { + "name": "GuardDuty" + }, + "mq": { + "name": "MQ" + }, + "comprehend": { + "name": "Comprehend", + "cors": true + }, + "iotjobsdataplane": { + "prefix": "iot-jobs-data", + "name": "IoTJobsDataPlane" + }, + "kinesisvideoarchivedmedia": { + "prefix": "kinesis-video-archived-media", + "name": "KinesisVideoArchivedMedia", + "cors": true + }, + "kinesisvideomedia": { + "prefix": "kinesis-video-media", + "name": "KinesisVideoMedia", + "cors": true + }, + "kinesisvideo": { + "name": "KinesisVideo", + "cors": true + }, + "sagemakerruntime": { + "prefix": "runtime.sagemaker", + "name": "SageMakerRuntime" + }, + "sagemaker": { + "name": "SageMaker" + }, + "translate": { + "name": "Translate", + "cors": true + }, + "resourcegroups": { + "prefix": "resource-groups", + "name": "ResourceGroups", + "cors": true + }, + "alexaforbusiness": { + "name": "AlexaForBusiness" + }, + "cloud9": { + "name": "Cloud9" + }, + "serverlessapplicationrepository": { + "prefix": "serverlessrepo", + "name": "ServerlessApplicationRepository" + }, + "servicediscovery": { + "name": "ServiceDiscovery" + }, + "workmail": { + "name": "WorkMail" + }, + "autoscalingplans": { + "prefix": "autoscaling-plans", + "name": "AutoScalingPlans" + }, + "transcribeservice": { + "prefix": "transcribe", + "name": "TranscribeService" + }, + "connect": { + "name": "Connect", + "cors": true + }, + "acmpca": { + "prefix": "acm-pca", + "name": "ACMPCA" + }, + "fms": { + "name": "FMS" + }, + "secretsmanager": { + "name": "SecretsManager", + "cors": true + }, + "iotanalytics": { + "name": "IoTAnalytics", + "cors": true + }, + "iot1clickdevicesservice": { + "prefix": "iot1click-devices", + "name": "IoT1ClickDevicesService" + }, + "iot1clickprojects": { + "prefix": "iot1click-projects", + "name": "IoT1ClickProjects" + }, + "pi": { + "name": "PI" + }, + "neptune": { + "name": "Neptune" + }, + "mediatailor": { + "name": "MediaTailor" + }, + "eks": { + "name": "EKS" + }, + "macie": { + "name": "Macie" + }, + "dlm": { + "name": "DLM" + }, + "signer": { + "name": "Signer" + }, + "chime": { + "name": "Chime" + }, + "pinpointemail": { + "prefix": "pinpoint-email", + "name": "PinpointEmail" + }, + "ram": { + "name": "RAM" + }, + "route53resolver": { + "name": "Route53Resolver" + }, + "pinpointsmsvoice": { + "prefix": "sms-voice", + "name": "PinpointSMSVoice" + }, + "quicksight": { + "name": "QuickSight" + }, + "rdsdataservice": { + "prefix": "rds-data", + "name": "RDSDataService" + }, + "amplify": { + "name": "Amplify" + }, + "datasync": { + "name": "DataSync" + }, + "robomaker": { + "name": "RoboMaker" + }, + "transfer": { + "name": "Transfer" + }, + "globalaccelerator": { + "name": "GlobalAccelerator" + }, + "comprehendmedical": { + "name": "ComprehendMedical", + "cors": true + }, + "kinesisanalyticsv2": { + "name": "KinesisAnalyticsV2" + }, + "mediaconnect": { + "name": "MediaConnect" + }, + "fsx": { + "name": "FSx" + }, + "securityhub": { + "name": "SecurityHub" + }, + "appmesh": { + "name": "AppMesh", + "versions": [ + "2018-10-01*" + ] + }, + "licensemanager": { + "prefix": "license-manager", + "name": "LicenseManager" + }, + "kafka": { + "name": "Kafka" + }, + "apigatewaymanagementapi": { + "name": "ApiGatewayManagementApi" + }, + "apigatewayv2": { + "name": "ApiGatewayV2" + }, + "docdb": { + "name": "DocDB" + }, + "backup": { + "name": "Backup" + }, + "worklink": { + "name": "WorkLink" + }, + "textract": { + "name": "Textract" + }, + "managedblockchain": { + "name": "ManagedBlockchain" + }, + "mediapackagevod": { + "prefix": "mediapackage-vod", + "name": "MediaPackageVod" + }, + "groundstation": { + "name": "GroundStation" + }, + "iotthingsgraph": { + "name": "IoTThingsGraph" + }, + "iotevents": { + "name": "IoTEvents" + }, + "ioteventsdata": { + "prefix": "iotevents-data", + "name": "IoTEventsData" + }, + "personalize": { + "name": "Personalize", + "cors": true + }, + "personalizeevents": { + "prefix": "personalize-events", + "name": "PersonalizeEvents", + "cors": true + }, + "personalizeruntime": { + "prefix": "personalize-runtime", + "name": "PersonalizeRuntime", + "cors": true + }, + "applicationinsights": { + "prefix": "application-insights", + "name": "ApplicationInsights" + }, + "servicequotas": { + "prefix": "service-quotas", + "name": "ServiceQuotas" + }, + "ec2instanceconnect": { + "prefix": "ec2-instance-connect", + "name": "EC2InstanceConnect" + }, + "eventbridge": { + "name": "EventBridge" + }, + "lakeformation": { + "name": "LakeFormation" + }, + "forecastservice": { + "prefix": "forecast", + "name": "ForecastService", + "cors": true + }, + "forecastqueryservice": { + "prefix": "forecastquery", + "name": "ForecastQueryService", + "cors": true + }, + "qldb": { + "name": "QLDB" + }, + "qldbsession": { + "prefix": "qldb-session", + "name": "QLDBSession" + }, + "workmailmessageflow": { + "name": "WorkMailMessageFlow" + }, + "codestarnotifications": { + "prefix": "codestar-notifications", + "name": "CodeStarNotifications" + }, + "savingsplans": { + "name": "SavingsPlans" + }, + "sso": { + "name": "SSO" + }, + "ssooidc": { + "prefix": "sso-oidc", + "name": "SSOOIDC" + }, + "marketplacecatalog": { + "prefix": "marketplace-catalog", + "name": "MarketplaceCatalog" + }, + "dataexchange": { + "name": "DataExchange" + }, + "sesv2": { + "name": "SESV2" + }, + "migrationhubconfig": { + "prefix": "migrationhub-config", + "name": "MigrationHubConfig" + }, + "connectparticipant": { + "name": "ConnectParticipant" + }, + "appconfig": { + "name": "AppConfig" + }, + "iotsecuretunneling": { + "name": "IoTSecureTunneling" + }, + "wafv2": { + "name": "WAFV2" + }, + "elasticinference": { + "prefix": "elastic-inference", + "name": "ElasticInference" + }, + "imagebuilder": { + "name": "Imagebuilder" + }, + "schemas": { + "name": "Schemas" + }, + "accessanalyzer": { + "name": "AccessAnalyzer" + }, + "codegurureviewer": { + "prefix": "codeguru-reviewer", + "name": "CodeGuruReviewer" + }, + "codeguruprofiler": { + "name": "CodeGuruProfiler" + }, + "computeoptimizer": { + "prefix": "compute-optimizer", + "name": "ComputeOptimizer" + }, + "frauddetector": { + "name": "FraudDetector" + }, + "kendra": { + "name": "Kendra" + }, + "networkmanager": { + "name": "NetworkManager" + }, + "outposts": { + "name": "Outposts" + }, + "augmentedairuntime": { + "prefix": "sagemaker-a2i-runtime", + "name": "AugmentedAIRuntime" + }, + "ebs": { + "name": "EBS" + }, + "kinesisvideosignalingchannels": { + "prefix": "kinesis-video-signaling", + "name": "KinesisVideoSignalingChannels", + "cors": true + }, + "detective": { + "name": "Detective" + }, + "codestarconnections": { + "prefix": "codestar-connections", + "name": "CodeStarconnections" + }, + "synthetics": { + "name": "Synthetics" + }, + "iotsitewise": { + "name": "IoTSiteWise" + }, + "macie2": { + "name": "Macie2" + }, + "codeartifact": { + "name": "CodeArtifact" + }, + "honeycode": { + "name": "Honeycode" + }, + "ivs": { + "name": "IVS" + }, + "braket": { + "name": "Braket" + }, + "identitystore": { + "name": "IdentityStore" + }, + "appflow": { + "name": "Appflow" + }, + "redshiftdata": { + "prefix": "redshift-data", + "name": "RedshiftData" + }, + "ssoadmin": { + "prefix": "sso-admin", + "name": "SSOAdmin" + }, + "timestreamquery": { + "prefix": "timestream-query", + "name": "TimestreamQuery" + }, + "timestreamwrite": { + "prefix": "timestream-write", + "name": "TimestreamWrite" + }, + "s3outposts": { + "name": "S3Outposts" + }, + "databrew": { + "name": "DataBrew" + }, + "servicecatalogappregistry": { + "prefix": "servicecatalog-appregistry", + "name": "ServiceCatalogAppRegistry" + }, + "networkfirewall": { + "prefix": "network-firewall", + "name": "NetworkFirewall" + }, + "mwaa": { + "name": "MWAA" + }, + "amplifybackend": { + "name": "AmplifyBackend" + }, + "appintegrations": { + "name": "AppIntegrations" + }, + "connectcontactlens": { + "prefix": "connect-contact-lens", + "name": "ConnectContactLens" + }, + "devopsguru": { + "prefix": "devops-guru", + "name": "DevOpsGuru" + }, + "ecrpublic": { + "prefix": "ecr-public", + "name": "ECRPUBLIC" + }, + "lookoutvision": { + "name": "LookoutVision" + }, + "sagemakerfeaturestoreruntime": { + "prefix": "sagemaker-featurestore-runtime", + "name": "SageMakerFeatureStoreRuntime" + }, + "customerprofiles": { + "prefix": "customer-profiles", + "name": "CustomerProfiles" + }, + "auditmanager": { + "name": "AuditManager" + }, + "emrcontainers": { + "prefix": "emr-containers", + "name": "EMRcontainers" + }, + "healthlake": { + "name": "HealthLake" + }, + "sagemakeredge": { + "prefix": "sagemaker-edge", + "name": "SagemakerEdge" + }, + "amp": { + "name": "Amp" + }, + "greengrassv2": { + "name": "GreengrassV2" + }, + "iotdeviceadvisor": { + "name": "IotDeviceAdvisor" + }, + "iotfleethub": { + "name": "IoTFleetHub" + }, + "iotwireless": { + "name": "IoTWireless" + }, + "location": { + "name": "Location", + "cors": true + }, + "wellarchitected": { + "name": "WellArchitected" + }, + "lexmodelsv2": { + "prefix": "models.lex.v2", + "name": "LexModelsV2" + }, + "lexruntimev2": { + "prefix": "runtime.lex.v2", + "name": "LexRuntimeV2", + "cors": true + }, + "fis": { + "name": "Fis" + }, + "lookoutmetrics": { + "name": "LookoutMetrics" + }, + "mgn": { + "name": "Mgn" + }, + "lookoutequipment": { + "name": "LookoutEquipment" + }, + "nimble": { + "name": "Nimble" + }, + "finspace": { + "name": "Finspace" + }, + "finspacedata": { + "prefix": "finspace-data", + "name": "Finspacedata" + }, + "ssmcontacts": { + "prefix": "ssm-contacts", + "name": "SSMContacts" + }, + "ssmincidents": { + "prefix": "ssm-incidents", + "name": "SSMIncidents" + }, + "applicationcostprofiler": { + "name": "ApplicationCostProfiler" + }, + "apprunner": { + "name": "AppRunner" + }, + "proton": { + "name": "Proton" + }, + "route53recoverycluster": { + "prefix": "route53-recovery-cluster", + "name": "Route53RecoveryCluster" + }, + "route53recoverycontrolconfig": { + "prefix": "route53-recovery-control-config", + "name": "Route53RecoveryControlConfig" + }, + "route53recoveryreadiness": { + "prefix": "route53-recovery-readiness", + "name": "Route53RecoveryReadiness" + }, + "chimesdkidentity": { + "prefix": "chime-sdk-identity", + "name": "ChimeSDKIdentity" + }, + "chimesdkmessaging": { + "prefix": "chime-sdk-messaging", + "name": "ChimeSDKMessaging" + }, + "snowdevicemanagement": { + "prefix": "snow-device-management", + "name": "SnowDeviceManagement" + }, + "memorydb": { + "name": "MemoryDB" + }, + "opensearch": { + "name": "OpenSearch" + }, + "kafkaconnect": { + "name": "KafkaConnect" + }, + "voiceid": { + "prefix": "voice-id", + "name": "VoiceID" + }, + "wisdom": { + "name": "Wisdom" + }, + "account": { + "name": "Account" + }, + "cloudcontrol": { + "name": "CloudControl" + }, + "grafana": { + "name": "Grafana" + }, + "panorama": { + "name": "Panorama" + }, + "chimesdkmeetings": { + "prefix": "chime-sdk-meetings", + "name": "ChimeSDKMeetings" + }, + "resiliencehub": { + "name": "Resiliencehub" + }, + "migrationhubstrategy": { + "name": "MigrationHubStrategy" + }, + "appconfigdata": { + "name": "AppConfigData" + }, + "drs": { + "name": "Drs" + }, + "migrationhubrefactorspaces": { + "prefix": "migration-hub-refactor-spaces", + "name": "MigrationHubRefactorSpaces" + }, + "evidently": { + "name": "Evidently" + }, + "inspector2": { + "name": "Inspector2" + }, + "rbin": { + "name": "Rbin" + }, + "rum": { + "name": "RUM" + }, + "backupgateway": { + "prefix": "backup-gateway", + "name": "BackupGateway" + }, + "iottwinmaker": { + "name": "IoTTwinMaker" + }, + "workspacesweb": { + "prefix": "workspaces-web", + "name": "WorkSpacesWeb" + }, + "amplifyuibuilder": { + "name": "AmplifyUIBuilder" + }, + "keyspaces": { + "name": "Keyspaces" + }, + "billingconductor": { + "name": "Billingconductor" + }, + "gamesparks": { + "name": "GameSparks" + }, + "pinpointsmsvoicev2": { + "prefix": "pinpoint-sms-voice-v2", + "name": "PinpointSMSVoiceV2" + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/integ-tests/lib/assertions/sdk.ts b/packages/@aws-cdk/integ-tests/lib/assertions/sdk.ts new file mode 100644 index 0000000000000..ead56af7732d9 --- /dev/null +++ b/packages/@aws-cdk/integ-tests/lib/assertions/sdk.ts @@ -0,0 +1,106 @@ +import { CustomResource, Reference, Lazy } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { EqualsAssertion } from './assertions'; +import { IAssertion } from './deploy-assert'; +import { md5hash } from './private/hash'; +import { AssertionsProvider, SDK_RESOURCE_TYPE_PREFIX } from './providers'; + +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct as CoreConstruct } from '@aws-cdk/core'; + +/** + * Options to perform an AWS JavaScript V2 API call + */ +export interface SdkQueryOptions { + /** + * The AWS service, i.e. S3 + */ + readonly service: string; + + /** + * The api call to make, i.e. getBucketLifecycle + */ + readonly api: string; + + /** + * Any parameters to pass to the api call + */ + readonly parameters?: any; +} + +/** + * Options for creating an SDKQuery provider + */ +export interface SdkQueryProps extends SdkQueryOptions {} + +export class SdkQuery extends CoreConstruct { + private readonly sdkCallResource: CustomResource; + private flattenResponse: string = 'false'; + + constructor(scope: Construct, id: string, props: SdkQueryProps) { + super(scope, id); + + const provider = new AssertionsProvider(this, 'SdkProvider'); + provider.addPolicyStatementFromSdkCall(props.service, props.api); + + this.sdkCallResource = new CustomResource(this, 'Default', { + serviceToken: provider.serviceToken, + properties: { + service: props.service, + api: props.api, + parameters: provider.encode(props.parameters), + flattenResponse: Lazy.string({ produce: () => this.flattenResponse }), + }, + resourceType: `${SDK_RESOURCE_TYPE_PREFIX}${props.service}${props.api}`, + }); + + // Needed so that all the policies set up by the provider should be available before the custom resource is provisioned. + this.sdkCallResource.node.addDependency(provider); + } + + /** + * Returns the value of an attribute of the custom resource of an arbitrary + * type. Attributes are returned from the custom resource provider through the + * `Data` map where the key is the attribute name. + * + * @param attributeName the name of the attribute + * @returns a token for `Fn::GetAtt`. Use `Token.asXxx` to encode the returned `Reference` as a specific type or + * use the convenience `getAttString` for string attributes. + */ + public getAtt(attributeName: string): Reference { + this.flattenResponse = 'true'; + return this.sdkCallResource.getAtt(`apiCallResponse.${attributeName}`); + } + + /** + * Returns the value of an attribute of the custom resource of type string. + * Attributes are returned from the custom resource provider through the + * `Data` map where the key is the attribute name. + * + * @param attributeName the name of the attribute + * @returns a token for `Fn::GetAtt` encoded as a string. + */ + public getAttString(attributeName: string): string { + this.flattenResponse = 'true'; + return this.sdkCallResource.getAttString(`apiCallResponse.${attributeName}`); + } + + /** + * Creates an assertion custom resource that will assert that the response + * from the SDKQuery equals the 'expected' value + */ + public assertEqual(expected: any, actualAttr?: string): IAssertion { + const hash = md5hash(expected); + let inputResourceAtt = 'apiCallResponse'; + if (actualAttr) { + this.flattenResponse = 'true'; + inputResourceAtt = `apiCallResponse.${actualAttr}`; + } + return new EqualsAssertion(this, `AssertEquals${hash}`, { + expected, + inputResource: this.sdkCallResource, + inputResourceAtt, + }); + } +} diff --git a/packages/@aws-cdk/integ-tests/lib/index.ts b/packages/@aws-cdk/integ-tests/lib/index.ts index 0553319f009fb..638d20a4d1d1a 100644 --- a/packages/@aws-cdk/integ-tests/lib/index.ts +++ b/packages/@aws-cdk/integ-tests/lib/index.ts @@ -1 +1 @@ -export * from './test-case'; \ No newline at end of file +export * from './test-case'; diff --git a/packages/@aws-cdk/integ-tests/package.json b/packages/@aws-cdk/integ-tests/package.json index 813925be51265..83d5b4f5af4f8 100644 --- a/packages/@aws-cdk/integ-tests/package.json +++ b/packages/@aws-cdk/integ-tests/package.json @@ -61,18 +61,37 @@ "license": "Apache-2.0", "devDependencies": { "@aws-cdk/cdk-build-tools": "0.0.0", + "@aws-cdk/assertions": "0.0.0", "@aws-cdk/pkglint": "0.0.0", "@types/fs-extra": "^8.1.2", "@types/jest": "^27.4.1", "@types/node": "^10.17.60", - "jest": "^27.5.1" + "jest": "^27.5.1", + "nock": "^13.2.4", + "aws-sdk-mock": "5.6.0", + "sinon": "^9.2.4", + "aws-sdk": "^2.1093.0" }, "dependencies": { "@aws-cdk/cloud-assembly-schema": "0.0.0", "@aws-cdk/core": "0.0.0", "@aws-cdk/cx-api": "0.0.0", + "@aws-cdk/aws-lambda": "0.0.0", + "@aws-cdk/triggers": "0.0.0", + "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/custom-resources": "0.0.0", "constructs": "^3.3.69" }, + "peerDependencies": { + "@aws-cdk/cloud-assembly-schema": "0.0.0", + "@aws-cdk/assertions": "0.0.0", + "@aws-cdk/core": "0.0.0", + "@aws-cdk/triggers": "0.0.0", + "@aws-cdk/custom-resources": "0.0.0", + "constructs": "^3.3.69", + "@aws-cdk/aws-lambda": "0.0.0", + "@aws-cdk/aws-iam": "0.0.0" + }, "repository": { "url": "https://github.com/aws/aws-cdk.git", "type": "git", @@ -98,11 +117,6 @@ "publishConfig": { "tag": "latest" }, - "peerDependencies": { - "@aws-cdk/cloud-assembly-schema": "0.0.0", - "@aws-cdk/core": "0.0.0", - "constructs": "^3.3.69" - }, "awscdkio": { "announce": false } diff --git a/packages/@aws-cdk/integ-tests/test/assertions/assertions.test.ts b/packages/@aws-cdk/integ-tests/test/assertions/assertions.test.ts new file mode 100644 index 0000000000000..c8558c6460b0a --- /dev/null +++ b/packages/@aws-cdk/integ-tests/test/assertions/assertions.test.ts @@ -0,0 +1,48 @@ +import { Template } from '@aws-cdk/assertions'; +import { App, CustomResource, Stack } from '@aws-cdk/core'; +import { IAssertion, DeployAssert, EqualsAssertion } from '../../lib/assertions'; + +describe('Assertion', () => { + test('registration', () => { + const app = new App(); + const stack = new Stack(app); + const deployAssert = new DeployAssert(stack); + + class MyAssertion implements IAssertion { + public result = 'result'; + } + const assertion = new MyAssertion(); + deployAssert.registerAssertion(assertion); + + expect(deployAssert._assertions).toContain(assertion); + }); +}); + +describe('EqualsAssertion', () => { + test('default', () => { + const app = new App(); + const stack = new Stack(app); + const deployAssert = new DeployAssert(stack); + const customRes = new CustomResource(stack, 'MyCustomResource', { + serviceToken: 'serviceToken', + }); + deployAssert.registerAssertion(new EqualsAssertion(stack, 'MyAssertion', { + expected: { foo: 'bar' }, + inputResource: customRes, + inputResourceAtt: 'foo', + })); + + Template.fromStack(stack).hasResourceProperties('Custom::DeployAssert@AssertEquals', { + actual: { + 'Fn::GetAtt': [ + 'MyCustomResource', + 'foo', + ], + }, + expected: { + foo: 'bar', + }, + assertionType: 'equals', + }); + }); +}); diff --git a/packages/@aws-cdk/integ-tests/test/assertions/deploy-assert.test.ts b/packages/@aws-cdk/integ-tests/test/assertions/deploy-assert.test.ts new file mode 100644 index 0000000000000..bb73e87b2da7e --- /dev/null +++ b/packages/@aws-cdk/integ-tests/test/assertions/deploy-assert.test.ts @@ -0,0 +1,99 @@ +import { Template } from '@aws-cdk/assertions'; +// import * as iam from '@aws-cdk/aws-iam'; +import { App, Stack } from '@aws-cdk/core'; +import { IAssertion, DeployAssert } from '../../lib/assertions'; + +describe('DeployAssert', () => { + describe('ResultsCollection', () => { + test('default', () => { + // GIVEN + const app = new App(); + const stack = new Stack(app, 'MyStack'); + + // WHEN + new DeployAssert(stack); + + + // THEN + const template = Template.fromStack(stack); + template.resourceCountIs('Custom::DeployAssert@ResultsCollection', 1); + + template.hasOutput('Results', {}); + }); + + test('assertion results are part of the output', () => { + // GIVEN + class MyAssertion implements IAssertion { + public readonly result: string; + constructor(result: string) { + this.result = result; + } + } + + const app = new App(); + const stack = new Stack(app, 'MyStack'); + + // WHEN + const deployAssert = new DeployAssert(stack); + deployAssert.registerAssertion( + new MyAssertion('MyAssertion1Result'), + ); + deployAssert.registerAssertion( + new MyAssertion('MyAssertion2Result'), + ); + + + // THEN + const template = Template.fromStack(stack); + template.hasResourceProperties('Custom::DeployAssert@ResultsCollection', { + assertionResults: ['MyAssertion1Result', 'MyAssertion2Result'], + }); + }); + }); + + describe('queryAws', () => { + test('default', () => { + // GIVEN + const app = new App(); + const stack = new Stack(app); + + // WHEN + const deplossert = new DeployAssert(stack); + deplossert.queryAws({ + service: 'MyService', + api: 'MyApi', + }); + + + // THEN + Template.fromStack(stack).hasResourceProperties('Custom::DeployAssert@SdkCallMyServiceMyApi', { + api: 'MyApi', + service: 'MyService', + }); + }); + + test('multiple queries can be configured', () => { + // GIVEN + const app = new App(); + const stack = new Stack(app); + + // WHEN + const deplossert = new DeployAssert(stack); + deplossert.queryAws({ + service: 'MyService', + api: 'MyApi1', + }); + deplossert.queryAws({ + service: 'MyService', + api: 'MyApi2', + }); + + + // THEN + const template = Template.fromStack(stack); + template.resourceCountIs('AWS::Lambda::Function', 1); + template.resourceCountIs('Custom::DeployAssert@SdkCallMyServiceMyApi1', 1); + template.resourceCountIs('Custom::DeployAssert@SdkCallMyServiceMyApi2', 1); + }); + }); +}); diff --git a/packages/@aws-cdk/integ-tests/test/assertions/private/hash.test.ts b/packages/@aws-cdk/integ-tests/test/assertions/private/hash.test.ts new file mode 100644 index 0000000000000..a6c40777189df --- /dev/null +++ b/packages/@aws-cdk/integ-tests/test/assertions/private/hash.test.ts @@ -0,0 +1,17 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +import { md5hash } from '../../../lib/assertions/private/hash'; + +describe('md5hash', () => { + test('default', () => { + const hash = md5hash({ key: 'value' }); + expect(hash).toEqual('a7353f7cddce808de0032747a0b7be50'); + }); + + test('fails if falsy', () => { + expect(() => md5hash(null)).toThrow(/falsy/); + expect(() => md5hash(undefined)).toThrow(/falsy/); + expect(() => md5hash({})).toThrow(/falsy/); + expect(() => md5hash('')).toThrow(/falsy/); + expect(() => md5hash([])).toThrow(/falsy/); + }); +}); diff --git a/packages/@aws-cdk/integ-tests/test/assertions/providers/lambda-handler/assertion.test.ts b/packages/@aws-cdk/integ-tests/test/assertions/providers/lambda-handler/assertion.test.ts new file mode 100644 index 0000000000000..911876c84bdfb --- /dev/null +++ b/packages/@aws-cdk/integ-tests/test/assertions/providers/lambda-handler/assertion.test.ts @@ -0,0 +1,80 @@ +import { AssertionRequest, AssertionResult, AssertionType } from '../../../../lib/assertions'; +import { AssertionHandler } from '../../../../lib/assertions/providers/lambda-handler/assertion'; + +function assertionHandler() { + const context: any = { + getRemainingTimeInMillis: () => 50000, + }; + return new AssertionHandler({} as any, context); // as any to ignore all type checks +} + +beforeAll(() => { + jest.useFakeTimers(); + jest.spyOn(console, 'log').mockImplementation(() => { return true; }); +}); +afterAll(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); +}); + +describe('AssertionHandler', () => { + describe('equals', () => { + test('pass', async () => { + // GIVEN + const handler = assertionHandler() as any; + const request: AssertionRequest = { + assertionType: AssertionType.EQUALS, + actual: { + stringParam: 'foo', + numberParam: 3, + booleanParam: true, + }, + expected: { + stringParam: 'foo', + numberParam: 3, + booleanParam: true, + }, + }; + + // WHEN + const response: AssertionResult = await handler.processEvent(request); + + // THEN + expect(response.data.status).toEqual('pass'); + }); + + test('fail', async () => { + // GIVEN + const handler = assertionHandler() as any; + const request: AssertionRequest = { + assertionType: AssertionType.EQUALS, + actual: { + stringParam: 'foo', + }, + expected: { + stringParam: 'bar', + }, + }; + + // WHEN + const response: AssertionResult = await handler.processEvent(request); + + // THEN + expect(response.data.status).toEqual('fail'); + }); + }); + + test('unsupported query', async () => { + // GIVEN + const handler = assertionHandler() as any; + const assertionType: any = 'somethingElse'; + const request: AssertionRequest = { + assertionType, + actual: 'foo', + expected: 'bar', + }; + + // THEN + await expect(handler.processEvent(request)).rejects.toThrow(/Unsupported query type/); + }); +}); diff --git a/packages/@aws-cdk/integ-tests/test/assertions/providers/lambda-handler/base.test.ts b/packages/@aws-cdk/integ-tests/test/assertions/providers/lambda-handler/base.test.ts new file mode 100644 index 0000000000000..d30f20a5678f3 --- /dev/null +++ b/packages/@aws-cdk/integ-tests/test/assertions/providers/lambda-handler/base.test.ts @@ -0,0 +1,199 @@ +import * as nock from 'nock'; +import { CustomResourceHandler } from '../../../../lib/assertions/providers/lambda-handler/base'; + +interface MyHandlerRequest { + readonly input: string; +} + +interface MyHandlerResponse { + readonly output: string; +} + +interface CloudFormationResponse extends Omit { + readonly Data: MyHandlerResponse; +} + +describe('CustomResourceHandler', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => { return true; }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + nock.cleanAll(); + }); + + test('default', async () => { + // GIVEN + class MyHandler extends CustomResourceHandler { + protected async processEvent(request: MyHandlerRequest): Promise { + return { output: `MyResponseTo${request.input}` }; + } + } + + const nocked = nockUp((body) => { + return body.Status === 'SUCCESS' + && body.Reason === 'OK' + && body.Data.output === 'MyResponseToYourRequest' + && body.StackId === 'MyStackId' + && body.RequestId === 'MyRequestId' + && body.NoEcho === false; + }); + + + // WHEN + const handler = new MyHandler(createEvent({ input: 'YourRequest' }), standardContext); + + await handler.handle(); + + // THEN + expect(nocked.isDone()).toEqual(true); + }); + + test('processEvent fails', async () => { + // GIVEN + class MyHandler extends CustomResourceHandler { + protected async processEvent(_: MyHandlerRequest): Promise { + throw new Error('FooFAIL'); + } + } + + const nocked = nockUp((body) => { + return body.Status === 'FAILED' + && body.Reason === 'FooFAIL'; + }); + + + // WHEN + const handler = new MyHandler(createEvent({ input: 'YourRequest' }), standardContext); + + await handler.handle(); + + // THEN + expect(nocked.isDone()).toEqual(true); + }); + + test('timeout kicks in', async () => { + // GIVEN + class MyHandler extends CustomResourceHandler { + protected async processEvent(_: MyHandlerRequest): Promise { + await new Promise((resolve) => { + setTimeout(resolve, 2000); + }); + return new Promise((resolve, _reject) => resolve(undefined)); + } + } + + const nocked = nockUp((body) => { + return body.Status === 'FAILED' + && body.Reason !== undefined + && /Timeout/.test(body.Reason); + }); + + const handler = new MyHandler(createEvent(), { + ...standardContext, + getRemainingTimeInMillis: () => 1300, + }); + + + // WHEN + await handler.handle(); + + + // THEN + expect(nocked.isDone()).toEqual(true); + }); + + describe('physicalResourceId', () => { + test('create event', async () => { + // GIVEN + class MyHandler extends CustomResourceHandler { + protected async processEvent(request: MyHandlerRequest): Promise { + return { output: `MyResponseTo${request.input}` }; + } + } + + const nocked = nockUp((body) => { + return body.PhysicalResourceId === 'MyLogicalResourceId'; + }); + + + // WHEN + const handler = new MyHandler(createEvent({ input: 'YourRequest' }), standardContext); + + await handler.handle(); + + // THEN + expect(nocked.isDone()).toEqual(true); + }); + + test('update event', async () => { + // GIVEN + class MyHandler extends CustomResourceHandler { + protected async processEvent(request: MyHandlerRequest): Promise { + return { output: `MyResponseTo${request.input}` }; + } + } + + const nocked = nockUp((body) => { + return body.PhysicalResourceId === 'MyPhysicalResourceId'; + }); + + + // WHEN + const handler = new MyHandler(updateEvent({ input: 'YourRequest' }), standardContext); + + await handler.handle(); + + // THEN + expect(nocked.isDone()).toEqual(true); + }); + }); +}); + +function nockUp(predicate: (body: CloudFormationResponse) => boolean) { + return nock('https://someurl.com') + .put('/', predicate) + .reply(200); +} + +const standardContext: any = { // keeping this as any so as to not have to fill all the mandatory attributes of AWSLambda.Context + getRemainingTimeInMillis: () => 5000, +}; + +function createEvent(data?: MyHandlerRequest): AWSLambda.CloudFormationCustomResourceCreateEvent { + return { + LogicalResourceId: 'MyLogicalResourceId', + RequestId: 'MyRequestId', + RequestType: 'Create', + ResourceType: 'MyResourceType', + ResourceProperties: { + ...data, + ServiceToken: 'MyServiceToken', + }, + ResponseURL: 'https://someurl.com', + ServiceToken: 'MyServiceToken', + StackId: 'MyStackId', + }; +} + +function updateEvent(data?: MyHandlerRequest): AWSLambda.CloudFormationCustomResourceUpdateEvent { + return { + LogicalResourceId: 'MyLogicalResourceId', + OldResourceProperties: { + ...data, + ServiceToken: 'MyServiceToken', + }, + PhysicalResourceId: 'MyPhysicalResourceId', + RequestId: 'MyRequestId', + RequestType: 'Update', + ResourceType: 'MyResourceType', + ResourceProperties: { + ...data, + ServiceToken: 'MyServiceToken', + }, + ResponseURL: 'https://someurl.com', + ServiceToken: 'MyServiceToken', + StackId: 'MyStackId', + }; +} diff --git a/packages/@aws-cdk/integ-tests/test/assertions/providers/lambda-handler/results.test.ts b/packages/@aws-cdk/integ-tests/test/assertions/providers/lambda-handler/results.test.ts new file mode 100644 index 0000000000000..33b0cef42677d --- /dev/null +++ b/packages/@aws-cdk/integ-tests/test/assertions/providers/lambda-handler/results.test.ts @@ -0,0 +1,59 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +import { ResultsCollectionRequest, ResultsCollectionResult } from '../../../../lib/assertions'; +import { ResultsCollectionHandler } from '../../../../lib/assertions/providers/lambda-handler/results'; + +function handler() { + const context: any = { + getRemainingTimeInMillis: () => 50000, + }; + return new ResultsCollectionHandler({} as any, context); // as any to ignore all type checks +} +beforeAll(() => { + jest.useFakeTimers(); + jest.spyOn(process.stderr, 'write').mockImplementation(() => { return true; }); + jest.spyOn(process.stdout, 'write').mockImplementation(() => { return true; }); +}); +afterAll(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); +}); + +describe('ResultsCollectionHandler', () => { + test('default', async () => { + // GIVEN + const resultsCollection = handler() as any; + const request: ResultsCollectionRequest = { + assertionResults: [ + { status: 'pass' }, + { status: 'fail', message: 'something failed' }, + ], + }; + + // WHEN + const result: ResultsCollectionResult = await resultsCollection.processEvent(request); + const split = result.message.split('\n'); + + // THEN + expect(split.length).toEqual(2); + expect(split[0]).toEqual('Test0: pass'); + expect(split[1]).toEqual('Test1: fail - something failed'); + }); + + test('message not displayed for pass', async () => { + // GIVEN + const resultsCollection = handler() as any; + const request: ResultsCollectionRequest = { + assertionResults: [ + { status: 'pass', message: 'OK' }, + ], + }; + + // WHEN + const result: ResultsCollectionResult = await resultsCollection.processEvent(request); + const split = result.message.split('\n'); + + // THEN + expect(split.length).toEqual(1); + expect(split[0]).toEqual('Test0: pass'); + }); +}); diff --git a/packages/@aws-cdk/integ-tests/test/assertions/providers/lambda-handler/sdk.test.ts b/packages/@aws-cdk/integ-tests/test/assertions/providers/lambda-handler/sdk.test.ts new file mode 100644 index 0000000000000..bce5f29548cb8 --- /dev/null +++ b/packages/@aws-cdk/integ-tests/test/assertions/providers/lambda-handler/sdk.test.ts @@ -0,0 +1,107 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +import * as SDK from 'aws-sdk'; +import * as AWS from 'aws-sdk-mock'; +import * as sinon from 'sinon'; +import { SdkRequest, SdkResult } from '../../../../lib/assertions'; +import { SdkHandler } from '../../../../lib/assertions/providers/lambda-handler/sdk'; + +function sdkHandler() { + const context: any = { + getRemainingTimeInMillis: () => 50000, + }; + return new SdkHandler({} as any, context); // as any to ignore all type checks +} +beforeAll(() => { + jest.useFakeTimers(); + jest.spyOn(console, 'log').mockImplementation(() => { return true; }); +}); +afterAll(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); +}); + +describe('SdkHandler', () => { + beforeEach(() => { + AWS.setSDKInstance(SDK); + }); + + afterEach(() => { + AWS.restore(); + }); + + test('default', async () => { + // GIVEN + const expectedResponse = { + Contents: [ + { + Key: 'first-key', + ETag: 'first-key-etag', + }, + { + Key: 'second-key', + ETag: 'second-key-etag', + }, + ], + } as SDK.S3.ListObjectsOutput; + AWS.mock('S3', 'listObjects', sinon.fake.resolves(expectedResponse)); + const handler = sdkHandler() as any; + const request: SdkRequest = { + service: 'S3', + api: 'listObjects', + parameters: { + Bucket: 'myBucket', + }, + }; + + // WHEN + const response: SdkResult = await handler.processEvent(request); + + + // THEN + expect(response.apiCallResponse).toEqual(expectedResponse); + }); + + describe('decode', () => { + test('boolean true', async () => { + // GIVEN + const fake = sinon.fake.resolves({}); + AWS.mock('EC2', 'describeInstances', fake); + const handler = sdkHandler() as any; + const request: SdkRequest = { + service: 'EC2', + api: 'describeInstances', + parameters: { + DryRun: 'TRUE:BOOLEAN', + }, + }; + + // WHEN + await handler.processEvent(request); + + + // THEN + sinon.assert.calledWith(fake, { DryRun: true }); + }); + + test('boolean false', async () => { + // GIVEN + const fake = sinon.fake.resolves({}); + AWS.mock('EC2', 'describeInstances', fake); + const handler = sdkHandler() as any; + const request: SdkRequest = { + service: 'EC2', + api: 'describeInstances', + parameters: { + DryRun: 'FALSE:BOOLEAN', + }, + }; + + // WHEN + await handler.processEvent(request); + + + // THEN + sinon.assert.calledWith(fake, { DryRun: false }); + }); + }); +}); diff --git a/packages/@aws-cdk/integ-tests/test/assertions/providers/provider.test.ts b/packages/@aws-cdk/integ-tests/test/assertions/providers/provider.test.ts new file mode 100644 index 0000000000000..376be437ddb8a --- /dev/null +++ b/packages/@aws-cdk/integ-tests/test/assertions/providers/provider.test.ts @@ -0,0 +1,122 @@ +import { Template } from '@aws-cdk/assertions'; +import { Stack } from '@aws-cdk/core'; +import { AssertionsProvider } from '../../../lib/assertions'; + +describe('AssertionProvider', () => { + test('default', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + const provider = new AssertionsProvider(stack, 'AssertionProvider'); + + // THEN + expect(stack.resolve(provider.serviceToken)).toEqual({ 'Fn::GetAtt': ['SingletonLambda1488541a7b23466481b69b4408076b81488C0898', 'Arn'] }); + Template.fromStack(stack).hasResourceProperties('AWS::Lambda::Function', { + Handler: 'index.handler', + Timeout: 120, + }); + }); + + describe('addPolicyStatementForSdkCall', () => { + test('default', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + const provider = new AssertionsProvider(stack, 'AssertionsProvider'); + provider.addPolicyStatementFromSdkCall('MyService', 'myApi'); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: 'myservice:MyApi', + Effect: 'Allow', + Resource: '*', + }, + ], + }, + Roles: [{ + Ref: 'SingletonLambda1488541a7b23466481b69b4408076b81ServiceRole4E21F0DA', + }], + }); + }); + + test('prefix different from service name', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + const provider = new AssertionsProvider(stack, 'AssertionsProvider'); + provider.addPolicyStatementFromSdkCall('applicationautoscaling', 'myApi'); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: 'application-autoscaling:MyApi', + Effect: 'Allow', + Resource: '*', + }, + ], + }, + }); + }); + }); + + describe('encode', () => { + test('booleans', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + const provider = new AssertionsProvider(stack, 'AssertionsProvider'); + const encoded = provider.encode({ + Key1: true, + Key2: false, + }); + + // THEN + expect(encoded).toEqual({ + Key1: 'TRUE:BOOLEAN', + Key2: 'FALSE:BOOLEAN', + }); + }); + + test('all other values return as usual', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + const provider = new AssertionsProvider(stack, 'AssertionsProvider'); + const encoded = provider.encode({ + Key1: 'foo', + Key2: 30, + Key3: ['hello', 'world'], + }); + + // THEN + expect(encoded).toEqual({ + Key1: 'foo', + Key2: 30, + Key3: ['hello', 'world'], + }); + }); + + test('nullish', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + const provider = new AssertionsProvider(stack, 'AssertionsProvider'); + + // THEN + expect(provider.encode(undefined)).toBeUndefined(); + expect(provider.encode(null)).toBeNull(); + expect(provider.encode({})).toEqual({}); + }); + }); +}); diff --git a/packages/@aws-cdk/integ-tests/test/assertions/sdk.test.ts b/packages/@aws-cdk/integ-tests/test/assertions/sdk.test.ts new file mode 100644 index 0000000000000..2b54beb326e2d --- /dev/null +++ b/packages/@aws-cdk/integ-tests/test/assertions/sdk.test.ts @@ -0,0 +1,108 @@ +import { Template, Match } from '@aws-cdk/assertions'; +import { App, Stack } from '@aws-cdk/core'; +import { DeployAssert, SdkQuery } from '../../lib/assertions'; + +describe('SdkQuery', () => { + test('default', () => { + // GIVEN + const app = new App(); + const stack = new Stack(app); + const deplossert = new DeployAssert(stack); + + // WHEN + new SdkQuery(deplossert, 'SdkQuery', { + service: 'MyService', + api: 'MyApi', + }); + + + // THEN + const template = Template.fromStack(stack); + template.resourceCountIs('AWS::Lambda::Function', 1); + template.hasResourceProperties('Custom::DeployAssert@SdkCallMyServiceMyApi', { + service: 'MyService', + api: 'MyApi', + parameters: Match.absent(), + }); + }); + + test('parameters', () => { + // GIVEN + const app = new App(); + const stack = new Stack(app); + const deplossert = new DeployAssert(stack); + + // WHEN + new SdkQuery(deplossert, 'SdkQuery', { + service: 'MyService', + api: 'MyApi', + parameters: { + param1: 'val1', + param2: 2, + }, + }); + + + // THEN + const template = Template.fromStack(stack); + template.resourceCountIs('AWS::Lambda::Function', 1); + template.hasResourceProperties('Custom::DeployAssert@SdkCallMyServiceMyApi', { + service: 'MyService', + api: 'MyApi', + parameters: { + param1: 'val1', + param2: 2, + }, + }); + }); + + describe('assertEqual', () => { + test('default', () => { + // GIVEN + const app = new App(); + const stack = new Stack(app); + const deplossert = new DeployAssert(stack); + + // WHEN + const query = new SdkQuery(deplossert, 'SdkQuery', { + service: 'MyService', + api: 'MyApi', + }); + query.assertEqual({ foo: 'bar' }); + + + // THEN + const template = Template.fromStack(stack); + template.hasResourceProperties('Custom::DeployAssert@AssertEquals', { + expected: { foo: 'bar' }, + actual: { + 'Fn::GetAtt': [ + 'DeployAssertSdkQuery94650089', + 'apiCallResponse', + ], + }, + assertionType: 'equals', + }); + }); + + test('multiple asserts to the same query', () => { + // GIVEN + const app = new App(); + const stack = new Stack(app); + const deplossert = new DeployAssert(stack); + + // WHEN + const query = new SdkQuery(deplossert, 'SdkQuery', { + service: 'MyService', + api: 'MyApi', + }); + query.assertEqual({ foo: 'bar' }); + query.assertEqual({ baz: 'zoo' }); + + + // THEN + const template = Template.fromStack(stack); + template.resourceCountIs('Custom::DeployAssert@AssertEquals', 2); + }); + }); +});