Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions dsc/tests/dsc_schema_default.tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
15 changes: 12 additions & 3 deletions lib/dsc-lib/src/dscresources/command_resource.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value> = 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 {
Expand Down
74 changes: 73 additions & 1 deletion lib/dsc-lib/src/dscresources/dscresource.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<String>::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());
Expand Down Expand Up @@ -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;
Expand Down
47 changes: 47 additions & 0 deletions tools/dsctest/dsctest.dsc.manifests.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
}
}
}
}
Expand Down
11 changes: 8 additions & 3 deletions tools/dsctest/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
38 changes: 38 additions & 0 deletions tools/dsctest/src/schema_default.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(default = "default_count")]
pub count: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nested: Option<Nested>,
#[serde(skip_serializing_if = "Option::is_none")]
pub referenced_nested: Option<Nested>,
}

#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)]
pub struct Nested {
pub value: String,
#[serde(skip_serializing)]
pub secret: Option<Secret>,
}

#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)]
pub struct Secret {
pub token: String,
}

fn default_enabled() -> Option<bool> {
Some(true)
}

fn default_count() -> Option<i32> {
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));
}
}
Loading