From d0d39c03b12c9b485e739a448ae6a0eaa74d6384 Mon Sep 17 00:00:00 2001 From: Yoshifumi Date: Wed, 17 Dec 2025 18:15:36 +0900 Subject: [PATCH] Fix Test-Json false positive errors when using oneOf or anyOf in schema (#26618) --- .../commands/utility/TestJsonCommand.cs | 39 +++- .../Test-Json.Tests.ps1 | 195 ++++++++++++++++++ 2 files changed, 224 insertions(+), 10 deletions(-) diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/TestJsonCommand.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/TestJsonCommand.cs index 32603f160f8..909cbff3c8f 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/TestJsonCommand.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/TestJsonCommand.cs @@ -264,19 +264,11 @@ protected override void ProcessRecord() if (_jschema != null) { - EvaluationResults evaluationResults = _jschema.Evaluate(parsedJson, new EvaluationOptions { OutputFormat = OutputFormat.List }); + EvaluationResults evaluationResults = _jschema.Evaluate(parsedJson, new EvaluationOptions { OutputFormat = OutputFormat.Hierarchical }); result = evaluationResults.IsValid; if (!result) { - HandleValidationErrors(evaluationResults); - - if (evaluationResults.HasDetails) - { - foreach (var nestedResult in evaluationResults.Details) - { - HandleValidationErrors(nestedResult); - } - } + ReportValidationErrors(evaluationResults); } } } @@ -298,6 +290,33 @@ protected override void ProcessRecord() WriteObject(result); } + /// + /// Recursively reports validation errors from hierarchical evaluation results. + /// Skips nodes (and their children) where IsValid is true to avoid false positives + /// from constructs like OneOf or AnyOf. + /// + /// The evaluation result to process. + private void ReportValidationErrors(EvaluationResults evaluationResult) + { + // Skip this node and all children if validation passed + if (evaluationResult.IsValid) + { + return; + } + + // Report errors at this level + HandleValidationErrors(evaluationResult); + + // Recursively process child results + if (evaluationResult.HasDetails) + { + foreach (var nestedResult in evaluationResult.Details) + { + ReportValidationErrors(nestedResult); + } + } + } + private void HandleValidationErrors(EvaluationResults evaluationResult) { if (!evaluationResult.HasErrors) diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/Test-Json.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/Test-Json.Tests.ps1 index 32a6cf3ce63..7b1b58d8258 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/Test-Json.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/Test-Json.Tests.ps1 @@ -86,6 +86,141 @@ Describe "Test-Json" -Tags "CI" { } '@ + # Schema using oneOf to allow either integer or string pattern for port items + $oneOfSchema = @' + { + "type": "object", + "properties": { + "ports": { + "type": "array", + "items": { + "oneOf": [ + { "type": "integer", "minimum": 0, "maximum": 65535 }, + { "type": "string", "pattern": "^\\d+-\\d+$" } + ] + } + } + } + } +'@ + + # Valid JSON where ports are integers (first oneOf choice matches) + $validOneOfJson = '{ "ports": [80, 443, 8080] }' + + # Invalid JSON where a port value matches neither oneOf choice + $invalidOneOfJson = '{ "ports": [80, "invalid-port", 8080] }' + + # Schema using oneOf to allow either smartphone or laptop device types + $oneOfDeviceSchema = @' + { + "type": "object", + "properties": { + "Devices": { + "type": "array", + "items": { + "type": "object", + "oneOf": [ + { + "properties": { + "id": { "type": "string" }, + "deviceType": { "const": "smartphone" }, + "os": { "type": "string", "enum": ["iOS", "Android"] } + }, + "required": ["deviceType", "os"] + }, + { + "properties": { + "id": { "type": "string" }, + "deviceType": { "const": "laptop" }, + "arch": { "type": "string", "enum": ["x86", "x64", "arm64"] } + }, + "required": ["deviceType", "arch"] + } + ] + } + } + }, + "required": ["Devices"] + } +'@ + + # Valid JSON with mixed device types (all matching their respective oneOf choice) + $validOneOfDeviceJson = @' + { + "Devices": [ + { "id": "0", "deviceType": "laptop", "arch": "x64" }, + { "id": "1", "deviceType": "smartphone", "os": "iOS" }, + { "id": "2", "deviceType": "laptop", "arch": "arm64" }, + { "id": "3", "deviceType": "smartphone", "os": "Android" } + ] + } +'@ + + # Invalid JSON where only Devices/3 has an invalid os value + $invalidOneOfDeviceJson = @' + { + "Devices": [ + { "id": "0", "deviceType": "laptop", "arch": "x64" }, + { "id": "1", "deviceType": "smartphone", "os": "iOS" }, + { "id": "2", "deviceType": "laptop", "arch": "arm64" }, + { "id": "3", "deviceType": "smartphone", "os": "WindowsPhone" } + ] + } +'@ + + # Schema using anyOf to allow either smartphone or laptop device types + $anyOfDeviceSchema = @' + { + "type": "object", + "properties": { + "Devices": { + "type": "array", + "items": { + "type": "object", + "anyOf": [ + { + "properties": { + "deviceType": { "const": "smartphone" }, + "os": { "type": "string", "enum": ["iOS", "Android"] } + }, + "required": ["deviceType", "os"] + }, + { + "properties": { + "deviceType": { "const": "laptop" }, + "arch": { "type": "string", "enum": ["x86", "x64", "arm64"] } + }, + "required": ["deviceType", "arch"] + } + ] + } + } + }, + "required": ["Devices"] + } +'@ + + # Valid JSON with mixed device types (all matching their respective anyOf choice) + $validAnyOfDeviceJson = @' + { + "Devices": [ + { "deviceType": "laptop", "arch": "x64" }, + { "deviceType": "smartphone", "os": "iOS" } + ] + } +'@ + + # Invalid JSON where only Devices/2 has an invalid os value + $invalidAnyOfDeviceJson = @' + { + "Devices": [ + { "deviceType": "laptop", "arch": "x64" }, + { "deviceType": "smartphone", "os": "iOS" }, + { "deviceType": "smartphone", "os": "WindowsPhone" } + ] + } +'@ + $validJsonPath = Join-Path -Path $TestDrive -ChildPath 'validJson.json' $validLiteralJsonPath = Join-Path -Path $TestDrive -ChildPath "[valid]Json.json" $invalidNodeInJsonPath = Join-Path -Path $TestDrive -ChildPath 'invalidNodeInJson.json' @@ -343,4 +478,64 @@ Describe "Test-Json" -Tags "CI" { # With options should pass ($Json | Test-Json -Option $Options -ErrorAction SilentlyContinue) | Should -BeTrue } + + It "Test-Json does not report false positives for valid oneOf matches" { + $errorVar = $null + $result = Test-Json -Json $validOneOfJson -Schema $oneOfSchema -ErrorVariable errorVar -ErrorAction SilentlyContinue + + $result | Should -BeTrue + $errorVar.Count | Should -Be 0 + } + + It "Test-Json reports only relevant errors for invalid oneOf values" { + $errorVar = $null + $result = Test-Json -Json $invalidOneOfJson -Schema $oneOfSchema -ErrorVariable errorVar -ErrorAction SilentlyContinue + + $result | Should -BeFalse + # Should report error only for the invalid item, not for valid items + $errorVar.Count | Should -BeGreaterThan 0 + $errorVar[0].Exception.Message | Should -Match "/ports/1" + } + + It "Test-Json does not report false positives for valid oneOf device matches" { + $errorVar = $null + $result = Test-Json -Json $validOneOfDeviceJson -Schema $oneOfDeviceSchema -ErrorVariable errorVar -ErrorAction SilentlyContinue + + $result | Should -BeTrue + $errorVar.Count | Should -Be 0 + } + + It "Test-Json reports errors only for the invalid device in oneOf schema" { + $errorVar = $null + $result = Test-Json -Json $invalidOneOfDeviceJson -Schema $oneOfDeviceSchema -ErrorVariable errorVar -ErrorAction SilentlyContinue + + $result | Should -BeFalse + # Should not report errors for valid devices (Devices/0, /1, /2) + $falsePositives = $errorVar | Where-Object { $_.Exception.Message -match '/Devices/(0|1|2)' } + $falsePositives.Count | Should -Be 0 + # Should report errors only for the invalid device (Devices/3) + $relevantErrors = $errorVar | Where-Object { $_.Exception.Message -match '/Devices/3' } + $relevantErrors.Count | Should -BeGreaterThan 0 + } + + It "Test-Json does not report false positives for valid anyOf device matches" { + $errorVar = $null + $result = Test-Json -Json $validAnyOfDeviceJson -Schema $anyOfDeviceSchema -ErrorVariable errorVar -ErrorAction SilentlyContinue + + $result | Should -BeTrue + $errorVar.Count | Should -Be 0 + } + + It "Test-Json reports errors only for the invalid device in anyOf schema" { + $errorVar = $null + $result = Test-Json -Json $invalidAnyOfDeviceJson -Schema $anyOfDeviceSchema -ErrorVariable errorVar -ErrorAction SilentlyContinue + + $result | Should -BeFalse + # Should not report errors for valid devices (Devices/0, /1) + $falsePositives = $errorVar | Where-Object { $_.Exception.Message -match '/Devices/(0|1)' } + $falsePositives.Count | Should -Be 0 + # Should report errors only for the invalid device (Devices/2) + $relevantErrors = $errorVar | Where-Object { $_.Exception.Message -match '/Devices/2' } + $relevantErrors.Count | Should -BeGreaterThan 0 + } }