Todos os artigos

// Knowledge.log — 技術記事

Testando stacks CDK sem deploy: assertions library e synth como contrato

Como usar cdk synth e aws-cdk-lib/assertions para travar propriedades de uma stack CDK no CI, antes de qualquer deploy real na AWS.

Um bucket perde a criptografia, uma policy IAM ganha um s3:* de última hora, e ninguém percebe até o cdk deploy já ter rodado em produção. Não porque faltou revisão de código — o diff parecia inocente — mas porque nada no pipeline verificava o CloudFormation que a stack realmente produz. cdk synth gera esse CloudFormation localmente, sem tocar em conta nenhuma, e a aws-cdk-lib/assertions permite escrever testes que falham quando o template sai do formato esperado. Isso vira um gate de CI que roda em segundos, sem Docker e sem credenciais.

Este artigo mostra como montar esse gate: uma stack simples (bucket S3 + role IAM), testes com node:test que travam encryption e a policy, e o cdk synth --lookups false como segunda camada de verificação. Tudo executado neste host, com números reais.

Synth não é deploy

Vale repetir porque é fácil confundir os dois comandos:

  • cdk synth lê o app CDK (o código TypeScript/JavaScript) e produz uma cloud assembly — um ou mais templates CloudFormation em cdk.out/. É uma transformação local: código vira JSON. Nenhum recurso é criado, nenhuma API da AWS precisa responder.
  • cdk deploy roda o synth internamente e depois envia esse template para o CloudFormation, o que exige ambiente bootstrapado e permissões válidas.

A ressalva fica em contexto lookups. Construtos como Vpc.fromLookup ou StringParameter.valueFromLookup consultam a conta durante o synth para resolver valores (uma VPC existente, um parâmetro). Se a stack não usa lookups, cdk synth roda sem credencial nenhuma — foi o caso verificado aqui, com AWS_PROFILE vazio. Se a stack usa lookups e não há um cdk.context.json commitado, o CLI precisa de credenciais para buscar o valor. A flag --lookups false resolve essa ambiguidade no CI: se algum lookup for necessário, o synth falha fechado em vez de tentar chamar a AWS silenciosamente.

Versões usadas

Tudo abaixo foi rodado neste host com Node.js 22.23.2 (linha 22.x, com suporte do CDK até 30/10/2027), aws-cdk-lib 2.269.0, constructs ^10.5.0 e o CLI publicado como aws-cdk 2.1141.0. Desde a 2.1000.0, o número do CLI não segue mais em lockstep com a versão da lib — cada aws-cdk-lib funciona com o CLI que estava atual no lançamento dela e com qualquer CLI mais novo. Node 24.x também está na tabela de suporte do CDK, mas o host aqui é 22; Node 18.x saiu de suporte em 30/11/2025 e não deve ser ensinado.

Assertions vêm de aws-cdk-lib/assertions, a API v2. O módulo @aws-cdk/assert é CDK v1 e não deve aparecer em código novo. O runner de teste é o node:test embutido no Node 22 — o guia oficial de testes usa Jest nos exemplos TypeScript, mas isso é escolha do artigo da AWS, não requisito da assertions library. Fica sem sentido puxar jest@^24.9.0 (a versão que o guia ainda cita) quando o runner nativo já resolve.

A stack de exemplo

Um bucket S3 com SSE-S3 explícito e bloqueio total de acesso público, mais uma role Lambda com permissão só de leitura:

import { CfnOutput, Stack, StackProps } from "aws-cdk-lib";
import { Bucket, BucketEncryption, BlockPublicAccess } from "aws-cdk-lib/aws-s3";
import { Effect, PolicyStatement, Role, ServicePrincipal } from "aws-cdk-lib/aws-iam";
import { Construct } from "constructs";

export interface SecureBucketStackProps extends StackProps {
  encryption?: BucketEncryption;
  actions?: string[];
}

export class SecureBucketStack extends Stack {
  constructor(scope: Construct, id: string, props?: SecureBucketStackProps) {
    super(scope, id, props);

    const bucket = new Bucket(this, "Data", {
      encryption: props?.encryption ?? BucketEncryption.S3_MANAGED,
      blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
      enforceSSL: true,
    });

    const role = new Role(this, "Reader", {
      assumedBy: new ServicePrincipal("lambda.amazonaws.com"),
    });
    role.addToPolicy(new PolicyStatement({
      effect: Effect.ALLOW,
      actions: props?.actions ?? ["s3:GetObject"],
      resources: [bucket.arnForObjects("*")],
    }));

    new CfnOutput(this, "BucketName", { value: bucket.bucketName });
  }
}

