diff --git a/lib/mesh-v2-stack.ts b/lib/mesh-v2-stack.ts index 15e7c6a..2d11396 100644 --- a/lib/mesh-v2-stack.ts +++ b/lib/mesh-v2-stack.ts @@ -2,6 +2,7 @@ import * as cdk from 'aws-cdk-lib/core'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; import * as appsync from 'aws-cdk-lib/aws-appsync'; import * as lambda from 'aws-cdk-lib/aws-lambda'; +import * as wafv2 from 'aws-cdk-lib/aws-wafv2'; import * as route53 from 'aws-cdk-lib/aws-route53'; import * as acm from 'aws-cdk-lib/aws-certificatemanager'; import * as targets from 'aws-cdk-lib/aws-route53-targets'; @@ -131,6 +132,62 @@ export class MeshV2Stack extends cdk.Stack { }, }); + // WAF configuration (Only for production) + if (stage === 'prod') { + const allowedOrigins = [ + 'https://smalruby.app', + 'https://smalruby.jp' + ]; + + const webAcl = new wafv2.CfnWebACL(this, 'MeshV2ApiWebAcl', { + defaultAction: { block: {} }, + scope: 'REGIONAL', + visibilityConfig: { + cloudWatchMetricsEnabled: true, + metricName: 'MeshV2ApiWebAcl', + sampledRequestsEnabled: true, + }, + rules: [ + { + name: 'AllowSpecificOrigins', + priority: 1, + action: { allow: {} }, + statement: { + orStatement: { + statements: allowedOrigins.map(origin => ({ + byteMatchStatement: { + fieldToMatch: { + singleHeader: { + name: 'origin', + }, + }, + positionalConstraint: 'EXACTLY', + searchString: origin, + textTransformations: [ + { + priority: 0, + type: 'LOWERCASE', + }, + ], + }, + })), + }, + }, + visibilityConfig: { + cloudWatchMetricsEnabled: true, + metricName: 'AllowSpecificOrigins', + sampledRequestsEnabled: true, + }, + }, + ], + }); + + new wafv2.CfnWebACLAssociation(this, 'MeshV2ApiWebAclAssociation', { + resourceArn: this.api.arn, + webAclArn: webAcl.attrArn, + }); + } + // Route53 Alias record for Custom Domain if (customDomain && zone) { // Extract subdomain from customDomain (e.g., "graphql.api.smalruby.app" -> "graphql") diff --git a/test/mesh-v2.test.ts b/test/mesh-v2.test.ts index 72362e0..8d72ac4 100644 --- a/test/mesh-v2.test.ts +++ b/test/mesh-v2.test.ts @@ -1,17 +1,99 @@ -// import * as cdk from 'aws-cdk-lib/core'; -// import { Template } from 'aws-cdk-lib/assertions'; -// import * as MeshV2 from '../lib/mesh-v2-stack'; - -// example test. To run these tests, uncomment this file along with the -// example resource in lib/mesh-v2-stack.ts -test('SQS Queue Created', () => { -// const app = new cdk.App(); -// // WHEN -// const stack = new MeshV2.MeshV2Stack(app, 'MyTestStack'); -// // THEN -// const template = Template.fromStack(stack); - -// template.hasResourceProperties('AWS::SQS::Queue', { -// VisibilityTimeout: 300 -// }); +import * as cdk from 'aws-cdk-lib/core'; +import { Template } from 'aws-cdk-lib/assertions'; +import * as MeshV2 from '../lib/mesh-v2-stack'; + +describe('MeshV2Stack', () => { + test('AppSync API and DynamoDB Table Created', () => { + const app = new cdk.App(); + const stack = new MeshV2.MeshV2Stack(app, 'MyTestStack', { + env: { account: '123456789012', region: 'us-east-1' } + }); + const template = Template.fromStack(stack); + + template.hasResourceProperties('AWS::DynamoDB::Table', { + TableName: 'MeshV2Table-stg' + }); + + template.hasResourceProperties('AWS::AppSync::GraphQLApi', { + Name: 'MeshV2Api-stg' + }); + }); + + test('WAF is created when stage is prod', () => { + const app = new cdk.App({ + context: { + stage: 'prod' + } + }); + const stack = new MeshV2.MeshV2Stack(app, 'MyProdTestStack', { + env: { account: '123456789012', region: 'us-east-1' } + }); + const template = Template.fromStack(stack); + + template.resourceCountIs('AWS::WAFv2::WebACL', 1); + template.hasResourceProperties('AWS::WAFv2::WebACL', { + DefaultAction: { Block: {} }, + Scope: 'REGIONAL', + Rules: [ + { + Name: 'AllowSpecificOrigins', + Priority: 1, + Action: { Allow: {} }, + Statement: { + OrStatement: { + Statements: [ + { + ByteMatchStatement: { + FieldToMatch: { + SingleHeader: { name: 'origin' } + }, + PositionalConstraint: 'EXACTLY', + SearchString: 'https://smalruby.app', + TextTransformations: [ + { + Priority: 0, + Type: 'LOWERCASE' + } + ] + } + }, + { + ByteMatchStatement: { + FieldToMatch: { + SingleHeader: { name: 'origin' } + }, + PositionalConstraint: 'EXACTLY', + SearchString: 'https://smalruby.jp', + TextTransformations: [ + { + Priority: 0, + Type: 'LOWERCASE' + } + ] + } + } + ] + } + } + } + ] + }); + + template.resourceCountIs('AWS::WAFv2::WebACLAssociation', 1); + }); + + test('WAF is not created when stage is stg', () => { + const app = new cdk.App({ + context: { + stage: 'stg' + } + }); + const stack = new MeshV2.MeshV2Stack(app, 'MyStgTestStack', { + env: { account: '123456789012', region: 'us-east-1' } + }); + const template = Template.fromStack(stack); + + template.resourceCountIs('AWS::WAFv2::WebACL', 0); + template.resourceCountIs('AWS::WAFv2::WebACLAssociation', 0); + }); }); diff --git a/tsconfig.json b/tsconfig.json index 7b0d9e5..a88fb41 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,7 @@ "experimentalDecorators": true, "strictPropertyInitialization": false, "skipLibCheck": true, + "isolatedModules": true, "typeRoots": [ "./node_modules/@types" ]