はじめに
本記事は、AWS CDK Advent Calendar 2022の23日目になります。
最近、CDKのテストを書くことが多いので、Asseritonモジュールの主な使い方について、まとめてみました。
CDKでは便利なコンストラクトが多数提供されており、CloudFormation(CFn)を直接作成するよりも設定を抽象化できるのが利点の1つです。一方、CDKで生成されるCFnテンプレートにどのようなリソースが含まれているか把握しづらく、悩ましいという声もよく聞きます。
CDKには、CFnテンプレートの定義を確認できるテスト用のモジュールがあるので、それを活用することでハイレベルコンストラクト利用時でも、セキュリティなどの要件を満たしながら開発可能です。
私のチームでは本モジュールを使用して、CFnテンプレートのアサーションテストを積極的に書いています。暗黙知なのか、CDKでのテストコードはそれほどサンプルが多くないので、ご参考になれば幸いです!
前提
前提が長めなので、スキップしたい方は本題からお読みください!
予備知識
CDK Intro Workshopでテストコードの実装フローを学べます。
CDKでのテスト未経験の方は、以下をご参照ください。
また、各テストコードでは、CFnのテンプレートを併記しています。
CFnの記法はAWS CloudFormation ユーザーガイド テンプレートをご参照ください。
環境
- CDK v2.54.0
- Jest v27.5.1
テスト対象のプロダクトコード
本記事では、以下リソースを保有するスタックについて、テストします。
- S3バケット
- Lambda関数
- Configルール
- カスタムConstruct(Configルールのタグ付け用カスタムリソース)
プロダクトコードサンプル
import { Duration, Stack, StackProps, Tags, RemovalPolicy } from "aws-cdk-lib";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as config from "aws-cdk-lib/aws-config";
import { Runtime } from "aws-cdk-lib/aws-lambda";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { Construct } from "constructs";
import { AttachConfigTag } from "./constructs/manageConfigTags";
export class SampleStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);
    /** S3バケット **/
    const accessLogsBucket = new s3.Bucket(this, "AccessLogsBucket", {
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      encryption: s3.BucketEncryption.KMS_MANAGED,
      enforceSSL: true,
      lifecycleRules: [
        {
          expiration: Duration.days(365),
        },
      ],
    });
    const sampleBucket = new s3.Bucket(this, "SampleBucket", {
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      encryption: s3.BucketEncryption.KMS_MANAGED,
      lifecycleRules: [
        {
          expiration: Duration.days(30),
        },
      ],
      serverAccessLogsBucket: accessLogsBucket,
      enforceSSL: true,
      autoDeleteObjects: true,
      removalPolicy: RemovalPolicy.DESTROY,
    });
    /** Lambda関数 **/
    const sampleFunction = new NodejsFunction(this, "SampleFunction", {
      functionName: "SampleFunction",
      handler: "handler",
      runtime: Runtime.NODEJS_16_X,
      entry: "./lambda/sampleFunction.ts",
      timeout: Duration.seconds(30),
      environment: {
        TARGET_S3_BUCKET: sampleBucket.bucketName,
      },
    });
    sampleBucket.grantReadWrite(sampleFunction);
    Tags.of(sampleFunction).add("HogeKey", "hogehoge");
    Tags.of(sampleFunction).add("Test", "true");
    /** Configルール **/
    const s3KmsRule = new config.ManagedRule(
      this,
      "S3DefaultEncryptionKmsRule",
      {
        identifier: config.ManagedRuleIdentifiers.S3_DEFAULT_ENCRYPTION_KMS,
        configRuleName: "S3DefaultEncryptionKmsRule",
      }
    );
    /** カスタムConstruct(Configルールのタグ付け用カスタムリソース) **/
    new AttachConfigTag(this, "S3DefaultEncryptionKmsRuleTag", {
      configArn: `arn:aws:config:${Stack.of(this).region}:${
        Stack.of(this).account
      }:config-rule/S3DefaultEncryptionKmsRule`,
      // 上記はCase4のserializedJson紹介用であり、デプロイ順が考慮されないので以下推奨
      // configArn: s3KmsRule.configRuleArn,
      configTags: [
        {
          Key: "Test",
          Value: "true",
        },
      ],
    });
  }
}
本筋ではないため、上記カスタムConstruct「AttachConfigTag」の詳細は割愛します。
Assertionモジュール紹介用なので、各リソースの関連にあまり脈絡がないのはご容赦ください…。
テストコードの共通部
テストの準備作業として、beforeAllでスタックとテンプレートを生成しています。
import { Template, Match, Capture } from "aws-cdk-lib/assertions";
import * as cdk from "aws-cdk-lib";
import { SampleStack } from "../lib/sampleStack";
describe("SampleStackのテスト", () => {
  let template: Template;
  // テストの確認対象となるスタック・テンプレートを生成
  beforeAll(() => {
    const app = new cdk.App();
    const stack = new SampleStack(app, "SampleStack", {
      env: { account: "012345678901", region: "ap-northeast-1" },
    });
    template = Template.fromStack(stack);
  });
  // 各テストコードで期待値を記述
  /** 省略 **/
});
元々、beforeEachを使用していたのですが、執筆時点ではテスト時にNodejsFunctionのbundlingを止める機能がなく、テスト時間爆増の問題があったため、beforeAllに変更しました。bundlingのissueについては以下をご参照ください。
(assertions): argument to skip bundling of assets #18125
AWSリソースのプロパティチェック
前置きが長くなりましたが、ここからが本題です。以下では、公式のリファレンスで紹介されている各テスト用クラスを、AWSリソースに置き換えて紹介します。(公式ではAWSリソースの例ではなく、CFnに存在しない仮タイプのサンプルが掲載されています。)
Case1. 特定リソースのプロパティを確認したい
シンプルにリソースのプロパティを確認するパターンです。
以下は、Lambda関数のタイムアウト値を確認する例です。
{
  "Type": "AWS::Lambda::Function",
  "Properties": {
    "FunctionName": "SampleFunction",
    "Timeout": 30,
    /** 省略 **/
  },
}
  test("Sample関数のタイムアウト値が30秒である", () => {
    template.hasResourceProperties("AWS::Lambda::Function", {
      FunctionName: "SampleFunction",
      Timeout: 30,
    });
  });
