use diagnostics::Diagnostic;
use schema_validator::{SchemaOverlayError, SchemaValidator};
use serde_json::{Value, json};
use std::sync::Arc;
use template_model::SemanticModel;
const REF_CHAIN_LIMIT: usize = 64;
const LAMBDA_WITH_OVERRIDE_PROP: &str = r#"
Resources:
Fn:
Type: AWS::Lambda::Function
Properties:
Code:
ZipFile: "exports.handler = async () => {};"
Role: arn:aws:iam::123456789012:role/lambda-role
Runtime: nodejs18.x
Handler: index.handler
TestForOverride:
TestOverrideValue: 24
"#;
const OVERRIDE_PROP_OVERLAY: &str = r#"{
"typeName": "AWS::Lambda::Function",
"properties": {
"TestForOverride": {
"type": "object",
"additionalProperties": false,
"properties": {
"TestOverrideValue": { "type": "integer", "enum": [3, 6, 12, 16, 24, 48, 96] }
},
"required": ["TestOverrideValue"]
}
}
}"#;
const LAMBDA_WITH_NEW_PACKAGE_TYPE: &str = r#"
Resources:
Fn:
Type: AWS::Lambda::Function
Properties:
Code:
ZipFile: "exports.handler = async () => {};"
Role: arn:aws:iam::123456789012:role/lambda-role
Runtime: nodejs18.x
Handler: index.handler
PackageType: NewUnpublishedMode
"#;
const PACKAGE_TYPE_ENUM_OVERLAY: &str = r#"{
"typeName": "AWS::Lambda::Function",
"properties": {
"PackageType": { "type": "string", "enum": ["Image", "Zip", "NewUnpublishedMode"] }
}
}"#;
fn overlay(json: &str) -> Value {
serde_json::from_str(json).expect("test overlay must be valid JSON")
}
fn model(template: &str) -> Arc<SemanticModel> {
Arc::new(SemanticModel::from_bytes(template.as_bytes()).expect("template must parse"))
}
fn validator(overlays: Vec<(&str, Value)>) -> SchemaValidator {
SchemaValidator::try_with_additional_schemas(overlays).expect("test overlays must apply")
}
fn rejection(overlays: Vec<(&str, Value)>) -> SchemaOverlayError {
match SchemaValidator::try_with_additional_schemas(overlays) {
Err(error) => error,
Ok(_) => panic!("the overlay should have been rejected"),
}
}
fn validate(sv: &SchemaValidator, template: &str) -> Vec<Diagnostic> {
sv.validate(&model(template), Some("us-east-1")).diagnostics
}
fn findings(sv: &SchemaValidator, template: &str) -> Vec<String> {
validate(sv, template).into_iter().map(|d| format!("{} {}", d.rule_id, d.message)).collect()
}
fn mentions(diags: &[Diagnostic], rule_id: &str, needle: &str) -> bool {
diags.iter().any(|d| {
d.rule_id == rule_id
&& (d.message.contains(needle) || d.property_path.as_deref().is_some_and(|p| p.contains(needle)))
})
}
#[test]
fn bundled_schema_flags_unpublished_property() {
let sv = SchemaValidator::default();
let diags = validate(&sv, LAMBDA_WITH_OVERRIDE_PROP);
assert!(
mentions(&diags, "F3002", "TestForOverride"),
"expected F3002 for TestForOverride without overlay, got: {:?}",
diags.iter().map(|d| (&d.rule_id, &d.property_path)).collect::<Vec<_>>()
);
}
#[test]
fn overlay_new_property_suppresses_additional_property_finding() {
let sv = validator(vec![("AWS::Lambda::Function", overlay(OVERRIDE_PROP_OVERLAY))]);
let diags = validate(&sv, LAMBDA_WITH_OVERRIDE_PROP);
assert!(
!mentions(&diags, "F3002", "TestForOverride"),
"TestForOverride should be accepted with the overlay, got: {:?}",
diags.iter().filter(|d| d.rule_id == "F3002").map(|d| &d.property_path).collect::<Vec<_>>()
);
}
#[test]
fn bundled_schema_flags_new_enum_value() {
let sv = SchemaValidator::default();
let diags = validate(&sv, LAMBDA_WITH_NEW_PACKAGE_TYPE);
assert!(
mentions(&diags, "W3030", "NewUnpublishedMode"),
"expected W3030 for the new PackageType value without overlay, got: {:?}",
diags.iter().filter(|d| d.rule_id == "W3030").map(|d| &d.message).collect::<Vec<_>>()
);
}
#[test]
fn overlay_enum_override_suppresses_allowed_value_finding() {
let sv = validator(vec![("AWS::Lambda::Function", overlay(PACKAGE_TYPE_ENUM_OVERLAY))]);
let diags = validate(&sv, LAMBDA_WITH_NEW_PACKAGE_TYPE);
assert!(
!mentions(&diags, "W3030", "NewUnpublishedMode"),
"the new PackageType enum value should be accepted with the overlay, got: {:?}",
diags.iter().filter(|d| d.rule_id == "W3030").map(|d| &d.message).collect::<Vec<_>>()
);
}
#[test]
fn overlay_registers_a_brand_new_resource_type_and_validates_against_it() {
let new_type = overlay(
r#"{
"typeName": "AWS::Test::OverlayOnly",
"properties": { "Name": { "type": "string" }, "Mode": { "type": "string", "enum": ["A"] } },
"required": ["Name"],
"additionalProperties": false
}"#,
);
let sv = validator(vec![("AWS::Test::OverlayOnly", new_type)]);
assert_eq!(sv.schema_count(), SchemaValidator::default().schema_count() + 1, "a new resource type should be added");
let valid = "Resources:\n R:\n Type: AWS::Test::OverlayOnly\n Properties:\n Name: n\n Mode: A\n";
assert!(
validate(&sv, valid).is_empty(),
"a template matching the new schema must be clean: {:?}",
findings(&sv, valid)
);
let invalid = "Resources:\n R:\n Type: AWS::Test::OverlayOnly\n Properties:\n Mode: Nope\n";
let diags = validate(&sv, invalid);
assert!(mentions(&diags, "F3003", "Name"), "the new schema's required property must be enforced: {diags:?}");
assert!(mentions(&diags, "W3030", "Nope"), "the new schema's enum must be enforced: {diags:?}");
}
#[test]
fn no_overlays_matches_default_construction() {
let overlaid = SchemaValidator::try_with_additional_schemas(Vec::<(String, Value)>::new()).expect("builds");
assert_eq!(overlaid.schema_count(), SchemaValidator::default().schema_count());
}
#[test]
fn overlays_for_one_type_apply_in_order() {
let sv = validator(vec![
("AWS::Lambda::Function", overlay(r#"{ "properties": { "PackageType": { "enum": ["Image", "First"] } } }"#)),
("AWS::Lambda::Function", overlay(r#"{ "properties": { "PackageType": { "enum": ["Image", "Second"] } } }"#)),
]);
let template = LAMBDA_WITH_NEW_PACKAGE_TYPE.replace("NewUnpublishedMode", "Second");
assert!(
!mentions(&validate(&sv, &template), "W3030", "Second"),
"the last overlay for a type must win, got: {:?}",
findings(&sv, &template)
);
}
const ALARM: &str = r#"
Resources:
A:
Type: AWS::CloudWatch::Alarm
Properties:
ComparisonOperator: GreaterThanThreshold
EvaluationPeriods: 1
MetricName: CPUUtilization
Namespace: AWS/EC2
Period: 60
Statistic: Average
Threshold: 80
"#;
#[test]
fn overlay_logical_group_replaces_instead_of_unioning() {
let baseline = findings(&SchemaValidator::default(), ALARM);
assert!(!baseline.iter().any(|d| d.starts_with("F3014")), "baseline alarm must be clean: {baseline:?}");
let sv = validator(vec![(
"AWS::CloudWatch::Alarm",
json!({ "properties": { "TestForOverride": { "type": "string" } }, "requiredXor": ["Namespace", "TestForOverride"] }),
)]);
let after = findings(&sv, ALARM);
assert!(!after.iter().any(|d| d.starts_with("F3014")), "the overlay must not fabricate an XOR group: {after:?}");
}
const SCALING_POLICY: &str = r#"
Resources:
P:
Type: AWS::ApplicationAutoScaling::ScalingPolicy
Properties:
PolicyName: p
PolicyType: TargetTrackingScaling
ResourceId: service/cluster/svc
"#;
#[test]
fn overlay_dependency_entry_extends_the_bundled_list() {
let baseline = findings(&SchemaValidator::default(), SCALING_POLICY);
let sv = validator(vec![(
"AWS::ApplicationAutoScaling::ScalingPolicy",
json!({ "dependentRequired": { "ResourceId": ["ScalableDimension"] } }),
)]);
let after = findings(&sv, SCALING_POLICY);
let lost: Vec<&String> = baseline.iter().filter(|d| !after.contains(d)).collect();
assert!(lost.is_empty(), "an overlay dependency entry must not delete bundled dependencies, lost: {lost:?}");
}
const DATASOURCE: &str = r#"
Resources:
D:
Type: AWS::AppSync::DataSource
Properties:
ApiId: abc
Name: d
Type: NONE
ElasticsearchConfig:
AwsRegion: us-east-1
Endpoint: https://example.com
"#;
#[test]
fn overlay_metadata_list_extends_the_bundled_list() {
let baseline = findings(&SchemaValidator::default(), DATASOURCE);
assert!(
baseline.iter().any(|d| d.starts_with("W9009") && d.contains("ElasticsearchConfig")),
"baseline must report the bundled deprecation: {baseline:?}"
);
let sv = validator(vec![(
"AWS::AppSync::DataSource",
json!({
"properties": { "TestForOverride": { "type": "string" } },
"deprecatedProperties": ["/properties/TestForOverride"]
}),
)]);
let after = findings(&sv, DATASOURCE);
assert!(
after.iter().any(|d| d.starts_with("W9009") && d.contains("ElasticsearchConfig")),
"the bundled deprecation must survive an additive metadata overlay: {after:?}"
);
}
const REST_API_DUPES: &str = r#"
Resources:
Api:
Type: AWS::ApiGateway::RestApi
Properties:
Name: api
BinaryMediaTypes:
- image/png
- image/png
"#;
#[test]
fn overlay_explicit_unique_items_false_relaxes_the_bundled_constraint() {
let baseline = findings(&SchemaValidator::default(), REST_API_DUPES);
assert!(baseline.iter().any(|d| d.starts_with("F3037")), "baseline must reject duplicates: {baseline:?}");
let sv = validator(vec![(
"AWS::ApiGateway::RestApi",
json!({
"properties": { "BinaryMediaTypes": { "type": "array", "uniqueItems": false, "items": { "type": "string" } } }
}),
)]);
let after = findings(&sv, REST_API_DUPES);
assert!(!after.iter().any(|d| d.starts_with("F3037")), "an explicit uniqueItems:false must relax it: {after:?}");
}
const BATCH_TEMPLATE: &str = r#"
Resources:
Env:
Type: AWS::Batch::ComputeEnvironment
Properties:
Type: MANAGED
ComputeResources:
Type: COMPUTE_TYPE
MaxvCpus: 4
Subnets: [subnet-0123456789abcdef0]
"#;
fn batch_enum_overlay(keyword: &str) -> Value {
json!({
"definitions": {
"ComputeResources": {
"type": "object",
"properties": {
"Type": { "type": "string", keyword: ["ec2", "fargate", "fargate_spot", "spot", "test_new_mode"] }
}
}
}
})
}
#[test]
fn overlay_enum_widens_a_case_insensitive_property() {
let template = BATCH_TEMPLATE.replace("COMPUTE_TYPE", "test_new_mode");
let baseline = findings(&SchemaValidator::default(), &template);
assert!(baseline.iter().any(|d| d.contains("test_new_mode")), "baseline must flag the new value: {baseline:?}");
for keyword in ["enum", "enumCaseInsensitive"] {
let sv = validator(vec![("AWS::Batch::ComputeEnvironment", batch_enum_overlay(keyword))]);
let after = findings(&sv, &template);
assert!(
!after.iter().any(|d| d.contains("test_new_mode")),
"widening via '{keyword}' must admit the new value: {after:?}"
);
}
}
#[test]
fn overlay_enum_does_not_reject_a_casing_that_validated_before() {
let template = BATCH_TEMPLATE.replace("COMPUTE_TYPE", "FARGATE");
let baseline = findings(&SchemaValidator::default(), &template);
assert!(!baseline.iter().any(|d| d.contains("FARGATE")), "baseline must accept the uppercase value: {baseline:?}");
let sv = validator(vec![("AWS::Batch::ComputeEnvironment", batch_enum_overlay("enum"))]);
let after = findings(&sv, &template);
assert!(
!after.iter().any(|d| d.contains("FARGATE")),
"an overlay must not turn a previously accepted casing into a finding: {after:?}"
);
}
#[test]
fn overlay_enum_never_reports_a_value_twice() {
let template = BATCH_TEMPLATE.replace("COMPUTE_TYPE", "totally_new");
let sv = validator(vec![(
"AWS::Batch::ComputeEnvironment",
json!({
"definitions": {
"ComputeResources": {
"type": "object",
"properties": { "Type": { "type": "string", "enum": ["alpha", "beta"] } }
}
}
}),
)]);
let allowed_value_findings: Vec<String> =
findings(&sv, &template).into_iter().filter(|d| d.contains("totally_new")).collect();
assert_eq!(
allowed_value_findings.len(),
1,
"a value must be reported against one allowed-value list, not two: {allowed_value_findings:?}"
);
}
const APPBLOCK: &str = r#"
Resources:
B:
Type: AWS::AppStream::AppBlock
Properties:
Name: b
SourceS3Location:
S3Bucket: bucket
PackagingType: TEST_NEW_MODE
"#;
#[test]
fn constraint_only_overlay_on_a_ref_property_takes_effect() {
let baseline = findings(&SchemaValidator::default(), APPBLOCK);
assert!(baseline.iter().any(|d| d.contains("TEST_NEW_MODE")), "baseline must flag the value: {baseline:?}");
let sv = validator(vec![(
"AWS::AppStream::AppBlock",
json!({ "properties": { "PackagingType": { "enum": ["APPSTREAM2", "CUSTOM", "TEST_NEW_MODE"] } } }),
)]);
let after = findings(&sv, APPBLOCK);
assert!(
!after.iter().any(|d| d.contains("TEST_NEW_MODE")),
"a constraint-only overlay on a $ref property must take effect: {after:?}"
);
}
#[test]
fn inline_overlay_follows_a_whole_ref_chain() {
let seed = json!({
"properties": { "P": { "$ref": "#/definitions/Level1" } },
"definitions": {
"Level1": { "$ref": "#/definitions/Level2" },
"Level2": { "type": "object", "additionalProperties": false, "properties": { "Old": { "type": "string" } } }
}
});
let extension = json!({ "properties": { "P": { "properties": { "TestForOverride": { "type": "integer" } } } } });
let sv = validator(vec![("AWS::Test::Chain", seed), ("AWS::Test::Chain", extension)]);
let template =
"Resources:\n R:\n Type: AWS::Test::Chain\n Properties:\n P:\n TestForOverride: 1\n";
let after = findings(&sv, template);
assert!(after.is_empty(), "an inline overlay must follow the chain to its terminal definition: {after:?}");
}
#[test]
fn definition_merge_observes_definitions_added_by_the_same_overlay() {
let seed = json!({
"properties": { "Cfg": { "$ref": "#/definitions/Config" } },
"definitions": { "Config": { "$ref": "#/definitions/Common" } }
});
let extension = json!({
"definitions": {
"Common": { "type": "object", "required": ["TestForOverride"] },
"Config": { "properties": { "Other": { "type": "string" } } }
}
});
let sv = validator(vec![("AWS::Test::Defs", seed), ("AWS::Test::Defs", extension)]);
let template = "Resources:\n R:\n Type: AWS::Test::Defs\n Properties:\n Cfg:\n Other: x\n";
let after = findings(&sv, template);
assert!(
after.iter().any(|d| d.contains("TestForOverride")),
"the constraint from the overlay-only definition must be enforced: {after:?}"
);
}
#[test]
fn a_definition_widened_by_a_later_overlay_reaches_an_already_extended_property() {
let seed = json!({
"properties": { "P": { "$ref": "#/definitions/D" } },
"definitions": { "D": { "type": "string", "enum": ["alpha", "beta"] } }
});
let extend_property = json!({ "properties": { "P": { "description": "documented" } } });
let widen_definition = json!({ "definitions": { "D": { "enum": ["alpha", "beta", "gamma"] } } });
let sv = validator(vec![
("AWS::Test::Seq", seed),
("AWS::Test::Seq", extend_property),
("AWS::Test::Seq", widen_definition),
]);
let template = "Resources:\n R:\n Type: AWS::Test::Seq\n Properties:\n P: gamma\n";
let after = findings(&sv, template);
assert!(after.is_empty(), "the later definition update must reach the property: {after:?}");
}
#[test]
fn mutually_referencing_definitions_see_each_others_updates() {
let seed = json!({
"properties": { "A": { "$ref": "#/definitions/DA" } },
"definitions": {
"DA": { "type": "object", "properties": { "ToB": { "$ref": "#/definitions/DB" } } },
"DB": { "type": "object", "properties": { "ToA": { "$ref": "#/definitions/DA" } } }
}
});
let update = json!({ "definitions": { "DB": { "required": ["TestForOverride"] } } });
let sv = validator(vec![("AWS::Test::Mutual", seed), ("AWS::Test::Mutual", update)]);
let template = "Resources:\n R:\n Type: AWS::Test::Mutual\n Properties:\n A:\n ToB: {}\n";
let after = findings(&sv, template);
assert!(
after.iter().any(|d| d.contains("TestForOverride")),
"an update to a mutually referenced definition must be enforced: {after:?}"
);
}
#[test]
fn a_ref_with_sibling_keywords_is_accepted_and_resolves_to_its_target() {
let sv = validator(vec![(
"AWS::Test::Sibling",
json!({
"properties": { "Cfg": { "$ref": "#/definitions/Common", "description": "documented in place" } },
"definitions": {
"Common": {
"type": "object",
"additionalProperties": false,
"properties": { "FromCommon": { "type": "string" } }
}
}
}),
)]);
let valid = "Resources:\n R:\n Type: AWS::Test::Sibling\n Properties:\n Cfg:\n FromCommon: a\n";
assert!(findings(&sv, valid).is_empty(), "the referenced shape must apply: {:?}", findings(&sv, valid));
let invalid =
"Resources:\n R:\n Type: AWS::Test::Sibling\n Properties:\n Cfg:\n Unexpected: a\n";
assert!(
findings(&sv, invalid).iter().any(|d| d.starts_with("F3002")),
"the referenced shape's constraints must apply: {:?}",
findings(&sv, invalid)
);
}
#[test]
fn a_ref_outside_definitions_is_rejected() {
let error = rejection(vec![(
"AWS::Test::Pointer",
json!({ "properties": { "Arn": { "$ref": "#/properties/Other" }, "Other": { "type": "string" } } }),
)]);
assert!(matches!(error, SchemaOverlayError::Unsupported { .. }), "got {error:?}");
}
#[test]
fn an_overlay_extending_a_ref_property_is_merged_onto_the_reference() {
let seed = json!({
"properties": { "Cfg": { "$ref": "#/definitions/Common" } },
"definitions": {
"Common": {
"type": "object",
"additionalProperties": false,
"properties": { "FromCommon": { "type": "string" } }
}
}
});
let extension = json!({ "properties": { "Cfg": { "properties": { "FromOverlay": { "type": "string" } } } } });
let sv = validator(vec![("AWS::Test::Chainmix", seed), ("AWS::Test::Chainmix", extension)]);
let template = "Resources:\n R:\n Type: AWS::Test::Chainmix\n Properties:\n Cfg:\n FromCommon: a\n FromOverlay: b\n";
let after = findings(&sv, template);
assert!(after.is_empty(), "the referenced shape and the overlay extension must both apply: {after:?}");
}
#[test]
fn a_constraining_keyword_beside_a_ref_is_rejected() {
validator(vec![(
"AWS::Test::SiblingConstraint",
json!({
"properties": { "Mode": { "$ref": "#/definitions/Mode", "enum": ["ONLY_THIS"], "maxLength": 2 } },
"definitions": { "Mode": { "type": "string" } }
}),
)]);
}
#[test]
fn a_keyword_the_model_cannot_represent_is_rejected() {
let error = rejection(vec![(
"AWS::Test::Unrepresented",
json!({
"properties": {
"Size": { "type": "integer", "propertyNames": { "pattern": "^x" } },
"Known": { "type": "string", "maxLength": 4 }
}
}),
)]);
assert!(
matches!(error, SchemaOverlayError::Unsupported { .. }),
"an unrepresented keyword must be rejected, got {error:?}"
);
}
#[test]
fn a_keyword_the_model_cannot_represent_does_not_silently_weaken_the_overlay() {
let error = rejection(vec![(
"AWS::Test::UnrepresentedSibling",
json!({ "properties": { "Size": { "type": "integer", "contains": { "type": "string" }, "maximum": 5 } } }),
)]);
assert!(
matches!(error, SchemaOverlayError::Unsupported { .. }),
"an unrepresented keyword must be rejected even alongside represented ones, got {error:?}"
);
}
#[test]
fn an_intermediate_hop_overrides_the_constraint_at_the_end_of_the_chain() {
let seed = json!({
"properties": { "Cfg": { "$ref": "#/definitions/Middle" } },
"definitions": {
"Middle": { "$ref": "#/definitions/Base" },
"Base": { "type": "object", "additionalProperties": false, "properties": { "Known": { "type": "string" } } }
}
});
let relax_middle = json!({ "definitions": { "Middle": { "additionalProperties": true } } });
let sv = validator(vec![("AWS::Test::Precedence", seed), ("AWS::Test::Precedence", relax_middle)]);
let template = "Resources:\n R:\n Type: AWS::Test::Precedence\n Properties:\n Cfg:\n Known: a\n Extra: b\n";
let after = findings(&sv, template);
assert!(after.is_empty(), "the nearer hop's relaxation must win over the end of the chain: {after:?}");
}
#[test]
fn a_blank_type_name_is_rejected() {
let error = rejection(vec![(" ", json!({ "properties": { "P": { "type": "string" } } }))]);
assert!(matches!(error, SchemaOverlayError::MissingTypeName), "got {error:?}");
}
#[test]
fn a_ref_chain_too_long_to_resolve_is_rejected() {
let hops = REF_CHAIN_LIMIT + 2;
let mut definitions = serde_json::Map::new();
for index in 0..hops {
definitions.insert(format!("D{index}"), json!({ "$ref": format!("#/definitions/D{}", index + 1) }));
}
definitions.insert(format!("D{hops}"), json!({ "type": "string", "enum": ["only"] }));
let error = rejection(vec![(
"AWS::Test::LongChain",
json!({ "properties": { "P": { "$ref": "#/definitions/D0" } }, "definitions": definitions }),
)]);
assert!(matches!(error, SchemaOverlayError::RefChainTooLong { .. }), "got {error:?}");
}
#[test]
fn a_chain_at_the_resolution_limit_is_accepted_and_enforced() {
let hops = REF_CHAIN_LIMIT - 1;
let mut definitions = serde_json::Map::new();
for index in 0..hops {
definitions.insert(format!("D{index}"), json!({ "$ref": format!("#/definitions/D{}", index + 1) }));
}
definitions.insert(format!("D{hops}"), json!({ "type": "string", "enum": ["only"] }));
let sv = validator(vec![(
"AWS::Test::LimitChain",
json!({ "properties": { "P": { "$ref": "#/definitions/D0" } }, "definitions": definitions }),
)]);
let template = "Resources:\n R:\n Type: AWS::Test::LimitChain\n Properties:\n P: other\n";
let after = findings(&sv, template);
assert!(
after.iter().any(|d| d.contains("'other'")),
"the constraint at the end of a resolvable chain must be enforced: {after:?}"
);
}
#[test]
fn a_reference_still_dangling_after_the_final_overlay_is_rejected() {
let error = rejection(vec![(
"AWS::Test::Dangling",
json!({ "properties": { "P": { "$ref": "#/definitions/NotDefined" } } }),
)]);
match error {
SchemaOverlayError::DanglingRef { path, target, .. } => {
assert_eq!(path, "P");
assert_eq!(target, "NotDefined");
}
other => panic!("expected a dangling-reference rejection, got {other:?}"),
}
}
#[test]
fn an_overlay_reference_to_a_bundled_definition_resolves() {
let sv = validator(vec![(
"AWS::Lambda::Function",
json!({ "properties": { "TestForOverride": { "$ref": "#/definitions/EphemeralStorage" } } }),
)]);
let template = r#"
Resources:
Fn:
Type: AWS::Lambda::Function
Properties:
Code:
ZipFile: "exports.handler = async () => {};"
Role: arn:aws:iam::123456789012:role/lambda-role
Runtime: nodejs18.x
Handler: index.handler
TestForOverride: {}
"#;
let diagnostics = validate(&sv, template);
assert!(
mentions(&diagnostics, "F3003", "Size"),
"the bundled EphemeralStorage definition must constrain the overlay property: {diagnostics:?}"
);
}
#[test]
fn a_forward_reference_resolved_by_a_later_overlay_is_accepted() {
let sv = validator(vec![
("AWS::Test::ForwardRef", json!({ "properties": { "P": { "$ref": "#/definitions/SuppliedLater" } } })),
(
"AWS::Test::ForwardRef",
json!({ "definitions": { "SuppliedLater": { "type": "string", "enum": ["only"] } } }),
),
]);
let template = "Resources:\n R:\n Type: AWS::Test::ForwardRef\n Properties:\n P: other\n";
let after = findings(&sv, template);
assert!(
after.iter().any(|finding| finding.contains("'other'")),
"the forward-supplied definition must be enforced: {after:?}"
);
}
#[test]
fn a_definition_that_also_carries_an_unresolved_reference_still_enforces_its_own_constraints() {
let mut store = schema_validator::CompiledSchemaStore::new();
store
.apply_overlay(
"AWS::Test::DanglingWithConstraint",
&json!({
"properties": { "Cfg": { "$ref": "#/definitions/Config" } },
"definitions": { "Config": { "$ref": "#/definitions/NotDefined" } }
}),
)
.expect("per-overlay application tolerates a forward reference");
store
.apply_overlay(
"AWS::Test::DanglingWithConstraint",
&json!({ "definitions": { "Config": { "type": "string", "pattern": "^ok$" } } }),
)
.expect("the second overlay merges");
let schema = store.get("AWS::Test::DanglingWithConstraint").expect("registered");
let resolved = schema.properties["Cfg"].resolve(&schema.definitions);
assert_eq!(
resolved.pattern.as_deref(),
Some("^ok$"),
"the definition's own constraint must survive beside the unresolved reference"
);
}
#[test]
fn cyclic_definition_graph_is_rejected() {
let error = rejection(vec![(
"AWS::Test::Cycle",
json!({
"properties": { "P": { "$ref": "#/definitions/D" } },
"definitions": { "D": { "$ref": "#/definitions/D" } }
}),
)]);
assert!(matches!(error, SchemaOverlayError::CyclicRef { .. }), "got {error:?}");
}
#[test]
fn multi_node_cyclic_definition_graph_is_rejected() {
let error = rejection(vec![(
"AWS::Test::Cycle",
json!({
"properties": { "P": { "$ref": "#/definitions/A" } },
"definitions": {
"A": { "$ref": "#/definitions/B" },
"B": { "$ref": "#/definitions/A" }
}
}),
)]);
assert!(matches!(error, SchemaOverlayError::CyclicRef { .. }), "got {error:?}");
}
#[test]
fn overlay_nested_past_the_depth_limit_is_rejected() {
let mut node = json!({ "type": "string" });
for _ in 0..200 {
node = json!({ "type": "object", "properties": { "N": node } });
}
let error = rejection(vec![("AWS::Test::Deep", json!({ "properties": { "Top": node } }))]);
assert!(matches!(error, SchemaOverlayError::TooDeep { .. }), "got {error:?}");
}
#[test]
fn non_object_overlay_is_rejected() {
let error = rejection(vec![("AWS::Test::T", json!([1, 2, 3]))]);
assert!(matches!(error, SchemaOverlayError::NotAnObject { .. }), "got {error:?}");
}
#[test]
fn empty_type_name_is_rejected() {
let error = rejection(vec![("", json!({ "properties": { "P": { "type": "string" } } }))]);
assert!(matches!(error, SchemaOverlayError::MissingTypeName), "got {error:?}");
}
#[test]
fn overlay_that_states_nothing_is_rejected() {
let error = rejection(vec![("AWS::Test::T", json!({ "unrecognised": 1 }))]);
assert!(matches!(error, SchemaOverlayError::NoEffect { .. }), "got {error:?}");
}
#[test]
fn a_rejected_overlay_does_not_leave_the_store_modified() {
let mut store = schema_validator::CompiledSchemaStore::new();
let before = store.len();
store
.apply_overlay(
"AWS::Test::Cycle",
&json!({ "definitions": { "D": { "$ref": "#/definitions/D" } }, "properties": { "P": { "$ref": "#/definitions/D" } } }),
)
.expect_err("a cyclic overlay must be rejected");
assert_eq!(store.len(), before, "a rejected overlay must not register a schema");
}
#[test]
fn a_mid_sequence_failure_builds_no_validator_and_leaves_no_shared_state() {
let baseline_schema_count = SchemaValidator::default().schema_count();
let construction = SchemaValidator::try_with_additional_schemas(vec![
("AWS::Test::AtomicSequence", json!({ "properties": { "First": { "type": "string" } } })),
(
"AWS::Test::AtomicSequence",
json!({
"properties": { "Broken": { "$ref": "#/definitions/Cycle" } },
"definitions": { "Cycle": { "$ref": "#/definitions/Cycle" } }
}),
),
("AWS::Test::AtomicSequence", json!({ "properties": { "Third": { "type": "string" } } })),
]);
let error = match construction {
Err(error) => error,
Ok(_) => panic!("the second overlay must fail the whole construction"),
};
assert!(matches!(error, SchemaOverlayError::CyclicRef { .. }), "got {error:?}");
let fresh_validator = SchemaValidator::default();
assert_eq!(
fresh_validator.schema_count(),
baseline_schema_count,
"a failed overlay sequence must not alter later validator construction"
);
assert!(
fresh_validator.overlay_catalog().is_empty(),
"a failed overlay sequence must not leave catalog state behind"
);
}
#[test]
fn overlay_outcome_distinguishes_merge_from_insertion() {
use schema_validator::OverlayOutcome;
let mut store = schema_validator::CompiledSchemaStore::new();
assert_eq!(
store
.apply_overlay(
"AWS::Lambda::Function",
&json!({ "properties": { "TestForOverride": { "type": "string" } } })
)
.expect("a bundled type merges"),
OverlayOutcome::Merged
);
assert_eq!(
store
.apply_overlay(
"AWS::Lambda::Funtcion",
&json!({ "properties": { "TestForOverride": { "type": "string" } } })
)
.expect("an unknown type is inserted"),
OverlayOutcome::Inserted,
"a misspelled type name must be reported as an insertion, not silently merged"
);
}
fn corpus_overlays() -> Vec<(&'static str, Value)> {
vec![
("AWS::Lambda::Function", overlay(OVERRIDE_PROP_OVERLAY)),
("AWS::Lambda::Function", overlay(PACKAGE_TYPE_ENUM_OVERLAY)),
("AWS::S3::Bucket", json!({ "properties": { "TestForOverride": { "type": "string" } } })),
(
"AWS::EC2::Instance",
json!({
"properties": { "TestForOverride": { "type": "string" } },
"deprecatedProperties": ["/properties/TestForOverride"],
"dependentRequired": { "TestForOverride": ["ImageId"] }
}),
),
("AWS::Batch::ComputeEnvironment", batch_enum_overlay("enum")),
("AWS::IAM::Role", json!({ "properties": { "Tags": { "uniqueItems": false } } })),
("AWS::CloudWatch::Alarm", json!({ "properties": { "TestForOverride": { "type": "string" } } })),
]
}
#[test]
fn overlays_never_introduce_a_diagnostic_on_the_good_corpus() {
let baseline = SchemaValidator::default();
let overlaid = validator(corpus_overlays());
let good = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../resources/templates/good");
let mut checked = 0usize;
let mut added: Vec<String> = Vec::new();
let mut errors: Vec<String> = Vec::new();
for path in walk_templates_recursive(&good) {
let bytes = std::fs::read(&path).expect("template must be readable");
let parsed = match SemanticModel::from_bytes(&bytes) {
Ok(m) => m,
Err(e) => {
errors.push(format!("{}: parse error: {e}", path.strip_prefix(&good).unwrap_or(&path).display()));
continue;
}
};
let parsed = Arc::new(parsed);
let before: Vec<String> = baseline
.validate(&parsed, Some("us-east-1"))
.diagnostics
.into_iter()
.map(|d| format!("{} {}", d.rule_id, d.message))
.collect();
let after: Vec<String> = overlaid
.validate(&parsed, Some("us-east-1"))
.diagnostics
.into_iter()
.map(|d| format!("{} {}", d.rule_id, d.message))
.collect();
checked += 1;
for finding in after {
if !before.contains(&finding) {
added.push(format!("{}: {finding}", path.strip_prefix(&good).unwrap_or(&path).display()));
}
}
}
assert!(errors.is_empty(), "good corpus templates failed to parse:\n{}", errors.join("\n"));
assert!(checked >= 100, "expected to recursively check the full good corpus, only reached {checked} templates");
assert!(added.is_empty(), "overlays introduced {} new diagnostic(s): {added:#?}", added.len());
}
fn walk_templates_recursive(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
walk_recursive_impl(dir, &mut out);
out.sort();
out
}
fn walk_recursive_impl(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk_recursive_impl(&path, out);
} else if matches!(path.extension().and_then(|s| s.to_str()), Some("yaml" | "yml" | "json")) {
out.push(path);
}
}
}
#[test]
fn schema_validator_new_has_empty_overlay_catalog() {
let validator = SchemaValidator::default();
assert!(validator.overlay_catalog().is_empty(), "a default-constructed SchemaValidator must have an empty catalog");
}
#[test]
fn schema_validator_with_overlays_exposes_populated_catalog() {
let overlays = vec![("AWS::Lambda::Function", overlay(r#"{"properties":{"TestForOverride":{"type":"string"}}}"#))];
let validator = SchemaValidator::try_with_additional_schemas(overlays).expect("valid overlay");
let catalog = validator.overlay_catalog();
assert!(!catalog.is_empty(), "an overlay-bearing validator must expose a non-empty catalog");
assert!(
catalog.type_names.contains(&"AWS::Lambda::Function".to_string()),
"the catalog must list the overlaid type"
);
}
#[test]
fn overlay_required_replacement_clears_base_required() {
let sv = validator(vec![(
"AWS::Test::RequiredClear",
json!({
"properties": {
"Name": { "type": "string" },
"Extra": { "type": "string" }
},
"required": ["Name"]
}),
)]);
let template_missing = "\
Resources:
R:
Type: AWS::Test::RequiredClear
Properties:
Extra: val
";
let diags = validate(&sv, template_missing);
assert!(
mentions(&diags, "F3003", "Name"),
"the overlay's required must be enforced: {:?}",
diags.iter().map(|d| (&d.rule_id, &d.message)).collect::<Vec<_>>()
);
let sv2 = SchemaValidator::try_with_additional_schemas(vec![
(
"AWS::Test::RequiredClear",
json!({
"properties": { "Name": { "type": "string" }, "Extra": { "type": "string" } },
"required": ["Name"]
}),
),
("AWS::Test::RequiredClear", json!({ "properties": { "Name": { "type": "string" } }, "required": [] })),
])
.expect("valid overlays");
let diags2 = validate(&sv2, template_missing);
assert!(
!mentions(&diags2, "F3003", "Name"),
"explicit empty required in second overlay must clear the required constraint: {:?}",
diags2.iter().map(|d| (&d.rule_id, &d.message)).collect::<Vec<_>>()
);
}
#[test]
fn a_ref_overlay_with_siblings_on_an_occupied_property_applies_both() {
let sv = validator(vec![
(
"AWS::Test::RefSiblingMerge",
json!({
"properties": { "P": { "type": "string", "maxLength": 100 } },
"definitions": { "Name": { "type": "string" } }
}),
),
(
"AWS::Test::RefSiblingMerge",
json!({
"properties": { "P": { "$ref": "#/definitions/Name", "minLength": 5 } },
"definitions": { "Name": { "type": "string" } }
}),
),
]);
let template = "Resources:\n R:\n Type: AWS::Test::RefSiblingMerge\n Properties:\n P: x\n";
let after = findings(&sv, template);
assert!(
after.iter().any(|finding| finding.contains("below minimum 5")),
"the overlay's minLength sibling beside its $ref must be enforced: {after:?}"
);
}