diff --git a/packages/@aws-cdk/toolkit-lib/docs/message-registry.md b/packages/@aws-cdk/toolkit-lib/docs/message-registry.md index 5e5866ecb..2fba211b7 100644 --- a/packages/@aws-cdk/toolkit-lib/docs/message-registry.md +++ b/packages/@aws-cdk/toolkit-lib/docs/message-registry.md @@ -146,6 +146,9 @@ Please let us know by [opening an issue](https://github.com/aws/aws-cdk-cli/issu | `CDK_TOOLKIT_I9500` | Stack diagnosis (no problems found) | `info` | {@link DiagnosedStack} | | `CDK_TOOLKIT_E9500` | Stack diagnosis (problems found) | `error` | {@link DiagnosedStack} | | `CDK_TOOLKIT_W9501` | Stack diagnosis (diagnosis could not be performed) | `warn` | {@link DiagnosedStack} | +| `CDK_TOOLKIT_I9600` | Validate passed with no problems | `info` | {@link ValidateResult} | +| `CDK_TOOLKIT_E9600` | Validate found problems | `error` | {@link ValidateResult} | +| `CDK_TOOLKIT_I9601` | No validation plugins configured | `info` | n/a | | `CDK_TOOLKIT_I0100` | Notices decoration (the header or footer of a list of notices) | `info` | n/a | | `CDK_TOOLKIT_W0101` | A notice that is marked as a warning | `warn` | n/a | | `CDK_TOOLKIT_E0101` | A notice that is marked as an error | `error` | n/a | diff --git a/packages/@aws-cdk/toolkit-lib/lib/actions/index.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/index.ts index 45a215a0e..ee126522f 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/actions/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/index.ts @@ -11,3 +11,4 @@ export * from './rollback'; export * from './synth'; export * from './watch'; export * from './diagnose'; +export * from './validate'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/actions/validate/index.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/validate/index.ts new file mode 100644 index 000000000..93f4b2272 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/validate/index.ts @@ -0,0 +1,33 @@ +import type { PolicyValidationReportJson, PolicyValidationReportConclusion, PluginReportJson } from '@aws-cdk/cloud-assembly-schema'; +import type { StackSelector } from '../../api/cloud-assembly'; + +export interface ValidateOptions { + /** + * Select the stacks to validate + */ + readonly stacks?: StackSelector; +} + +/** + * The result of the validate action + */ +export interface ValidateResult { + /** + * Whether validation passed or failed overall. + * 'success' if no plugins reported failures (or no plugins ran). + * 'failure' if any plugin reported a failure. + */ + readonly conclusion: PolicyValidationReportConclusion; + + /** + * The title of the validation report + */ + readonly title?: string; + + /** + * Reports from each validation plugin + */ + readonly pluginReports: PluginReportJson[]; +} + +export type { PolicyValidationReportJson, PolicyValidationReportConclusion, PluginReportJson }; diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts index f6998f7eb..9b33e4347 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts @@ -2,6 +2,7 @@ import type * as cxapi from '@aws-cdk/cloud-assembly-api'; import * as make from './message-maker'; import type { SpanDefinition } from './span'; import type { DiagnosedStack } from '../../../actions/diagnose'; +import type { ValidateResult } from '../../../actions/validate'; import type { StackDiff, DiffResult } from '../../../payloads'; import type { BootstrapEnvironmentProgress } from '../../../payloads/bootstrap-environment-progress'; import type { MissingContext, UpdatedContext } from '../../../payloads/context'; @@ -504,6 +505,24 @@ export const IO = { interface: 'DiagnosedStack', }), + // validate (96xx) + CDK_TOOLKIT_I9600: make.info({ + code: 'CDK_TOOLKIT_I9600', + description: 'Validate passed with no problems', + interface: 'ValidateResult', + }), + + CDK_TOOLKIT_E9600: make.error({ + code: 'CDK_TOOLKIT_E9600', + description: 'Validate found problems', + interface: 'ValidateResult', + }), + + CDK_TOOLKIT_I9601: make.info({ + code: 'CDK_TOOLKIT_I9601', + description: 'No validation plugins configured', + }), + // Notices CDK_TOOLKIT_I0100: make.info({ code: 'CDK_TOOLKIT_I0100', diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/toolkit-action.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/toolkit-action.ts index 8a79257d2..aa25e06e7 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/toolkit-action.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/toolkit-action.ts @@ -22,4 +22,5 @@ export type ToolkitAction = | 'refactor' | 'diagnose' | 'orphan' -| 'flags'; +| 'flags' +| 'validate'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index e3372bd29..2be20f742 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -2,7 +2,7 @@ import '../private/dispose-polyfill'; import * as path from 'node:path'; import * as cxapi from '@aws-cdk/cloud-assembly-api'; import type { FeatureFlagReportProperties } from '@aws-cdk/cloud-assembly-schema'; -import { ArtifactType } from '@aws-cdk/cloud-assembly-schema'; +import { ArtifactType, Manifest } from '@aws-cdk/cloud-assembly-schema'; import type { TemplateDiff } from '@aws-cdk/cloudformation-diff'; import * as chalk from 'chalk'; import * as chokidar from 'chokidar'; @@ -57,6 +57,7 @@ import type { PublishAssetsOptions, PublishAssetsResult } from '../actions/publi import type { RefactorOptions } from '../actions/refactor'; import { type RollbackOptions } from '../actions/rollback'; import { type SynthOptions } from '../actions/synth'; +import type { ValidateOptions, ValidateResult, PolicyValidationReportConclusion } from '../actions/validate'; import type { IWatcher, WatchOptions } from '../actions/watch'; import { countAssemblyResults } from './private/count-assembly-results'; import { WATCH_EXCLUDE_DEFAULTS } from '../actions/watch/private'; @@ -113,6 +114,8 @@ import { pLimit } from '../util/concurrency'; import { createIgnoreMatcher } from '../util/glob-matcher'; import { promiseWithResolvers } from '../util/promises'; +const POLICY_VALIDATION_REPORT_FILE = 'policy-validation-report.json'; + export interface ToolkitOptions { /** * The IoHost implementation, handling the inline interactions between the Toolkit and an integration. @@ -649,6 +652,49 @@ export class Toolkit extends CloudAssemblySourceBuilder { return { stacks }; } + /** + * Validate Action + * + * Synthesizes the CDK app and reads the policy validation report + * from the cloud assembly output directory. + */ + public async validate(cx: ICloudAssemblySource, options: ValidateOptions = {}): Promise { + const ioHelper = asIoHelper(this.ioHost, 'validate'); + const selectStacks = stacksOpt(options); + await using assembly = await synthAndMeasure(ioHelper, cx, selectStacks); + + const reportPath = path.join(assembly.directory, POLICY_VALIDATION_REPORT_FILE); + + if (!await fs.pathExists(reportPath)) { + const result: ValidateResult = { + conclusion: 'success', + pluginReports: [], + }; + await ioHelper.notify(IO.CDK_TOOLKIT_I9601.msg('No validation plugins configured. Add a plugin to your CDK app to enable validation.')); + return result; + } + + const report = Manifest.loadValidationReport(reportPath); + + const conclusion: PolicyValidationReportConclusion = report.pluginReports.some( + (pr) => pr.conclusion === 'failure', + ) ? 'failure' : 'success'; + + const result: ValidateResult = { + conclusion, + title: report.title, + pluginReports: report.pluginReports, + }; + + if (conclusion === 'failure') { + await ioHelper.notify(IO.CDK_TOOLKIT_E9600.msg('❌ cdk validate found problems', result)); + } else { + await ioHelper.notify(IO.CDK_TOOLKIT_I9600.msg('✅ No problems found', result)); + } + + return result; + } + /** * Deploy Action * diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-malformed-validation-report/cdk.out/Stack1.template.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-malformed-validation-report/cdk.out/Stack1.template.json new file mode 100644 index 000000000..520ef7435 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-malformed-validation-report/cdk.out/Stack1.template.json @@ -0,0 +1,9 @@ +{ + "Resources": { + "MyBucketF68F3FF0": { + "Type": "AWS::S3::Bucket", + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" + } + } +} diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-malformed-validation-report/cdk.out/manifest.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-malformed-validation-report/cdk.out/manifest.json new file mode 100644 index 000000000..e2a5952cf --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-malformed-validation-report/cdk.out/manifest.json @@ -0,0 +1,21 @@ +{ + "version": "53.0.0", + "artifacts": { + "Stack1": { + "type": "aws:cloudformation:stack", + "environment": "aws://123456789012/us-east-1", + "properties": { + "templateFile": "Stack1.template.json", + "terminationProtection": false, + "validateOnSynth": false + }, + "displayName": "Stack1" + }, + "Tree": { + "type": "cdk:tree", + "properties": { + "file": "tree.json" + } + } + } +} diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-malformed-validation-report/cdk.out/policy-validation-report.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-malformed-validation-report/cdk.out/policy-validation-report.json new file mode 100644 index 000000000..d949a82a4 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-malformed-validation-report/cdk.out/policy-validation-report.json @@ -0,0 +1,4 @@ +{ + "title": "Validation Report", + "someUnexpectedField": true +} diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-malformed-validation-report/cdk.out/tree.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-malformed-validation-report/cdk.out/tree.json new file mode 100644 index 000000000..569cfa7e4 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-malformed-validation-report/cdk.out/tree.json @@ -0,0 +1,17 @@ +{ + "version": "tree-0.1", + "tree": { + "id": "App", + "path": "", + "children": { + "Stack1": { + "id": "Stack1", + "path": "Stack1", + "constructInfo": { + "fqn": "aws-cdk-lib.Stack", + "version": "0.0.0" + } + } + } + } +} diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-no-title-validation/cdk.out/Stack1.template.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-no-title-validation/cdk.out/Stack1.template.json new file mode 100644 index 000000000..520ef7435 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-no-title-validation/cdk.out/Stack1.template.json @@ -0,0 +1,9 @@ +{ + "Resources": { + "MyBucketF68F3FF0": { + "Type": "AWS::S3::Bucket", + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" + } + } +} diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-no-title-validation/cdk.out/manifest.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-no-title-validation/cdk.out/manifest.json new file mode 100644 index 000000000..e2a5952cf --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-no-title-validation/cdk.out/manifest.json @@ -0,0 +1,21 @@ +{ + "version": "53.0.0", + "artifacts": { + "Stack1": { + "type": "aws:cloudformation:stack", + "environment": "aws://123456789012/us-east-1", + "properties": { + "templateFile": "Stack1.template.json", + "terminationProtection": false, + "validateOnSynth": false + }, + "displayName": "Stack1" + }, + "Tree": { + "type": "cdk:tree", + "properties": { + "file": "tree.json" + } + } + } +} diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-no-title-validation/cdk.out/policy-validation-report.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-no-title-validation/cdk.out/policy-validation-report.json new file mode 100644 index 000000000..f42957765 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-no-title-validation/cdk.out/policy-validation-report.json @@ -0,0 +1,17 @@ +{ + "version": "1.0.0", + "pluginReports": [ + { + "pluginName": "TestPlugin", + "conclusion": "failure", + "violations": [ + { + "ruleName": "no-public-buckets", + "description": "S3 Buckets must not be publicly accessible", + "severity": "error", + "violatingConstructs": [] + } + ] + } + ] +} diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-no-title-validation/cdk.out/tree.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-no-title-validation/cdk.out/tree.json new file mode 100644 index 000000000..569cfa7e4 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-no-title-validation/cdk.out/tree.json @@ -0,0 +1,17 @@ +{ + "version": "tree-0.1", + "tree": { + "id": "App", + "path": "", + "children": { + "Stack1": { + "id": "Stack1", + "path": "Stack1", + "constructInfo": { + "fqn": "aws-cdk-lib.Stack", + "version": "0.0.0" + } + } + } + } +} diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-passing-validation/cdk.out/Stack1.template.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-passing-validation/cdk.out/Stack1.template.json new file mode 100644 index 000000000..520ef7435 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-passing-validation/cdk.out/Stack1.template.json @@ -0,0 +1,9 @@ +{ + "Resources": { + "MyBucketF68F3FF0": { + "Type": "AWS::S3::Bucket", + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" + } + } +} diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-passing-validation/cdk.out/manifest.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-passing-validation/cdk.out/manifest.json new file mode 100644 index 000000000..e2a5952cf --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-passing-validation/cdk.out/manifest.json @@ -0,0 +1,21 @@ +{ + "version": "53.0.0", + "artifacts": { + "Stack1": { + "type": "aws:cloudformation:stack", + "environment": "aws://123456789012/us-east-1", + "properties": { + "templateFile": "Stack1.template.json", + "terminationProtection": false, + "validateOnSynth": false + }, + "displayName": "Stack1" + }, + "Tree": { + "type": "cdk:tree", + "properties": { + "file": "tree.json" + } + } + } +} diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-passing-validation/cdk.out/policy-validation-report.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-passing-validation/cdk.out/policy-validation-report.json new file mode 100644 index 000000000..09675e364 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-passing-validation/cdk.out/policy-validation-report.json @@ -0,0 +1,12 @@ +{ + "version": "1.0.0", + "title": "Validation Report", + "pluginReports": [ + { + "pluginName": "TestPlugin", + "pluginVersion": "1.0.0", + "conclusion": "success", + "violations": [] + } + ] +} diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-passing-validation/cdk.out/tree.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-passing-validation/cdk.out/tree.json new file mode 100644 index 000000000..569cfa7e4 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-passing-validation/cdk.out/tree.json @@ -0,0 +1,17 @@ +{ + "version": "tree-0.1", + "tree": { + "id": "App", + "path": "", + "children": { + "Stack1": { + "id": "Stack1", + "path": "Stack1", + "constructInfo": { + "fqn": "aws-cdk-lib.Stack", + "version": "0.0.0" + } + } + } + } +} diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-validation-report/cdk.out/Stack1.template.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-validation-report/cdk.out/Stack1.template.json new file mode 100644 index 000000000..520ef7435 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-validation-report/cdk.out/Stack1.template.json @@ -0,0 +1,9 @@ +{ + "Resources": { + "MyBucketF68F3FF0": { + "Type": "AWS::S3::Bucket", + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" + } + } +} diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-validation-report/cdk.out/manifest.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-validation-report/cdk.out/manifest.json new file mode 100644 index 000000000..e2a5952cf --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-validation-report/cdk.out/manifest.json @@ -0,0 +1,21 @@ +{ + "version": "53.0.0", + "artifacts": { + "Stack1": { + "type": "aws:cloudformation:stack", + "environment": "aws://123456789012/us-east-1", + "properties": { + "templateFile": "Stack1.template.json", + "terminationProtection": false, + "validateOnSynth": false + }, + "displayName": "Stack1" + }, + "Tree": { + "type": "cdk:tree", + "properties": { + "file": "tree.json" + } + } + } +} diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-validation-report/cdk.out/policy-validation-report.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-validation-report/cdk.out/policy-validation-report.json new file mode 100644 index 000000000..726e8f9cc --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-validation-report/cdk.out/policy-validation-report.json @@ -0,0 +1,39 @@ +{ + "version": "1.0.0", + "title": "Validation Report", + "pluginReports": [ + { + "pluginName": "TestPlugin", + "pluginVersion": "1.0.0", + "conclusion": "failure", + "violations": [ + { + "ruleName": "no-public-buckets", + "description": "S3 Buckets must not be publicly accessible", + "suggestedFix": "Set PublicAccessBlockConfiguration on the bucket", + "severity": "error", + "violatingConstructs": [ + { + "constructPath": "Stack1/MyBucket/Resource", + "constructFqn": "aws-cdk-lib/aws-s3.Bucket", + "libraryVersion": "2.200.0", + "cloudFormationResource": { + "templatePath": "Stack1.template.json", + "logicalId": "MyBucketF68F3FF0", + "propertyPaths": ["/Resources/MyBucketF68F3FF0"] + }, + "stackTraces": [ + "new Bucket (lib/my-stack.ts:12:5)\nnew MyStack (lib/my-stack.ts:30:5)" + ] + } + ] + } + ] + }, + { + "pluginName": "Construct Annotations", + "conclusion": "success", + "violations": [] + } + ] +} diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-validation-report/cdk.out/tree.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-validation-report/cdk.out/tree.json new file mode 100644 index 000000000..569cfa7e4 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-validation-report/cdk.out/tree.json @@ -0,0 +1,17 @@ +{ + "version": "tree-0.1", + "tree": { + "id": "App", + "path": "", + "children": { + "Stack1": { + "id": "Stack1", + "path": "Stack1", + "constructInfo": { + "fqn": "aws-cdk-lib.Stack", + "version": "0.0.0" + } + } + } + } +} diff --git a/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts b/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts new file mode 100644 index 000000000..38c9ad239 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts @@ -0,0 +1,137 @@ +import { StackSelectionStrategy } from '../../lib/api/cloud-assembly'; +import { Toolkit } from '../../lib/toolkit'; +import { cdkOutFixture, TestIoHost } from '../_helpers'; + +let ioHost: TestIoHost; +let toolkit: Toolkit; + +beforeEach(() => { + ioHost = new TestIoHost(); + toolkit = new Toolkit({ ioHost }); +}); + +describe('validate', () => { + test('returns failure when report contains failing plugin', async () => { + const cx = await cdkOutFixture(toolkit, 'stack-with-validation-report'); + const result = await toolkit.validate(cx); + + expect(result.conclusion).toBe('failure'); + expect(result.title).toBe('Validation Report'); + expect(result.pluginReports).toHaveLength(2); + expect(result.pluginReports[0].pluginName).toBe('TestPlugin'); + expect(result.pluginReports[0].conclusion).toBe('failure'); + expect(result.pluginReports[0].violations).toHaveLength(1); + expect(result.pluginReports[0].violations[0].ruleName).toBe('no-public-buckets'); + expect(result.pluginReports[0].violations[0].violatingConstructs[0].constructPath).toBe('Stack1/MyBucket/Resource'); + }); + + test('returns success when all plugins pass', async () => { + const cx = await cdkOutFixture(toolkit, 'stack-with-passing-validation'); + const result = await toolkit.validate(cx); + + expect(result.conclusion).toBe('success'); + expect(result.pluginReports).toHaveLength(1); + expect(result.pluginReports[0].conclusion).toBe('success'); + expect(result.pluginReports[0].violations).toHaveLength(0); + }); + + test('returns success with no reports when no report file exists', async () => { + const cx = await cdkOutFixture(toolkit, 'stack-with-bucket'); + const result = await toolkit.validate(cx); + + expect(result.conclusion).toBe('success'); + expect(result.pluginReports).toHaveLength(0); + ioHost.expectMessage({ containing: 'No validation plugins configured', level: 'info' }); + }); + + test('emits error IO message on failure', async () => { + const cx = await cdkOutFixture(toolkit, 'stack-with-validation-report'); + await toolkit.validate(cx); + + ioHost.expectMessage({ containing: 'cdk validate found problems', level: 'error' }); + }); + + test('emits info IO message on success', async () => { + const cx = await cdkOutFixture(toolkit, 'stack-with-passing-validation'); + await toolkit.validate(cx); + + ioHost.expectMessage({ containing: 'No problems found', level: 'info' }); + }); + + test('can invoke without options', async () => { + const cx = await cdkOutFixture(toolkit, 'stack-with-bucket'); + const result = await toolkit.validate(cx); + + expect(result.conclusion).toBe('success'); + }); + + test('passes stack selector to synthesis', async () => { + const cx = await cdkOutFixture(toolkit, 'stack-with-validation-report'); + const result = await toolkit.validate(cx, { + stacks: { strategy: StackSelectionStrategy.ALL_STACKS }, + }); + + expect(result.conclusion).toBe('failure'); + }); + + test('parses violation details correctly', async () => { + const cx = await cdkOutFixture(toolkit, 'stack-with-validation-report'); + const result = await toolkit.validate(cx); + + const violation = result.pluginReports[0].violations[0]; + expect(violation.severity).toBe('error'); + expect(violation.suggestedFix).toBe('Set PublicAccessBlockConfiguration on the bucket'); + expect(violation.violatingConstructs).toHaveLength(1); + expect(violation.violatingConstructs[0].cloudFormationResource?.logicalId).toBe('MyBucketF68F3FF0'); + expect(violation.violatingConstructs[0].cloudFormationResource?.templatePath).toBe('Stack1.template.json'); + expect(violation.violatingConstructs[0].cloudFormationResource?.propertyPaths).toEqual(['/Resources/MyBucketF68F3FF0']); + }); + + test('includes plugin version in report', async () => { + const cx = await cdkOutFixture(toolkit, 'stack-with-validation-report'); + const result = await toolkit.validate(cx); + + expect(result.pluginReports[0].pluginVersion).toBe('1.0.0'); + expect(result.pluginReports[1].pluginVersion).toBeUndefined(); + }); + + test('parses stack traces correctly', async () => { + const cx = await cdkOutFixture(toolkit, 'stack-with-validation-report'); + const result = await toolkit.validate(cx); + + const construct = result.pluginReports[0].violations[0].violatingConstructs[0]; + expect(construct.stackTraces).toBeDefined(); + expect(construct.stackTraces![0]).toContain('new Bucket (lib/my-stack.ts:12:5)'); + expect(construct.stackTraces![0]).toContain('new MyStack (lib/my-stack.ts:30:5)'); + }); + + test('IO message payload contains full ValidateResult', async () => { + const cx = await cdkOutFixture(toolkit, 'stack-with-validation-report'); + await toolkit.validate(cx); + + const errorMsg = ioHost.messages.find( + (m) => m.code === 'CDK_TOOLKIT_E9600', + ); + expect(errorMsg).toBeDefined(); + expect(errorMsg!.data).toMatchObject({ + conclusion: 'failure', + title: 'Validation Report', + pluginReports: expect.arrayContaining([ + expect.objectContaining({ + pluginName: 'TestPlugin', + conclusion: 'failure', + }), + ]), + }); + }); + + test('handles report with missing title field', async () => { + const cx = await cdkOutFixture(toolkit, 'stack-with-no-title-validation'); + const result = await toolkit.validate(cx); + + expect(result.conclusion).toBe('failure'); + expect(result.title).toBeUndefined(); + expect(result.pluginReports).toHaveLength(1); + expect(result.pluginReports[0].violations[0].ruleName).toBe('no-public-buckets'); + }); +});