diff --git a/dsc/tests/dsc_schema_default.tests.ps1 b/dsc/tests/dsc_schema_default.tests.ps1 index 286e76eda..8cd53500c 100644 --- a/dsc/tests/dsc_schema_default.tests.ps1 +++ b/dsc/tests/dsc_schema_default.tests.ps1 @@ -51,4 +51,57 @@ Describe 'Synthetic test uses schema defaults' { $out.inDesiredState | Should -Be $true $out.differingProperties | Should -BeNullOrEmpty } + + It 'Nested writeOnly object is not reported as differing' { + $inputJson = @{ + name = 'test' + nested = @{ + value = 'actual' + secret = @{ + token = 'sensitive' + } + } + } | ConvertTo-Json -Compress -Depth 5 + + $out = $inputJson | dsc resource test -r Test/SchemaDefault -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.actualState.nested.PSObject.Properties.Name | Should -Not -Contain 'secret' + $out.inDesiredState | Should -Be $true + $out.differingProperties | Should -BeNullOrEmpty + } + + It 'Nested non-writeOnly property is reported as differing' { + $inputJson = @{ + name = 'test' + nested = @{ + value = 'expected' + secret = @{ + token = 'sensitive' + } + } + } | ConvertTo-Json -Compress -Depth 5 + + $out = $inputJson | dsc resource test -r Test/SchemaDefault -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.inDesiredState | Should -Be $false + $out.differingProperties | Should -Be @('nested') + } + + It 'Nested writeOnly object under a referenced schema is not reported as differing' { + $inputJson = @{ + name = 'test' + referencedNested = @{ + value = 'actual' + secret = @{ + token = 'sensitive' + } + } + } | ConvertTo-Json -Compress -Depth 5 + + $out = $inputJson | dsc resource test -r Test/SchemaDefault -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.actualState.referencedNested.PSObject.Properties.Name | Should -Not -Contain 'secret' + $out.inDesiredState | Should -Be $true + $out.differingProperties | Should -BeNullOrEmpty + } } diff --git a/lib/dsc-lib/src/dscresources/command_resource.rs b/lib/dsc-lib/src/dscresources/command_resource.rs index 5b4479b72..fb97bb463 100644 --- a/lib/dsc-lib/src/dscresources/command_resource.rs +++ b/lib/dsc-lib/src/dscresources/command_resource.rs @@ -457,9 +457,18 @@ fn invoke_synthetic_test(resource: &DscResource, expected: &str, target_resource let cached_resource = target_resource.unwrap_or(resource); let schema: Option = get_resource_schema(&cached_resource.type_name, &cached_resource.version) .or_else(|| { - // Populate the cache on a miss, then read from cache - get_schema(resource, target_resource).ok(); - get_resource_schema(&cached_resource.type_name, &cached_resource.version) + // Cache miss: parse and use the schema returned by get_schema. This covers cases + // where get_schema returns early (e.g. target_resource.schema) without caching. + let schema_str = get_schema(resource, target_resource).ok()?; + let schema_value: Value = serde_json::from_str(&schema_str).ok()?; + // Best-effort cache population for future callers. + locked_insert!( + RESOURCE_SCHEMAS, + cached_resource.type_name.clone(), + cached_resource.version.clone(), + schema_value.clone() + ); + Some(schema_value) }); let diff_properties = get_diff_with_schema(&expected_value, &actual_state, schema.as_ref()); Ok(TestResult::Resource(ResourceTestResponse { diff --git a/lib/dsc-lib/src/dscresources/dscresource.rs b/lib/dsc-lib/src/dscresources/dscresource.rs index 29eda6a08..d4053e494 100644 --- a/lib/dsc-lib/src/dscresources/dscresource.rs +++ b/lib/dsc-lib/src/dscresources/dscresource.rs @@ -702,7 +702,30 @@ pub(crate) fn get_diff_with_schema(expected: &Value, actual: &Value, schema: Opt } if value.is_object() { - let sub_diff = get_diff(value, &actual[key]); + // When comparing nested objects, pass the corresponding nested schema so that + // nested `writeOnly` properties and nested defaults are handled correctly. + let sub_schema = schema.and_then(|schema| { + let mut property_schema = schema + .get("properties") + .and_then(Value::as_object) + .and_then(|properties| properties.get(key))?; + // Resolve local refs so nested comparisons can still see `properties`, `default`, and `writeOnly`. + let mut visited_references = HashSet::::new(); + while let Some(reference) = property_schema.get("$ref").and_then(Value::as_str) { + let Some(pointer) = reference.strip_prefix('#') else { + break; + }; + if !visited_references.insert(pointer.to_string()) { + break; + } + let Some(resolved_schema) = schema.pointer(pointer) else { + break; + }; + property_schema = resolved_schema; + } + Some(property_schema) + }); + let sub_diff = get_diff_with_schema(value, &actual[key], sub_schema); if !sub_diff.is_empty() { debug!("{}", t!("dscresources.dscresource.subDiff", key = key)); diff_properties.push(key.to_string()); @@ -1115,6 +1138,55 @@ fn diff_with_schema_write_only_local_ref_ignores_missing_property() { assert!(diff.is_empty(), "Expected referenced write-only property to be ignored, got: {diff:?}"); } +#[test] +fn diff_with_schema_nested_external_ref_falls_back_to_normal_comparison() { + use serde_json::json; + let expected = json!({"nested": {"value": "expected"}}); + let actual = json!({"nested": {"value": "actual"}}); + let schema = json!({ + "type": "object", + "properties": { + "nested": { "$ref": "https://example.com/nested.schema.json" } + } + }); + let diff = get_diff_with_schema(&expected, &actual, Some(&schema)); + assert_eq!(diff, vec!["nested".to_string()]); +} + +#[test] +fn diff_with_schema_nested_cyclic_ref_falls_back_to_normal_comparison() { + use serde_json::json; + let expected = json!({"nested": {"value": "expected"}}); + let actual = json!({"nested": {"value": "actual"}}); + let schema = json!({ + "type": "object", + "properties": { + "nested": { "$ref": "#/$defs/first" } + }, + "$defs": { + "first": { "$ref": "#/$defs/second" }, + "second": { "$ref": "#/$defs/first" } + } + }); + let diff = get_diff_with_schema(&expected, &actual, Some(&schema)); + assert_eq!(diff, vec!["nested".to_string()]); +} + +#[test] +fn diff_with_schema_nested_missing_ref_falls_back_to_normal_comparison() { + use serde_json::json; + let expected = json!({"nested": {"value": "expected"}}); + let actual = json!({"nested": {"value": "actual"}}); + let schema = json!({ + "type": "object", + "properties": { + "nested": { "$ref": "#/$defs/missing" } + } + }); + let diff = get_diff_with_schema(&expected, &actual, Some(&schema)); + assert_eq!(diff, vec!["nested".to_string()]); +} + #[test] fn diff_with_schema_write_only_false_reports_missing_property() { use serde_json::json; diff --git a/tools/dsctest/dsctest.dsc.manifests.json b/tools/dsctest/dsctest.dsc.manifests.json index e5bcc8a3c..9aae63809 100644 --- a/tools/dsctest/dsctest.dsc.manifests.json +++ b/tools/dsctest/dsctest.dsc.manifests.json @@ -314,6 +314,53 @@ "type": "integer", "description": "The count value.", "default": 5 + }, + "nested": { + "type": "object", + "additionalProperties": false, + "required": ["value"], + "properties": { + "value": { + "type": "string" + }, + "secret": { + "type": "object", + "writeOnly": true, + "additionalProperties": false, + "required": ["token"], + "properties": { + "token": { + "type": "string" + } + } + } + } + }, + "referencedNested": { + "$ref": "#/$defs/nested" + } + }, + "$defs": { + "nested": { + "type": "object", + "additionalProperties": false, + "required": ["value"], + "properties": { + "value": { + "type": "string" + }, + "secret": { + "type": "object", + "writeOnly": true, + "additionalProperties": false, + "required": ["token"], + "properties": { + "token": { + "type": "string" + } + } + } + } } } } diff --git a/tools/dsctest/src/main.rs b/tools/dsctest/src/main.rs index a7e8f4ebe..2363f5b2f 100644 --- a/tools/dsctest/src/main.rs +++ b/tools/dsctest/src/main.rs @@ -298,9 +298,14 @@ fn main() { std::process::exit(1); } }; - // Only return 'name' in the output - omit 'enabled' and 'count' - // to test schema default comparison - serde_json::json!({"name": schema_default.name}).to_string() + let mut actual = serde_json::json!({"name": schema_default.name}); + if schema_default.nested.is_some() { + actual["nested"] = serde_json::json!({"value": "actual"}); + } + if schema_default.referenced_nested.is_some() { + actual["referencedNested"] = serde_json::json!({"value": "actual"}); + } + actual.to_string() }, SubCommand::Schema { subcommand } => { let schema = match subcommand { diff --git a/tools/dsctest/src/schema_default.rs b/tools/dsctest/src/schema_default.rs index 6a661de48..7e4f079c6 100644 --- a/tools/dsctest/src/schema_default.rs +++ b/tools/dsctest/src/schema_default.rs @@ -5,10 +5,48 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] pub struct SchemaDefault { pub name: String, #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(default = "default_enabled")] pub enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(default = "default_count")] pub count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub nested: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub referenced_nested: Option, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +pub struct Nested { + pub value: String, + #[serde(skip_serializing)] + pub secret: Option, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +pub struct Secret { + pub token: String, +} + +fn default_enabled() -> Option { + Some(true) +} + +fn default_count() -> Option { + Some(5) +} + +#[cfg(test)] +mod tests { + use super::{default_count, default_enabled}; + + #[test] + fn schema_defaults_match_embedded_manifest() { + assert_eq!(default_enabled(), Some(true)); + assert_eq!(default_count(), Some(5)); + } }