上記のTemplate.hasResourcePropertiesを使用することで、指定したCFnリソースタイプのプロパティを確認できます。CDKのアサーションテストでは、まずhasResourcePropertiesを使っていくのが基本です。
リソース毎に異なる設定値を与えられるのが一般的ですので、リソース名と合わせて、プロパティを且つ条件でチェックするのがポイントです。
上記の場合、AWS::Lambda::Functionが2種以上生成されていても、FunctionNameを併せて指定することでテストしたいLambda関数を絞り込みできます。
Case2. プロパティ中の配列における何れかの要素を確認したい
特定のプロパティが配列になっており、配列から何れかの要素を確認したいパターンです。
以下は、Lambda関数に複数付与されたタグの中から、Testタグの値を確認する例です。
{
  "Type": "AWS::Lambda::Function",
  "Properties": {
    "FunctionName": "SampleFunction",
    "Tags": [
      {
        "Key": "HogeKey",
        "Value": "hogehoge"
      },
      {
        "Key": "Test",
        "Value": "true"
      }
    ],
    /** 省略 **/
  },
}
test("Sample関数のTestタグにtrueが付与されている", () => {
  template.hasResourceProperties("AWS::Lambda::Function", {
    FunctionName: "SampleFunction",
    Tags: Match.arrayWith([
      {
        Key: "Test",
        Value: "true",
      },
    ]),
  });
});
上記のMatch.arrayWithを使用することで、任意の要素を確認できます。
Case3. プロパティ中のオブジェクトにおける何れかのKeyを確認したい
特定のプロパティがオブジェクトになっており、オブジェクトから何れかのKeyを確認したいパターンです。
以下は、ConfigルールのSourceから、SourceIdentifierの値を確認する例です。
{
  "Type": "AWS::Config::ConfigRule",
  "Properties": {
    "Source": {
      "Owner": "AWS",
      "SourceIdentifier": "S3_DEFAULT_ENCRYPTION_KMS"
    },
    "ConfigRuleName": "S3DefaultEncryptionKmsRule"
  }
}
test("S3DefaultEncryptionKmsのConfigルールが作成される", () => {
  template.hasResourceProperties("AWS::Config::ConfigRule", {
    ConfigRuleName: "S3DefaultEncryptionKmsRule",
    Source: Match.objectLike({
      SourceIdentifier: "S3_DEFAULT_ENCRYPTION_KMS",
    }),
  });
});
※Owner Keyも確認すべきケースかもしれませんが、あくまでobjectLikeのサンプルなのでご容赦ください。
上記のMatch.objectLikeを使用することで、任意のObject Keyを確認できます。
なお、Case2のMatch.arrayWithと組み合わせて、以下のように確認することもできます。
arrayWithとobjectLikeの組み合わせ例
以下は、S3バケットポリシーでSSL通信が強制されているか確認する例です。
{
  "Type": "AWS::S3::BucketPolicy",
  "Properties": {
  "Bucket": {
    "Ref": "SampleBucket0123ABCD"
  },
  "PolicyDocument": {
    "Statement": [
      {
        "Action": "s3:*",
        "Condition": {
        "Bool": {
          "aws:SecureTransport": "false"
        }
        },
        "Effect": "Deny",
        "Principal": {
          "AWS": "*"
        },
        /** 省略 **/
      },
      {
        "Action": [
          "s3:DeleteObject*",
          "s3:GetBucket*",
          "s3:List*"
       ],
       "Effect": "Allow",
       /** 省略 **/
      },
    ],
    "Version": "2012-10-17"
  }
}
test("SampleバケットでSSL通信が強制されている", () => {
  template.hasResourceProperties("AWS::S3::BucketPolicy", {
    Bucket: {
      Ref: Match.stringLikeRegexp("SampleBucket"),
    },
    PolicyDocument: {
      Statement: Match.arrayWith([
        Match.objectLike({
          Principal: {
            AWS: "*",
          },
          Effect: "Deny",
          Action: "s3:*",
          Condition: {
            Bool: {
              "aws:SecureTransport": "false",
            },
          },
        }),
      ]),
    },
  });
});
上記のMatch.stringLikeRegexpでは、対象のKeyを正規表現で確認することができます。(本例ではBucketのRef Keyに、Construct IDの"SampleBucket"が含まれているか確認しています。)
Case4. 文字列としてエンコードされたJSONを確認したい
CFnテンプレートでは、文字列としてJSONをエンコードする場合があります。文字列のままだとテストしづらいため、テストコード上ではKey-Value形式で確認したいパターンです。
以下は、Configルールに付与するタグの値を確認する例です。
※2022年12月時点のCFnでは、ConfigルールのプロパティにTagsがないため、AwsCustomResourceでタグを付与しています。
{
  "Resources": {
    "Type": "Custom::AWS",
    "Properties": {
      "Create": "{\"service\":\"ConfigService\",\"action\":\"tagResource\",\"parameters\":{\"ResourceArn\":\"arn:aws:config:ap-northeast-1:012345678901:config-rule/S3DefaultEncryptionKmsRule\",\"Tags\":[{\"Key\":\"Test\",\"Value\":\"true\"}]},\"physicalResourceId\":{\"id\":\"xxx\"}}",
      "Update": /** 省略 **/
    }
  }
}
test("スタック作成時、ConfigルールにTestタグが付与される", () => {
  template.hasResourceProperties("Custom::AWS", {
    Create: Match.serializedJson({
      service: "ConfigService",
      action: "tagResource",
      parameters: {
        ResourceArn: "arn:aws:config:ap-northeast-1:012345678901:config-rule/S3DefaultEncryptionKmsRule",
        Tags: [
          {
            Key: "Test",
            Value: "true",
          }
        ],
      },
      physicalResourceId: Match.anyValue(),
    }),
  });
});
上記のMatch.serializedJsonを使用することで、Key-Value形式でアサーションテストを書けます。
注意点として、以下のように文字列中のJSONで他リソースを動的に参照するパターンでは、serializedJsonで整形されません。
{
  "Create": {
    "Fn::Join": [
      "",
      [
        "{\"service\":\"ConfigService\",\"action\":\"tagResource\",\"parameters\":{\"ResourceArn\":\"",
        {
          "Fn::GetAtt": [
            "S3DefaultEncryptionKmsRule0123ABC",
            "Arn"
          ]
        },
        "\",\"Tags\":[{\"Key\":\"Test\",\"Value\":\"true\"}]},\"physicalResourceId\":{\"id\":\"xxx\"}}"
      ]
    ]
  }
}
実際のCFnテンプレートは上記のようになることが大半です。
CDKプロダクトコードの記述上、serializedJsonを活用するには動的なリソース間参照を使えないため、本ケースの出番はやや少ない印象です。
Case5. テンプレートの一部を抽出してプロパティをチェックしたい
テンプレートの中から、条件に一致するリソースを抽出して、プロパティを確認したいパターンです。テストコードはネストが深くなりやすいので、ネスト数を減らしたいときに活用できます。
以下は、Lambda関数の環境変数を確認する例です。
{
  "Type": "AWS::Lambda::Function",
  "Properties": {
    "Environment": {
     "Variables": {
        "TARGET_S3_BUCKET": {
          "Ref": "SampleBucket0123ABC"
        },
        "AWS_NODEJS_CONNECTION_REUSE_ENABLED": "1"
      }
    },
    /** 省略 **/
  },
}
test("Sample関数の環境変数でS3バケットが参照されている", () => {
  const envS3bucket = new Capture();
  template.hasResourceProperties("AWS::Lambda::Function", {
    FunctionName: "SampleFunction",
    Environment: {
      Variables: {
        TARGET_S3_BUCKET: envS3bucket
      }
    }
  });
  expect(envS3bucket.asObject()).toEqual({
    Ref: expect.stringContaining("SampleBucket") as string,
  });
});
上記のCaptureを使用することで、抽出したプロパティを確認できます。
注意点として、Captureしたプロパティの確認にMatchクラスは使えないため、JestのExpectでアサーションする必要があります。
  expect(envCapture.asObject()).toEqual({
    Ref: Match.stringLikeRegexp("SampleBucket"),
  });
✕ Sample関数の環境変数でS3バケットが参照されている (7 ms)
  expect(received).toEqual(expected) // deep equality
  - Expected  - 4
  + Received  + 1
    Object {
  -   "Ref": StringLikeRegexpMatch {
  -     "name": "stringLikeRegexp",
  -     "pattern": "SampleBucket",
  -   },
  +   "Ref": "SampleBucket7F6F8160",
    }
なお、Captureを使うテストパターンは、CDK Intro Workshopでも紹介されていました。
Case6. 特定のリソースタイプでプロパティを一括確認したい
テンプレートの特定リソースタイプで、プロパティが全て同じか確認したいパターンです。
以下は、全S3バケットのブロックパブリックアクセスを確認する例です。
{
  "Type": "AWS::S3::Bucket",
  "Properties": {
    "AccessControl": "LogDeliveryWrite",
    "PublicAccessBlockConfiguration": {
      "BlockPublicAcls": true,
      "BlockPublicPolicy": true,
      "IgnorePublicAcls": true,
      "RestrictPublicBuckets": true
    },
    /** 省略 **/
  },
},
{
  "Type": "AWS::S3::Bucket",
  "Properties": {
    "PublicAccessBlockConfiguration": {
      "BlockPublicAcls": true,
      "BlockPublicPolicy": true,
      "IgnorePublicAcls": true,
      "RestrictPublicBuckets": true
    },
    /** 省略 **/
  },
}
test("全てのバケットでブロックパブリックアクセスが有効化されている", () => {
  template.allResourcesProperties("AWS::S3::Bucket", {
    PublicAccessBlockConfiguration: {
      BlockPublicAcls: true,
      BlockPublicPolicy: true,
      IgnorePublicAcls: true,
      RestrictPublicBuckets: true,
    },
  });
});
上記のTemplate.allResourcesPropertiesを使用することで、プロパティを一括確認できます。
Case7. リソースでプロパティ以外の要素を確認したい
各リソースで、プロパティ以外の要素も確認したいパターンです。
例えば、スタック削除時のリソース保持を決めるDeletionPolicyはProperties外で保持されているため、本パターンを適用できます。
以下は、S3バケットのDeletionPolicyを確認する例です。
{
  "Type": "AWS::S3::Bucket",
  "Properties": {
    "AccessControl": "LogDeliveryWrite",
    /** 省略 **/
  },
  "DeletionPolicy": "Retain",
}
test("スタック削除時にアクセスログバケットが保持される", () => {
  template.hasResource("AWS::S3::Bucket", {
    Properties: {
      AccessControl: "LogDeliveryWrite"
    },
    DeletionPolicy: "Retain",
  });
});
上記のTemplate.hasResourceを使用することで、Properties以外の要素も確認できます。
Case6のように一括でチェックしたい場合は、以下のようにTemplate.allResourcesで実現できます。
test("スタック削除時に保持されるカスタムリソースが存在しない", () => {
  template.allResources("Custom::AWS", {
    DeletionPolicy: Match.not("Retain"),
  });
});
AWSリソース数チェック
以下では、テンプレートで定義されているリソース数を確認します。
Case8. リソースタイプ毎のリソース数を確認したい
テンプレートで定義されたリソースタイプ毎のリソース数を確認したいパターンです。
様々なリソースを作成するL2・L3コンストラクトなど、CDKのプロダクトコードで明記していないリソースも含めて把握できます。
以下は、Lambda関数の数を確認する例です。
// NodeJSFucntionコンストラクトで作成
{
  "Type": "AWS::Lambda::Function",
  "Properties": { /** 省略 **/ },
},
// s3.BucketコンストラクトのautoDeleteObjects指定により作成
{
  "Type": "AWS::Lambda::Function",
  "Properties": { /** 省略 **/ },
},
// AwsCustomResourceコンストラクトで作成(カスタムConstruct内で定義)
{
  "Type": "AWS::Lambda::Function",
  "Properties": { /** 省略 **/ },
}
test("Lambda関数が3つ存在する", () => {
  template.resourceCountIs("AWS::Lambda::Function", 3);
});
上記のTemplate.resourceCountIsを使用することで、リソース数を確認できます。
今回のケースだと、Lambda関数を意図的に定義しているのはNodejsFunctionのみですが、他のコンストラクトでもLambda関数が作成されていることが分かります。
Case9. 設定準拠しているリソース数を確認したい
指定したプロパティで生成されるリソース数を確認したいパターンです。
活用方法は様々ですが、リソース数を1で指定すると設定重複したリソースが存在しないか、把握できます。
以下は、アクセスログ用S3バケットの数を確認する例です。
{
  "Type": "AWS::S3::Bucket",
  "Properties": {
    "AccessControl": "LogDeliveryWrite",
    /** 省略 **/
  },
}
test("アクセスログ用S3バケットが1つ存在する", () => {
  template.resourcePropertiesCountIs("AWS::S3::Bucket", {
    AccessControl: "LogDeliveryWrite",
  }, 1);
});
上記のTemplate.resourcePropertiesCountIsを使用することで、指定したプロパティで生成されるリソース数を確認できます。
テンプレート抽出
以下では、Templateのfindメソッドを使用し、テンプレートから抽出したオブジェクトに対して、アサーションテストを実行します。
findResourcesのみを紹介しておりますが、その他にもCfnOutputを抽出するfindOutputsメソッドなどが用意されているので、詳細はリファレンスをご参照ください。
Case10. リソース名ではなく、Construct IDでプロパティをチェックしたい
以下のCDKベストプラクティスに従ってリソース名を指定しない場合、Case1のようにリソースを絞れないため、テストを書き辛いです。
自動で生成されるリソース名を使用し、物理的な名前を使用しない
例えば、S3バケットのライフサイクルを確認する以下例では、テンプレートにバケット名が出力されないため、どのバケットが期待通りなのか分かりません。
{
  "Type": "AWS::S3::Bucket",
  "Properties": {
    "LifecycleConfiguration": {
      "Rules": [
        {
          "ExpirationInDays": 30,
          "Status": "Enabled"
        }
      ]
    },
    // 開発者がプロダクトコードでBucketNameを記述しない場合、テンプレートに出力されない
    /** 省略 **/
  },
}
test("何れかのバケットでライフサイクルルールが30日である", () => {
  template.hasResourceProperties("AWS::S3::Bucket", {
    LifecycleConfiguration: {
      Rules: [{
        ExpirationInDays: 30,
        Status: "Enabled"
      }]
    },
    // テストでバケット名を指定できないので、どのバケットが一致しているのか分からない
  });
});
本ケースは、条件に一致する特定のリソースタイプを全て抽出し、期待するConstruct IDが存在するか確認するパターンになります。
以下は、ライフサイクルが30日のS3バケットを抽出し、論理ID一覧に"SampleBucket"のConstructIDを含むか確認する例です。
test("Sampleバケットのライフサイクルルールが30日である", () => {
  // 1. テンプレートからライフサイクルが30日のS3バケットを抽出
  const extractedTemplate = template.findResources("AWS::S3::Bucket", {
    Properties: {
      LifecycleConfiguration: {
        Rules: [{
          ExpirationInDays: 30,
          Status: "Enabled"
        }]
      },
    },
  });
  // 2. 抽出したテンプレートから論理IDの一覧を出力
  const extractedLogicalIds = Object.keys(extractedTemplate);
  // (論理ID一覧の例) [ 'SampleBucket0123ABCD', ... ]
  // 3. 期待値として、"SampleBucket"のConstruct IDを含む論理IDを定義
  const expectedLogicalId = expect.stringMatching("SampleBucket");
  
  // 4. 2の論理ID一覧に、3の期待値が含まれているか確認
  expect(extractedLogicalIds).toEqual(
    expect.arrayContaining([expectedLogicalId])
  );
});
論理IDは「"CDKで記述したConstruct ID" + "自動付与のサフィックス"」で構成されるため、Construct IDでテストするには一工夫必要です。
上記では、まずTemplate.findResourcesを使って、テンプレートからプロパティ条件に一致するリソースを抽出しています。抽出後は、Jestのメソッドを使って一致するConstruct IDが存在するか確認しています。Jest部分の詳細はexpect.stringMatchingのサンプルもご参照ください。
なお、ベストプラクティスに反する形ではありますが、私のチームでは環境識別子を含める独自の命名規則クラスで、物理名を原則指定しています。この辺りの話は以下が参考になりましたので、ご参照ください。
- AWS CDK Conference Japan で「それでも俺はAWS CDKが作るリソースに物理名を付けたい」というタイトルで登壇しました #jawsug #cdkconf
- 「それでも俺はAWS CDKが作るリソースに物理名を付けたい」アーキテクトが辿り着いた、リソース名のベタープラクティス
おわりに
具体的な10のテストパターンと共に、CDK Asseritonモジュールの使い方を紹介しました。
本記事の作成中にも、Asseritonモジュールの新たな発見があり、自身にとっても良い学びとなりました。「こんな使い方があるよ!」や「こっちの方がよくない?」というご意見、ウェルカムですのでぜひコメントなどいただければと思います。
「いいね」を押していただけると、今後の執筆モチベーションに繋がりますので、何卒よろしくお願いします…!
(付録)テストコードサンプルの全量
本記事で紹介したテストコードの全量 + α を以下に掲載します。
テストコードサンプル
import { Template, Match, Capture } from "aws-cdk-lib/assertions";
import * as cdk from "aws-cdk-lib";
import { SampleStack } from "../lib/sampleStack";
describe("SampleStackのテスト", () => {
  let template: Template;
  beforeAll(() => {
    const app = new cdk.App();
    const stack = new SampleStack(app, "SampleStack", {
      env: { account: "012345678901", region: "ap-northeast-1" },
    });
    template = Template.fromStack(stack);
  });
  describe("リソース数の確認", () => {
    test("S3バケットが2つ存在する", () => {
      template.resourceCountIs("AWS::S3::Bucket", 2);
    });
    test("アクセスログ用S3バケットが1つ存在する", () => {
      template.resourcePropertiesCountIs("AWS::S3::Bucket", {
        AccessControl: "LogDeliveryWrite",
      }, 1);
    });
    test("Lambda関数が3つ存在する", () => {
      template.resourceCountIs("AWS::Lambda::Function", 3);
    });
    test("ConfigRuleが1つ存在する", () => {
      template.resourceCountIs("AWS::Config::ConfigRule", 1);
    });
    test("カスタムリソースが1つ存在する", () => {
      template.resourceCountIs("Custom::AWS", 1);
    });
  });
  describe("Lambdaのプロパティチェック", () => {
    test("Sample関数のタイムアウト値が30秒である", () => {
      template.hasResourceProperties("AWS::Lambda::Function", {
        FunctionName: "SampleFunction",
        Timeout: 30,
      });
    });
    test("Sample関数のTestタグにtrueが付与されている", () => {
      template.hasResourceProperties("AWS::Lambda::Function", {
        FunctionName: "SampleFunction",
        Tags: Match.arrayWith([
          {
            Key: "Test",
            Value: "true",
          },
        ]),
      });
    });
    test("Sample関数の環境変数でS3バケットが参照されている", () => {
      const envS3bucket = new Capture();
      template.hasResourceProperties("AWS::Lambda::Function", {
        FunctionName: "SampleFunction",
        Environment: {
          Variables: {
            TARGET_S3_BUCKET: envS3bucket,
          },
        },
      });
      expect(envS3bucket.asObject()).toEqual({
        Ref: expect.stringContaining("SampleBucket") as string,
      });
    });
  });
  describe("S3のプロパティチェック", () => {
    test("全てのバケットでブロックパブリックアクセスが有効化されている", () => {
      template.allResourcesProperties("AWS::S3::Bucket", {
        PublicAccessBlockConfiguration: {
          BlockPublicAcls: true,
          BlockPublicPolicy: true,
          IgnorePublicAcls: true,
          RestrictPublicBuckets: true,
        },
      });
    });
    test("全てのバケットでSSE-KMS暗号化が有効化されている", () => {
      template.allResourcesProperties("AWS::S3::Bucket", {
        BucketEncryption: {
          ServerSideEncryptionConfiguration: [
            {
              ServerSideEncryptionByDefault: {
                SSEAlgorithm: "aws:kms",
              },
            },
          ],
        },
      });
    });
    test("スタック削除時にアクセスログバケットが保持される", () => {
      template.hasResource("AWS::S3::Bucket", {
        Properties: {
          AccessControl: "LogDeliveryWrite",
        },
        DeletionPolicy: "Retain",
      });
    });
    test("何れかのバケットでライフサイクルルールが30日である", () => {
      template.hasResourceProperties("AWS::S3::Bucket", {
        LifecycleConfiguration: {
          Rules: [
            {
              ExpirationInDays: 30,
              Status: "Enabled",
            },
          ],
        },
      });
    });
    test("Sampleバケットのライフサイクルルールが30日である", () => {
      const extractedTemplate = template.findResources("AWS::S3::Bucket", {
        Properties: {
          LifecycleConfiguration: {
            Rules: [
              {
                ExpirationInDays: 30,
                Status: "Enabled",
              },
            ],
          },
        },
      });
      const extractedLogicalIds = Object.keys(extractedTemplate);
      const expectedLogicalId = expect.stringMatching("SampleBucket") as string;
      expect(extractedLogicalIds).toEqual(
        expect.arrayContaining([expectedLogicalId])
      );
    });
    test("SampleバケットでSSL通信が強制されている", () => {
      template.hasResourceProperties("AWS::S3::BucketPolicy", {
        Bucket: {
          Ref: Match.stringLikeRegexp("SampleBucket"),
        },
        PolicyDocument: {
          Statement: Match.arrayWith([
            Match.objectLike({
              Principal: {
                AWS: "*",
              },
              Effect: "Deny",
              Action: "s3:*",
              Condition: {
                Bool: {
                  "aws:SecureTransport": "false",
                },
              },
            }),
          ]),
        },
      });
    });
  });
  describe("Configのプロパティチェック", () => {
    test("S3DefaultEncryptionKmsのConfigルールが作成される", () => {
      template.hasResourceProperties("AWS::Config::ConfigRule", {
        ConfigRuleName: "S3DefaultEncryptionKmsRule",
        Source: Match.objectLike({
          SourceIdentifier: "S3_DEFAULT_ENCRYPTION_KMS",
        }),
      });
    });
  });
  describe("カスタムリソースのプロパティチェック", () => {
    test("スタック作成時、ConfigルールにTestタグが付与される", () => {
      template.hasResourceProperties("Custom::AWS", {
        Create: Match.serializedJson({
          service: "ConfigService",
          action: "tagResource",
          parameters: {
            ResourceArn:
              "arn:aws:config:ap-northeast-1:012345678901:config-rule/S3DefaultEncryptionKmsRule",
            Tags: [
              {
                Key: "Test",
                Value: "true",
              },
            ],
          },
          physicalResourceId: Match.anyValue(),
        }),
      });
    });
    test("スタック削除時に保持されるカスタムリソースが存在しない", () => {
      template.allResources("Custom::AWS", {
        DeletionPolicy: Match.not("Retain"),
      });
    });
  });
});

