aws2-5 Exercise 2: Tests, a Least-Privilege Failure, a Snapshot, an Aspect, and the Guide's Own Example ========================================================================================================== Run with Node 24.18.0 on Windows 11 in a scratch folder (not installed globally): aws-cdk 2.1145.0 (CLI), aws-cdk-lib 2.273.0, constructs 10.8.1, typescript 7.0.2, jest 30.5.2, @types/jest, @types/node; cfn-lint 1.57.2 from the chapter 4 environment. Setup: npm install --save-exact aws-cdk@2.1145.0 aws-cdk-lib@2.273.0 constructs@10.8.1 typescript@7.0.2 jest@30.5.2 @types/jest @types/node ; build with: node node_modules/typescript/bin/tsc -p tsconfig.json ; synth with: node node_modules/aws-cdk/bin/cdk synth -c environment=dev ; test with: node node_modules/jest/bin/jest.js. VERIFICATION LEVEL: 1 (run here; no AWS account, no AWS credentials, nothing deployed). Outputs below are saved from the real runs of 2026-10-13 because some steps (the failing test, the narrowed grants, the snapshot update) happen in sequence and cannot be replayed by one command. --- test/reports-stack.test.ts --- import * as cdk from "aws-cdk-lib"; import { Annotations, Match, Template } from "aws-cdk-lib/assertions"; import * as s3 from "aws-cdk-lib/aws-s3"; import { RequireBucketVersioning } from "../lib/aspects"; import { Environment, ReportsStack } from "../lib/reports-stack"; const ADMIN = "arn:aws:iam::111122223333:role/KeyAdmin"; function build(environment: Environment, retentionDays = 30, env?: cdk.Environment) { const app = new cdk.App(); const stack = new ReportsStack(app, `Reports-${environment}`, { environment, retentionDays, keyAdminRoleArn: ADMIN, env }); return { app, stack, template: Template.fromStack(stack) }; } describe("ReportsStack, fine-grained assertions", () => { const dev = build("dev").template; const prod = build("prod").template; test("the data key rotates automatically and is kept when the stack is deleted", () => { dev.hasResourceProperties("AWS::KMS::Key", { EnableKeyRotation: true }); dev.hasResource("AWS::KMS::Key", { DeletionPolicy: "Retain", UpdateReplacePolicy: "Retain" }); }); test("the key policy keeps the root statement that delegates to IAM, and names only the administrator role", () => { dev.hasResourceProperties("AWS::KMS::Key", { KeyPolicy: { Statement: Match.arrayWith([ Match.objectLike({ Action: "kms:*", Principal: { AWS: Match.anyValue() } }), Match.objectLike({ Sid: "Allow access for Key Administrators", Principal: { AWS: ADMIN } }), ]) }, }); const statements = Object.values(dev.findResources("AWS::KMS::Key"))[0].Properties.KeyPolicy.Statement; expect(statements).toHaveLength(2); // no statement names the worker role: the role is authorised by its own policy }); test("the bucket is encrypted with the key, versioned, and blocks all public access", () => { dev.hasResourceProperties("AWS::S3::Bucket", { BucketEncryption: { ServerSideEncryptionConfiguration: [Match.objectLike({ BucketKeyEnabled: true, ServerSideEncryptionByDefault: Match.objectLike({ SSEAlgorithm: "aws:kms" }) })] }, VersioningConfiguration: { Status: "Enabled" }, PublicAccessBlockConfiguration: { BlockPublicAcls: true, BlockPublicPolicy: true, IgnorePublicAcls: true, RestrictPublicBuckets: true }, }); }); test("the bucket policy denies requests that do not use TLS", () => { dev.hasResourceProperties("AWS::S3::BucketPolicy", { PolicyDocument: { Statement: Match.arrayWith([Match.objectLike({ Effect: "Deny", Condition: { Bool: { "aws:SecureTransport": "false" } } })]) }, }); }); test("every queue is encrypted with the key", () => { const queues = Object.values(dev.findResources("AWS::SQS::Queue")); expect(queues).toHaveLength(2); for (const q of queues) { expect(q.Properties.KmsMasterKeyId).toBeDefined(); } }); test("the work queue's settings come from the environment table", () => { dev.hasResourceProperties("AWS::SQS::Queue", { VisibilityTimeout: 30, RedrivePolicy: Match.objectLike({ maxReceiveCount: 3 }) }); prod.hasResourceProperties("AWS::SQS::Queue", { VisibilityTimeout: 120, RedrivePolicy: Match.objectLike({ maxReceiveCount: 5 }) }); }); test("the backlog alarm exists only in production", () => { dev.resourceCountIs("AWS::CloudWatch::Alarm", 0); prod.resourceCountIs("AWS::CloudWatch::Alarm", 1); prod.hasResourceProperties("AWS::CloudWatch::Alarm", { Threshold: 1000, ComparisonOperator: "GreaterThanThreshold", TreatMissingData: "notBreaching" }); expect(Object.keys(dev.toJSON().Outputs)).not.toContain("AlarmName"); expect(Object.keys(prod.toJSON().Outputs)).toContain("AlarmName"); }); test("a retention outside 1 to 3650 days stops synthesis", () => { expect(() => build("dev", 0)).toThrow(/retentionDays must be 1 to 3650/); expect(() => build("dev", 3651)).toThrow(/retentionDays/); expect(() => build("dev", 3650)).not.toThrow(); }); test("least privilege: the worker role is not allowed to delete objects or use a wildcard action", () => { const policies = Object.values(dev.findResources("AWS::IAM::Policy")); const allowed: string[] = policies.flatMap((p) => p.Properties.PolicyDocument.Statement.flatMap((s: { Effect: string; Action: string | string[] }) => (s.Effect === "Allow" ? [s.Action].flat() : []))); expect(allowed.filter((a) => a === "*" || a.endsWith(":*"))).toEqual([]); expect(allowed.filter((a) => a.startsWith("s3:Delete"))).toEqual([]); }); test("the template has no CloudFormation Conditions or Mappings of its own: those decisions were made while the app ran", () => { const j = dev.toJSON(); expect(Object.keys(j.Conditions ?? {}).filter((k) => k !== "CDKMetadataAvailable")).toEqual([]); // the CLI adds that one; a test template may not have it expect(j.Mappings).toBeUndefined(); expect(Object.keys(j.Parameters)).toEqual(["BootstrapVersion"]); }); }); describe("snapshot", () => { test("the dev template matches the stored baseline", () => { expect(build("dev").template.toJSON()).toMatchSnapshot(); }); }); describe("aspects and environments", () => { test("the versioning aspect turns an unversioned bucket into a synthesis error", () => { const stack = new cdk.Stack(new cdk.App(), "Bad"); new s3.Bucket(stack, "Plain"); cdk.Aspects.of(stack).add(new RequireBucketVersioning()); Template.fromStack(stack); Annotations.fromStack(stack).hasError("*", Match.stringLikeRegexp("versioning must be Enabled")); }); test("the same aspect is quiet for a versioned bucket", () => { const stack = new cdk.Stack(new cdk.App(), "Good"); new s3.Bucket(stack, "Versioned", { versioned: true }); cdk.Aspects.of(stack).add(new RequireBucketVersioning()); Template.fromStack(stack); Annotations.fromStack(stack).hasNoError("*", Match.anyValue()); }); test("an environment-agnostic stack leaves the Region as a token; an environment-specific one fixes it", () => { expect(cdk.Token.isUnresolved(build("dev").stack.region)).toBe(true); const fixed = build("dev", 30, { account: "111122223333", region: "eu-west-1" }).stack; expect(fixed.region).toBe("eu-west-1"); expect(fixed.account).toBe("111122223333"); }); }); --- lib/aspects.ts --- import * as cdk from "aws-cdk-lib"; import * as s3 from "aws-cdk-lib/aws-s3"; import { IConstruct } from "constructs"; /** An Aspect visits every construct in the tree. This one turns a missing rule into a synthesis ERROR: no deployable template is produced. */ export class RequireBucketVersioning implements cdk.IAspect { visit(node: IConstruct): void { if (node instanceof s3.CfnBucket) { const v = node.versioningConfiguration as s3.CfnBucket.VersioningConfigurationProperty | undefined; if (!v || v.status !== "Enabled") { cdk.Annotations.of(node).addError("Bucket versioning must be Enabled (organisation rule)"); } } } } --- jest, first run: the stack still used grantReadWrite; 13 pass and the least-privilege test fails on purpose (saved output of the real run) --- FAIL dist/test/reports-stack.test.js (8.296 s) ● ReportsStack, fine-grained assertions › least privilege: the worker role is not allowed to delete objects or use a wildcard action expect(received).toEqual(expected) // deep equality - Expected - 1 + Received + 3 - Array [] + Array [ + "s3:DeleteObject*", + ] 101 | const allowed = policies.flatMap((p) => p.Properties.PolicyDocument.Statement.flatMap((s) => (s.Effect === "Allow" ? [s.Action].flat() : []))); 102 | expect(allowed.filter((a) => a === "*" || a.endsWith(":*"))).toEqual([]); > 103 | expect(allowed.filter((a) => a.startsWith("s3:Delete"))).toEqual([]); | ^ 104 | }); 105 | test("the template has no CloudFormation Conditions or Mappings of its own: those decisions were made while the app ran", () => { 106 | const j = dev.toJSON(); at Object.toEqual (dist/test/reports-stack.test.js:103:66) Test Suites: 1 failed, 1 total Tests: 1 failed, 13 passed, 14 total Snapshots: 1 passed, 1 total Time: 9.654 s Ran all test suites. --- jest, second run (after replacing grantReadWrite with grantRead + grantPut): the snapshot now fails (saved output of the real run) --- FAIL dist/test/reports-stack.test.js (8.296 s) ● snapshot › the dev template matches the stored baseline expect(received).toMatchSnapshot() Snapshot name: `snapshot the dev template matches the stored baseline 1` - Snapshot - 7 + Received + 37 @@ -281,17 +281,10 @@ { "Action": [ "s3:GetObject*", "s3:GetBucket*", "s3:List*", - "s3:DeleteObject*", - "s3:PutObject", - "s3:PutObjectLegalHold", - "s3:PutObjectRetention", - "s3:PutObjectTagging", - "s3:PutObjectVersionTagging", - "s3:Abort*", ], "Effect": "Allow", "Resource": [ { "Fn::GetAtt": [ @@ -317,13 +310,50 @@ }, { "Action": [ "kms:Decrypt", "kms:DescribeKey", + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "DataKey17AFBB84", + "Arn", --- jest, second run, summary (saved output of the real run) --- › 1 snapshot failed. Snapshot Summary › 1 snapshot failed from 1 test suite. Inspect your code changes or re-run jest with `-u` to update them. Test Suites: 1 failed, 1 total Tests: 1 failed, 13 passed, 14 total Snapshots: 1 failed, 1 total Time: 9.679 s Ran all test suites. --- jest -u (accepts the new snapshot) (saved output of the real run) --- Snapshot Summary › 1 snapshot updated from 1 test suite. Test Suites: 1 passed, 1 total Tests: 14 passed, 14 total Snapshots: 1 updated, 1 total Time: 7.705 s, estimated 9 s Ran all test suites. --- jest results, final (--json summarised) (saved output of the real run) --- passed ReportsStack, fine-grained assertions the data key rotates automatically and is kept when the stack is deleted passed ReportsStack, fine-grained assertions the key policy keeps the root statement that delegates to IAM, and names only the administrator role passed ReportsStack, fine-grained assertions the bucket is encrypted with the key, versioned, and blocks all public access passed ReportsStack, fine-grained assertions the bucket policy denies requests that do not use TLS passed ReportsStack, fine-grained assertions every queue is encrypted with the key passed ReportsStack, fine-grained assertions the work queue's settings come from the environment table passed ReportsStack, fine-grained assertions the backlog alarm exists only in production passed ReportsStack, fine-grained assertions a retention outside 1 to 3650 days stops synthesis passed ReportsStack, fine-grained assertions least privilege: the worker role is not allowed to delete objects or use a wildcard action passed ReportsStack, fine-grained assertions the template has no CloudFormation Conditions or Mappings of its own: those decisions were made while the app ran passed snapshot the dev template matches the stored baseline passed aspects and environments the versioning aspect turns an unversioned bucket into a synthesis error passed aspects and environments the same aspect is quiet for a versioned bucket passed aspects and environments an environment-agnostic stack leaves the Region as a token; an environment-specific one fixes it 14 passed, 0 failed of 14 --- docex/lib/state-machine-stack.ts (typed in from the CDK guide's testing page) --- import * as cdk from "aws-cdk-lib"; import * as sns from "aws-cdk-lib/aws-sns"; import * as sns_subscriptions from "aws-cdk-lib/aws-sns-subscriptions"; import * as lambda from "aws-cdk-lib/aws-lambda"; import * as sfn from "aws-cdk-lib/aws-stepfunctions"; import { Construct } from "constructs"; export interface StateMachineStackProps extends cdk.StackProps { readonly topics: sns.Topic[]; } export class StateMachineStack extends cdk.Stack { constructor(scope: Construct, id: string, props: StateMachineStackProps) { super(scope, id, props); // In the future this state machine will do some work... const stateMachine = new sfn.StateMachine(this, "StateMachine", { definition: new sfn.Pass(this, "StartState"), }); // This Lambda function starts the state machine. const func = new lambda.Function(this, "LambdaFunction", { runtime: lambda.Runtime.NODEJS_18_X, handler: "handler", code: lambda.Code.fromAsset("./start-state-machine"), environment: { STATE_MACHINE_ARN: stateMachine.stateMachineArn, }, }); stateMachine.grants.startExecution(func); const subscription = new sns_subscriptions.LambdaSubscription(func); for (const topic of props.topics) { topic.addSubscription(subscription); } } } --- docex/test/state-machine.test.ts (the guide's assertions as printed, plus its matcher example) --- import { Match, Template } from "aws-cdk-lib/assertions"; import * as cdk from "aws-cdk-lib"; import * as sns from "aws-cdk-lib/aws-sns"; import { StateMachineStack } from "../lib/state-machine-stack"; describe("StateMachineStack (the AWS CDK guide's testing example, as printed)", () => { test("synthesizes the way we expect", () => { const app = new cdk.App(); const topicsStack = new cdk.Stack(app, "TopicsStack"); const topics = [new sns.Topic(topicsStack, "Topic1", {})]; const stateMachineStack = new StateMachineStack(app, "StateMachineStack", { topics: topics }); const template = Template.fromStack(stateMachineStack); // Assert it creates the function with the correct properties... template.hasResourceProperties("AWS::Lambda::Function", { Handler: "handler", Runtime: "nodejs14.x", }); // Creates the subscription... template.resourceCountIs("AWS::SNS::Subscription", 1); // the guide's matcher example: the state machine's IAM role template.hasResourceProperties( "AWS::IAM::Role", Match.objectEquals({ AssumeRolePolicyDocument: { Version: "2012-10-17", Statement: [{ Action: "sts:AssumeRole", Effect: "Allow", Principal: { Service: { "Fn::Join": ["", ["states.", Match.anyValue(), ".amazonaws.com"]] } } }], }, }) ); }); }); --- docex/start-state-machine/index.js --- exports.handler = async function (event, context) { return 'hello world'; }; --- docex/tsconfig.json --- { "compilerOptions": { "target": "ES2022", "module": "node16", "moduleResolution": "node16", "lib": ["es2022"], "outDir": "dist", "rootDir": ".", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "types": ["node", "jest"], "typeRoots": ["../node_modules/@types"] }, "include": ["lib/**/*.ts", "test/**/*.ts"] } --- jest on the guide's example (selected lines; run from the docex folder) (saved output of the real run) --- [WARNING] aws-cdk-lib.aws_stepfunctions.StateMachineProps#definition is deprecated. use definitionBody: DefinitionBody.fromChainable() WARNING Runtime: Runtime 'nodejs18.x' was deprecated on '2025-09-01'. Creation will be disabled on '2027-02-01', and updates will be disabled on '2027-03-03'. Please consider updating to 'nodejs24.x' (CloudFormation Validate) Template has 1 resources with type AWS::Lambda::Function, but none match as expected. !! Expected nodejs14.x but received nodejs18.x Tests: 1 failed, 1 total WHY THIS WORKS AS AN ANSWER --------------------------- Each fine-grained test asserts one behaviour of the generated template, as the CDK guide recommends, so a failure names the behaviour. The least-privilege test failed against the first version of the stack (grantReadWrite had added s3:DeleteObject*), which is what it was written to do; narrowing the grants fixed it and made the stored snapshot fail, which shows the guide's warning that snapshots change with intended edits as well as with CDK upgrades. The aspect test checks both outcomes: an unversioned bucket gives an error annotation, a versioned one does not. The tests run against compiled JavaScript to keep a TypeScript transform out of the loop with TypeScript 7. The guide's own example fails as printed: the stack uses Runtime.NODEJS_18_X while the assertion expects nodejs14.x; it also uses the deprecated 'definition' property and a Lambda runtime the CloudFormation validation says was deprecated on 2025-09-01. Not run: ts-jest or any TypeScript runner, integration tests in a real account.