diff --git a/lib/index.ts b/lib/index.ts index 32b68cb..1b05d67 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1 +1 @@ -export * from './spa-deploy/spa-deploy-construct'; +export * from './spa-deploy/spa-deploy-construct'; diff --git a/lib/spa-deploy/spa-deploy-construct.ts b/lib/spa-deploy/spa-deploy-construct.ts index d754f8d..39a1467 100644 --- a/lib/spa-deploy/spa-deploy-construct.ts +++ b/lib/spa-deploy/spa-deploy-construct.ts @@ -1,292 +1,300 @@ -import { - CloudFrontWebDistribution, - ViewerCertificate, - OriginAccessIdentity, - Behavior, - SSLMethod, - SecurityPolicyProtocol, -} from 'aws-cdk-lib/aws-cloudfront'; -import { PolicyStatement, Role, AnyPrincipal, Effect } from 'aws-cdk-lib/aws-iam'; -import { HostedZone, ARecord, RecordTarget } from 'aws-cdk-lib/aws-route53'; -import { DnsValidatedCertificate } from 'aws-cdk-lib/aws-certificatemanager'; -import { HttpsRedirect } from 'aws-cdk-lib/aws-route53-patterns'; -import { CloudFrontTarget } from 'aws-cdk-lib/aws-route53-targets'; -import { CfnOutput } from 'aws-cdk-lib'; -import s3deploy= require('aws-cdk-lib/aws-s3-deployment'); -import s3 = require('aws-cdk-lib/aws-s3'); -import { Construct } from 'constructs'; - -export interface SPADeployConfig { - readonly indexDoc:string, - readonly errorDoc?:string, - readonly websiteFolder: string, - readonly certificateARN?: string, - readonly cfBehaviors?: Behavior[], - readonly cfAliases?: string[], - readonly exportWebsiteUrlOutput?:boolean, - readonly exportWebsiteUrlName?: string, - readonly blockPublicAccess?:s3.BlockPublicAccess - readonly sslMethod?: SSLMethod, - readonly securityPolicy?: SecurityPolicyProtocol, - readonly role?:Role, -} - -export interface HostedZoneConfig { - readonly indexDoc:string, - readonly errorDoc?:string, - readonly cfBehaviors?: Behavior[], - readonly websiteFolder: string, - readonly zoneName: string, - readonly subdomain?: string, - readonly role?: Role, -} - -export interface SPAGlobalConfig { - readonly encryptBucket?:boolean, - readonly ipFilter?:boolean, - readonly ipList?:string[], - readonly role?:Role, -} - -export interface SPADeployment { - readonly websiteBucket: s3.Bucket, -} - -export interface SPADeploymentWithCloudFront extends SPADeployment { - readonly distribution: CloudFrontWebDistribution, -} - -export class SPADeploy extends Construct { - globalConfig: SPAGlobalConfig; - - constructor(scope: Construct, id:string, config?:SPAGlobalConfig) { - super(scope, id); - - if (typeof config !== 'undefined') { - this.globalConfig = config; - } else { - this.globalConfig = { - encryptBucket: false, - ipFilter: false, - }; - } - } - - /** - * Helper method to provide a configured s3 bucket - */ - private getS3Bucket(config:SPADeployConfig, isForCloudFront: boolean) { - const bucketConfig:any = { - websiteIndexDocument: config.indexDoc, - websiteErrorDocument: config.errorDoc, - publicReadAccess: true, - }; - - if (this.globalConfig.encryptBucket === true) { - bucketConfig.encryption = s3.BucketEncryption.S3_MANAGED; - } - - if (this.globalConfig.ipFilter === true || isForCloudFront === true || typeof config.blockPublicAccess !== 'undefined') { - bucketConfig.publicReadAccess = false; - if (typeof config.blockPublicAccess !== 'undefined') { - bucketConfig.blockPublicAccess = config.blockPublicAccess; - } - } - - const bucket = new s3.Bucket(this, 'WebsiteBucket', bucketConfig); - - if (this.globalConfig.ipFilter === true && isForCloudFront === false) { - if (typeof this.globalConfig.ipList === 'undefined') { - throw new Error('When IP Filter is true then the IP List is required'); - } - - const bucketPolicy = new PolicyStatement(); - bucketPolicy.addAnyPrincipal(); - bucketPolicy.addActions('s3:GetObject'); - bucketPolicy.addResources(`${bucket.bucketArn}/*`); - bucketPolicy.addCondition('IpAddress', { - 'aws:SourceIp': this.globalConfig.ipList, - }); - - bucket.addToResourcePolicy(bucketPolicy); - } - - //The below "reinforces" the IAM Role's attached policy, it's not required but it allows for customers using permission boundaries to write into the bucket. - if (config.role) { - bucket.addToResourcePolicy( - new PolicyStatement({ - actions: [ - "s3:GetObject*", - "s3:GetBucket*", - "s3:List*", - "s3:DeleteObject*", - "s3:PutObject*", - "s3:Abort*" - ], - effect: Effect.ALLOW, - resources: [bucket.arnForObjects('*'), bucket.bucketArn], - conditions: { - StringEquals: { - 'aws:PrincipalArn': config.role.roleArn, - }, - }, - principals: [new AnyPrincipal()] - }) - ); - } - - return bucket; - } - - /** - * Helper method to provide configuration for cloudfront - */ - private getCFConfig(websiteBucket:s3.Bucket, config:any, accessIdentity: OriginAccessIdentity, cert?:DnsValidatedCertificate) { - const cfConfig:any = { - originConfigs: [ - { - s3OriginSource: { - s3BucketSource: websiteBucket, - originAccessIdentity: accessIdentity, - }, - behaviors: config.cfBehaviors ? config.cfBehaviors : [{ isDefaultBehavior: true }], - }, - ], - // We need to redirect all unknown routes back to index.html for angular routing to work - errorConfigurations: [{ - errorCode: 403, - responsePagePath: (config.errorDoc ? `/${config.errorDoc}` : `/${config.indexDoc}`), - responseCode: 200, - }, - { - errorCode: 404, - responsePagePath: (config.errorDoc ? `/${config.errorDoc}` : `/${config.indexDoc}`), - responseCode: 200, - }], - }; - - if (typeof config.certificateARN !== 'undefined' && typeof config.cfAliases !== 'undefined') { - cfConfig.aliasConfiguration = { - acmCertRef: config.certificateARN, - names: config.cfAliases, - }; - } - if (typeof config.sslMethod !== 'undefined') { - cfConfig.aliasConfiguration.sslMethod = config.sslMethod; - } - - if (typeof config.securityPolicy !== 'undefined') { - cfConfig.aliasConfiguration.securityPolicy = config.securityPolicy; - } - - if (typeof config.zoneName !== 'undefined' && typeof cert !== 'undefined') { - cfConfig.viewerCertificate = ViewerCertificate.fromAcmCertificate(cert, { - aliases: [config.subdomain ? `${config.subdomain}.${config.zoneName}` : config.zoneName], - }); - } - - return cfConfig; - } - - /** - * Basic setup needed for a non-ssl, non vanity url, non cached s3 website - */ - public createBasicSite(config:SPADeployConfig): SPADeployment { - const websiteBucket = this.getS3Bucket(config, false); - - new s3deploy.BucketDeployment(this, 'BucketDeployment', { - sources: [s3deploy.Source.asset(config.websiteFolder)], - role: config.role, - destinationBucket: websiteBucket, - }); - - const cfnOutputConfig:any = { - description: 'The url of the website', - value: websiteBucket.bucketWebsiteUrl, - }; - - if (config.exportWebsiteUrlOutput === true) { - if (typeof config.exportWebsiteUrlName === 'undefined' || config.exportWebsiteUrlName === '') { - throw new Error('When Output URL as AWS Export property is true then the output name is required'); - } - cfnOutputConfig.exportName = config.exportWebsiteUrlName; - } - - let output = new CfnOutput(this, 'URL', cfnOutputConfig); - //set the output name to be the same as the export name - if(typeof config.exportWebsiteUrlName !== 'undefined' && config.exportWebsiteUrlName !== ''){ - output.overrideLogicalId(config.exportWebsiteUrlName); - } - - return { websiteBucket }; - } - - /** - * This will create an s3 deployment fronted by a cloudfront distribution - * It will also setup error forwarding and unauth forwarding back to indexDoc - */ - public createSiteWithCloudfront(config:SPADeployConfig): SPADeploymentWithCloudFront { - const websiteBucket = this.getS3Bucket(config, true); - const accessIdentity = new OriginAccessIdentity(this, 'OriginAccessIdentity', { comment: `${websiteBucket.bucketName}-access-identity` }); - const distribution = new CloudFrontWebDistribution(this, 'cloudfrontDistribution', this.getCFConfig(websiteBucket, config, accessIdentity)); - - new s3deploy.BucketDeployment(this, 'BucketDeployment', { - sources: [s3deploy.Source.asset(config.websiteFolder)], - destinationBucket: websiteBucket, - // Invalidate the cache for / and index.html when we deploy so that cloudfront serves latest site - distribution, - distributionPaths: ['/', `/${config.indexDoc}`], - role: config.role, - }); - - new CfnOutput(this, 'cloudfront domain', { - description: 'The domain of the website', - value: distribution.distributionDomainName, - }); - - return { websiteBucket, distribution }; - } - - /** - * S3 Deployment, cloudfront distribution, ssl cert and error forwarding auto - * configured by using the details in the hosted zone provided - */ - public createSiteFromHostedZone(config:HostedZoneConfig): SPADeploymentWithCloudFront { - const websiteBucket = this.getS3Bucket(config, true); - const zone = HostedZone.fromLookup(this, 'HostedZone', { domainName: config.zoneName }); - const domainName = config.subdomain ? `${config.subdomain}.${config.zoneName}` : config.zoneName; - const cert = new DnsValidatedCertificate(this, 'Certificate', { - hostedZone: zone, - domainName, - region: 'us-east-1', - }); - - const accessIdentity = new OriginAccessIdentity(this, 'OriginAccessIdentity', { comment: `${websiteBucket.bucketName}-access-identity` }); - const distribution = new CloudFrontWebDistribution(this, 'cloudfrontDistribution', this.getCFConfig(websiteBucket, config, accessIdentity, cert)); - - new s3deploy.BucketDeployment(this, 'BucketDeployment', { - sources: [s3deploy.Source.asset(config.websiteFolder)], - destinationBucket: websiteBucket, - // Invalidate the cache for / and index.html when we deploy so that cloudfront serves latest site - distribution, - role: config.role, - distributionPaths: ['/', `/${config.indexDoc}`], - }); - - new ARecord(this, 'Alias', { - zone, - recordName: domainName, - target: RecordTarget.fromAlias(new CloudFrontTarget(distribution)), - }); - - if (!config.subdomain) { - new HttpsRedirect(this, 'Redirect', { - zone, - recordNames: [`www.${config.zoneName}`], - targetDomain: config.zoneName, - }); - } - - return { websiteBucket, distribution }; - } -} +import { + CloudFrontWebDistribution, + ViewerCertificate, + OriginAccessIdentity, + Behavior, + SSLMethod, + SecurityPolicyProtocol, + GeoRestriction, +} from 'aws-cdk-lib/aws-cloudfront'; +import { + PolicyStatement, Role, AnyPrincipal, Effect, +} from 'aws-cdk-lib/aws-iam'; +import { HostedZone, ARecord, RecordTarget } from 'aws-cdk-lib/aws-route53'; +import { DnsValidatedCertificate } from 'aws-cdk-lib/aws-certificatemanager'; +import { HttpsRedirect } from 'aws-cdk-lib/aws-route53-patterns'; +import { CloudFrontTarget } from 'aws-cdk-lib/aws-route53-targets'; +import { CfnOutput } from 'aws-cdk-lib'; +import s3deploy= require('aws-cdk-lib/aws-s3-deployment'); +import s3 = require('aws-cdk-lib/aws-s3'); +import { Construct } from 'constructs'; + +export interface SPADeployConfig { + readonly indexDoc:string, + readonly errorDoc?:string, + readonly websiteFolder: string, + readonly certificateARN?: string, + readonly cfBehaviors?: Behavior[], + readonly cfAliases?: string[], + readonly exportWebsiteUrlOutput?:boolean, + readonly exportWebsiteUrlName?: string, + readonly blockPublicAccess?:s3.BlockPublicAccess + readonly sslMethod?: SSLMethod, + readonly securityPolicy?: SecurityPolicyProtocol, + readonly role?:Role, + readonly geoRestriction?: GeoRestriction | undefined, +} + +export interface HostedZoneConfig { + readonly indexDoc:string, + readonly errorDoc?:string, + readonly cfBehaviors?: Behavior[], + readonly websiteFolder: string, + readonly zoneName: string, + readonly subdomain?: string, + readonly role?: Role, +} + +export interface SPAGlobalConfig { + readonly encryptBucket?:boolean, + readonly ipFilter?:boolean, + readonly ipList?:string[], + readonly role?:Role, +} + +export interface SPADeployment { + readonly websiteBucket: s3.Bucket, +} + +export interface SPADeploymentWithCloudFront extends SPADeployment { + readonly distribution: CloudFrontWebDistribution, +} + +export class SPADeploy extends Construct { + globalConfig: SPAGlobalConfig; + + constructor(scope: Construct, id:string, config?:SPAGlobalConfig) { + super(scope, id); + + if (typeof config !== 'undefined') { + this.globalConfig = config; + } else { + this.globalConfig = { + encryptBucket: false, + ipFilter: false, + }; + } + } + + /** + * Helper method to provide a configured s3 bucket + */ + private getS3Bucket(config:SPADeployConfig, isForCloudFront: boolean) { + const bucketConfig:any = { + websiteIndexDocument: config.indexDoc, + websiteErrorDocument: config.errorDoc, + publicReadAccess: true, + }; + + if (this.globalConfig.encryptBucket === true) { + bucketConfig.encryption = s3.BucketEncryption.S3_MANAGED; + } + + if (this.globalConfig.ipFilter === true || isForCloudFront === true || typeof config.blockPublicAccess !== 'undefined') { + bucketConfig.publicReadAccess = false; + if (typeof config.blockPublicAccess !== 'undefined') { + bucketConfig.blockPublicAccess = config.blockPublicAccess; + } + } + + const bucket = new s3.Bucket(this, 'WebsiteBucket', bucketConfig); + + if (this.globalConfig.ipFilter === true && isForCloudFront === false) { + if (typeof this.globalConfig.ipList === 'undefined') { + throw new Error('When IP Filter is true then the IP List is required'); + } + + const bucketPolicy = new PolicyStatement(); + bucketPolicy.addAnyPrincipal(); + bucketPolicy.addActions('s3:GetObject'); + bucketPolicy.addResources(`${bucket.bucketArn}/*`); + bucketPolicy.addCondition('IpAddress', { + 'aws:SourceIp': this.globalConfig.ipList, + }); + + bucket.addToResourcePolicy(bucketPolicy); + } + + // The below "reinforces" the IAM Role's attached policy, it's not required but it allows for customers using permission boundaries to write into the bucket. + if (config.role) { + bucket.addToResourcePolicy( + new PolicyStatement({ + actions: [ + 's3:GetObject*', + 's3:GetBucket*', + 's3:List*', + 's3:DeleteObject*', + 's3:PutObject*', + 's3:Abort*', + ], + effect: Effect.ALLOW, + resources: [bucket.arnForObjects('*'), bucket.bucketArn], + conditions: { + StringEquals: { + 'aws:PrincipalArn': config.role.roleArn, + }, + }, + principals: [new AnyPrincipal()], + }), + ); + } + + return bucket; + } + + /** + * Helper method to provide configuration for cloudfront + */ + private getCFConfig(websiteBucket:s3.Bucket, config:any, accessIdentity: OriginAccessIdentity, cert?:DnsValidatedCertificate) { + const cfConfig:any = { + originConfigs: [ + { + s3OriginSource: { + s3BucketSource: websiteBucket, + originAccessIdentity: accessIdentity, + }, + behaviors: config.cfBehaviors ? config.cfBehaviors : [{ isDefaultBehavior: true }], + }, + ], + // We need to redirect all unknown routes back to index.html for angular routing to work + errorConfigurations: [{ + errorCode: 403, + responsePagePath: (config.errorDoc ? `/${config.errorDoc}` : `/${config.indexDoc}`), + responseCode: 200, + }, + { + errorCode: 404, + responsePagePath: (config.errorDoc ? `/${config.errorDoc}` : `/${config.indexDoc}`), + responseCode: 200, + }], + }; + + if (typeof config.certificateARN !== 'undefined' && typeof config.cfAliases !== 'undefined') { + cfConfig.aliasConfiguration = { + acmCertRef: config.certificateARN, + names: config.cfAliases, + }; + } + if (typeof config.sslMethod !== 'undefined') { + cfConfig.aliasConfiguration.sslMethod = config.sslMethod; + } + + if (typeof config.securityPolicy !== 'undefined') { + cfConfig.aliasConfiguration.securityPolicy = config.securityPolicy; + } + + if (typeof config.zoneName !== 'undefined' && typeof cert !== 'undefined') { + cfConfig.viewerCertificate = ViewerCertificate.fromAcmCertificate(cert, { + aliases: [config.subdomain ? `${config.subdomain}.${config.zoneName}` : config.zoneName], + }); + } + + if (typeof config.geoRestriction !== 'undefined') { + cfConfig.geoRestriction = config.geoRestriction; + } + + return cfConfig; + } + + /** + * Basic setup needed for a non-ssl, non vanity url, non cached s3 website + */ + public createBasicSite(config:SPADeployConfig): SPADeployment { + const websiteBucket = this.getS3Bucket(config, false); + + new s3deploy.BucketDeployment(this, 'BucketDeployment', { + sources: [s3deploy.Source.asset(config.websiteFolder)], + role: config.role, + destinationBucket: websiteBucket, + }); + + const cfnOutputConfig:any = { + description: 'The url of the website', + value: websiteBucket.bucketWebsiteUrl, + }; + + if (config.exportWebsiteUrlOutput === true) { + if (typeof config.exportWebsiteUrlName === 'undefined' || config.exportWebsiteUrlName === '') { + throw new Error('When Output URL as AWS Export property is true then the output name is required'); + } + cfnOutputConfig.exportName = config.exportWebsiteUrlName; + } + + const output = new CfnOutput(this, 'URL', cfnOutputConfig); + // set the output name to be the same as the export name + if (typeof config.exportWebsiteUrlName !== 'undefined' && config.exportWebsiteUrlName !== '') { + output.overrideLogicalId(config.exportWebsiteUrlName); + } + + return { websiteBucket }; + } + + /** + * This will create an s3 deployment fronted by a cloudfront distribution + * It will also setup error forwarding and unauth forwarding back to indexDoc + */ + public createSiteWithCloudfront(config:SPADeployConfig): SPADeploymentWithCloudFront { + const websiteBucket = this.getS3Bucket(config, true); + const accessIdentity = new OriginAccessIdentity(this, 'OriginAccessIdentity', { comment: `${websiteBucket.bucketName}-access-identity` }); + const distribution = new CloudFrontWebDistribution(this, 'cloudfrontDistribution', this.getCFConfig(websiteBucket, config, accessIdentity)); + + new s3deploy.BucketDeployment(this, 'BucketDeployment', { + sources: [s3deploy.Source.asset(config.websiteFolder)], + destinationBucket: websiteBucket, + // Invalidate the cache for / and index.html when we deploy so that cloudfront serves latest site + distribution, + distributionPaths: ['/', `/${config.indexDoc}`], + role: config.role, + }); + + new CfnOutput(this, 'cloudfront domain', { + description: 'The domain of the website', + value: distribution.distributionDomainName, + }); + + return { websiteBucket, distribution }; + } + + /** + * S3 Deployment, cloudfront distribution, ssl cert and error forwarding auto + * configured by using the details in the hosted zone provided + */ + public createSiteFromHostedZone(config:HostedZoneConfig): SPADeploymentWithCloudFront { + const websiteBucket = this.getS3Bucket(config, true); + const zone = HostedZone.fromLookup(this, 'HostedZone', { domainName: config.zoneName }); + const domainName = config.subdomain ? `${config.subdomain}.${config.zoneName}` : config.zoneName; + const cert = new DnsValidatedCertificate(this, 'Certificate', { + hostedZone: zone, + domainName, + region: 'us-east-1', + }); + + const accessIdentity = new OriginAccessIdentity(this, 'OriginAccessIdentity', { comment: `${websiteBucket.bucketName}-access-identity` }); + const distribution = new CloudFrontWebDistribution(this, 'cloudfrontDistribution', this.getCFConfig(websiteBucket, config, accessIdentity, cert)); + + new s3deploy.BucketDeployment(this, 'BucketDeployment', { + sources: [s3deploy.Source.asset(config.websiteFolder)], + destinationBucket: websiteBucket, + // Invalidate the cache for / and index.html when we deploy so that cloudfront serves latest site + distribution, + role: config.role, + distributionPaths: ['/', `/${config.indexDoc}`], + }); + + new ARecord(this, 'Alias', { + zone, + recordName: domainName, + target: RecordTarget.fromAlias(new CloudFrontTarget(distribution)), + }); + + if (!config.subdomain) { + new HttpsRedirect(this, 'Redirect', { + zone, + recordNames: [`www.${config.zoneName}`], + targetDomain: config.zoneName, + }); + } + + return { websiteBucket, distribution }; + } +} diff --git a/test/cdk-spa-deploy.test.ts b/test/cdk-spa-deploy.test.ts index 71d4752..9a1e2e9 100644 --- a/test/cdk-spa-deploy.test.ts +++ b/test/cdk-spa-deploy.test.ts @@ -1,893 +1,923 @@ -import { Match, Template } from "aws-cdk-lib/assertions"; -import * as cf from 'aws-cdk-lib/aws-cloudfront'; -import { Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam'; -import { BlockPublicAccess } from 'aws-cdk-lib/aws-s3'; -import { Stack, App } from 'aws-cdk-lib/'; -import { SPADeploy } from '../lib'; - - -test('Cloudfront Distribution Included', () => { - const stack = new Stack(); - // WHEN - const deploy = new SPADeploy(stack, 'spaDeploy'); - - deploy.createSiteWithCloudfront({ - indexDoc: 'index.html', - websiteFolder: 'website', - }); - - const template = Template.fromStack(stack); - // THEN - template.hasResourceProperties('AWS::S3::Bucket', - Match.objectLike({ - WebsiteConfiguration: { - IndexDocument: 'index.html', - }, - })); - - template.hasResource('Custom::CDKBucketDeployment', {}); - - template.hasResourceProperties('AWS::CloudFront::Distribution', - Match.objectLike({ - DistributionConfig: { - CustomErrorResponses: [ - { - ErrorCode: 403, - ResponseCode: 200, - ResponsePagePath: '/index.html', - }, - { - ErrorCode: 404, - ResponseCode: 200, - ResponsePagePath: '/index.html', - }, - ], - DefaultCacheBehavior: { - ViewerProtocolPolicy: 'redirect-to-https', - }, - DefaultRootObject: 'index.html', - HttpVersion: 'http2', - IPV6Enabled: true, - PriceClass: 'PriceClass_100', - ViewerCertificate: { - CloudFrontDefaultCertificate: true, - }, - }, - })); - - template.hasResourceProperties('AWS::S3::BucketPolicy', - Match.objectLike({ - PolicyDocument: { - Statement: [ - Match.objectLike({ - Action: 's3:GetObject', - Effect: 'Allow' - })], - }, - })); -}); - -test('Cloudfront Distribution Included - with non-default error-doc cfg set', () => { - const stack = new Stack(); - // WHEN - const deploy = new SPADeploy(stack, 'spaDeploy'); - - deploy.createSiteWithCloudfront({ - indexDoc: 'index.html', - errorDoc: 'error.html', - websiteFolder: 'website', - }); - - const template = Template.fromStack(stack); - - - // THEN - template.hasResourceProperties('AWS::S3::Bucket', - Match.objectLike({ - WebsiteConfiguration: { - IndexDocument: 'index.html', - ErrorDocument: 'error.html', - }, - })); - - template.hasResource('Custom::CDKBucketDeployment', {}); - - template.hasResourceProperties('AWS::CloudFront::Distribution', - Match.objectLike({ - DistributionConfig: { - CustomErrorResponses: [ - { - ErrorCode: 403, - ResponseCode: 200, - ResponsePagePath: '/error.html', - }, - { - ErrorCode: 404, - ResponseCode: 200, - ResponsePagePath: '/error.html', - }, - ], - DefaultCacheBehavior: { - ViewerProtocolPolicy: 'redirect-to-https', - }, - DefaultRootObject: 'index.html', - HttpVersion: 'http2', - IPV6Enabled: true, - PriceClass: 'PriceClass_100', - ViewerCertificate: { - CloudFrontDefaultCertificate: true, - }, - }, - })); - - template.hasResourceProperties('AWS::S3::BucketPolicy', - Match.objectLike({ - PolicyDocument: { - Statement: [ - Match.objectLike({ - Action: 's3:GetObject', - Effect: 'Allow' - })], - }, - })); -}); - -test('Cloudfront With Custom Cert and Aliases', () => { - const stack = new Stack(); - // WHEN - const deploy = new SPADeploy(stack, 'spaDeploy'); - - deploy.createSiteWithCloudfront({ - indexDoc: 'index.html', - websiteFolder: 'website', - certificateARN: 'arn:1234', - cfAliases: ['www.test.com'], - }); - - const template = Template.fromStack(stack); - - // THEN - template.hasResourceProperties('AWS::S3::Bucket', - Match.objectLike({ - WebsiteConfiguration: { - IndexDocument: 'index.html' - }, - })); - - template.hasResource('Custom::CDKBucketDeployment', {}); - - template.hasResourceProperties('AWS::CloudFront::Distribution', - Match.objectLike({ - DistributionConfig: { - Aliases: [ - 'www.test.com', - ], - ViewerCertificate: { - AcmCertificateArn: 'arn:1234', - SslSupportMethod: 'sni-only', - }, - }, - })); -}); - - -test('Cloudfront With Custom Role', () => { - const stack = new Stack(); - // WHEN - const deploy = new SPADeploy(stack, 'spaDeploy'); - - deploy.createSiteWithCloudfront({ - indexDoc: 'index.html', - websiteFolder: 'website', - certificateARN: 'arn:1234', - cfAliases: ['www.test.com'], - role: new Role(stack, 'myRole', {roleName: 'testRole', assumedBy: new ServicePrincipal('lambda.amazonaws.com')}) - }); - - const template = Template.fromStack(stack); - - // THEN - template.hasResourceProperties('AWS::Lambda::Function', - Match.objectLike({ - Role: { - "Fn::GetAtt": [ - "myRoleE60D68E8", - "Arn" - ] - } - })); -}); - - -test('Basic Site Setup', () => { - const stack = new Stack(); - - // WHEN - const deploy = new SPADeploy(stack, 'spaDeploy'); - - deploy.createBasicSite({ - indexDoc: 'index.html', - websiteFolder: 'website', - }); - - const template = Template.fromStack(stack); - - // THEN - template.hasResourceProperties('AWS::S3::Bucket', - Match.objectLike({ - WebsiteConfiguration: { - IndexDocument: 'index.html' - }, - })); - - template.hasResource('Custom::CDKBucketDeployment', {}); - - template.hasResourceProperties('AWS::S3::BucketPolicy', - Match.objectLike({ - PolicyDocument: { - Statement: [ - Match.objectLike({ - Action: 's3:GetObject', - Effect: 'Allow', - Principal: { - "AWS": "*" - }, - })], - }, - })); -}); - -test('Basic Site Setup with Error Doc set', () => { - const stack = new Stack(); - - // WHEN - const deploy = new SPADeploy(stack, 'spaDeploy'); - - deploy.createBasicSite({ - indexDoc: 'index.html', - errorDoc: 'error.html', - websiteFolder: 'website', - }); - - const template = Template.fromStack(stack); - - // THEN - template.hasResourceProperties('AWS::S3::Bucket', - Match.objectLike({ - WebsiteConfiguration: { - IndexDocument: 'index.html', - ErrorDocument: 'error.html', - }, - })); - - template.hasResource('Custom::CDKBucketDeployment', {}); - - template.hasResourceProperties('AWS::S3::BucketPolicy', - Match.objectLike({ - PolicyDocument: { - Statement: [ - Match.objectLike({ - Action: 's3:GetObject', - Effect: 'Allow', - Principal: { - "AWS": "*" - }, - })], - }, - })); -}); - -test('Basic Site Setup with Custom Role', () => { - const stack = new Stack(); - - // WHEN - const deploy = new SPADeploy(stack, 'spaDeploy'); - - deploy.createBasicSite({ - indexDoc: 'index.html', - errorDoc: 'error.html', - websiteFolder: 'website', - role: new Role(stack, 'myRole', {roleName: 'testRole', assumedBy: new ServicePrincipal('lambda.amazonaws.com')}), - }); - - const template = Template.fromStack(stack); - - // THEN - template.hasResourceProperties('AWS::Lambda::Function', - Match.objectLike({ - Role: { - "Fn::GetAtt": [ - "myRoleE60D68E8", - "Arn" - ] - } - })); - - template.hasResourceProperties('AWS::S3::BucketPolicy', - Match.objectLike({ - PolicyDocument: { - Statement: [ - Match.objectLike({ - Action: 's3:GetObject', - Effect: 'Allow', - Principal: { - "AWS": "*" - } - }), - Match.objectLike({ - Action: [ - "s3:GetObject*", - "s3:GetBucket*", - "s3:List*", - "s3:DeleteObject*", - "s3:PutObject*", - "s3:Abort*" - ], - Condition: { - StringEquals: { - "aws:PrincipalArn": { - "Fn::GetAtt": [ - "myRoleE60D68E8", - "Arn" - ] - } - } - }, - Effect: 'Allow', - Principal: { - "AWS": "*" - } - })], - } - })); -}); - - -test('Basic Site Setup with Undefined Role', () => { - const stack = new Stack(); - - // WHEN - const deploy = new SPADeploy(stack, 'spaDeploy'); - - deploy.createBasicSite({ - indexDoc: 'index.html', - errorDoc: 'error.html', - websiteFolder: 'website', - role: undefined - }); - - const template = Template.fromStack(stack); - - // THEN - template.hasResourceProperties('AWS::Lambda::Function', - Match.objectLike({ - Runtime: "python3.7", - Role: { - "Fn::GetAtt": [ - "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole89A01265", - "Arn" - ] - } - })); -}); - - -test('Basic Site Setup, Encrypted Bucket', () => { - const stack = new Stack(); - - // WHEN - new SPADeploy(stack, 'spaDeploy', { encryptBucket: true }) - .createBasicSite({ - indexDoc: 'index.html', - websiteFolder: 'website', - }); - - const template = Template.fromStack(stack); - - // THEN - template.hasResourceProperties('AWS::S3::Bucket', - Match.objectLike({ - BucketEncryption: { - ServerSideEncryptionConfiguration: [ - { - ServerSideEncryptionByDefault: { - SSEAlgorithm: 'AES256', - }, - }, - ], - }, - WebsiteConfiguration: { - IndexDocument: 'index.html', - }, - })); - - template.hasResource('Custom::CDKBucketDeployment', {}); - - template.hasResourceProperties('AWS::S3::BucketPolicy', - Match.objectLike({ - PolicyDocument: { - Statement: [ - Match.objectLike({ - Action: 's3:GetObject', - Effect: 'Allow', - Principal: { - "AWS": "*" - }, - })], - }, - })); -}); - -test('Cloudfront With Encrypted Bucket', () => { - const stack = new Stack(); - // WHEN - const deploy = new SPADeploy(stack, 'spaDeploy', { encryptBucket: true }); - - deploy.createSiteWithCloudfront({ - indexDoc: 'index.html', - websiteFolder: 'website', - certificateARN: 'arn:1234', - cfAliases: ['www.test.com'], - }); - - const template = Template.fromStack(stack); - - // THEN - template.hasResourceProperties('AWS::S3::Bucket', - Match.objectLike({ - BucketEncryption: { - ServerSideEncryptionConfiguration: [ - { - ServerSideEncryptionByDefault: { - SSEAlgorithm: 'AES256', - }, - }, - ], - }, - WebsiteConfiguration: { - IndexDocument: 'index.html', - }, - })); - - template.hasResource('Custom::CDKBucketDeployment', {}); - - template.hasResourceProperties('AWS::CloudFront::Distribution', Match.objectLike({ - DistributionConfig: { - Aliases: [ - 'www.test.com', - ], - ViewerCertificate: { - AcmCertificateArn: 'arn:1234', - SslSupportMethod: 'sni-only', - }, - }, - })); -}); - -test('Cloudfront With Custom Defined Behaviors', () => { - const stack = new Stack(); - - // WHEN - const deploy = new SPADeploy(stack, 'spaDeploy'); - - deploy.createSiteWithCloudfront({ - indexDoc: 'index.html', - websiteFolder: 'website', - cfBehaviors: [ - { - isDefaultBehavior: true, - allowedMethods: cf.CloudFrontAllowedMethods.ALL, - forwardedValues: { - queryString: true, - cookies: { forward: 'all' }, - headers: ['*'], - }, - }, - { - pathPattern: '/virtual-path', - allowedMethods: cf.CloudFrontAllowedMethods.GET_HEAD, - cachedMethods: cf.CloudFrontAllowedCachedMethods.GET_HEAD, - }, - ], - }); - - const template = Template.fromStack(stack); - - // THEN - template.hasResource('Custom::CDKBucketDeployment', {}); - - template.hasResourceProperties('AWS::CloudFront::Distribution', Match.objectLike({ - DistributionConfig: { - CacheBehaviors: [ - Match.objectLike({ - AllowedMethods: ['GET', 'HEAD'], - CachedMethods: ['GET', 'HEAD'], - PathPattern: '/virtual-path', - }), - ], - DefaultCacheBehavior: { - AllowedMethods: ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT'], - ForwardedValues: { - Cookies: { Forward: 'all' }, - Headers: ['*'], - QueryString: true, - }, - TargetOriginId: 'origin1', - }, - }, - })); -}); - -test('Cloudfront With Custom Security Policy', () => { - const stack = new Stack(); - // WHEN - const deploy = new SPADeploy(stack, 'spaDeploy'); - - deploy.createSiteWithCloudfront({ - indexDoc: 'index.html', - websiteFolder: 'website', - certificateARN: 'arn:1234', - cfAliases: ['www.test.com'], - securityPolicy: cf.SecurityPolicyProtocol.TLS_V1_2_2019, - }); - - const template = Template.fromStack(stack); - - // THEN - template.hasResource('Custom::CDKBucketDeployment', {}); - - template.hasResourceProperties('AWS::CloudFront::Distribution', Match.objectLike({ - DistributionConfig: { - Aliases: [ - 'www.test.com', - ], - ViewerCertificate: { - AcmCertificateArn: 'arn:1234', - SslSupportMethod: 'sni-only', - MinimumProtocolVersion: 'TLSv1.2_2019', - }, - }, - })); -}); - -test('Cloudfront With Custom SSL Method', () => { - const stack = new Stack(); - // WHEN - const deploy = new SPADeploy(stack, 'spaDeploy'); - - deploy.createSiteWithCloudfront({ - indexDoc: 'index.html', - websiteFolder: 'website', - certificateARN: 'arn:1234', - cfAliases: ['www.test.com'], - sslMethod: cf.SSLMethod.VIP, - }); - - const template = Template.fromStack(stack); - - // THEN - template.hasResource('Custom::CDKBucketDeployment', {}); - - template.hasResourceProperties('AWS::CloudFront::Distribution', Match.objectLike({ - DistributionConfig: { - Aliases: [ - 'www.test.com', - ], - ViewerCertificate: { - AcmCertificateArn: 'arn:1234', - SslSupportMethod: 'vip', - }, - }, - })); -}); - -test('Basic Site Setup, IP Filter', () => { - const stack = new Stack(); - - // WHEN - new SPADeploy(stack, 'spaDeploy', { encryptBucket: true, ipFilter: true, ipList: ['1.1.1.1'] }) - .createBasicSite({ - indexDoc: 'index.html', - websiteFolder: 'website', - }); - - const template = Template.fromStack(stack); - - // THEN - template.hasResourceProperties('AWS::S3::Bucket', - Match.objectLike({ - BucketEncryption: { - ServerSideEncryptionConfiguration: [ - { - ServerSideEncryptionByDefault: { - SSEAlgorithm: 'AES256', - }, - }, - ], - }, - WebsiteConfiguration: { - IndexDocument: 'index.html', - }, - })); - - template.hasResource('Custom::CDKBucketDeployment', {}); - - template.hasResourceProperties('AWS::S3::BucketPolicy', Match.objectLike({ - PolicyDocument: { - Statement: [ - Match.objectLike({ - Action: 's3:GetObject', - Condition: { - IpAddress: { - 'aws:SourceIp': [ - '1.1.1.1', - ], - }, - }, - Effect: 'Allow', - Principal: { - "AWS": "*" - }, - })], - }, - })); -}); - -test('Create From Hosted Zone', () => { - const app = new App(); - const stack = new Stack(app, 'testStack', { - env: { - region: 'us-east-1', - account: '1234', - }, - }); - // WHEN - new SPADeploy(stack, 'spaDeploy', { encryptBucket: true }) - .createSiteFromHostedZone({ - zoneName: 'cdkspadeploy.com', - indexDoc: 'index.html', - websiteFolder: 'website', - }); - - const template = Template.fromStack(stack); - - // THEN - template.hasResourceProperties('AWS::S3::Bucket', - Match.objectLike({ - BucketEncryption: { - ServerSideEncryptionConfiguration: [ - { - ServerSideEncryptionByDefault: { - SSEAlgorithm: 'AES256', - }, - }, - ], - }, - WebsiteConfiguration: { - IndexDocument: 'index.html', - }, - })); - - template.hasResource('Custom::CDKBucketDeployment', {}); - - template.hasResourceProperties('AWS::CloudFront::Distribution', Match.objectLike({ - DistributionConfig: { - Aliases: [ - 'www.cdkspadeploy.com', - ], - ViewerCertificate: { - SslSupportMethod: 'sni-only', - }, - }, - })); - - template.hasResourceProperties('AWS::S3::BucketPolicy', - Match.objectLike({ - PolicyDocument: { - Statement: [ - Match.objectLike({ - Action: 's3:GetObject', - Effect: 'Allow' - })], - }, - })); -}); - -test('Create From Hosted Zone with subdomain', () => { - const app = new App(); - const stack = new Stack(app, 'testStack', { - env: { - region: 'us-east-1', - account: '1234', - }, - }); - // WHEN - new SPADeploy(stack, 'spaDeploy', { encryptBucket: true }) - .createSiteFromHostedZone({ - zoneName: 'cdkspadeploy.com', - indexDoc: 'index.html', - websiteFolder: 'website', - subdomain: 'myhost', - }); - - const template = Template.fromStack(stack); - - // THEN - template.hasResourceProperties('AWS::CloudFront::Distribution', Match.objectLike({ - DistributionConfig: { - Aliases: [ - 'myhost.cdkspadeploy.com', - ], - ViewerCertificate: { - SslSupportMethod: 'sni-only', - }, - }, - })); -}); - -test('Create From Hosted Zone with Custom Role', () => { - const app = new App(); - const stack = new Stack(app, 'testStack', { - env: { - region: 'us-east-1', - account: '1234', - }, - }); - // WHEN - new SPADeploy(stack, 'spaDeploy', { encryptBucket: true }) - .createSiteFromHostedZone({ - zoneName: 'cdkspadeploy.com', - indexDoc: 'index.html', - errorDoc: 'error.html', - websiteFolder: 'website', - role: new Role(stack, 'myRole', {roleName: 'testRole', assumedBy: new ServicePrincipal('lambda.amazonaws.com')}) - }); - - const template = Template.fromStack(stack); - - // THEN - - template.hasResourceProperties('AWS::Lambda::Function', Match.objectLike({ - Role: { - "Fn::GetAtt": [ - "myRoleE60D68E8", - "Arn" - ] - } - })); -}); - -test('Create From Hosted Zone with Error Bucket', () => { - const app = new App(); - const stack = new Stack(app, 'testStack', { - env: { - region: 'us-east-1', - account: '1234', - }, - }); - // WHEN - new SPADeploy(stack, 'spaDeploy', { encryptBucket: true }) - .createSiteFromHostedZone({ - zoneName: 'cdkspadeploy.com', - indexDoc: 'index.html', - errorDoc: 'error.html', - websiteFolder: 'website', - }); - - const template = Template.fromStack(stack); - - // THEN - template.hasResourceProperties('AWS::S3::Bucket', Match.objectLike({ - BucketEncryption: { - ServerSideEncryptionConfiguration: [ - { - ServerSideEncryptionByDefault: { - SSEAlgorithm: 'AES256', - }, - }, - ], - }, - WebsiteConfiguration: { - IndexDocument: 'index.html', - ErrorDocument: 'error.html', - }, - })); - - template.hasResource('Custom::CDKBucketDeployment', {}); - - template.hasResourceProperties('AWS::CloudFront::Distribution', Match.objectLike({ - DistributionConfig: { - Aliases: [ - 'www.cdkspadeploy.com', - ], - ViewerCertificate: { - SslSupportMethod: 'sni-only', - }, - }, - })); -}); - -test('Basic Site Setup, Block Public Enabled', () => { - const stack = new Stack(); - - // WHEN - new SPADeploy(stack, 'spaDeploy') - .createBasicSite({ - indexDoc: 'index.html', - websiteFolder: 'website', - blockPublicAccess: BlockPublicAccess.BLOCK_ALL - }); - - const template = Template.fromStack(stack); - - // THEN - template.hasResourceProperties('AWS::S3::Bucket', Match.objectLike({ - WebsiteConfiguration: { - IndexDocument: 'index.html', - }, - PublicAccessBlockConfiguration: { - BlockPublicAcls: true, - BlockPublicPolicy: true, - IgnorePublicAcls: true, - RestrictPublicBuckets: true, - }, - })); -}); - - -test('Basic Site Setup, URL Output Enabled With Name', () => { - const stack = new Stack(); - const exportName = 'test-export-name'; - - // WHEN - new SPADeploy(stack, 'spaDeploy', {}) - .createBasicSite({ - indexDoc: 'index.html', - websiteFolder: 'website', - exportWebsiteUrlOutput: true, - exportWebsiteUrlName: exportName, - }); - - const template = Template.fromStack(stack); - - // THEN - template.hasOutput(exportName, { - "Export": { - "Name": exportName - } - }); -}); - -test('Basic Site Setup, URL Output Enabled With No Name', () => { - const stack = new Stack(); - - // WHEN - expect(() => {new SPADeploy(stack, 'spaDeploy', {}) - .createBasicSite({ - indexDoc: 'index.html', - websiteFolder: 'website', - exportWebsiteUrlOutput: true, - })}).toThrowError(); -}); - -test('Basic Site Setup, URL Output Not Enabled', () => { - const stack = new Stack(); - const exportName = 'test-export-name'; - - // WHEN - new SPADeploy(stack, 'spaDeploy', {}) - .createBasicSite({ - indexDoc: 'index.html', - websiteFolder: 'website', - exportWebsiteUrlOutput: false, - }); - - const template = Template.fromStack(stack); - - // THEN - expect(() => {template.hasOutput(exportName, { - "Export": { - "Name": exportName - } - })}).toThrowError(); -}); +import { Match, Template } from 'aws-cdk-lib/assertions'; +import * as cf from 'aws-cdk-lib/aws-cloudfront'; +import { Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam'; +import { BlockPublicAccess } from 'aws-cdk-lib/aws-s3'; +import { Stack, App } from 'aws-cdk-lib/'; +import { SPADeploy } from '../lib'; + +test('Cloudfront Distribution Included', () => { + const stack = new Stack(); + // WHEN + const deploy = new SPADeploy(stack, 'spaDeploy'); + + deploy.createSiteWithCloudfront({ + indexDoc: 'index.html', + websiteFolder: 'website', + }); + + const template = Template.fromStack(stack); + // THEN + template.hasResourceProperties('AWS::S3::Bucket', + Match.objectLike({ + WebsiteConfiguration: { + IndexDocument: 'index.html', + }, + })); + + template.hasResource('Custom::CDKBucketDeployment', {}); + + template.hasResourceProperties('AWS::CloudFront::Distribution', + Match.objectLike({ + DistributionConfig: { + CustomErrorResponses: [ + { + ErrorCode: 403, + ResponseCode: 200, + ResponsePagePath: '/index.html', + }, + { + ErrorCode: 404, + ResponseCode: 200, + ResponsePagePath: '/index.html', + }, + ], + DefaultCacheBehavior: { + ViewerProtocolPolicy: 'redirect-to-https', + }, + DefaultRootObject: 'index.html', + HttpVersion: 'http2', + IPV6Enabled: true, + PriceClass: 'PriceClass_100', + ViewerCertificate: { + CloudFrontDefaultCertificate: true, + }, + }, + })); + + template.hasResourceProperties('AWS::S3::BucketPolicy', + Match.objectLike({ + PolicyDocument: { + Statement: [ + Match.objectLike({ + Action: 's3:GetObject', + Effect: 'Allow', + })], + }, + })); +}); + +test('Cloudfront Distribution Included - with non-default error-doc cfg set', () => { + const stack = new Stack(); + // WHEN + const deploy = new SPADeploy(stack, 'spaDeploy'); + + deploy.createSiteWithCloudfront({ + indexDoc: 'index.html', + errorDoc: 'error.html', + websiteFolder: 'website', + }); + + const template = Template.fromStack(stack); + + // THEN + template.hasResourceProperties('AWS::S3::Bucket', + Match.objectLike({ + WebsiteConfiguration: { + IndexDocument: 'index.html', + ErrorDocument: 'error.html', + }, + })); + + template.hasResource('Custom::CDKBucketDeployment', {}); + + template.hasResourceProperties('AWS::CloudFront::Distribution', + Match.objectLike({ + DistributionConfig: { + CustomErrorResponses: [ + { + ErrorCode: 403, + ResponseCode: 200, + ResponsePagePath: '/error.html', + }, + { + ErrorCode: 404, + ResponseCode: 200, + ResponsePagePath: '/error.html', + }, + ], + DefaultCacheBehavior: { + ViewerProtocolPolicy: 'redirect-to-https', + }, + DefaultRootObject: 'index.html', + HttpVersion: 'http2', + IPV6Enabled: true, + PriceClass: 'PriceClass_100', + ViewerCertificate: { + CloudFrontDefaultCertificate: true, + }, + }, + })); + + template.hasResourceProperties('AWS::S3::BucketPolicy', + Match.objectLike({ + PolicyDocument: { + Statement: [ + Match.objectLike({ + Action: 's3:GetObject', + Effect: 'Allow', + })], + }, + })); +}); + +test('Cloudfront With Custom Cert and Aliases', () => { + const stack = new Stack(); + // WHEN + const deploy = new SPADeploy(stack, 'spaDeploy'); + + deploy.createSiteWithCloudfront({ + indexDoc: 'index.html', + websiteFolder: 'website', + certificateARN: 'arn:1234', + cfAliases: ['www.test.com'], + }); + + const template = Template.fromStack(stack); + + // THEN + template.hasResourceProperties('AWS::S3::Bucket', + Match.objectLike({ + WebsiteConfiguration: { + IndexDocument: 'index.html', + }, + })); + + template.hasResource('Custom::CDKBucketDeployment', {}); + + template.hasResourceProperties('AWS::CloudFront::Distribution', + Match.objectLike({ + DistributionConfig: { + Aliases: [ + 'www.test.com', + ], + ViewerCertificate: { + AcmCertificateArn: 'arn:1234', + SslSupportMethod: 'sni-only', + }, + }, + })); +}); + +test('Cloudfront With Custom Role', () => { + const stack = new Stack(); + // WHEN + const deploy = new SPADeploy(stack, 'spaDeploy'); + + deploy.createSiteWithCloudfront({ + indexDoc: 'index.html', + websiteFolder: 'website', + certificateARN: 'arn:1234', + cfAliases: ['www.test.com'], + role: new Role(stack, 'myRole', { roleName: 'testRole', assumedBy: new ServicePrincipal('lambda.amazonaws.com') }), + }); + + const template = Template.fromStack(stack); + + // THEN + template.hasResourceProperties('AWS::Lambda::Function', + Match.objectLike({ + Role: { + 'Fn::GetAtt': [ + 'myRoleE60D68E8', + 'Arn', + ], + }, + })); +}); + +test('Basic Site Setup', () => { + const stack = new Stack(); + + // WHEN + const deploy = new SPADeploy(stack, 'spaDeploy'); + + deploy.createBasicSite({ + indexDoc: 'index.html', + websiteFolder: 'website', + }); + + const template = Template.fromStack(stack); + + // THEN + template.hasResourceProperties('AWS::S3::Bucket', + Match.objectLike({ + WebsiteConfiguration: { + IndexDocument: 'index.html', + }, + })); + + template.hasResource('Custom::CDKBucketDeployment', {}); + + template.hasResourceProperties('AWS::S3::BucketPolicy', + Match.objectLike({ + PolicyDocument: { + Statement: [ + Match.objectLike({ + Action: 's3:GetObject', + Effect: 'Allow', + Principal: { + AWS: '*', + }, + })], + }, + })); +}); + +test('Basic Site Setup with Error Doc set', () => { + const stack = new Stack(); + + // WHEN + const deploy = new SPADeploy(stack, 'spaDeploy'); + + deploy.createBasicSite({ + indexDoc: 'index.html', + errorDoc: 'error.html', + websiteFolder: 'website', + }); + + const template = Template.fromStack(stack); + + // THEN + template.hasResourceProperties('AWS::S3::Bucket', + Match.objectLike({ + WebsiteConfiguration: { + IndexDocument: 'index.html', + ErrorDocument: 'error.html', + }, + })); + + template.hasResource('Custom::CDKBucketDeployment', {}); + + template.hasResourceProperties('AWS::S3::BucketPolicy', + Match.objectLike({ + PolicyDocument: { + Statement: [ + Match.objectLike({ + Action: 's3:GetObject', + Effect: 'Allow', + Principal: { + AWS: '*', + }, + })], + }, + })); +}); + +test('Basic Site Setup with Custom Role', () => { + const stack = new Stack(); + + // WHEN + const deploy = new SPADeploy(stack, 'spaDeploy'); + + deploy.createBasicSite({ + indexDoc: 'index.html', + errorDoc: 'error.html', + websiteFolder: 'website', + role: new Role(stack, 'myRole', { roleName: 'testRole', assumedBy: new ServicePrincipal('lambda.amazonaws.com') }), + }); + + const template = Template.fromStack(stack); + + // THEN + template.hasResourceProperties('AWS::Lambda::Function', + Match.objectLike({ + Role: { + 'Fn::GetAtt': [ + 'myRoleE60D68E8', + 'Arn', + ], + }, + })); + + template.hasResourceProperties('AWS::S3::BucketPolicy', + Match.objectLike({ + PolicyDocument: { + Statement: [ + Match.objectLike({ + Action: 's3:GetObject', + Effect: 'Allow', + Principal: { + AWS: '*', + }, + }), + Match.objectLike({ + Action: [ + 's3:GetObject*', + 's3:GetBucket*', + 's3:List*', + 's3:DeleteObject*', + 's3:PutObject*', + 's3:Abort*', + ], + Condition: { + StringEquals: { + 'aws:PrincipalArn': { + 'Fn::GetAtt': [ + 'myRoleE60D68E8', + 'Arn', + ], + }, + }, + }, + Effect: 'Allow', + Principal: { + AWS: '*', + }, + })], + }, + })); +}); + +test('Basic Site Setup with Undefined Role', () => { + const stack = new Stack(); + + // WHEN + const deploy = new SPADeploy(stack, 'spaDeploy'); + + deploy.createBasicSite({ + indexDoc: 'index.html', + errorDoc: 'error.html', + websiteFolder: 'website', + role: undefined, + }); + + const template = Template.fromStack(stack); + + // THEN + template.hasResourceProperties('AWS::Lambda::Function', + Match.objectLike({ + Runtime: 'python3.7', + Role: { + 'Fn::GetAtt': [ + 'CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole89A01265', + 'Arn', + ], + }, + })); +}); + +test('Basic Site Setup, Encrypted Bucket', () => { + const stack = new Stack(); + + // WHEN + new SPADeploy(stack, 'spaDeploy', { encryptBucket: true }) + .createBasicSite({ + indexDoc: 'index.html', + websiteFolder: 'website', + }); + + const template = Template.fromStack(stack); + + // THEN + template.hasResourceProperties('AWS::S3::Bucket', + Match.objectLike({ + BucketEncryption: { + ServerSideEncryptionConfiguration: [ + { + ServerSideEncryptionByDefault: { + SSEAlgorithm: 'AES256', + }, + }, + ], + }, + WebsiteConfiguration: { + IndexDocument: 'index.html', + }, + })); + + template.hasResource('Custom::CDKBucketDeployment', {}); + + template.hasResourceProperties('AWS::S3::BucketPolicy', + Match.objectLike({ + PolicyDocument: { + Statement: [ + Match.objectLike({ + Action: 's3:GetObject', + Effect: 'Allow', + Principal: { + AWS: '*', + }, + })], + }, + })); +}); + +test('Cloudfront With Encrypted Bucket', () => { + const stack = new Stack(); + // WHEN + const deploy = new SPADeploy(stack, 'spaDeploy', { encryptBucket: true }); + + deploy.createSiteWithCloudfront({ + indexDoc: 'index.html', + websiteFolder: 'website', + certificateARN: 'arn:1234', + cfAliases: ['www.test.com'], + }); + + const template = Template.fromStack(stack); + + // THEN + template.hasResourceProperties('AWS::S3::Bucket', + Match.objectLike({ + BucketEncryption: { + ServerSideEncryptionConfiguration: [ + { + ServerSideEncryptionByDefault: { + SSEAlgorithm: 'AES256', + }, + }, + ], + }, + WebsiteConfiguration: { + IndexDocument: 'index.html', + }, + })); + + template.hasResource('Custom::CDKBucketDeployment', {}); + + template.hasResourceProperties('AWS::CloudFront::Distribution', Match.objectLike({ + DistributionConfig: { + Aliases: [ + 'www.test.com', + ], + ViewerCertificate: { + AcmCertificateArn: 'arn:1234', + SslSupportMethod: 'sni-only', + }, + }, + })); +}); + +test('Cloudfront With Custom Defined Behaviors', () => { + const stack = new Stack(); + + // WHEN + const deploy = new SPADeploy(stack, 'spaDeploy'); + + deploy.createSiteWithCloudfront({ + indexDoc: 'index.html', + websiteFolder: 'website', + cfBehaviors: [ + { + isDefaultBehavior: true, + allowedMethods: cf.CloudFrontAllowedMethods.ALL, + forwardedValues: { + queryString: true, + cookies: { forward: 'all' }, + headers: ['*'], + }, + }, + { + pathPattern: '/virtual-path', + allowedMethods: cf.CloudFrontAllowedMethods.GET_HEAD, + cachedMethods: cf.CloudFrontAllowedCachedMethods.GET_HEAD, + }, + ], + }); + + const template = Template.fromStack(stack); + + // THEN + template.hasResource('Custom::CDKBucketDeployment', {}); + + template.hasResourceProperties('AWS::CloudFront::Distribution', Match.objectLike({ + DistributionConfig: { + CacheBehaviors: [ + Match.objectLike({ + AllowedMethods: ['GET', 'HEAD'], + CachedMethods: ['GET', 'HEAD'], + PathPattern: '/virtual-path', + }), + ], + DefaultCacheBehavior: { + AllowedMethods: ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT'], + ForwardedValues: { + Cookies: { Forward: 'all' }, + Headers: ['*'], + QueryString: true, + }, + TargetOriginId: 'origin1', + }, + }, + })); +}); + +test('Cloudfront With Custom Security Policy', () => { + const stack = new Stack(); + // WHEN + const deploy = new SPADeploy(stack, 'spaDeploy'); + + deploy.createSiteWithCloudfront({ + indexDoc: 'index.html', + websiteFolder: 'website', + certificateARN: 'arn:1234', + cfAliases: ['www.test.com'], + securityPolicy: cf.SecurityPolicyProtocol.TLS_V1_2_2019, + }); + + const template = Template.fromStack(stack); + + // THEN + template.hasResource('Custom::CDKBucketDeployment', {}); + + template.hasResourceProperties('AWS::CloudFront::Distribution', Match.objectLike({ + DistributionConfig: { + Aliases: [ + 'www.test.com', + ], + ViewerCertificate: { + AcmCertificateArn: 'arn:1234', + SslSupportMethod: 'sni-only', + MinimumProtocolVersion: 'TLSv1.2_2019', + }, + }, + })); +}); + +test('Cloudfront With Custom SSL Method', () => { + const stack = new Stack(); + // WHEN + const deploy = new SPADeploy(stack, 'spaDeploy'); + + deploy.createSiteWithCloudfront({ + indexDoc: 'index.html', + websiteFolder: 'website', + certificateARN: 'arn:1234', + cfAliases: ['www.test.com'], + sslMethod: cf.SSLMethod.VIP, + }); + + const template = Template.fromStack(stack); + + // THEN + template.hasResource('Custom::CDKBucketDeployment', {}); + + template.hasResourceProperties('AWS::CloudFront::Distribution', Match.objectLike({ + DistributionConfig: { + Aliases: [ + 'www.test.com', + ], + ViewerCertificate: { + AcmCertificateArn: 'arn:1234', + SslSupportMethod: 'vip', + }, + }, + })); +}); + +type RestrictionType = 'whitelist' | 'blacklist'; +test.each(['whitelist' as RestrictionType, 'blacklist' as RestrictionType])('Cloudfront With GeoRestriction for GB', (restrictionType: RestrictionType) => { + const stack = new Stack(); + const gbLocation = 'GB'; + // WHEN + const deploy = new SPADeploy(stack, 'spaDeploy', { }); + + deploy.createSiteWithCloudfront({ + indexDoc: 'index.html', + websiteFolder: 'website', + certificateARN: 'arn:1234', + cfAliases: ['www.test.com'], + geoRestriction: { + restrictionType, + locations: [gbLocation], + }, + }); + + const template = Template.fromStack(stack); + + // THEN + template.hasResourceProperties('AWS::CloudFront::Distribution', Match.objectLike({ + DistributionConfig: { + Restrictions: { + GeoRestriction: { + Locations: [gbLocation], + RestrictionType: restrictionType, + }, + }, + }, + })); +}); + +test('Basic Site Setup, IP Filter', () => { + const stack = new Stack(); + + // WHEN + new SPADeploy(stack, 'spaDeploy', { encryptBucket: true, ipFilter: true, ipList: ['1.1.1.1'] }) + .createBasicSite({ + indexDoc: 'index.html', + websiteFolder: 'website', + }); + + const template = Template.fromStack(stack); + + // THEN + template.hasResourceProperties('AWS::S3::Bucket', + Match.objectLike({ + BucketEncryption: { + ServerSideEncryptionConfiguration: [ + { + ServerSideEncryptionByDefault: { + SSEAlgorithm: 'AES256', + }, + }, + ], + }, + WebsiteConfiguration: { + IndexDocument: 'index.html', + }, + })); + + template.hasResource('Custom::CDKBucketDeployment', {}); + + template.hasResourceProperties('AWS::S3::BucketPolicy', Match.objectLike({ + PolicyDocument: { + Statement: [ + Match.objectLike({ + Action: 's3:GetObject', + Condition: { + IpAddress: { + 'aws:SourceIp': [ + '1.1.1.1', + ], + }, + }, + Effect: 'Allow', + Principal: { + AWS: '*', + }, + })], + }, + })); +}); + +test('Create From Hosted Zone', () => { + const app = new App(); + const stack = new Stack(app, 'testStack', { + env: { + region: 'us-east-1', + account: '1234', + }, + }); + // WHEN + new SPADeploy(stack, 'spaDeploy', { encryptBucket: true }) + .createSiteFromHostedZone({ + zoneName: 'cdkspadeploy.com', + indexDoc: 'index.html', + websiteFolder: 'website', + }); + + const template = Template.fromStack(stack); + + // THEN + template.hasResourceProperties('AWS::S3::Bucket', + Match.objectLike({ + BucketEncryption: { + ServerSideEncryptionConfiguration: [ + { + ServerSideEncryptionByDefault: { + SSEAlgorithm: 'AES256', + }, + }, + ], + }, + WebsiteConfiguration: { + IndexDocument: 'index.html', + }, + })); + + template.hasResource('Custom::CDKBucketDeployment', {}); + + template.hasResourceProperties('AWS::CloudFront::Distribution', Match.objectLike({ + DistributionConfig: { + Aliases: [ + 'www.cdkspadeploy.com', + ], + ViewerCertificate: { + SslSupportMethod: 'sni-only', + }, + }, + })); + + template.hasResourceProperties('AWS::S3::BucketPolicy', + Match.objectLike({ + PolicyDocument: { + Statement: [ + Match.objectLike({ + Action: 's3:GetObject', + Effect: 'Allow', + })], + }, + })); +}); + +test('Create From Hosted Zone with subdomain', () => { + const app = new App(); + const stack = new Stack(app, 'testStack', { + env: { + region: 'us-east-1', + account: '1234', + }, + }); + // WHEN + new SPADeploy(stack, 'spaDeploy', { encryptBucket: true }) + .createSiteFromHostedZone({ + zoneName: 'cdkspadeploy.com', + indexDoc: 'index.html', + websiteFolder: 'website', + subdomain: 'myhost', + }); + + const template = Template.fromStack(stack); + + // THEN + template.hasResourceProperties('AWS::CloudFront::Distribution', Match.objectLike({ + DistributionConfig: { + Aliases: [ + 'myhost.cdkspadeploy.com', + ], + ViewerCertificate: { + SslSupportMethod: 'sni-only', + }, + }, + })); +}); + +test('Create From Hosted Zone with Custom Role', () => { + const app = new App(); + const stack = new Stack(app, 'testStack', { + env: { + region: 'us-east-1', + account: '1234', + }, + }); + // WHEN + new SPADeploy(stack, 'spaDeploy', { encryptBucket: true }) + .createSiteFromHostedZone({ + zoneName: 'cdkspadeploy.com', + indexDoc: 'index.html', + errorDoc: 'error.html', + websiteFolder: 'website', + role: new Role(stack, 'myRole', { roleName: 'testRole', assumedBy: new ServicePrincipal('lambda.amazonaws.com') }), + }); + + const template = Template.fromStack(stack); + + // THEN + + template.hasResourceProperties('AWS::Lambda::Function', Match.objectLike({ + Role: { + 'Fn::GetAtt': [ + 'myRoleE60D68E8', + 'Arn', + ], + }, + })); +}); + +test('Create From Hosted Zone with Error Bucket', () => { + const app = new App(); + const stack = new Stack(app, 'testStack', { + env: { + region: 'us-east-1', + account: '1234', + }, + }); + // WHEN + new SPADeploy(stack, 'spaDeploy', { encryptBucket: true }) + .createSiteFromHostedZone({ + zoneName: 'cdkspadeploy.com', + indexDoc: 'index.html', + errorDoc: 'error.html', + websiteFolder: 'website', + }); + + const template = Template.fromStack(stack); + + // THEN + template.hasResourceProperties('AWS::S3::Bucket', Match.objectLike({ + BucketEncryption: { + ServerSideEncryptionConfiguration: [ + { + ServerSideEncryptionByDefault: { + SSEAlgorithm: 'AES256', + }, + }, + ], + }, + WebsiteConfiguration: { + IndexDocument: 'index.html', + ErrorDocument: 'error.html', + }, + })); + + template.hasResource('Custom::CDKBucketDeployment', {}); + + template.hasResourceProperties('AWS::CloudFront::Distribution', Match.objectLike({ + DistributionConfig: { + Aliases: [ + 'www.cdkspadeploy.com', + ], + ViewerCertificate: { + SslSupportMethod: 'sni-only', + }, + }, + })); +}); + +test('Basic Site Setup, Block Public Enabled', () => { + const stack = new Stack(); + + // WHEN + new SPADeploy(stack, 'spaDeploy') + .createBasicSite({ + indexDoc: 'index.html', + websiteFolder: 'website', + blockPublicAccess: BlockPublicAccess.BLOCK_ALL, + }); + + const template = Template.fromStack(stack); + + // THEN + template.hasResourceProperties('AWS::S3::Bucket', Match.objectLike({ + WebsiteConfiguration: { + IndexDocument: 'index.html', + }, + PublicAccessBlockConfiguration: { + BlockPublicAcls: true, + BlockPublicPolicy: true, + IgnorePublicAcls: true, + RestrictPublicBuckets: true, + }, + })); +}); + +test('Basic Site Setup, URL Output Enabled With Name', () => { + const stack = new Stack(); + const exportName = 'test-export-name'; + + // WHEN + new SPADeploy(stack, 'spaDeploy', {}) + .createBasicSite({ + indexDoc: 'index.html', + websiteFolder: 'website', + exportWebsiteUrlOutput: true, + exportWebsiteUrlName: exportName, + }); + + const template = Template.fromStack(stack); + + // THEN + template.hasOutput(exportName, { + Export: { + Name: exportName, + }, + }); +}); + +test('Basic Site Setup, URL Output Enabled With No Name', () => { + const stack = new Stack(); + + // WHEN + expect(() => { + new SPADeploy(stack, 'spaDeploy', {}) + .createBasicSite({ + indexDoc: 'index.html', + websiteFolder: 'website', + exportWebsiteUrlOutput: true, + }); + }).toThrowError(); +}); + +test('Basic Site Setup, URL Output Not Enabled', () => { + const stack = new Stack(); + const exportName = 'test-export-name'; + + // WHEN + new SPADeploy(stack, 'spaDeploy', {}) + .createBasicSite({ + indexDoc: 'index.html', + websiteFolder: 'website', + exportWebsiteUrlOutput: false, + }); + + const template = Template.fromStack(stack); + + // THEN + expect(() => { + template.hasOutput(exportName, { + Export: { + Name: exportName, + }, + }); + }).toThrowError(); +});