use diagnostics::Diagnostic;
use diagnostics::Phase;
use rules::Severity;
use schema_validator::CompiledSchemaStore;
use schema_validator::SchemaValidator;
use schema_validator::validate::validate_all_resources;
use std::sync::{Arc, LazyLock};
use template_model::SemanticModel;
const TEMPLATES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../resources/templates");
static SV: LazyLock<SchemaValidator> = LazyLock::new(SchemaValidator::default);
fn validate_fixture(path: &str) -> Vec<Diagnostic> {
let full = format!("{}/{}", TEMPLATES, path);
let bytes = std::fs::read(&full).unwrap_or_else(|e| panic!("read {}: {}", full, e));
let model = Arc::new(SemanticModel::from_bytes(&bytes).unwrap_or_else(|e| panic!("parse {}: {}", full, e)));
SV.validate(&model, Some("us-east-1")).diagnostics
}
fn has_rule(diags: &[Diagnostic], rule_id: &str) -> bool {
diags.iter().any(|d| d.rule_id == rule_id)
}
fn diags_for<'a>(diags: &'a [Diagnostic], rule_id: &str) -> Vec<&'a Diagnostic> {
diags.iter().filter(|d| d.rule_id == rule_id).collect()
}
#[test]
fn schema_store_loads_schemas() {
assert!(SV.schema_count() > 100, "expected 100+ schemas, got {}", SV.schema_count());
}
#[test]
fn list_rules_returns_known_rule_ids() {
let rules = SV.list_rules();
let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
for expected in ["F3002", "F3003", "F3012", "W3030", "F3034"] {
assert!(ids.contains(&expected), "missing rule {}", expected);
}
}
#[test]
fn valid_resources_produce_no_fatal_diagnostics() {
let diags = validate_fixture("good/schema_valid_resources.yaml");
let fatals: Vec<_> = diags.iter().filter(|d| d.severity == Severity::Fatal).collect();
assert!(fatals.is_empty(), "unexpected fatals: {:?}", fatals);
}
#[test]
fn minimal_template_no_schema_errors() {
let diags = validate_fixture("good/minimal.yaml");
let schema_fatals: Vec<_> =
diags.iter().filter(|d| d.phase == Some(Phase::Schema) && d.severity == Severity::Fatal).collect();
assert!(schema_fatals.is_empty(), "unexpected schema fatals: {:?}", schema_fatals);
}
#[test]
fn type_mismatch_integer_for_string() {
let diags = validate_fixture("bad/schema_type_mismatch.yaml");
assert!(
has_rule(&diags, "F3012") || has_rule(&diags, "W9003"),
"expected type mismatch diagnostic, got: {:?}",
diags.iter().map(|d| &d.rule_id).collect::<Vec<_>>()
);
}
#[test]
fn type_mismatch_boolean_for_string() {
let diags = validate_fixture("bad/schema_type_mismatch.yaml");
let type_diags: Vec<_> = diags
.iter()
.filter(|d| {
(d.rule_id == "F3012" || d.rule_id == "W9003")
&& d.property_path.as_deref().is_some_and(|p| p.contains("Status"))
})
.collect();
assert!(
!type_diags.is_empty(),
"expected type mismatch on Status, got: {:?}",
diags.iter().map(|d| (&d.rule_id, &d.property_path)).collect::<Vec<_>>()
);
}
#[test]
fn enum_violation_invalid_access_control() {
let diags = validate_fixture("bad/schema_enum_violation.yaml");
let enum_diags = diags_for(&diags, "W3030");
assert!(!enum_diags.is_empty(), "expected W3030 for invalid AccessControl");
assert!(
enum_diags.iter().any(|d| d.message.contains("InvalidAccessControl")),
"expected message mentioning InvalidAccessControl, got: {:?}",
enum_diags.iter().map(|d| &d.message).collect::<Vec<_>>()
);
}
#[test]
fn additional_properties_rejected() {
let diags = validate_fixture("bad/schema_additional_props.yaml");
let f3002 = diags_for(&diags, "F3002");
assert!(f3002.len() >= 2, "expected at least 2 F3002 diagnostics, got {}", f3002.len());
}
#[test]
fn additional_properties_typo_suggestion() {
let diags = validate_fixture("bad/schema_additional_props.yaml");
let typo_diag = diags.iter().find(|d| d.rule_id == "F3002" && d.message.contains("BukcetName"));
let typo_diag = typo_diag.expect("expected F3002 for BukcetName");
assert!(
!typo_diag.message.contains("Did you mean"),
"similarity 0.8 should not trigger suggestion (threshold is > 0.8)"
);
}
#[test]
fn numeric_bounds_exceeded() {
let diags = validate_fixture("bad/schema_numeric_bounds.yaml");
let f3034 = diags_for(&diags, "F3034");
assert!(!f3034.is_empty(), "expected F3034 for numeric bounds violation");
}
#[test]
fn string_length_too_short() {
let diags = validate_fixture("bad/schema_string_length.yaml");
let f3033 = diags_for(&diags, "F3033");
if !f3033.is_empty() {
assert!(f3033.iter().any(|d| { d.property_path.as_deref().is_some_and(|p| p.contains("FunctionName")) }));
}
}
#[test]
fn format_violation_bad_subnet_id() {
let diags = validate_fixture("bad/schema_format_violation.yaml");
let e1154 = diags_for(&diags, "E1154");
assert!(
e1154.iter().any(|d| d.property_path.as_deref().is_some_and(|p| p.contains("SubnetId"))),
"expected E1154 for bad SubnetId, got: {:?}",
e1154.iter().map(|d| (&d.property_path, &d.message)).collect::<Vec<_>>()
);
}
#[test]
fn conditional_type_mismatch_with_scenario() {
let diags = validate_fixture("bad/schema_conditional_type.yaml");
let type_diags: Vec<_> = diags
.iter()
.filter(|d| {
(d.rule_id == "F3012" || d.rule_id == "W9003")
&& d.property_path.as_deref().is_some_and(|p| p.contains("BucketName"))
})
.collect();
assert!(!type_diags.is_empty(), "expected type diagnostic on conditional BucketName");
let with_scenario = type_diags.iter().find(|d| d.condition_scenario.is_some());
assert!(with_scenario.is_some(), "expected condition_scenario on conditional diagnostic");
}
#[test]
fn unique_items_violation() {
let diags = validate_fixture("bad/unique_items.yaml");
assert!(has_rule(&diags, "F3002"), "expected F3002 for unknown AvailabilityZones property");
}
#[test]
fn unknown_resource_type_no_crash() {
let diags = validate_fixture("bad/unknown_properties.yaml");
assert!(has_rule(&diags, "F3002"), "expected F3002 for FakeProperty on S3 Bucket");
}
#[test]
fn deprecated_resource_type_flagged() {
let diags = validate_fixture("bad/deprecated_type.yaml");
let _ = diags;
}
#[test]
fn generic_bad_template_produces_multiple_schema_violations() {
let diags = validate_fixture("bad/generic.yaml");
let schema_diags: Vec<_> = diags.iter().filter(|d| d.phase == Some(Phase::Schema)).collect();
assert!(schema_diags.len() >= 3, "expected 3+ schema diagnostics, got {}", schema_diags.len());
}
#[test]
fn format_validation_with_refs() {
let diags = validate_fixture("integration/formats.yaml");
let vpc_format_errors: Vec<_> = diags
.iter()
.filter(|d| d.rule_id == "W3045" && d.property_path.as_deref().is_some_and(|p| p.contains("VpcId")))
.collect();
for d in &vpc_format_errors {
assert!(!d.message.contains("Ref to 'Vpc'"), "Ref to VPC resource should be format-compatible: {}", d.message);
}
}
#[test]
fn ref_to_wrong_arn_format_is_not_a_schema_format_violation() {
let diags = validate_fixture("integration/ref-types.yaml");
let format_violations: Vec<_> = diags
.iter()
.filter(|d| {
matches!(d.rule_id.as_str(), "E1150" | "E1151" | "E1152" | "E1154")
&& d.resource_logical_id() == Some("Subnet2")
})
.collect();
assert!(
format_violations.is_empty(),
"schema validator must not emit an ARN-format violation for a reference-valued property; got {:?}",
format_violations.iter().map(|d| (&d.rule_id, &d.message)).collect::<Vec<_>>()
);
}
#[test]
fn getatt_type_mismatch_detected() {
let diags = validate_fixture("integration/getatt-types.yaml");
let ssm_diags: Vec<_> = diags.iter().filter(|d| d.resource_logical_id() == Some("SsmParameter")).collect();
let type_diag = ssm_diags.iter().any(|d| d.rule_id == "F3012" || d.rule_id == "W9003");
if !type_diag {
assert!(
ssm_diags.is_empty() || ssm_diags.iter().any(|d| d.rule_id != "F3012"),
"unexpected diagnostics for SsmParameter: {:?}",
ssm_diags.iter().map(|d| (&d.rule_id, &d.message)).collect::<Vec<_>>()
);
}
}
#[test]
fn enrich_context_adds_documentation_url() {
let full = format!("{}/bad/schema_enum_violation.yaml", TEMPLATES);
let bytes = std::fs::read(&full).unwrap();
let model = Arc::new(SemanticModel::from_bytes(&bytes).unwrap());
let mut result = SV.validate(&model, Some("us-east-1"));
SV.enrich_context(&mut result.diagnostics, &model);
let enum_diag = result.diagnostics.iter().find(|d| d.rule_id == "W3030");
assert!(enum_diag.is_some(), "expected W3030 diagnostic after enrichment");
}
#[test]
fn enrich_context_adds_allowed_values_for_enum() {
let full = format!("{}/bad/schema_enum_violation.yaml", TEMPLATES);
let bytes = std::fs::read(&full).unwrap();
let model = Arc::new(SemanticModel::from_bytes(&bytes).unwrap());
let mut result = SV.validate(&model, Some("us-east-1"));
SV.enrich_context(&mut result.diagnostics, &model);
let enum_diag = result.diagnostics.iter().find(|d| d.rule_id == "W3030");
if let Some(d) = enum_diag
&& let Some(ref ctx) = d.context
{
assert!(
ctx.extra.as_ref().is_some_and(|e| e.contains_key("allowed_values")),
"expected allowed_values in context for W3030"
);
}
}
#[test]
fn enrich_context_describes_a_deploy_time_value_by_its_real_source() {
const TEMPLATE: &[u8] = br#"{
"Parameters": { "SubnetParam": { "Type": "AWS::EC2::Subnet::Id" } },
"Resources": {
"FromParameter": {
"Type": "AWS::EC2::NetworkInterface",
"Properties": { "SubnetId": { "Ref": "SubnetParam" }, "PrivateIpAddress": 10 }
},
"FromImport": {
"Type": "AWS::EC2::NetworkInterface",
"Properties": { "SubnetId": { "Fn::ImportValue": "SharedSubnet" }, "PrivateIpAddress": 10 }
}
}
}"#;
let model = Arc::new(SemanticModel::from_bytes(TEMPLATE).unwrap());
let mut result = SV.validate(&model, Some("us-east-1"));
SV.enrich_context(&mut result.diagnostics, &model);
let source_for = |logical_id: &str| -> String {
result
.diagnostics
.iter()
.filter(|d| d.entity.as_ref().is_some_and(|e| e.logical_id == logical_id))
.find_map(|d| d.context.as_ref().and_then(|c| c.resolution_source.clone()))
.unwrap_or_else(|| panic!("expected a resolution source for {logical_id}"))
};
assert_eq!(source_for("FromParameter"), "parameter 'SubnetParam' (type AWS::EC2::Subnet::Id)");
let import_source = source_for("FromImport");
assert!(!import_source.starts_with("parameter "), "a cross-stack import is not a parameter, got {import_source:?}");
assert!(import_source.contains("cross-stack import"), "expected the import to be named, got {import_source:?}");
}
#[test]
fn lifecycle_e3710_shutdown_service() {
let diags = validate_fixture("bad/schema_lifecycle.yaml");
let e3710 = diags_for(&diags, "E3710");
assert!(!e3710.is_empty(), "expected E3710 for shutdown service (CodeStar)");
assert!(e3710.iter().any(|d| d.message.contains("shut down")));
}
#[test]
fn lifecycle_w3696_sunset_service() {
let diags = validate_fixture("bad/schema_lifecycle.yaml");
let w3696 = diags_for(&diags, "W3696");
assert!(!w3696.is_empty(), "expected W3696 for sunset service (AppMesh)");
assert!(w3696.iter().any(|d| d.message.contains("shut down on")));
}
#[test]
fn lifecycle_w3697_maintenance_service() {
let diags = validate_fixture("bad/schema_lifecycle.yaml");
let w3697 = diags_for(&diags, "W3697");
assert!(!w3697.is_empty(), "expected W3697 for maintenance mode (LaunchConfiguration)");
assert!(w3697.iter().any(|d| d.message.contains("maintenance mode")));
}
#[test]
fn lifecycle_e2533_eol_runtime() {
let diags = validate_fixture("bad/schema_lifecycle.yaml");
let e2533 = diags_for(&diags, "E2533");
assert!(e2533.iter().any(|diagnostic| {
diagnostic.message
== "Runtime 'dotnetcore2.1' was deprecated on '2022-01-05'. Creation was disabled on '2022-01-05', and updates were disabled on '2022-04-13'. Please consider updating to 'dotnet10'"
}));
}
#[test]
fn lifecycle_w2531_deprecated_runtime() {
let diags = validate_fixture("bad/schema_lifecycle.yaml");
let w2531 = diags_for(&diags, "W2531");
assert!(w2531.iter().any(|diagnostic| {
diagnostic.message
== "Runtime 'nodejs16.x' was deprecated on '2024-06-12'. Creation will be disabled on '2027-02-01', and updates will be disabled on '2027-03-03'. Please consider updating to 'nodejs24.x'"
}));
}
#[test]
fn lifecycle_e2531_create_blocked_runtime() {
const TEMPLATE: &[u8] = br#"
Resources:
Function:
Type: AWS::Lambda::Function
Properties:
Runtime: nodejs14.x
Handler: index.handler
Code:
ZipFile: test
Role: arn:aws:iam::123456789012:role/role
"#;
let model = Arc::new(SemanticModel::from_bytes(TEMPLATE).expect("template must parse"));
let diagnostics = SV.validate(&model, Some("us-east-1")).diagnostics;
let finding =
diagnostics.iter().find(|diagnostic| diagnostic.rule_id == "E2531").expect("create-blocked runtime finding");
assert_eq!(
finding.message,
"Runtime 'nodejs14.x' was deprecated on '2023-12-04'. Creation was disabled on '2024-01-09', and updates will be disabled on '2027-03-03'. Please consider updating to 'nodejs24.x'"
);
}
#[test]
fn structural_f3020_dependent_excluded() {
let diags = validate_fixture("bad/schema_structural.yaml");
let f3020: Vec<_> =
diags.iter().filter(|d| d.rule_id == "F3020" && d.resource_logical_id() == Some("AlarmBothStats")).collect();
assert!(!f3020.is_empty(), "expected F3020 for ExtendedStatistic + Statistic on CloudWatch Alarm");
}
#[test]
fn structural_f3058_required_or() {
let diags = validate_fixture("bad/schema_structural.yaml");
let f3058: Vec<_> =
diags.iter().filter(|d| d.rule_id == "F3058" && d.resource_logical_id() == Some("SubnetNoCidr")).collect();
assert!(!f3058.is_empty(), "expected F3058 for Subnet missing CidrBlock/Ipv4IpamPoolId/etc");
}
#[test]
fn structural_f3014_required_xor() {
let diags = validate_fixture("bad/schema_structural.yaml");
let f3014: Vec<_> = diags
.iter()
.filter(|d| d.rule_id == "F3014" && d.resource_logical_id() == Some("ScalingPolicyBothIds"))
.collect();
assert!(!f3014.is_empty(), "expected F3014 for ScalingPolicy with both ScalingTargetId and ResourceId");
}
#[test]
fn f3014_reports_reachable_duplicate_dynamic_member_world() {
const TEMPLATE: &[u8] = br#"
Parameters:
TargetId: {Type: String}
Toggle: {Type: String, AllowedValues: ['true', 'false']}
Conditions:
DuplicateTarget: !Equals [!Ref Toggle, 'true']
Resources:
Policy:
Type: AWS::ApplicationAutoScaling::ScalingPolicy
Properties:
PolicyName: example
PolicyType: StepScaling
ScalingTargetId: !Ref TargetId
ResourceId: !If [DuplicateTarget, !Ref TargetId, !Ref AWS::NoValue]
"#;
let model = Arc::new(SemanticModel::from_bytes(TEMPLATE).expect("template must parse"));
let diagnostics = SV.validate(&model, Some("us-east-1")).diagnostics;
let findings: Vec<_> = diagnostics
.iter()
.filter(|diagnostic| diagnostic.rule_id == "F3014" && diagnostic.resource_logical_id() == Some("Policy"))
.collect();
assert_eq!(findings.len(), 1, "only the duplicate-present world violates requiredXor: {diagnostics:?}");
assert_eq!(
findings[0].condition_scenario.as_ref().and_then(|scenario| scenario.get("DuplicateTarget")),
Some(&true)
);
}
#[test]
fn f3014_excludes_novalue_from_exclusive_count() {
let diags = validate_fixture("good/resources/properties/exclusive.yaml");
let f3014: Vec<_> =
diags.iter().filter(|d| d.rule_id == "F3014" && d.resource_logical_id() == Some("Ingress")).collect();
assert!(f3014.is_empty(), "AWS::NoValue property must not count toward the exclusive-one tally");
}
#[test]
fn f3037_ignores_novalue_collapsed_list_items() {
let diags = validate_fixture("good/resources/properties/list_duplicates.yaml");
let f3037: Vec<_> = diags
.iter()
.filter(|d| d.rule_id == "F3037" && d.resource_logical_id() == Some("IamRoleWithNestedConditions"))
.collect();
assert!(f3037.is_empty(), "null (AWS::NoValue) list items must not be treated as duplicates");
}
#[test]
fn f3012_property_type_error_reported_once_across_conditional_branches() {
let diags = validate_fixture("bad/resources/iam/iam_policy.yaml");
let f3012: Vec<_> =
diags.iter().filter(|d| d.rule_id == "F3012" && d.resource_logical_id() == Some("rIamPolicy")).collect();
assert_eq!(f3012.len(), 1, "expected a single F3012 for the list-typed PolicyDocument, got {}", f3012.len());
}
#[test]
fn structural_f3021_dependent_required() {
let diags = validate_fixture("bad/schema_structural.yaml");
let f3021: Vec<_> = diags
.iter()
.filter(|d| d.rule_id == "F3021" && d.resource_logical_id() == Some("ScalingPolicyMissingDeps"))
.collect();
assert!(!f3021.is_empty(), "expected F3021 for ResourceId without ScalableDimension/ServiceNamespace");
}
#[test]
fn property_f3031_pattern_violation() {
let diags = validate_fixture("bad/schema_property_constraints.yaml");
let f3031: Vec<_> =
diags.iter().filter(|d| d.rule_id == "F3031" && d.resource_logical_id() == Some("PatternBucket")).collect();
assert!(!f3031.is_empty(), "expected F3031 for uppercase S3 BucketName");
assert!(f3031[0].message.contains("does not match pattern"));
}
#[test]
fn literal_dollar_brace_log_group_name_enforces_pattern_and_format() {
let diagnostics = validate_fixture("bad/F3031_log_group_name_dollar_brace.yaml");
let relevant: Vec<_> =
diagnostics.iter().filter(|diagnostic| matches!(diagnostic.rule_id.as_str(), "F3031" | "E1155")).collect();
assert_eq!(relevant.len(), 2, "the literal must violate both schema constraints: {diagnostics:?}");
assert!(relevant.iter().any(|diagnostic| diagnostic.rule_id == "F3031"));
assert!(relevant.iter().any(|diagnostic| diagnostic.rule_id == "E1155"));
}
#[test]
fn unresolved_log_group_name_substitution_is_not_schema_validated_as_a_literal() {
let diagnostics = validate_fixture("good/some_logs_stream_lambda.yaml");
assert!(
diagnostics.iter().all(|diagnostic| !matches!(diagnostic.rule_id.as_str(), "F3031" | "E1155")),
"deployment-time substitutions must remain deferred: {diagnostics:?}"
);
}
#[test]
fn read_only_property_not_flagged() {
let diags = validate_fixture("bad/schema_property_constraints.yaml");
let e3040: Vec<_> = diags.iter().filter(|d| d.rule_id == "E3040").collect();
assert!(e3040.is_empty(), "E3040 was removed and must not be emitted, got: {:?}", e3040);
}
#[test]
fn property_i3043_create_only() {
let diags = validate_fixture("bad/schema_property_constraints.yaml");
let i3043: Vec<_> =
diags.iter().filter(|d| d.rule_id == "I9001" && d.resource_logical_id() == Some("ReadOnlyProp")).collect();
assert!(!i3043.is_empty(), "expected I9001 for create-only properties on ACMPCA Certificate");
assert!(i3043[0].message.contains("create-only"));
}
#[test]
fn property_w3042_deprecated() {
let diags = validate_fixture("bad/schema_property_constraints.yaml");
let w3042: Vec<_> =
diags.iter().filter(|d| d.rule_id == "W9009" && d.resource_logical_id() == Some("DeprecatedProp")).collect();
assert!(!w3042.is_empty(), "expected W9009 for deprecated WorkGroupConfigurationUpdates");
assert!(w3042[0].message.contains("deprecated"));
}
#[test]
fn property_w9054_write_only_in_output() {
let diags = validate_fixture("bad/schema_write_only.yaml");
let w9054 = diags_for(&diags, "W9054");
assert!(!w9054.is_empty(), "expected W9054 for write-only Certificate referenced in output");
assert!(w9054[0].message.contains("Write-only") || w9054[0].message.contains("write-only"));
}
#[test]
fn region_availability_e3037() {
let full = format!("{}/good/minimal.yaml", TEMPLATES);
let bytes = std::fs::read(&full).unwrap();
let model = Arc::new(SemanticModel::from_bytes(&bytes).unwrap());
let mut store = CompiledSchemaStore::new();
let region_json = serde_json::json!({
"region_resource_types": {
"us-east-1": { "AWS::S3::Bucket": true }
}
});
store
.load_region_data(serde_json::to_vec(®ion_json).unwrap().as_slice())
.expect("valid region availability fixture");
let diags = validate_all_resources(&store, &model, Some("us-east-1"));
let f3006 = diags.iter().filter(|d| d.rule_id == "F3006").count();
assert!(
f3006 > 0,
"expected F3006 for resource type not in region, got: {:?}",
diags.iter().map(|d| (&d.rule_id, &d.message)).collect::<Vec<_>>()
);
}
#[test]
fn region_availability_widens_to_union_when_no_region() {
let full = format!("{}/good/minimal.yaml", TEMPLATES);
let bytes = std::fs::read(&full).unwrap();
let model = Arc::new(SemanticModel::from_bytes(&bytes).unwrap());
let rtype = model.resources.values().next().unwrap().resource_type.clone();
let mut store = CompiledSchemaStore::new();
let region_json = serde_json::json!({
"region_resource_types": {
"us-east-1": { "AWS::S3::Bucket": true },
"us-west-2": { rtype.clone(): true }
}
});
store
.load_region_data(serde_json::to_vec(®ion_json).unwrap().as_slice())
.expect("valid region availability fixture");
let at_us_east_1 = validate_all_resources(&store, &model, Some("us-east-1"));
assert!(
at_us_east_1.iter().any(|d| d.rule_id == "F3006"),
"type absent in us-east-1 must be flagged when that region is configured"
);
let no_region = validate_all_resources(&store, &model, None);
assert!(
!no_region.iter().any(|d| d.rule_id == "F3006"),
"type available in us-west-2 must not be flagged when no region is configured (union of all regions)"
);
}
#[test]
fn array_bounds_f3032_max_items() {
let diags = validate_fixture("bad/resources_iam_instanceprofile_roles.yaml");
let f3032: Vec<_> = diags.iter().filter(|d| d.rule_id == "F3032").collect();
assert!(!f3032.is_empty(), "expected F3032 for InstanceProfile with 2 roles (max 1)");
assert!(f3032[0].message.contains("maximum"), "expected max items message: {}", f3032[0].message);
}
#[test]
fn unique_items_f3037_duplicate_roles() {
let diags = validate_fixture("bad/schema_unique_items.yaml");
let f3037 = diags_for(&diags, "F3037");
assert!(!f3037.is_empty(), "expected F3037 for duplicate roles in InstanceProfile");
assert!(f3037[0].message.contains("not unique"));
}
#[test]
fn type_mismatch_array_for_object() {
let diags = validate_fixture("bad/resources_cognito_userpool_tag_is_list.yaml");
let type_diags: Vec<_> = diags
.iter()
.filter(|d| d.rule_id == "F3012" && d.property_path.as_deref().is_some_and(|p| p.contains("UserPoolTags")))
.collect();
assert!(!type_diags.is_empty(), "expected F3012 for array where object expected on UserPoolTags");
}
#[test]
fn composition_f3018_one_of_zero_matches() {
let diags = validate_fixture("bad/schema_composition.yaml");
let f3018: Vec<_> =
diags.iter().filter(|d| d.rule_id == "F3018" && d.resource_logical_id() == Some("NoImage")).collect();
assert!(!f3018.is_empty(), "expected F3018 for ImageBuilder missing both ImageName and ImageArn");
}
#[test]
fn intrinsic_built_kms_alias_arn_is_not_rejected_by_format_composition() {
let diagnostics = validate_fixture("cdk/ddb-stream-lambda-sns--DdbStreamStack.template.json");
assert!(
!diagnostics.iter().any(|diagnostic| {
diagnostic.rule_id == "F3017"
&& diagnostic.resource_logical_id() == Some("ddbstreamtopic7821AF6E")
&& diagnostic.property_path.as_deref() == Some("Properties.KmsMasterKeyId")
}),
"a valid intrinsic-built KMS alias ARN must pass format composition: {diagnostics:?}"
);
}
#[test]
fn every_valid_kms_key_identifier_form_passes_format_composition() {
let diagnostics = validate_fixture("good/kms_key_identifier_forms.yaml");
let rejected: Vec<_> = diagnostics
.iter()
.filter(|diagnostic| {
diagnostic.rule_id == "F3017" && diagnostic.property_path.as_deref() == Some("Properties.KmsMasterKeyId")
})
.collect();
assert!(
rejected.is_empty(),
"key ID, key ARN, alias name, alias ARN, and multi-Region forms are all valid: {rejected:?}"
);
}
#[test]
fn sns_kms_identifier_format_is_runtime_validated() {
let diagnostics = validate_fixture("bad/hardcoded_partition.yaml");
assert!(
diagnostics.iter().all(|diagnostic| {
diagnostic.rule_id != "F3017"
|| diagnostic.resource_logical_id() != Some("Topic")
|| diagnostic.property_path.as_deref() != Some("Properties.KmsMasterKeyId")
}),
"SNS accepts the KMS identifier as an opaque runtime value, so its format cannot prove a fatal template failure: {diagnostics:?}"
);
}
#[test]
fn composition_defers_overridable_parameter_defaults_but_rejects_literals() {
const TEMPLATE: &[u8] = br#"
Parameters:
DatabaseUser:
Type: String
Default: rdsadmin
Resources:
FromParameter:
Type: AWS::RDS::DBCluster
Properties:
Engine: aurora-mysql
MasterUsername: !Ref DatabaseUser
MasterUserPassword: '{{resolve:secretsmanager:example:SecretString:password}}'
FromLiteral:
Type: AWS::RDS::DBCluster
Properties:
Engine: aurora-mysql
MasterUsername: rdsadmin
MasterUserPassword: '{{resolve:secretsmanager:example:SecretString:password}}'
"#;
let model = Arc::new(SemanticModel::from_bytes(TEMPLATE).expect("template must parse"));
let diagnostics = SV.validate(&model, Some("us-east-1")).diagnostics;
assert!(
diagnostics.iter().all(|diagnostic| {
diagnostic.rule_id != "F3017" || diagnostic.resource_logical_id() != Some("FromParameter")
}),
"an overridable parameter default cannot prove a fatal property violation: {diagnostics:?}"
);
assert!(
diagnostics.iter().any(|diagnostic| {
diagnostic.rule_id == "F3017"
&& diagnostic.resource_logical_id() == Some("FromLiteral")
&& diagnostic.property_path.as_deref() == Some("Properties.MasterUsername")
}),
"the prohibited literal control must remain rejected: {diagnostics:?}"
);
}
#[test]
fn composition_f3017_any_of_no_match() {
let diags = validate_fixture("bad/schema_composition.yaml");
let f3017: Vec<_> =
diags.iter().filter(|d| d.rule_id == "F3017" && d.resource_logical_id() == Some("NoAZ")).collect();
assert!(!f3017.is_empty(), "expected F3017 for Volume missing all required AZ/Size/Snapshot combos");
}
#[test]
fn extension_cfn_gather_no_duplicate_numeric_constraint() {
let diags = validate_fixture("integration/cfn-gather.yaml");
assert!(
!diags.iter().any(|d| d.rule_id == "F3034"),
"F3034 must not double-report a gather constraint, got: {:?}",
diags.iter().map(|d| (&d.rule_id, &d.message)).collect::<Vec<_>>()
);
}
#[test]
fn i9001_conditional_create_only_emitted_for_instance_tenancy() {
let diagnostics = validate_fixture("bad/I9001_conditional_create_only.yaml");
let conditional_diagnostics: Vec<_> = diagnostics
.iter()
.filter(|diagnostic| {
diagnostic.rule_id == "I9001"
&& diagnostic.resource_logical_id() == Some("VpcWithConditionalCreateOnly")
&& diagnostic.property_path.as_deref() == Some("Properties.InstanceTenancy")
})
.collect();
assert_eq!(
conditional_diagnostics.len(),
1,
"expected exactly one I9001 for InstanceTenancy, got: {conditional_diagnostics:?}"
);
let diagnostic = conditional_diagnostics[0];
assert_eq!(diagnostic.severity, Severity::Info, "conditional replacement risk must be informational");
assert_eq!(diagnostic.property_path.as_deref(), Some("Properties.InstanceTenancy"));
assert_eq!(
diagnostic.message,
"Property 'InstanceTenancy' is conditionally create-only; updating it may cause resource replacement"
);
let location = diagnostic.location.as_ref().expect("conditional replacement risk must have a source location");
assert_eq!(
(location.start_line, location.start_column, location.end_line, location.end_column),
(9, 7, 9, 22),
"conditional replacement risk must point to the InstanceTenancy key"
);
}
#[test]
fn i9001_context_distinguishes_conditional_from_unconditional_create_only() {
let full = format!("{}/bad/I9001_conditional_create_only.yaml", TEMPLATES);
let bytes = std::fs::read(&full).unwrap();
let model = Arc::new(SemanticModel::from_bytes(&bytes).unwrap());
let mut result = SV.validate(&model, Some("us-east-1"));
SV.enrich_context(&mut result.diagnostics, &model);
let conditional_diagnostic = result
.diagnostics
.iter()
.find(|diagnostic| {
diagnostic.rule_id == "I9001"
&& diagnostic.resource_logical_id() == Some("VpcWithConditionalCreateOnly")
&& diagnostic.property_path.as_deref() == Some("Properties.InstanceTenancy")
})
.expect("expected I9001 for InstanceTenancy");
let conditional_context =
conditional_diagnostic.context.as_ref().expect("expected context on enriched InstanceTenancy diagnostic");
assert_eq!(conditional_context.lifecycle.as_deref(), Some("conditional-create-only"));
let unconditional_diagnostic = result
.diagnostics
.iter()
.find(|diagnostic| {
diagnostic.rule_id == "I9001"
&& diagnostic.resource_logical_id() == Some("VpcWithConditionalCreateOnly")
&& diagnostic.property_path.as_deref() == Some("Properties.CidrBlock")
})
.expect("expected I9001 for CidrBlock");
let unconditional_context =
unconditional_diagnostic.context.as_ref().expect("expected context on enriched CidrBlock diagnostic");
assert_eq!(unconditional_context.lifecycle.as_deref(), Some("create-only"));
assert_eq!(
unconditional_diagnostic.message,
"Property 'CidrBlock' is create-only; updating it will cause resource replacement"
);
}
#[test]
fn i9001_conditional_create_only_not_emitted_when_property_is_absent() {
let diagnostics = validate_fixture("bad/I9001_conditional_create_only.yaml");
let control_diagnostics: Vec<_> = diagnostics
.iter()
.filter(|diagnostic| {
diagnostic.rule_id == "I9001"
&& diagnostic.resource_logical_id() == Some("VpcControl")
&& diagnostic.property_path.as_deref() == Some("Properties.InstanceTenancy")
})
.collect();
assert!(
control_diagnostics.is_empty(),
"control VPC without InstanceTenancy must not emit I9001 for InstanceTenancy, got: {control_diagnostics:?}"
);
}