cloudformation-validate-schema-validator 1.15.0

AWS CloudFormation Validate
Documentation
use diagnostics::Diagnostic;
use schema_validator::{AdditionalSchemaSource, SchemaValidator, SchemaValidatorConfig, SchemaValidatorConfigError};
use std::fs;
use std::path::Path;
use std::sync::Arc;
use template_model::SemanticModel;

const ADDITIONAL_SCHEMA_SOURCES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../resources/additional_schema_sources");
const LAMBDA_SCHEMA: &str = "aws-lambda-function.json";
const WIDGET_SCHEMA: &str = "aws-test-widget.json";
const WIDGET_ATTRIBUTES_SCHEMA: &str = "aws-test-widget-attributes.json";

fn schema_source(file_name: &str) -> AdditionalSchemaSource {
    let path = Path::new(ADDITIONAL_SCHEMA_SOURCES).join(file_name);
    let schema = fs::read_to_string(&path).unwrap_or_else(|error| panic!("read {}: {error}", path.display()));
    AdditionalSchemaSource { type_name: None, schema }
}

fn validator_with_sources(file_names: &[&str]) -> SchemaValidator {
    let config = SchemaValidatorConfig::new()
        .with_additional_schemas(file_names.iter().map(|file_name| schema_source(file_name)));
    SchemaValidator::new(config).expect("fixture schemas must build a validator")
}

fn validate_template(validator: &SchemaValidator, template: &[u8]) -> Vec<Diagnostic> {
    let model = Arc::new(SemanticModel::from_bytes(template).expect("test template must parse"));
    validator.validate(&model, Some("us-east-1")).diagnostics
}

fn has_rule(diagnostics: &[Diagnostic], rule_id: &str) -> bool {
    diagnostics.iter().any(|diagnostic| diagnostic.rule_id == rule_id)
}

#[test]
fn additional_schema_source_extends_a_bundled_resource_schema() {
    let template = br#"
Resources:
  Function:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        ZipFile: "exports.handler = async () => {};"
      Handler: index.handler
      Role: arn:aws:iam::123456789012:role/lambda-role
      Runtime: nodejs20.x
      AdditionalSchemaProperty: enabled
"#;

    let baseline = validate_template(&SchemaValidator::default(), template);
    assert!(has_rule(&baseline, "F3002"), "the bundled schema must reject the fixture-only property");

    let diagnostics = validate_template(&validator_with_sources(&[LAMBDA_SCHEMA]), template);
    assert!(
        !has_rule(&diagnostics, "F3002"),
        "the additional source must make its property valid, got: {:?}",
        diagnostics.iter().map(|diagnostic| (&diagnostic.rule_id, &diagnostic.message)).collect::<Vec<_>>()
    );
}

#[test]
fn multiple_additional_schema_sources_compose_a_new_resource_type() {
    let template = br#"
Resources:
  Widget:
    Type: AWS::Test::Widget
    Properties:
      Name: primary-widget
      Port: 443
      Tags:
        - Key: environment
          Value: test
"#;

    let validator = validator_with_sources(&[WIDGET_SCHEMA, WIDGET_ATTRIBUTES_SCHEMA]);
    let diagnostics = validate_template(&validator, template);
    assert!(
        !diagnostics.iter().any(|diagnostic| diagnostic.severity == rules::Severity::Fatal),
        "the composed schema must accept properties from both sources, got: {:?}",
        diagnostics.iter().map(|diagnostic| (&diagnostic.rule_id, &diagnostic.message)).collect::<Vec<_>>()
    );
}

#[test]
fn additional_schema_source_constraints_are_enforced() {
    let template = br#"
Resources:
  Widget:
    Type: AWS::Test::Widget
    Properties:
      Name: x
      Port: 70000
"#;
    let validator = validator_with_sources(&[WIDGET_SCHEMA]);

    let diagnostics = validate_template(&validator, template);

    assert!(has_rule(&diagnostics, "F3033"), "the source's string length constraint must be enforced");
    assert!(has_rule(&diagnostics, "F3034"), "the source's numeric bound must be enforced");
}

#[test]
fn additional_schema_source_definition_constraints_are_enforced_after_composition() {
    let template = br#"
Resources:
  Widget:
    Type: AWS::Test::Widget
    Properties:
      Name: primary-widget
      Port: 443
      Tags:
        - Key: environment
"#;
    let validator = validator_with_sources(&[WIDGET_SCHEMA, WIDGET_ATTRIBUTES_SCHEMA]);

    let diagnostics = validate_template(&validator, template);

    assert!(
        has_rule(&diagnostics, "F3003"),
        "the definition supplied by the second source must require a tag value, got: {:?}",
        diagnostics.iter().map(|diagnostic| (&diagnostic.rule_id, &diagnostic.message)).collect::<Vec<_>>()
    );
}

#[test]
fn invalid_additional_schema_source_is_reported_as_a_source_error() {
    let config = SchemaValidatorConfig::new().with_additional_schemas([AdditionalSchemaSource {
        type_name: Some("AWS::Test::Invalid".to_string()),
        schema: "{ invalid json".to_string(),
    }]);

    let error = match SchemaValidator::new(config) {
        Err(error) => error,
        Ok(_) => panic!("invalid JSON must fail validator construction"),
    };

    assert!(matches!(error, SchemaValidatorConfigError::Source(_)));
    assert!(
        error.to_string().contains("AWS::Test::Invalid"),
        "the source error must identify the failing type: {error}"
    );
}