From 152948a2c810659aa5195c697f55f0d523aaff3a Mon Sep 17 00:00:00 2001 From: 9pace Date: Mon, 27 Oct 2025 14:11:21 -0400 Subject: [PATCH 1/2] feat: manually generated json-schema with validation --- .projenrc.ts | 1 + .../cli-lib-alpha/THIRD_PARTY_LICENSES | 26 ++ packages/aws-cdk/.projen/deps.json | 4 + packages/aws-cdk/.projen/tasks.json | 2 +- packages/aws-cdk/THIRD_PARTY_LICENSES | 26 ++ .../aws-cdk/lib/cli/user-configuration.ts | 98 +++++ .../aws-cdk/lib/schema/cdk-config.schema.json | 245 ++++++++++++ packages/aws-cdk/lib/schema/index.ts | 12 + packages/aws-cdk/package.json | 1 + .../test/cli/schema-validation.test.ts | 352 ++++++++++++++++++ 10 files changed, 766 insertions(+), 1 deletion(-) create mode 100644 packages/aws-cdk/lib/schema/cdk-config.schema.json create mode 100644 packages/aws-cdk/lib/schema/index.ts create mode 100644 packages/aws-cdk/test/cli/schema-validation.test.ts diff --git a/.projenrc.ts b/.projenrc.ts index 6e8c625da..f1d3b790d 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -1169,6 +1169,7 @@ const cli = configureProject( 'wrap-ansi@^7', // Last non-ESM version 'yaml@^1', 'yargs@^15', + 'jsonschema', ], tsconfig: { compilerOptions: { diff --git a/packages/@aws-cdk/cli-lib-alpha/THIRD_PARTY_LICENSES b/packages/@aws-cdk/cli-lib-alpha/THIRD_PARTY_LICENSES index c577f8c76..aa7c95463 100644 --- a/packages/@aws-cdk/cli-lib-alpha/THIRD_PARTY_LICENSES +++ b/packages/@aws-cdk/cli-lib-alpha/THIRD_PARTY_LICENSES @@ -22580,6 +22580,32 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +---------------- + +** jsonschema@1.5.0 - https://www.npmjs.com/package/jsonschema/v/1.5.0 | MIT +jsonschema is licensed under MIT license. + +Copyright (C) 2012-2015 Tom de Grunt + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + ---------------- ** lazystream@1.0.1 - https://www.npmjs.com/package/lazystream/v/1.0.1 | MIT diff --git a/packages/aws-cdk/.projen/deps.json b/packages/aws-cdk/.projen/deps.json index cd2092fc6..63eeaf811 100644 --- a/packages/aws-cdk/.projen/deps.json +++ b/packages/aws-cdk/.projen/deps.json @@ -354,6 +354,10 @@ "name": "glob", "type": "runtime" }, + { + "name": "jsonschema", + "type": "runtime" + }, { "name": "minimatch", "type": "runtime" diff --git a/packages/aws-cdk/.projen/tasks.json b/packages/aws-cdk/.projen/tasks.json index eaf1f7ed8..8f2983241 100644 --- a/packages/aws-cdk/.projen/tasks.json +++ b/packages/aws-cdk/.projen/tasks.json @@ -53,7 +53,7 @@ }, "steps": [ { - "exec": "npx npm-check-updates@16 --upgrade --target=minor --peer --no-deprecated --dep=dev,peer,prod,optional --filter=@cdklabs/eslint-plugin,@types/archiver,@types/jest,@types/mockery,@types/promptly,@types/semver,@types/sinon,aws-cdk-lib,aws-sdk-client-mock,aws-sdk-client-mock-jest,axios,eslint-config-prettier,eslint-import-resolver-typescript,eslint-plugin-import,eslint-plugin-jest,eslint-plugin-jsdoc,eslint-plugin-prettier,fast-check,jest,jest-environment-node,jest-mock,license-checker,madge,node-backpack,sinon,ts-jest,ts-mock-imports,xml-js,@aws-cdk/cx-api,@aws-sdk/client-appsync,@aws-sdk/client-cloudcontrol,@aws-sdk/client-cloudformation,@aws-sdk/client-cloudwatch-logs,@aws-sdk/client-codebuild,@aws-sdk/client-ec2,@aws-sdk/client-ecr,@aws-sdk/client-ecs,@aws-sdk/client-elastic-load-balancing-v2,@aws-sdk/client-iam,@aws-sdk/client-kms,@aws-sdk/client-lambda,@aws-sdk/client-route-53,@aws-sdk/client-s3,@aws-sdk/client-secrets-manager,@aws-sdk/client-sfn,@aws-sdk/client-ssm,@aws-sdk/client-sts,@aws-sdk/credential-providers,@aws-sdk/ec2-metadata-service,@aws-sdk/lib-storage,@smithy/middleware-endpoint,@smithy/property-provider,@smithy/shared-ini-file-loader,@smithy/types,@smithy/util-retry,@smithy/util-waiter,archiver,cdk-from-cfn,enquirer,glob,minimatch,promptly,proxy-agent,semver,uuid" + "exec": "npx npm-check-updates@16 --upgrade --target=minor --peer --no-deprecated --dep=dev,peer,prod,optional --filter=@cdklabs/eslint-plugin,@types/archiver,@types/jest,@types/mockery,@types/promptly,@types/semver,@types/sinon,aws-cdk-lib,aws-sdk-client-mock,aws-sdk-client-mock-jest,axios,eslint-config-prettier,eslint-import-resolver-typescript,eslint-plugin-import,eslint-plugin-jest,eslint-plugin-jsdoc,eslint-plugin-prettier,fast-check,jest,jest-environment-node,jest-mock,license-checker,madge,node-backpack,sinon,ts-jest,ts-mock-imports,xml-js,@aws-cdk/cx-api,@aws-sdk/client-appsync,@aws-sdk/client-cloudcontrol,@aws-sdk/client-cloudformation,@aws-sdk/client-cloudwatch-logs,@aws-sdk/client-codebuild,@aws-sdk/client-ec2,@aws-sdk/client-ecr,@aws-sdk/client-ecs,@aws-sdk/client-elastic-load-balancing-v2,@aws-sdk/client-iam,@aws-sdk/client-kms,@aws-sdk/client-lambda,@aws-sdk/client-route-53,@aws-sdk/client-s3,@aws-sdk/client-secrets-manager,@aws-sdk/client-sfn,@aws-sdk/client-ssm,@aws-sdk/client-sts,@aws-sdk/credential-providers,@aws-sdk/ec2-metadata-service,@aws-sdk/lib-storage,@smithy/middleware-endpoint,@smithy/property-provider,@smithy/shared-ini-file-loader,@smithy/types,@smithy/util-retry,@smithy/util-waiter,archiver,cdk-from-cfn,enquirer,glob,jsonschema,minimatch,promptly,proxy-agent,semver,uuid" } ] }, diff --git a/packages/aws-cdk/THIRD_PARTY_LICENSES b/packages/aws-cdk/THIRD_PARTY_LICENSES index 12a8ffad6..91d00d756 100644 --- a/packages/aws-cdk/THIRD_PARTY_LICENSES +++ b/packages/aws-cdk/THIRD_PARTY_LICENSES @@ -22373,6 +22373,32 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +---------------- + +** jsonschema@1.5.0 - https://www.npmjs.com/package/jsonschema/v/1.5.0 | MIT +jsonschema is licensed under MIT license. + +Copyright (C) 2012-2015 Tom de Grunt + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + ---------------- ** lazystream@1.0.1 - https://www.npmjs.com/package/lazystream/v/1.0.1 | MIT diff --git a/packages/aws-cdk/lib/cli/user-configuration.ts b/packages/aws-cdk/lib/cli/user-configuration.ts index 733f56a83..2c1608e69 100644 --- a/packages/aws-cdk/lib/cli/user-configuration.ts +++ b/packages/aws-cdk/lib/cli/user-configuration.ts @@ -2,10 +2,12 @@ import * as os from 'os'; import * as fs_path from 'path'; import { ToolkitError } from '@aws-cdk/toolkit-lib'; import * as fs from 'fs-extra'; +import { validate } from 'jsonschema'; import { Context, PROJECT_CONTEXT } from '../api/context'; import { Settings } from '../api/settings'; import type { Tag } from '../api/tags'; import type { IoHelper } from '../api-private'; +import { cdkConfigSchema } from '../schema'; export const PROJECT_CONFIG = 'cdk.json'; export { PROJECT_CONTEXT } from '../api/context'; @@ -210,6 +212,9 @@ async function settingsFromFile(ioHelper: IoHelper, fileName: string): Promise 0 ? tags : undefined; } + +/** + * Find similar property names to suggest corrections for typos + */ +function getTypeCorrectionHint(expectedType: string, actualValue: any): string { + if (expectedType === 'boolean' && (actualValue === 'true' || actualValue === 'false')) { + return ` (use ${actualValue} without quotes)`; + } + if (expectedType === 'string' && typeof actualValue === 'number') { + return ` (use "${actualValue}" with quotes)`; + } + return ''; +} + +/** + * Validates configuration data against the CDK JSON Schema and emits warnings for issues + * + * @param data - The configuration object to validate + * @param fileName - The file name for error reporting + * @param ioHelper - IoHelper for logging warnings + */ +async function validateConfigurationFile(data: any, fileName: string, ioHelper: IoHelper): Promise { + try { + const schema = cdkConfigSchema; + const result = validate(data, schema); + + await handleSchemaErrors(result.errors, fileName, ioHelper); + + await handleUnknownProperties(data, schema, fileName, ioHelper); + } catch (error) { + await ioHelper.defaults.debug(`Schema validation failed for ${fileName}: ${error}`); + } +} + +/** + * Handles schema validation errors and emits appropriate warnings + */ +async function handleSchemaErrors(errors: any[], fileName: string, ioHelper: IoHelper): Promise { + if (!errors || errors.length === 0) { + return; + } + + for (const error of errors) { + const propertyPath = error.property?.replace('instance.', '') || 'root'; + const propertyName = propertyPath || 'property'; + + if (error.name === 'type') { + const errorSchema = error.schema as any; + const expectedType = Array.isArray(errorSchema.type) + ? errorSchema.type.join(' or ') + : errorSchema.type || 'unknown'; + const actualType = typeof error.instance; + const hint = getTypeCorrectionHint(expectedType, error.instance); + + await ioHelper.defaults.warn( + `${fileName}: '${propertyName}' should be ${expectedType}, got ${actualType}${hint}`, + ); + } else if (error.name === 'enum') { + const allowedValues = (error.schema as any).enum?.join(', ') || 'unknown'; + await ioHelper.defaults.warn( + `${fileName}: '${propertyName}' must be one of: ${allowedValues}`, + ); + } else if (error.name !== 'additionalProperties') { + // Generic fallback for other validation errors (skip additionalProperties as we handle those separately) + await ioHelper.defaults.warn( + `${fileName}: ${error.message}`, + ); + } + } +} + +/** + * Handles unknown properties + */ +async function handleUnknownProperties( + data: any, + schema: any, + fileName: string, + ioHelper: IoHelper, +): Promise { + if (!data || typeof data !== 'object' || Array.isArray(data)) { + return; + } + + const knownProperties = Object.keys(schema.properties || {}); + const unknownProperties = Object.keys(data).filter(prop => !knownProperties.includes(prop)); + + for (const prop of unknownProperties) { + await ioHelper.defaults.warn( + `${fileName}: Unknown property '${prop}' (not a standard CDK property)`, + ); + } +} diff --git a/packages/aws-cdk/lib/schema/cdk-config.schema.json b/packages/aws-cdk/lib/schema/cdk-config.schema.json new file mode 100644 index 000000000..28ab14a87 --- /dev/null +++ b/packages/aws-cdk/lib/schema/cdk-config.schema.json @@ -0,0 +1,245 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/aws/aws-cdk-cli/main/packages/aws-cdk/lib/schema/cdk-config.schema.json", + "title": "CDK Configuration", + "description": "Schema for cdk.json configuration files used by the AWS CDK CLI", + "type": "object", + "properties": { + "app": { + "type": "string", + "description": "The command that executes the CDK application" + }, + "build": { + "type": "string", + "description": "The command that compiles or builds the CDK application before synthesis (not permitted in ~/.cdk.json)" + }, + "language": { + "type": "string", + "description": "The language to be used for initializing new projects", + "enum": [ + "typescript", + "javascript", + "python", + "java", + "csharp", + "go", + "fsharp" + ] + }, + "output": { + "type": "string", + "default": "cdk.out", + "description": "The name of the directory into which the synthesized cloud assembly will be emitted" + }, + "outputsFile": { + "type": "string", + "description": "The file to which AWS CloudFormation outputs from deployed stacks will be written (in JSON format)" + }, + "profile": { + "type": "string", + "description": "Name of the default AWS profile used for specifying Region and account credentials" + }, + "toolkitStackName": { + "type": "string", + "description": "The name of the bootstrap stack" + }, + "toolkitBucketName": { + "type": "string", + "description": "The name of the Amazon S3 bucket used for deploying assets such as Lambda functions and container images" + }, + "bootstrapKmsKeyId": { + "type": "string", + "description": "Overrides the ID of the AWS KMS key used to encrypt the Amazon S3 deployment bucket" + }, + "requireApproval": { + "type": "string", + "description": "Default approval level for security changes", + "enum": [ + "never", + "any-change", + "broadening" + ] + }, + "assetMetadata": { + "type": "boolean", + "default": true, + "description": "If false, CDK does not add metadata to resources that use assets" + }, + "pathMetadata": { + "type": "boolean", + "default": true, + "description": "If false, CDK path metadata is not added to synthesized templates" + }, + "notices": { + "type": "boolean", + "description": "If false, suppresses the display of messages about security vulnerabilities, regressions, and unsupported versions" + }, + "versionReporting": { + "type": "boolean", + "default": true, + "description": "If false, opts out of version reporting" + }, + "debug": { + "type": "boolean", + "description": "If true, CDK CLI emits more detailed information useful for debugging" + }, + "staging": { + "type": "boolean", + "description": "If false, assets are not copied to the output directory" + }, + "lookups": { + "type": "boolean", + "description": "If false, no context lookups are permitted" + }, + "rollback": { + "type": "boolean", + "description": "If false, failed deployments are not rolled back" + }, + "progress": { + "type": "string", + "description": "If set to 'events', the CDK CLI displays all AWS CloudFormation events during deployment, rather than a progress bar", + "enum": [ + "bar", + "events" + ] + }, + "browser": { + "type": "string", + "description": "The command for launching a Web browser for the cdk docs subcommand" + }, + "context": { + "type": "object", + "description": "Context values and feature flags (values in configuration file will not be erased by cdk context --clear)", + "additionalProperties": true + }, + "plugin": { + "type": "array", + "items": { + "type": "string" + }, + "description": "JSON array specifying the package names or local paths of packages that extend the CDK" + }, + "tags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "Key": { + "type": "string" + }, + "Value": { + "type": "string" + } + }, + "required": [ + "Key", + "Value" + ], + "additionalProperties": false + }, + "description": "JSON array containing tags (key-value pairs) for the stack" + }, + "watch": { + "type": "object", + "properties": { + "include": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], + "description": "Files or directories that should trigger a rebuild when changed" + }, + "exclude": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], + "description": "Files or directories that should not trigger a rebuild when changed" + } + }, + "additionalProperties": false, + "description": "Configuration for watch mode file monitoring" + }, + "proxy": { + "type": "string", + "description": "Proxy server URL for HTTP requests" + }, + "caBundlePath": { + "type": "string", + "description": "Path to CA certificate bundle for HTTPS requests" + }, + "assetParallelism": { + "type": "integer", + "minimum": 1, + "description": "Number of parallel asset uploads" + }, + "assetPrebuild": { + "type": "boolean", + "description": "Whether to prebuild all assets before deployment" + }, + "ignoreNoStacks": { + "type": "boolean", + "description": "Whether to ignore the error when no stacks are selected" + }, + "hotswap": { + "type": "object", + "properties": { + "ecs": { + "type": "object", + "properties": { + "minimumHealthyPercent": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "Minimum healthy percent for ECS hotswap deployments" + }, + "maximumHealthyPercent": { + "type": "integer", + "minimum": 100, + "description": "Maximum healthy percent for ECS hotswap deployments" + }, + "stabilizationTimeoutSeconds": { + "type": "integer", + "minimum": 0, + "description": "Stabilization timeout in seconds for ECS hotswap deployments" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "description": "Configuration for hotswap deployments" + }, + "unstable": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of unstable features to enable" + }, + "quiet": { + "type": "boolean", + "description": "Suppress output during synthesis" + }, + "custom": { + "type": "object", + "description": "Area for user-defined custom properties that won't trigger validation warnings", + "additionalProperties": true + } + }, + "additionalProperties": true +} \ No newline at end of file diff --git a/packages/aws-cdk/lib/schema/index.ts b/packages/aws-cdk/lib/schema/index.ts new file mode 100644 index 000000000..0dcec6442 --- /dev/null +++ b/packages/aws-cdk/lib/schema/index.ts @@ -0,0 +1,12 @@ +/** + * CDK Configuration Schema + * + * This module exports the JSON Schema for cdk.json configuration files + * for use by external tooling. + */ + +/** + * The JSON Schema for CDK configuration files + */ +// eslint-disable-next-line @typescript-eslint/no-require-imports +export const cdkConfigSchema = require('./cdk-config.schema.json'); diff --git a/packages/aws-cdk/package.json b/packages/aws-cdk/package.json index e4429df27..5196193a1 100644 --- a/packages/aws-cdk/package.json +++ b/packages/aws-cdk/package.json @@ -120,6 +120,7 @@ "enquirer": "^2.4.1", "fs-extra": "^9", "glob": "^11.0.3", + "jsonschema": "^1.5.0", "minimatch": "10.0.3", "p-limit": "^3", "promptly": "^3.2.0", diff --git a/packages/aws-cdk/test/cli/schema-validation.test.ts b/packages/aws-cdk/test/cli/schema-validation.test.ts new file mode 100644 index 000000000..a60eb3b1e --- /dev/null +++ b/packages/aws-cdk/test/cli/schema-validation.test.ts @@ -0,0 +1,352 @@ +/* eslint-disable import/order */ +import * as fs from 'fs-extra'; +import * as path from 'path'; +import { Configuration, PROJECT_CONFIG } from '../../lib/cli/user-configuration'; +import { cdkConfigSchema } from '../../lib/schema'; +import { TestIoHost } from '../_helpers/io-host'; + +// mock fs deeply +jest.mock('fs-extra'); +const mockedFs = jest.mocked(fs, { shallow: true }); + +describe('Schema Validation', () => { + let ioHost: TestIoHost; + let ioHelper: any; + + function mockConfig(config: any) { + const GIVEN_CONFIG: Map = new Map([[PROJECT_CONFIG, config]]); + mockedFs.pathExists.mockImplementation(path => GIVEN_CONFIG.has(path)); + mockedFs.readJSON.mockImplementation(path => GIVEN_CONFIG.get(path)); + } + + beforeEach(() => { + ioHost = new TestIoHost(); + ioHelper = ioHost.asHelper(); + jest.clearAllMocks(); + }); + + describe('Schema Export and Structure', () => { + test('schema is properly exported and has valid JSON Schema structure', () => { + expect(cdkConfigSchema).toBeDefined(); + expect(typeof cdkConfigSchema).toBe('object'); + expect(cdkConfigSchema).toHaveProperty('$schema'); + expect(cdkConfigSchema).toHaveProperty('type', 'object'); + expect(cdkConfigSchema).toHaveProperty('properties'); + expect(typeof cdkConfigSchema.properties).toBe('object'); + }); + + test('schema allows additional properties', () => { + expect(cdkConfigSchema.additionalProperties).toBe(true); + }); + + test('schema has required metadata fields', () => { + expect(cdkConfigSchema).toHaveProperty('title'); + expect(cdkConfigSchema).toHaveProperty('description'); + expect(typeof cdkConfigSchema.title).toBe('string'); + expect(typeof cdkConfigSchema.description).toBe('string'); + }); + }); + + describe('Configuration Loading', () => { + test('loads empty configuration without errors', async () => { + // GIVEN + mockConfig({}); + + // WHEN + const config = await Configuration.fromArgsAndFiles(ioHelper); + + // THEN + expect(config).toBeDefined(); + expect(ioHost.notifySpy).not.toHaveBeenCalledWith( + expect.objectContaining({ level: 'error' }), + ); + }); + + test('loads configuration and preserves all provided values', async () => { + // GIVEN + const testConfig = { + app: 'test-app', + output: 'test-output', + customProperty: 'custom-value', + }; + mockConfig(testConfig); + + // WHEN + const config = await Configuration.fromArgsAndFiles(ioHelper); + + // THEN + expect(config.settings.get(['app'])).toBe('test-app'); + expect(config.settings.get(['output'])).toBe('test-output'); + expect(config.settings.get(['customProperty'])).toBe('custom-value'); + }); + + test('handles malformed JSON gracefully', async () => { + // GIVEN + mockedFs.pathExists.mockImplementation(() => true); + mockedFs.readJSON.mockImplementation(() => { + throw new Error('Unexpected token in JSON'); + }); + + // WHEN & THEN + await expect(Configuration.fromArgsAndFiles(ioHelper)).rejects.toThrow(); + }); + }); + + describe('Validation Behavior', () => { + test('preserves unknown properties but generates warnings', async () => { + // GIVEN + const configWithUnknownProps = { + app: 'valid-app', + unknownProperty: 'some-value', + anotherUnknown: 123, + }; + mockConfig(configWithUnknownProps); + + // WHEN + const config = await Configuration.fromArgsAndFiles(ioHelper); + + // THEN + expect(config.settings.get(['app'])).toBe('valid-app'); + expect(config.settings.get(['unknownProperty'])).toBe('some-value'); + expect(config.settings.get(['anotherUnknown'])).toBe(123); + + // Should warn about unknown properties + expect(ioHost.notifySpy).toHaveBeenCalledWith( + expect.objectContaining({ + level: 'warn', + message: expect.stringContaining('Unknown property'), + }), + ); + }); + + test('preserves invalid property types but generates warnings', async () => { + // GIVEN + const configWithInvalidTypes = { + debug: 'true', // Should be boolean + output: 42, // Should be string + versionReporting: 'false', // Should be boolean + }; + mockConfig(configWithInvalidTypes); + + // WHEN + const config = await Configuration.fromArgsAndFiles(ioHelper); + + // THEN + // Values should be preserved as-is + expect(config.settings.get(['debug'])).toBe('true'); + expect(config.settings.get(['output'])).toBe(42); + expect(config.settings.get(['versionReporting'])).toBe('false'); + + // Should warn about type mismatches + expect(ioHost.notifySpy).toHaveBeenCalledWith( + expect.objectContaining({ + level: 'warn', + message: expect.stringMatching(/should be.*got/), + }), + ); + }); + + test('does not warn for valid configurations', async () => { + // GIVEN + const validConfig = { + app: 'npx ts-node bin/app.ts', + debug: true, + versionReporting: false, + output: 'cdk.out', + context: { + 'feature-flag': true, + }, + }; + mockConfig(validConfig); + + // WHEN + await Configuration.fromArgsAndFiles(ioHelper); + + // THEN + expect(ioHost.notifySpy).not.toHaveBeenCalledWith( + expect.objectContaining({ level: 'warn' }), + ); + }); + }); + + describe('Real Configuration Files', () => { + // Test with actual configuration files from the test directory + const testConfigsDir = path.resolve(__dirname, '../../../test-cdk-app'); + + test('validates against valid extended configuration', async () => { + // GIVEN + const validExtendedConfig = { + app: 'npx ts-node --prefer-ts-exts bin/app.ts', + build: 'npm run build', + requireApproval: 'broadening', + debug: true, + versionReporting: false, + pathMetadata: true, + assetMetadata: true, + staging: true, + output: 'cdk.out', + profile: 'default', + toolkitStackName: 'CDKToolkit', + rollback: true, + watch: { + include: ['**'], + exclude: ['node_modules'], + }, + context: { + '@aws-cdk/core:newStyleStackSynthesis': true, + }, + }; + mockConfig(validExtendedConfig); + + // WHEN + const config = await Configuration.fromArgsAndFiles(ioHelper); + + // THEN + expect(config).toBeDefined(); + expect(ioHost.notifySpy).not.toHaveBeenCalledWith( + expect.objectContaining({ level: 'warn' }), + ); + }); + + test('handles configuration with validation errors appropriately', async () => { + // GIVEN + const configWithErrors = { + app: 'npx ts-node --prefer-ts-exts bin/app.ts', + debug: 'true', // Should be boolean + versionReporting: 'false', // Should be boolean + requireApproval: 'always', // Invalid enum value + unknownProperty: 'this should trigger a warning', + anotherUnknown: 123, + output: 42, // Should be string + context: { + '@aws-cdk/core:enableStackNameDuplicates': 'yes', + }, + }; + mockConfig(configWithErrors); + + // WHEN + const config = await Configuration.fromArgsAndFiles(ioHelper); + + // THEN + // Configuration should still load + expect(config).toBeDefined(); + expect(config.settings.get(['app'])).toBe('npx ts-node --prefer-ts-exts bin/app.ts'); + + // Should preserve invalid values + expect(config.settings.get(['debug'])).toBe('true'); + expect(config.settings.get(['versionReporting'])).toBe('false'); + expect(config.settings.get(['unknownProperty'])).toBe('this should trigger a warning'); + + // Should generate warnings + expect(ioHost.notifySpy).toHaveBeenCalledWith( + expect.objectContaining({ + level: 'warn', + message: expect.stringContaining('Unknown property'), + }), + ); + }); + + test('validates basic configuration structure', async () => { + // GIVEN + const basicConfig = { + app: 'npx ts-node --prefer-ts-exts bin/app.ts', + watch: { + include: ['**'], + exclude: [ + 'README.md', + 'cdk*.json', + '**/*.d.ts', + '**/*.js', + 'tsconfig.json', + 'package*.json', + 'yarn.lock', + 'node_modules', + 'test', + ], + }, + context: { + '@aws-cdk/aws-lambda:recognizeLayerVersion': true, + '@aws-cdk/core:checkSecretUsage': true, + '@aws-cdk/core:target-partitions': ['aws', 'aws-cn'], + }, + }; + mockConfig(basicConfig); + + // WHEN + const config = await Configuration.fromArgsAndFiles(ioHelper); + + // THEN + expect(config).toBeDefined(); + expect(config.settings.get(['app'])).toBe('npx ts-node --prefer-ts-exts bin/app.ts'); + expect(config.settings.get(['watch'])).toEqual(basicConfig.watch); + expect(config.settings.get(['context'])).toEqual(basicConfig.context); + + // Should not generate warnings for valid configuration + expect(ioHost.notifySpy).not.toHaveBeenCalledWith( + expect.objectContaining({ level: 'warn' }), + ); + }); + }); + + describe('Schema Validation Integration', () => { + test('validation system correctly identifies schema violations', async () => { + // GIVEN - Mix of valid and invalid properties + const mixedConfig = { + // Valid properties + app: 'valid-command', + debug: true, + + // Invalid types + output: 123, + versionReporting: 'not-a-boolean', + + // Unknown properties + customField: 'value', + numericField: 42, + }; + mockConfig(mixedConfig); + + // WHEN + const config = await Configuration.fromArgsAndFiles(ioHelper); + + // THEN + // All values should be preserved + expect(config.settings.get(['app'])).toBe('valid-command'); + expect(config.settings.get(['debug'])).toBe(true); + expect(config.settings.get(['output'])).toBe(123); + expect(config.settings.get(['customField'])).toBe('value'); + + // Should have generated warnings for violations + const warnCalls = ioHost.notifySpy.mock.calls.filter( + call => call[0].level === 'warn' + ); + expect(warnCalls.length).toBeGreaterThan(0); + }); + + test('validation respects schema additionalProperties setting', async () => { + // GIVEN + const configWithAdditionalProps = { + app: 'test-app', + customProperty: 'should-be-allowed', + userDefinedField: { nested: 'object' }, + }; + mockConfig(configWithAdditionalProps); + + // WHEN + const config = await Configuration.fromArgsAndFiles(ioHelper); + + // THEN + // Additional properties should be preserved (schema allows them) + expect(config.settings.get(['customProperty'])).toBe('should-be-allowed'); + expect(config.settings.get(['userDefinedField'])).toEqual({ nested: 'object' }); + + // Should warn about unknown properties but still preserve them + expect(ioHost.notifySpy).toHaveBeenCalledWith( + expect.objectContaining({ + level: 'warn', + message: expect.stringContaining('Unknown property'), + }), + ); + }); + }); +}); From b3d8705c79a00452773b72e9251175cbe501cd4f Mon Sep 17 00:00:00 2001 From: 9pace Date: Tue, 28 Oct 2025 11:33:36 -0400 Subject: [PATCH 2/2] feat: unit tests for json schema --- .../test/cli/schema-validation.test.ts | 40 ++++++------------- 1 file changed, 12 insertions(+), 28 deletions(-) diff --git a/packages/aws-cdk/test/cli/schema-validation.test.ts b/packages/aws-cdk/test/cli/schema-validation.test.ts index a60eb3b1e..3a73c7850 100644 --- a/packages/aws-cdk/test/cli/schema-validation.test.ts +++ b/packages/aws-cdk/test/cli/schema-validation.test.ts @@ -5,7 +5,6 @@ import { Configuration, PROJECT_CONFIG } from '../../lib/cli/user-configuration' import { cdkConfigSchema } from '../../lib/schema'; import { TestIoHost } from '../_helpers/io-host'; -// mock fs deeply jest.mock('fs-extra'); const mockedFs = jest.mocked(fs, { shallow: true }); @@ -49,13 +48,10 @@ describe('Schema Validation', () => { describe('Configuration Loading', () => { test('loads empty configuration without errors', async () => { - // GIVEN mockConfig({}); - // WHEN const config = await Configuration.fromArgsAndFiles(ioHelper); - // THEN expect(config).toBeDefined(); expect(ioHost.notifySpy).not.toHaveBeenCalledWith( expect.objectContaining({ level: 'error' }), @@ -63,7 +59,6 @@ describe('Schema Validation', () => { }); test('loads configuration and preserves all provided values', async () => { - // GIVEN const testConfig = { app: 'test-app', output: 'test-output', @@ -71,30 +66,25 @@ describe('Schema Validation', () => { }; mockConfig(testConfig); - // WHEN const config = await Configuration.fromArgsAndFiles(ioHelper); - // THEN expect(config.settings.get(['app'])).toBe('test-app'); expect(config.settings.get(['output'])).toBe('test-output'); expect(config.settings.get(['customProperty'])).toBe('custom-value'); }); test('handles malformed JSON gracefully', async () => { - // GIVEN mockedFs.pathExists.mockImplementation(() => true); mockedFs.readJSON.mockImplementation(() => { throw new Error('Unexpected token in JSON'); }); - // WHEN & THEN await expect(Configuration.fromArgsAndFiles(ioHelper)).rejects.toThrow(); }); }); describe('Validation Behavior', () => { test('preserves unknown properties but generates warnings', async () => { - // GIVEN const configWithUnknownProps = { app: 'valid-app', unknownProperty: 'some-value', @@ -102,10 +92,8 @@ describe('Schema Validation', () => { }; mockConfig(configWithUnknownProps); - // WHEN const config = await Configuration.fromArgsAndFiles(ioHelper); - // THEN expect(config.settings.get(['app'])).toBe('valid-app'); expect(config.settings.get(['unknownProperty'])).toBe('some-value'); expect(config.settings.get(['anotherUnknown'])).toBe(123); @@ -120,7 +108,6 @@ describe('Schema Validation', () => { }); test('preserves invalid property types but generates warnings', async () => { - // GIVEN const configWithInvalidTypes = { debug: 'true', // Should be boolean output: 42, // Should be string @@ -128,10 +115,8 @@ describe('Schema Validation', () => { }; mockConfig(configWithInvalidTypes); - // WHEN const config = await Configuration.fromArgsAndFiles(ioHelper); - // THEN // Values should be preserved as-is expect(config.settings.get(['debug'])).toBe('true'); expect(config.settings.get(['output'])).toBe(42); @@ -147,7 +132,6 @@ describe('Schema Validation', () => { }); test('does not warn for valid configurations', async () => { - // GIVEN const validConfig = { app: 'npx ts-node bin/app.ts', debug: true, @@ -159,10 +143,10 @@ describe('Schema Validation', () => { }; mockConfig(validConfig); - // WHEN + await Configuration.fromArgsAndFiles(ioHelper); - // THEN + expect(ioHost.notifySpy).not.toHaveBeenCalledWith( expect.objectContaining({ level: 'warn' }), ); @@ -198,10 +182,10 @@ describe('Schema Validation', () => { }; mockConfig(validExtendedConfig); - // WHEN + const config = await Configuration.fromArgsAndFiles(ioHelper); - // THEN + expect(config).toBeDefined(); expect(ioHost.notifySpy).not.toHaveBeenCalledWith( expect.objectContaining({ level: 'warn' }), @@ -224,10 +208,10 @@ describe('Schema Validation', () => { }; mockConfig(configWithErrors); - // WHEN + const config = await Configuration.fromArgsAndFiles(ioHelper); - // THEN + // Configuration should still load expect(config).toBeDefined(); expect(config.settings.get(['app'])).toBe('npx ts-node --prefer-ts-exts bin/app.ts'); @@ -272,10 +256,10 @@ describe('Schema Validation', () => { }; mockConfig(basicConfig); - // WHEN + const config = await Configuration.fromArgsAndFiles(ioHelper); - // THEN + expect(config).toBeDefined(); expect(config.settings.get(['app'])).toBe('npx ts-node --prefer-ts-exts bin/app.ts'); expect(config.settings.get(['watch'])).toEqual(basicConfig.watch); @@ -306,10 +290,10 @@ describe('Schema Validation', () => { }; mockConfig(mixedConfig); - // WHEN + const config = await Configuration.fromArgsAndFiles(ioHelper); - // THEN + // All values should be preserved expect(config.settings.get(['app'])).toBe('valid-command'); expect(config.settings.get(['debug'])).toBe(true); @@ -332,10 +316,10 @@ describe('Schema Validation', () => { }; mockConfig(configWithAdditionalProps); - // WHEN + const config = await Configuration.fromArgsAndFiles(ioHelper); - // THEN + // Additional properties should be preserved (schema allows them) expect(config.settings.get(['customProperty'])).toBe('should-be-allowed'); expect(config.settings.get(['userDefinedField'])).toEqual({ nested: 'object' });