O tipo SecureBucketStackProps estende StackProps com dois campos opcionais, encryption e actions. Em produção ninguém precisa informá-los — os defaults (BucketEncryption.S3_MANAGED e ["s3:GetObject"]) já são o comportamento que a stack deve ter. Eles existem para os testes de falha, mais adiante, poderem trocar esses valores e provar que o template muda quando a definição afrouxa, sem recorrer a props as any.

O synth dessa stack (npx aws-cdk@2.1141.0 synth --lookups false, sem AWS_PROFILE) terminou com exit 0 e produziu, entre outras coisas, este trecho no AWS::S3::Bucket:

"BucketEncryption": {
  "ServerSideEncryptionConfiguration": [
    { "ServerSideEncryptionByDefault": { "SSEAlgorithm": "AES256" } }
  ]
},
"PublicAccessBlockConfiguration": {
  "BlockPublicAcls": true,
  "BlockPublicPolicy": true,
  "IgnorePublicAcls": true,
  "RestrictPublicBuckets": true
}

e a policy IAM com uma única action, que o CloudFormation representa como string — não array:

"Action": "s3:GetObject",
"Effect": "Allow"

Repare nisso porque é uma armadilha comum: assim que uma segunda action entra na lista, o CFN passa a serializar Action como array. Um matcher que espera a string exata quebra silenciosamente quando alguém "só adiciona mais uma permissão". Nenhum aviso, nenhum changelog: um dia é string, no outro é array, e o teste que devia proteger a policy vira ele mesmo o furo. Trato isso na seção de armadilhas.

O enforceSSL: true também gera um AWS::S3::BucketPolicy separado, com um Deny de s3:* condicionado a aws:SecureTransport: false. Isso não é a policy IAM da role — é a bucket policy que recusa tráfego não-TLS. Não confunda as duas ao escrever os testes: o wildcard aqui é esperado e correto, faz parte do enforce de SSL — é o único s3:* do template que você deveria comemorar em vez de travar, e não é o wildcard perigoso que você quer barrar na policy do Reader.

O template completo synthesizado teve 7.800 bytes. O synth avisou "83 feature flags are not configured" — é um aviso informativo do CDK sobre flags de comportamento opcionais, não uma falha.

Template.fromStack e os matchers

A assertions library trabalha em cima do template já sintetizado, em memória, sem cdk.out no disco:

import { test } from "node:test";
import assert from "node:assert/strict";
import { App } from "aws-cdk-lib";
import { BucketEncryption } from "aws-cdk-lib/aws-s3";
import { Match, Template } from "aws-cdk-lib/assertions";
import { SecureBucketStack, SecureBucketStackProps } from "../lib/secure-bucket-stack.ts";

function synth(props?: SecureBucketStackProps) {
  const app = new App();
  return Template.fromStack(new SecureBucketStack(app, "T", props));
}

test("encrypted bucket and tight IAM pass", () => {
  const template = synth();

  template.resourceCountIs("AWS::S3::Bucket", 1);

  template.hasResourceProperties("AWS::S3::Bucket", {
    BucketEncryption: {
      ServerSideEncryptionConfiguration: [
        { ServerSideEncryptionByDefault: { SSEAlgorithm: "AES256" } },
      ],
    },
  });

  template.hasResourceProperties("AWS::IAM::Policy", {
    PolicyDocument: {
      Statement: Match.arrayWith([
        Match.objectLike({ Action: "s3:GetObject", Effect: "Allow" }),
      ]),
    },
  });

  template.hasOutput("BucketName", { Value: Match.anyValue() });
});

hasResourceProperties faz partial match nas Properties do recurso via Match.objectLike por padrão — o teste falha se a propriedade citada estiver ausente ou diferente, mas ignora outras propriedades do mesmo recurso que não foram mencionadas. resourceCountIs verifica a contagem de um tipo CFN inteiro. hasOutput checa um output pelo logicalId (ou '*' para todos). Match.arrayWith exige que pelo menos um item do array bata com o padrão, sem exigir que seja o único; Match.objectLike faz o mesmo partial match em nível de objeto.

Rodando esse arquivo com node --test test.mjs (equivalente ao .ts com o runner nativo), o resultado neste host foi:

tests 3
pass 3
fail 0
duration_ms 2163.172547

Com a variante --experimental-strip-types direto em .ts (sem transpilar antes):

node --experimental-strip-types --test test-strip.ts
tests 1
pass 1
duration_ms 1650.379329

O Node 22 já processa TypeScript sem build step prévio para casos simples como este teste — nada de ts-node ou tsc --watch rodando em paralelo. Vale só lembrar que --experimental-strip-types remove tipos, não faz type-checking; se o time quer o tsc como gate também, ele continua rodando à parte.

O teste que trava quando alguém afrouxa a stack

A parte que importa não é o teste feliz — é o que acontece quando a definição muda:

test("fails when encryption is omitted (UNENCRYPTED)", () => {
  const template = synth({ encryption: BucketEncryption.UNENCRYPTED });

  assert.throws(() => {
    template.hasResourceProperties("AWS::S3::Bucket", {
      BucketEncryption: {
        ServerSideEncryptionConfiguration: [
          { ServerSideEncryptionByDefault: { SSEAlgorithm: "AES256" } },
        ],
      },
    });
  });
});

test("fails when IAM Action is wildcard", () => {
  const template = synth({ actions: ["s3:*"] });

  assert.throws(() => {
    template.hasResourceProperties("AWS::IAM::Policy", {
      PolicyDocument: {
        Statement: Match.arrayWith([
          Match.objectLike({ Action: "s3:GetObject", Effect: "Allow" }),
        ]),
      },
    });
  });
});

