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 synthlê o app CDK (o código TypeScript/JavaScript) e produz uma cloud assembly — um ou mais templates CloudFormation emcdk.out/. É uma transformação local: código vira JSON. Nenhum recurso é criado, nenhuma API da AWS precisa responder.cdk deployroda 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.
- Fonte: Referência do comando cdk synth
- Fonte: Guia de testes do CDK
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.arrayWithporMatch.arrayEqualsquando 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 campoAction, 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/Stackem 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 deployjá criou os recursos de verdade — HTTP contra um endpoint, SDK contra o bucket, checagem de IAM efetivo viasimulate-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/failpor 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.