Os dois casos são reproduções deliberadas do erro que o artigo abre descrevendo, e só funcionam porque SecureBucketStackProps repassa encryption e actions até os construtos internos — sem esse encanamento, os dois assert.throws estariam testando a mesma stack de sempre e passariam pelo motivo errado. BucketEncryption.UNENCRYPTED está marcado como deprecated — o próprio CDK avisa em runtime (aws-cdk-lib.aws_s3.BucketEncryption#UNENCRYPTED is deprecated. S3 applies SSE-S3 when default encryption is not configured. API will be removed in the next major release). Isso não significa que um bucket sem encryption explícito fica com dados em texto puro — o S3 aplica SSE-S3 por padrão de qualquer forma. O que muda é o template: sem a propriedade encryption no L2, o CloudFormation não emite a chave BucketEncryption, e é exatamente essa ausência que o hasResourceProperties detecta. O contrato é "o template declara SSE-S3 explicitamente", não "o S3 vai guardar em texto plano se eu esquecer".

Resultado dos três testes juntos, incluindo os dois de falha esperada:

ok 1 - encrypted bucket and tight IAM pass (1329.886104ms)
ok 2 - fails when encryption is omitted (UNENCRYPTED) (43.638388ms)
ok 3 - fails when IAM Action is wildcard (21.630355ms)
1..3
# tests 3
# pass 3
# fail 0

Repare na diferença de tempo entre o primeiro teste (1329ms) e os dois seguintes (43ms e 21ms) — isso não é o synth sendo pulado. Os três testes chamam synth(), que monta a App, instancia a SecureBucketStack e roda Template.fromStack até o fim; nenhum deles corta caminho na síntese. A diferença é cache de módulos do Node: o primeiro import de aws-cdk-lib carrega a biblioteca inteira uma vez por processo, e os dois testes seguintes, no mesmo processo, reaproveitam esse cache. assert.throws entra depois disso — ele intercepta a exceção que o matcher lança quando a propriedade não bate —, não no lugar da síntese.

A armadilha do partial match

Match.arrayWith e Match.objectLike existem para tolerar campos que o teste não quer travar — mas o preço é que eles também toleram vizinhos indesejados. Se alguém adicionar uma segunda statement com Action: "s3:*" na mesma PolicyDocument.Statement, ao lado da statement de s3:GetObject, o teste acima continua passando: arrayWith só exige que exista pelo menos um item que bata com o padrão, não que seja o único item da lista.

Duas formas de fechar esse buraco, ambas usando a API já documentada:

  • Trocar Match.arrayWith por Match.arrayEquals quando a lista de statements for pequena e conhecida, forçando a igualdade exata do array inteiro.
  • Adicionar uma asserção específica com Match.not(Match.stringLikeRegexp(".*\\*.*")) no campo Action, para travar "nenhuma statement desta policy tem wildcard", independente de quantas statements existirem.

E lembre da diferença de serialização: com uma única action, Action é string ("s3:GetObject"); com duas ou mais, vira array (["s3:GetObject", "s3:PutObject"]). Um matcher escrito como Action: "s3:GetObject" (igualdade exata, sem objectLike) quebra no dia em que alguém adiciona uma segunda permissão legítima — mesmo que GetObject continue lá. Match.arrayWith do exemplo acima já é resiliente a isso porque não exige igualdade da lista inteira, só a presença do objeto esperado.

O gate de CI

O pipeline não precisa de mais do que Node, o CLI do CDK via npx e o runner de teste. Sem Docker, sem chave AWS, sem login step:

jobs:
  cdk-contract:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: npm
      - run: npm ci
      - run: node --experimental-strip-types --test test/*.ts
      - run: npx aws-cdk@2.1141.0 synth --lookups false --quiet

Dois comandos fazem o trabalho: os testes de assertions travam propriedades específicas (encryption, IAM), e o synth --lookups false garante que a stack inteira ainda sintetiza sem precisar de contexto externo — se alguém introduzir um Vpc.fromLookup sem cdk.context.json commitado, esse passo falha em vez de silenciosamente exigir credenciais mais tarde. Pin o aws-cdk como devDependency do projeto (como no package.json usado aqui) para que o npx aws-cdk resolvido seja sempre a versão que o time escolheu, não a mais recente do registry no dia do build.

Nenhum desses passos cria recursos, então não há bootstrap, não há STS, não há ordem de deploy para coordenar. É teste de definição, roda em paralelo com qualquer outro job, e falha em segundos.

Teste de definição não é smoke test pós-deploy

Vale separar os dois porque são coisas diferentes e um não substitui o outro:

  • Teste de definição (o que este artigo cobre): App/Stack em memória → CloudFormation → matchers. Nenhum recurso existe. A falha acontece no merge, antes de qualquer deploy. Prova que o template ainda declara o que deveria declarar.
  • Smoke test pós-deploy: roda depois que cdk deploy já criou os recursos de verdade — HTTP contra um endpoint, SDK contra o bucket, checagem de IAM efetivo via simulate-principal-policy. Prova que o ambiente real está saudável, não que o código-fonte da stack é seguro.

A AWS documenta uma terceira categoria, integ-tests-alpha com o integ-runner, para testes de integração que de fato fazem deploy de uma app CDK temporária e afirmam contra recursos reais (DeployAssert). Isso é experimental e resolve um problema diferente — vale a pena quando o time precisa validar comportamento runtime que só existe depois do deploy. Não é pré-requisito para o gate deste artigo; os dois primeiros passos do YAML já cobrem "a definição da infraestrutura ainda está correta" sem precisar de conta AWS nenhuma.

Observação em produção

Nenhum dashboard aqui — o sinal já está nos logs de CI. Duas coisas para monitorar:

  • O output do node --test: pass/fail por teste, mais a mensagem de deprecação (BucketEncryption#UNENCRYPTED) quando ela aparecer nos logs. Se ela aparecer num PR que não deveria mexer em encryption, é sinal de regressão silenciosa.
  • O exit code do npx aws-cdk synth --lookups false: 0 é sucesso, qualquer coisa diferente de 0 no CI é o gate fazendo o trabalho — trate como falha de build, não como warning para investigar depois.

Não há necessidade de instrumentar nada além disso: os dois comandos já são determinísticos e rodam a cada push.

Recomendação

Vale adotar esse par — assertions para propriedades específicas e synth --lookups false como verificação estrutural — em qualquer repositório CDK que já tenha mais de uma pessoa mexendo na mesma stack, porque o custo de setup é baixo (nenhuma dependência nova além do que já está no package.json) e o retorno aparece na primeira vez que alguém reverte uma propriedade de segurança sem querer. Onde a recomendação muda: uma stack de protótipo, mantida por uma pessoa só, que ainda está mudando de forma todo dia, não ganha muito com testes de propriedade fixa — eles vão quebrar a cada iteração legítima e virar ruído. Nesse estágio, o synth --lookups false sozinho já vale a pena como smoke check de "isso ainda compila", e os testes de hasResourceProperties entram quando a stack estabilizar o suficiente para ter um contrato que faça sentido travar.

awscdk

// Continue.training — 次のステップ

Conhecimento só conta quando vira prática.

Volte ao artigo, execute os exemplos e compartilhe o que aprendeu.

Explorar mais artigos