diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerSerializer.cs b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerSerializer.cs
index f4e9723..8181b26 100644
--- a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerSerializer.cs
+++ b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerSerializer.cs
@@ -14,6 +14,8 @@ public static class BrokerSerializer
/// Serialization options matching the broker wire format: PascalCase property names
/// (via explicit [JsonPropertyName] attributes), PascalCase enum values, and
/// null optionals omitted (mirroring the Rust skip_serializing_if = "Option::is_none").
+ /// Deserialization rejects duplicate property names throughout the input using ordinal,
+ /// case-sensitive name comparison.
///
public static readonly JsonSerializerOptions Options = CreateOptions(writeIndented: false);
@@ -27,6 +29,7 @@ public static string Serialize(T value)
public static T? Deserialize(string json)
{
+ PolicyJsonInput.RejectDuplicatePropertyNames(json, BrokerSerializerContext.Default.Options);
var value = JsonSerializer.Deserialize(json, TypeInfo());
ValidateSemanticValue(value);
return value;
@@ -34,6 +37,7 @@ public static string Serialize(T value)
public static T? DeserializeStrict(string json)
{
+ PolicyJsonInput.RejectDuplicatePropertyNames(json, BrokerStrictSerializerContext.Default.Options);
var value = JsonSerializer.Deserialize(json, StrictTypeInfo());
ValidateSemanticValue(value);
return value;
@@ -49,14 +53,24 @@ private static void ValidateSemanticValue(object? value)
case PolicyDraftDocument draft:
PolicySerializer.ValidateRequiredCollectionElements(draft);
break;
+ case PolicyMetadata
+ or PolicyDraftMetadata
+ or PolicyRule
+ or PolicyMatch
+ or PackageIdentifierCondition
+ or VersionCondition
+ or VersionRange
+ or PolicyConstraints:
+ PolicySerializer.ValidateSemanticValue(value);
+ break;
case PolicyResponse response:
- PolicySerializer.ValidateRequiredCollectionElements(response.Policy);
+ PolicySerializer.ValidateRequiredCollectionElements(response.Policy, "$.Policy");
break;
case PolicyManagementResponse response:
- ValidateManagement(response.Management);
+ ValidateManagement(response.Management, "$.Management");
break;
case PolicyValidationResponse response:
- ValidateValidation(response.Validation);
+ ValidateValidation(response.Validation, "$.Validation");
break;
case PolicyReplacementResponse response:
ValidateReplacement(response);
@@ -73,24 +87,30 @@ private static void ValidateSemanticValue(object? value)
}
}
- private static JsonTypeInfo TypeInfo() =>
- typeof(T) == typeof(PackageRequest) ? Cast(BrokerSerializerContext.Default.PackageRequest) :
- typeof(T) == typeof(StatusRequest) ? Cast(BrokerSerializerContext.Default.StatusRequest) :
- typeof(T) == typeof(CancelRequest) ? Cast(BrokerSerializerContext.Default.CancelRequest) :
- typeof(T) == typeof(PolicyValidationRequest) ? Cast(BrokerPolicySerializerContext.Default.PolicyValidationRequest) :
- typeof(T) == typeof(PolicyReplacementRequest) ? Cast(BrokerPolicySerializerContext.Default.PolicyReplacementRequest) :
- typeof(T) == typeof(HealthResponse) ? Cast(BrokerSerializerContext.Default.HealthResponse) :
- typeof(T) == typeof(CapabilitiesResponse) ? Cast(BrokerSerializerContext.Default.CapabilitiesResponse) :
- typeof(T) == typeof(PolicyResponse) ? Cast(BrokerPolicySerializerContext.Default.PolicyResponse) :
- typeof(T) == typeof(PolicyManagementResponse) ? Cast(BrokerPolicySerializerContext.Default.PolicyManagementResponse) :
- typeof(T) == typeof(PolicyValidationResponse) ? Cast(BrokerPolicySerializerContext.Default.PolicyValidationResponse) :
- typeof(T) == typeof(PolicyReplacementResponse) ? Cast(BrokerPolicySerializerContext.Default.PolicyReplacementResponse) :
- typeof(T) == typeof(EvaluationResponse) ? Cast(BrokerSerializerContext.Default.EvaluationResponse) :
- typeof(T) == typeof(ExecutionResponse) ? Cast(BrokerSerializerContext.Default.ExecutionResponse) :
- typeof(T) == typeof(StatusResponse) ? Cast(BrokerSerializerContext.Default.StatusResponse) :
- typeof(T) == typeof(CancelResponse) ? Cast(BrokerSerializerContext.Default.CancelResponse) :
- typeof(T) == typeof(ErrorResponse) ? Cast(BrokerErrorSerializerContext.Default.ErrorResponse) :
- throw new NotSupportedException($"Broker JSON serialization for {typeof(T).FullName} is not source-generated.");
+ private static JsonTypeInfo TypeInfo()
+ {
+ _ = typeof(T) == typeof(PackageRequest)
+ || typeof(T) == typeof(StatusRequest)
+ || typeof(T) == typeof(CancelRequest)
+ || typeof(T) == typeof(PolicyValidationRequest)
+ || typeof(T) == typeof(PolicyReplacementRequest)
+ || typeof(T) == typeof(HealthResponse)
+ || typeof(T) == typeof(CapabilitiesResponse)
+ || typeof(T) == typeof(PolicyResponse)
+ || typeof(T) == typeof(PolicyManagementResponse)
+ || typeof(T) == typeof(PolicyValidationResponse)
+ || typeof(T) == typeof(PolicyReplacementResponse)
+ || typeof(T) == typeof(EvaluationResponse)
+ || typeof(T) == typeof(ExecutionResponse)
+ || typeof(T) == typeof(StatusResponse)
+ || typeof(T) == typeof(CancelResponse)
+ || typeof(T) == typeof(ErrorResponse)
+ ? true
+ : throw new NotSupportedException(
+ $"Broker JSON serialization for {typeof(T).FullName} is not source-generated.");
+
+ return Cast(Options.GetTypeInfo(typeof(T)));
+ }
private static JsonTypeInfo StrictTypeInfo() =>
typeof(T) == typeof(PackageRequest) ? Cast(BrokerStrictSerializerContext.Default.PackageRequest) :
@@ -114,7 +134,9 @@ private static JsonTypeInfo StrictTypeInfo() =>
private static JsonTypeInfo Cast(JsonTypeInfo jsonTypeInfo) =>
(JsonTypeInfo)jsonTypeInfo;
- private static void ValidateManagement(PolicyManagementSnapshot management)
+ private static void ValidateManagement(
+ PolicyManagementSnapshot management,
+ string path = "$")
{
switch (management.State)
{
@@ -150,7 +172,7 @@ private static void ValidateManagement(PolicyManagementSnapshot management)
if (management.Policy is { } policy)
{
- PolicySerializer.ValidateRequiredCollectionElements(policy);
+ PolicySerializer.ValidateRequiredCollectionElements(policy, $"{path}.Policy");
}
}
@@ -163,16 +185,18 @@ private static void ValidateError(ErrorResponse error)
if (error.Management is { } management)
{
- ValidateManagement(management);
+ ValidateManagement(management, "$.Management");
}
if (error.Validation is { } validation)
{
- ValidateValidation(validation);
+ ValidateValidation(validation, "$.Validation");
}
}
- private static void ValidateValidation(PolicyValidationResult validation)
+ private static void ValidateValidation(
+ PolicyValidationResult validation,
+ string path = "$")
{
RejectNullElements(validation.Findings, "Validation.Findings");
var hasError = validation.Findings.Any(finding => finding.Severity == PolicyFindingSeverity.Error);
@@ -188,7 +212,9 @@ private static void ValidateValidation(PolicyValidationResult validation)
throw new JsonException("Valid policy validation results must not contain Error findings.");
}
- PolicySerializer.ValidateRequiredCollectionElements(validation.CanonicalDraft);
+ PolicySerializer.ValidateRequiredCollectionElements(
+ validation.CanonicalDraft,
+ $"{path}.CanonicalDraft");
}
else
{
@@ -207,9 +233,9 @@ private static void ValidateValidation(PolicyValidationResult validation)
private static void ValidateReplacement(PolicyReplacementResponse response)
{
- PolicySerializer.ValidateRequiredCollectionElements(response.Policy);
- ValidateValidation(response.Validation);
- ValidateManagement(response.Management);
+ PolicySerializer.ValidateRequiredCollectionElements(response.Policy, "$.Policy");
+ ValidateValidation(response.Validation, "$.Validation");
+ ValidateManagement(response.Management, "$.Management");
if (!response.Validation.IsValid)
{
@@ -264,11 +290,114 @@ private static JsonSerializerOptions CreateOptions(bool writeIndented)
BrokerErrorSerializerContext.Default)
.WithAddedModifier(AttachSemanticValidation);
- return new JsonSerializerOptions(BrokerSerializerContext.Default.Options)
+ var options = new JsonSerializerOptions(BrokerSerializerContext.Default.Options)
{
TypeInfoResolver = resolver,
WriteIndented = writeIndented,
};
+
+ AddDuplicateRejectingConverters(options);
+ return options;
+ }
+
+ private static void AddDuplicateRejectingConverters(JsonSerializerOptions options)
+ {
+ // Explicit closed-world registrations are Native AOT safe. The client test suite enumerates
+ // all public object metadata from these source-generated contexts to prevent omissions.
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerSerializerContext.Default.PackageRequest));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerSerializerContext.Default.RequestSource));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerSerializerContext.Default.RequestPackage));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerSerializerContext.Default.RequestOptions));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerSerializerContext.Default.ClientContext));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerSerializerContext.Default.StatusRequest));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerSerializerContext.Default.CancelRequest));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerPolicySerializerContext.Default.PolicyValidationRequest));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerPolicySerializerContext.Default.PolicyReplacementRequest));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerSerializerContext.Default.HealthResponse));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerSerializerContext.Default.CapabilitiesResponse));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerSerializerContext.Default.ManagerCapability));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerPolicySerializerContext.Default.PolicyResponse));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerPolicySerializerContext.Default.PolicyManagementResponse));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerPolicySerializerContext.Default.PolicyValidationResponse));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerPolicySerializerContext.Default.PolicyReplacementResponse));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerSerializerContext.Default.EvaluationResponse));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerSerializerContext.Default.ExecutionResponse));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerSerializerContext.Default.ServerContext));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerSerializerContext.Default.RequestSummary));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerSerializerContext.Default.DecisionInfo));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerSerializerContext.Default.ResponsePolicyInfo));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerSerializerContext.Default.OperationDiagnostics));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerSerializerContext.Default.OperationSubmission));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerSerializerContext.Default.EventChannel));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerSerializerContext.Default.StatusResponse));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerSerializerContext.Default.CancelResponse));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerErrorSerializerContext.Default.ErrorResponse));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerErrorSerializerContext.Default.ErrorDetail));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerPolicySerializerContext.Default.PolicyDocument,
+ static value => ValidateSemanticValue(value)));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerPolicySerializerContext.Default.PolicyDraftDocument,
+ static value => ValidateSemanticValue(value)));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerPolicySerializerContext.Default.PolicyMetadata,
+ static value => ValidateSemanticValue(value)));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerPolicySerializerContext.Default.PolicyDraftMetadata,
+ static value => ValidateSemanticValue(value)));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerPolicySerializerContext.Default.PolicyEnforcement));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerPolicySerializerContext.Default.PolicyRule));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerPolicySerializerContext.Default.PolicyMatch));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerPolicySerializerContext.Default.PackageIdentifierCondition));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerPolicySerializerContext.Default.VersionCondition));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerPolicySerializerContext.Default.VersionRange));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerPolicySerializerContext.Default.PolicyConstraints));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerPolicySerializerContext.Default.PolicyFinding));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerPolicySerializerContext.Default.PolicyValidationResult,
+ static value => ValidateSemanticValue(value)));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerPolicySerializerContext.Default.InvalidPolicyDiagnostics));
+ options.Converters.Add(new DuplicatePropertyNameRejectingConverter(
+ BrokerPolicySerializerContext.Default.PolicyManagementSnapshot,
+ static value => ValidateSemanticValue(value)));
}
private static void AttachSemanticValidation(JsonTypeInfo typeInfo)
@@ -278,6 +407,20 @@ private static void AttachSemanticValidation(JsonTypeInfo typeInfo)
return;
}
+ if (typeInfo.Type.Assembly == typeof(PolicyDocument).Assembly)
+ {
+ typeInfo.UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow;
+ }
+ PolicySerializer.ConfigureCanonicalSerialization(typeInfo);
+ if (typeInfo.Type == typeof(PolicyDocument)
+ || typeInfo.Type == typeof(PolicyDraftDocument)
+ || typeInfo.Type == typeof(PolicyMetadata)
+ || typeInfo.Type == typeof(PolicyDraftMetadata)
+ || typeInfo.Type == typeof(PolicyManagementSnapshot)
+ || typeInfo.Type == typeof(PolicyValidationResult))
+ {
+ return;
+ }
typeInfo.OnSerializing = ValidateSemanticValue;
typeInfo.OnDeserialized = ValidateSemanticValue;
}
diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs b/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs
index 6875840..61626b1 100644
--- a/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs
+++ b/policies/dotnet/Devolutions.Now.Policy.Api/PolicyManagementModels.cs
@@ -129,13 +129,11 @@ public enum PolicyFindingCode
InvalidFieldType,
InvalidFieldValue,
DuplicateRuleId,
- IneffectiveBooleanMatch,
InvalidVersionRange,
EmptyVersionRange,
InvalidWildcardPattern,
ContradictoryConstraints,
InvalidValidityInterval,
- UnsupportedPolicyType,
UnsupportedPolicyFormatVersion,
AuditModeEnabled,
DefaultAllow,
diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/README.md b/policies/dotnet/Devolutions.Now.Policy.Api/README.md
index 9dc5edd..4ba702d 100644
--- a/policies/dotnet/Devolutions.Now.Policy.Api/README.md
+++ b/policies/dotnet/Devolutions.Now.Policy.Api/README.md
@@ -33,6 +33,7 @@ Architecture
- `MetaModels.cs` defines health, capabilities, manager capability, and error DTOs.
- `Enums.cs` defines package broker API enums and JSON string enum converters.
- `BrokerSerializer.cs` defines source-generated serializer options for the broker wire format. Public `BrokerSerializer.Options` and `BrokerSerializer.PrettyOptions` support every broker DTO, including the embedded policy model, without reflection and reject JSON null for non-nullable contract members.
+- Both strict and non-strict broker deserialization reject duplicate property names before typed deserialization, including duplicates inside opaque draft JSON and embedded policy/management response objects. Embedded policy models also reject unknown members on every broker input path so removed restrictions cannot be discarded. Escaped names are decoded and compared with ordinal, case-sensitive equality.
- `PolicyCompatibility.cs` maps compatible API enums to and from `Devolutions.Now.Policy.Model` enums.
Opaque policy store tokens and validation receipts are restricted to safe printable ASCII (`A-Z`, `a-z`, `0-9`, `.`, `_`, `~`, `:`, `-`) beginning with an ASCII alphanumeric character, so Rust and .NET enforce identical bounds.
diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs
index 3eb6b8c..95952ee 100644
--- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs
+++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs
@@ -333,6 +333,42 @@ public async Task GetPolicy_rejects_unmapped_property(string objectPath)
await AssertInvalidPolicyResponse(document);
}
+ [Fact]
+ public async Task GetPolicy_rejects_duplicate_nested_policy_property_before_deserialization()
+ {
+ var path = Path.Combine(TestData.SamplesDir, "responses", "policy.response.json");
+ var body = await File.ReadAllTextAsync(path);
+ body = body.Replace(
+ "\"PolicyFormatVersion\": \"1.0.0\",",
+ "\"PolicyFormatVersion\": \"1.0.0\",\n\"PolicyFormatVersi\\u006fn\": \"1.0.0\",",
+ StringComparison.Ordinal);
+ var client = CreateClient(new FakeBrokerTransport(body));
+
+ var exception = await Assert.ThrowsAsync(() => client.GetPolicy());
+
+ Assert.Equal(BrokerClientErrorKind.InvalidResponse, exception.Kind);
+ Assert.Equal("/v1/policy", exception.Endpoint);
+ Assert.IsAssignableFrom(exception.InnerException);
+ }
+
+ [Fact]
+ public async Task GetPolicy_wraps_invalid_surrogate_property_names()
+ {
+ var path = Path.Combine(TestData.SamplesDir, "responses", "policy.response.json");
+ var body = await File.ReadAllTextAsync(path);
+ body = body.Replace(
+ "\"PolicyFormatVersion\": \"1.0.0\",",
+ "\"PolicyFormatVersion\": \"1.0.0\",\n\"\\uD800\": true,",
+ StringComparison.Ordinal);
+ var client = CreateClient(new FakeBrokerTransport(body));
+
+ var exception = await Assert.ThrowsAsync(() => client.GetPolicy());
+
+ Assert.Equal(BrokerClientErrorKind.InvalidResponse, exception.Kind);
+ Assert.Equal("/v1/policy", exception.Endpoint);
+ Assert.IsAssignableFrom(exception.InnerException);
+ }
+
[Fact]
public async Task GetPolicy_rejects_integer_policy_enum_token()
{
@@ -347,7 +383,6 @@ public async Task GetPolicy_rejects_integer_policy_enum_token()
[Theory]
[InlineData("Server.Transport", "httpnamedpipe")]
[InlineData("Policy.Enforcement.DefaultDecision", "deny")]
- [InlineData("Policy.Enforcement.RulePrecedence", "prioritythendeny")]
[InlineData("Policy.Rules.0.Decision", "deny")]
[InlineData("Policy.Rules.0.Match.Operations.0", "install")]
public async Task GetPolicy_rejects_noncanonical_enum_casing(string propertyPath, string value)
@@ -362,7 +397,7 @@ public async Task GetPolicy_rejects_noncanonical_enum_casing(string propertyPath
[Theory]
[InlineData("Policy.Rules.0")]
- [InlineData("Policy.Rules.3.Match.Sources.0")]
+ [InlineData("Policy.Rules.3.Match.SourceNames.0")]
public async Task GetPolicy_rejects_null_collection_element(string elementPath)
{
var path = Path.Combine(TestData.SamplesDir, "responses", "policy.response.json");
@@ -373,6 +408,18 @@ public async Task GetPolicy_rejects_null_collection_element(string elementPath)
await AssertInvalidPolicyResponse(document);
}
+ [Fact]
+ public async Task GetPolicy_rejects_constraints_on_deny_rule()
+ {
+ var path = Path.Combine(TestData.SamplesDir, "responses", "policy.response.json");
+ var document = JsonNode.Parse(await File.ReadAllTextAsync(path))
+ ?? throw new InvalidOperationException("policy response sample should parse");
+ document["Policy"]!["Rules"]![0]!["Constraints"] =
+ new JsonObject { ["AllowInteractive"] = false };
+
+ await AssertInvalidPolicyResponse(document);
+ }
+
[Fact]
public async Task GetPolicy_propagates_cancellation()
{
diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs
index 158592d..c02e7da 100644
--- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs
+++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs
@@ -86,7 +86,7 @@ public void PolicyResponse_rejects_null_non_nullable_property(string propertyPat
[Theory]
[InlineData("Policy.Rules.0")]
- [InlineData("Policy.Rules.3.Match.Sources.0")]
+ [InlineData("Policy.Rules.3.Match.SourceNames.0")]
public void Strict_policy_response_rejects_null_collection_element(string elementPath)
{
var path = Path.Combine(TestData.SamplesDir, "responses", "policy.response.json");
@@ -142,6 +142,8 @@ public void Public_json_options_source_generate_all_broker_dtos()
typeof(PolicyEnforcement),
typeof(PolicyRule),
typeof(PolicyMatch),
+ typeof(PackageIdentifierCondition),
+ typeof(VersionCondition),
typeof(VersionRange),
typeof(PolicyConstraints),
];
@@ -169,6 +171,23 @@ public void Public_json_options_round_trip_policy_response_without_reflection()
Assert.Contains(Environment.NewLine, pretty);
}
+ [Fact]
+ public void Non_strict_broker_policy_inputs_reject_removed_policy_members()
+ {
+ var document = JsonNode.Parse(
+ File.ReadAllText(Path.Combine(TestData.SamplesDir, "responses", "policy.response.json")))!;
+ document["Policy"]!["Rules"]![3]!["Match"]!["PackageNames"] =
+ new JsonArray("Visual Studio Code");
+ var json = document.ToJsonString();
+
+ Assert.Throws(() => BrokerSerializer.Deserialize(json));
+ foreach (var options in new[] { BrokerSerializer.Options, BrokerSerializer.PrettyOptions })
+ {
+ Assert.Throws(
+ () => JsonSerializer.Deserialize(json, options));
+ }
+ }
+
[Fact]
public void Strict_policy_response_rejects_schema_member_and_unsupported_format_version()
{
diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs
index ec91b92..fdf2e06 100644
--- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs
+++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/PolicyManagementClientTests.cs
@@ -1,13 +1,22 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
+using Devolutions.Now.Policy.Api;
using Devolutions.Now.Policy.Client;
using Xunit;
+using PackageIdentifierCondition = Devolutions.Now.Policy.Model.PackageIdentifierCondition;
+using PolicyConstraints = Devolutions.Now.Policy.Model.PolicyConstraints;
using PolicyDocument = Devolutions.Now.Policy.Model.PolicyDocument;
using PolicyDraftDocument = Devolutions.Now.Policy.Model.PolicyDraftDocument;
+using PolicyDraftMetadata = Devolutions.Now.Policy.Model.PolicyDraftMetadata;
+using PolicyMetadata = Devolutions.Now.Policy.Model.PolicyMetadata;
+using VersionCondition = Devolutions.Now.Policy.Model.VersionCondition;
+using VersionRange = Devolutions.Now.Policy.Model.VersionRange;
namespace Devolutions.Now.Policy.Client.Tests;
@@ -223,6 +232,375 @@ await ReadFixture("requests", "policy-replacement.update.request.json"))!)),
Assert.Equal(BrokerClientErrorKind.InvalidResponse, exception.Kind);
}
+ [Fact]
+ public async Task Policy_contract_serializers_reject_duplicates_in_embedded_documents()
+ {
+ var policyResponse = WithEscapedPolicyFormatVersionDuplicate(
+ await ReadFixture("responses", "policy.response.json"));
+ var managementResponse = WithEscapedPolicyFormatVersionDuplicate(
+ await ReadFixture("responses", "policy-management.active.response.json"));
+ var validationRequest = WithEscapedPolicyFormatVersionDuplicate(
+ await ReadFixture("requests", "policy-validation.request.json"));
+ var validationResponse = WithEscapedPolicyFormatVersionDuplicate(
+ await ReadFixture("responses", "policy-validation.valid.response.json"));
+ var replacementRequest = WithEscapedPolicyFormatVersionDuplicate(
+ await ReadFixture("requests", "policy-replacement.update.request.json"));
+ var replacementResponse = WithEscapedPolicyFormatVersionDuplicate(
+ await ReadFixture("responses", "policy-replacement.response.json"));
+ var errorResponse = WithEscapedPolicyFormatVersionDuplicate(
+ await ReadFixture("responses", "policy-stale-token.error.json"));
+
+ Assert.False(JsonSerializer.IsReflectionEnabledByDefault);
+ Assert.Throws(() => BrokerSerializer.Deserialize(policyResponse));
+ Assert.Throws(() => BrokerSerializer.DeserializeStrict(policyResponse));
+ Assert.Throws(
+ () => BrokerSerializer.DeserializeStrict(managementResponse));
+ Assert.Throws(
+ () => BrokerSerializer.DeserializeStrict(validationRequest));
+ Assert.Throws(
+ () => BrokerSerializer.DeserializeStrict(validationResponse));
+ Assert.Throws(
+ () => BrokerSerializer.DeserializeStrict(replacementRequest));
+ Assert.Throws(
+ () => BrokerSerializer.DeserializeStrict(replacementResponse));
+ Assert.Throws(() => BrokerSerializer.Deserialize(errorResponse));
+
+ foreach (var options in new[] { BrokerSerializer.Options, BrokerSerializer.PrettyOptions })
+ {
+ Assert.Throws(
+ () => JsonSerializer.Deserialize(policyResponse, options));
+ Assert.Throws(
+ () => JsonSerializer.Deserialize(managementResponse, options));
+ Assert.Throws(
+ () => JsonSerializer.Deserialize(validationRequest, options));
+ Assert.Throws(
+ () => JsonSerializer.Deserialize(validationResponse, options));
+ Assert.Throws(
+ () => JsonSerializer.Deserialize(replacementRequest, options));
+ Assert.Throws(
+ () => JsonSerializer.Deserialize(replacementResponse, options));
+ Assert.Throws(
+ () => JsonSerializer.Deserialize(errorResponse, options));
+ }
+ }
+
+ [Fact]
+ public void Public_broker_options_reject_duplicates_in_direct_policy_management_types()
+ {
+ const string Metadata = """
+ {
+ "Id": "first",
+ "\u0049d": "second",
+ "Publisher": "Test",
+ "Revision": 1,
+ "PublishedAt": "2026-01-01T00:00:00Z"
+ }
+ """;
+ const string Finding = """
+ {
+ "FindingVersion": "1.0",
+ "Severity": "Warning",
+ "Code": "DefaultAllow",
+ "Path": "",
+ "Message": "first",
+ "Message": "second"
+ }
+ """;
+ const string Diagnostics = """
+ {
+ "DiagnosticsVersion": "1.0",
+ "DiagnosticsVersion": "2.0",
+ "Findings": []
+ }
+ """;
+
+ foreach (var options in new[] { BrokerSerializer.Options, BrokerSerializer.PrettyOptions })
+ {
+ Assert.Throws(
+ () => JsonSerializer.Deserialize(Metadata, options));
+ Assert.Throws(
+ () => JsonSerializer.Deserialize(Finding, options));
+ Assert.Throws(
+ () => JsonSerializer.Deserialize(Diagnostics, options));
+ }
+ }
+
+ [Fact]
+ public void Public_broker_options_enforce_standalone_policy_condition_invariants()
+ {
+ foreach (var options in new[] { BrokerSerializer.Options, BrokerSerializer.PrettyOptions })
+ {
+ foreach (var invalid in new[]
+ {
+ "{}",
+ """{"Exact":["Microsoft.*"]}""",
+ """{"Exact":["Git.Git"],"Patterns":["Git.*"]}""",
+ """{"Exact":["Git.Git"],"Patterns":null}""",
+ })
+ {
+ Assert.Throws(
+ () => JsonSerializer.Deserialize(invalid, options));
+ }
+
+ foreach (var invalid in new[]
+ {
+ "{}",
+ """{"Exact":[]}""",
+ """{"Exact":["1.0.0"],"Range":{"MinVersion":"1.0.0"}}""",
+ """{"Exact":["1.0.0"],"Range":null}""",
+ })
+ {
+ Assert.Throws(
+ () => JsonSerializer.Deserialize(invalid, options));
+ }
+
+ Assert.Throws(
+ () => JsonSerializer.Serialize(new PackageIdentifierCondition(), options));
+ Assert.Throws(
+ () => JsonSerializer.Serialize(new VersionCondition(), options));
+ foreach (var invalid in new[]
+ {
+ "{}",
+ """{"MinVersion":"not-semver"}""",
+ })
+ {
+ Assert.Throws(
+ () => JsonSerializer.Deserialize(invalid, options));
+ }
+ Assert.Throws(
+ () => JsonSerializer.Serialize(new VersionRange(), options));
+ }
+ }
+
+ [Fact]
+ public async Task Public_broker_options_enforce_standalone_validity_windows()
+ {
+ const string Metadata = """
+ {
+ "Id": "validity.test",
+ "Publisher": "Test",
+ "Revision": 1,
+ "PublishedAt": "2026-01-01T00:00:00Z",
+ "ValidFrom": "2026-01-01T01:00:00+01:00",
+ "ValidUntil": "2026-01-01T00:00:00Z"
+ }
+ """;
+ const string DraftMetadata = """
+ {
+ "Id": "validity.test",
+ "Publisher": "Test",
+ "ValidFrom": "2026-01-01T00:30:00Z",
+ "ValidUntil": "2026-01-01T01:00:00+01:00"
+ }
+ """;
+
+ foreach (var options in new[] { BrokerSerializer.Options, BrokerSerializer.PrettyOptions })
+ {
+ var exception = Assert.Throws(
+ () => JsonSerializer.Deserialize(Metadata, options));
+ Assert.Equal("$.ValidUntil", exception.Path);
+ exception = Assert.Throws(
+ () => JsonSerializer.Deserialize(DraftMetadata, options));
+ Assert.Equal("$.ValidUntil", exception.Path);
+ Assert.Throws(
+ () => JsonSerializer.Serialize(
+ new PolicyDraftMetadata
+ {
+ Id = "validity.test",
+ Publisher = "Test",
+ ValidFrom = DateTimeOffset.Parse("2026-01-01T00:00:00Z"),
+ ValidUntil = DateTimeOffset.Parse("2026-01-01T00:00:00Z"),
+ },
+ options));
+ }
+
+ var response = JsonNode.Parse(await ReadFixture("responses", "policy.response.json"))!;
+ response["Policy"]!["Metadata"]![nameof(PolicyMetadata.ValidFrom)] = "2026-01-01T00:00:00Z";
+ response["Policy"]!["Metadata"]![nameof(PolicyMetadata.ValidUntil)] = "2026-01-01T00:00:00Z";
+ foreach (var options in new[] { BrokerSerializer.Options, BrokerSerializer.PrettyOptions })
+ {
+ var exception = Assert.Throws(
+ () => JsonSerializer.Deserialize(response.ToJsonString(), options));
+ Assert.Equal("$.Policy.Metadata.ValidUntil", exception.Path);
+ }
+
+ static void Invalidate(JsonNode metadata)
+ {
+ metadata[nameof(PolicyMetadata.ValidFrom)] = "2026-01-01T00:00:00Z";
+ metadata[nameof(PolicyMetadata.ValidUntil)] = "2026-01-01T00:00:00Z";
+ }
+
+ var management = JsonNode.Parse(
+ await ReadFixture("responses", "policy-management.active.response.json"))!;
+ Invalidate(management["Management"]!["Policy"]!["Metadata"]!);
+ var validation = JsonNode.Parse(
+ await ReadFixture("responses", "policy-validation.valid.response.json"))!;
+ Invalidate(validation["Validation"]!["CanonicalDraft"]!["Metadata"]!);
+ var replacement = JsonNode.Parse(
+ await ReadFixture("responses", "policy-replacement.response.json"))!;
+ Invalidate(replacement["Validation"]!["CanonicalDraft"]!["Metadata"]!);
+ var error = JsonNode.Parse(
+ await ReadFixture("responses", "policy-stale-token.error.json"))!;
+ Invalidate(error["Management"]!["Policy"]!["Metadata"]!);
+
+ foreach (var (document, type, expectedPath) in new[]
+ {
+ (management, typeof(PolicyManagementResponse), "$.Management.Policy.Metadata.ValidUntil"),
+ (validation, typeof(PolicyValidationResponse), "$.Validation.CanonicalDraft.Metadata.ValidUntil"),
+ (replacement, typeof(PolicyReplacementResponse), "$.Validation.CanonicalDraft.Metadata.ValidUntil"),
+ (error, typeof(ErrorResponse), "$.Management.Policy.Metadata.ValidUntil"),
+ })
+ {
+ foreach (var options in new[] { BrokerSerializer.Options, BrokerSerializer.PrettyOptions })
+ {
+ var exception = Assert.Throws(
+ () => JsonSerializer.Deserialize(document.ToJsonString(), type, options));
+ Assert.Equal(expectedPath, exception.Path);
+ }
+ }
+ }
+
+ [Fact]
+ public void Public_broker_options_reject_duplicates_in_every_direct_broker_object_type()
+ {
+ var contractAssemblies = new[]
+ {
+ typeof(BrokerSerializer).Assembly,
+ typeof(PolicyDocument).Assembly,
+ };
+
+ foreach (var options in new[] { BrokerSerializer.Options, BrokerSerializer.PrettyOptions })
+ {
+ var metadataOptions = new JsonSerializerOptions(options);
+ metadataOptions.Converters.Clear();
+ var resolver = Assert.IsAssignableFrom(
+ metadataOptions.TypeInfoResolver);
+ var publicObjectTypes = contractAssemblies
+ .SelectMany(assembly => assembly.ExportedTypes)
+ .Where(type => type.IsClass && !type.IsAbstract)
+ .Where(type => resolver.GetTypeInfo(type, metadataOptions)?.Kind == JsonTypeInfoKind.Object)
+ .ToList();
+
+ Assert.Contains(typeof(RequestSource), publicObjectTypes);
+ Assert.Contains(typeof(ServerContext), publicObjectTypes);
+ Assert.Contains(typeof(ManagerCapability), publicObjectTypes);
+ Assert.Contains(typeof(ErrorDetail), publicObjectTypes);
+
+ foreach (var type in publicObjectTypes)
+ {
+ AssertDirectOptionsRejectDuplicates(type, options);
+ }
+ }
+ }
+
+ [Fact]
+ public void Duplicate_rejecting_broker_options_preserve_caller_strictness()
+ {
+ const string Request = """
+ {
+ "RequestKind": "PolicyValidationRequest",
+ "RequestVersion": "1.0",
+ "Draft": {},
+ "Unexpected": true
+ }
+ """;
+ var options = new JsonSerializerOptions(BrokerSerializer.Options)
+ {
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
+ };
+
+ Assert.Throws(
+ () => JsonSerializer.Deserialize(Request, options));
+ }
+
+ [Fact]
+ public void Invalid_surrogate_in_opaque_draft_remains_a_json_error()
+ {
+ const string Request = """
+ {
+ "RequestKind": "PolicyValidationRequest",
+ "RequestVersion": "1.0",
+ "Draft": {
+ "\uD800": true
+ }
+ }
+ """;
+
+ Assert.ThrowsAny(
+ () => BrokerSerializer.DeserializeStrict(Request));
+ Assert.ThrowsAny(
+ () => JsonSerializer.Deserialize(
+ Request,
+ BrokerSerializer.Options));
+ }
+
+ [Theory]
+ [InlineData("management")]
+ [InlineData("validation")]
+ [InlineData("replacement")]
+ [InlineData("error")]
+ public async Task BrokerClient_rejects_duplicate_policy_properties_in_management_responses(
+ string operation)
+ {
+ var responseFixture = operation switch
+ {
+ "management" => "policy-management.active.response.json",
+ "validation" => "policy-validation.valid.response.json",
+ "replacement" => "policy-replacement.response.json",
+ _ => "policy-stale-token.error.json",
+ };
+ var statusCode = operation == "error" ? 409 : 200;
+ var body = WithEscapedPolicyFormatVersionDuplicate(
+ await ReadFixture("responses", responseFixture));
+ var client = CreateClient(new FakeBrokerTransport(
+ new BrokerTransportResponse { StatusCode = statusCode, Body = body }));
+
+ var exception = operation switch
+ {
+ "management" => await Assert.ThrowsAsync(
+ () => client.GetPolicyManagement()),
+ "validation" => await Assert.ThrowsAsync(
+ () => client.ValidatePolicy(JsonDocument.Parse("{}").RootElement)),
+ "replacement" => await Assert.ThrowsAsync(
+ async () => await client.ReplacePolicy(
+ BrokerSerializer.DeserializeStrict(
+ await ReadFixture("requests", "policy-replacement.update.request.json"))!)),
+ _ => await Assert.ThrowsAsync(
+ async () => await client.ReplacePolicy(
+ BrokerSerializer.DeserializeStrict(
+ await ReadFixture("requests", "policy-replacement.update.request.json"))!)),
+ };
+
+ Assert.Equal(
+ operation == "error"
+ ? BrokerClientErrorKind.BrokerError
+ : BrokerClientErrorKind.InvalidResponse,
+ exception.Kind);
+ Assert.IsAssignableFrom(exception.InnerException);
+ Assert.Null(exception.BrokerError);
+ }
+
+ [Fact]
+ public async Task BrokerClient_wraps_invalid_surrogate_names_in_structured_error_responses()
+ {
+ var body = await ReadFixture("responses", "policy-stale-token.error.json");
+ body = body.Replace(
+ "\"Message\": \"The configured policy changed after it was read.\",",
+ "\"Message\": \"The configured policy changed after it was read.\",\n\"\\uD800\": true,",
+ StringComparison.Ordinal);
+ var client = CreateClient(new FakeBrokerTransport(
+ new BrokerTransportResponse { StatusCode = 409, Body = body }));
+ var request = BrokerSerializer.DeserializeStrict(
+ await ReadFixture("requests", "policy-replacement.update.request.json"))!;
+
+ var exception = await Assert.ThrowsAsync(
+ () => client.ReplacePolicy(request));
+
+ Assert.Equal(BrokerClientErrorKind.BrokerError, exception.Kind);
+ Assert.IsAssignableFrom(exception.InnerException);
+ Assert.Null(exception.BrokerError);
+ }
+
[Fact]
public async Task Management_methods_propagate_cancellation()
{
@@ -544,13 +922,21 @@ public async Task Public_serializer_options_enforce_policy_root_semantic_invaria
committedDto.Metadata.Revision = (uint)int.MaxValue + 1;
Assert.Throws(() => JsonSerializer.Serialize(committedDto, options));
+ var draftDto = JsonSerializer.Deserialize(draft.ToJsonString(), options)!;
+ draftDto.Rules[0].Match.SkipHashCheck = null;
+ draftDto.Rules[0].Match.Operations.Clear();
+ Assert.DoesNotContain(
+ "\"SkipHashCheck\"",
+ JsonSerializer.Serialize(draftDto, options),
+ StringComparison.Ordinal);
+ Assert.DoesNotContain(
+ "\"Operations\"",
+ JsonSerializer.Serialize(draftDto, options),
+ StringComparison.Ordinal);
+
committedDto = JsonSerializer.Deserialize(committed.ToJsonString(), options)!;
- committedDto.Rules[0].Match.SkipHashCheck = [false, true];
+ committedDto.Rules[0].Constraints = new PolicyConstraints { AllowInteractive = false };
Assert.Throws(() => JsonSerializer.Serialize(committedDto, options));
-
- var draftDto = JsonSerializer.Deserialize(draft.ToJsonString(), options)!;
- draftDto.Rules[0].Match.SkipHashCheck = [false, true];
- Assert.Throws(() => JsonSerializer.Serialize(draftDto, options));
}
}
@@ -615,6 +1001,35 @@ public async Task GetPolicyManagement_preserves_legacy_route_not_found(string bo
private static async Task ReadFixture(string directory, string file) =>
await File.ReadAllTextAsync(Path.Combine(TestData.SamplesDir, directory, file));
+ private static string WithEscapedPolicyFormatVersionDuplicate(string json) =>
+ json.Replace(
+ "\"PolicyFormatVersion\": \"1.0.0\",",
+ "\"PolicyFormatVersion\": \"1.0.0\",\n\"PolicyFormatVersi\\u006fn\": \"1.0.0\",",
+ StringComparison.Ordinal);
+
+ private static void AssertDirectOptionsRejectDuplicates(
+ Type type,
+ JsonSerializerOptions options)
+ {
+ foreach (var json in new[]
+ {
+ """{"Duplicate":true,"\u0044uplicate":true}""",
+ """{"Container":{"Duplicate":true,"\u0044uplicate":false}}""",
+ })
+ {
+ var exception = Record.Exception(
+ () => JsonSerializer.Deserialize(json, type, options));
+ Assert.True(
+ exception is JsonException,
+ $"{type.FullName} did not reject duplicate properties: {exception}");
+ var jsonException = (JsonException)exception;
+ Assert.Contains(
+ "Duplicate JSON property name",
+ jsonException.Message,
+ StringComparison.Ordinal);
+ }
+ }
+
private static JsonNode ReplaceProperty(JsonNode source, string propertyName, JsonNode value)
{
var copy = source.DeepClone();
@@ -649,6 +1064,10 @@ private static IEnumerable InvalidCommittedPolicies(JsonNode committed
var mixedBooleanMatch = committed.DeepClone();
mixedBooleanMatch["Rules"]![0]!["Match"]!["SkipHashCheck"] = new JsonArray(false, true);
yield return mixedBooleanMatch;
+
+ var constraintsOnDeny = committed.DeepClone();
+ constraintsOnDeny["Rules"]![0]!["Constraints"] = new JsonObject { ["AllowInteractive"] = false };
+ yield return constraintsOnDeny;
}
private static IEnumerable InvalidDraftPolicies(JsonNode draft)
diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/SchemaValidationTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/SchemaValidationTests.cs
index 9e5d7ec..9e08cce 100644
--- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/SchemaValidationTests.cs
+++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/SchemaValidationTests.cs
@@ -1,3 +1,5 @@
+using System.Text.Json.Nodes;
+
using NJsonSchema;
using Xunit;
@@ -87,6 +89,47 @@ public async Task Capabilities_response_samples_are_schema_valid(string path)
public async Task Policy_response_samples_are_schema_valid(string path)
=> await AssertValid(path, await TestData.SchemaAsync("PolicyResponse"));
+ [Fact]
+ public async Task OpenApi_policy_match_boolean_characteristics_are_optional_nullable_scalars()
+ {
+ var schema = await TestData.SchemaAsync("PolicyResponse");
+ var template = JsonNode.Parse(
+ await File.ReadAllTextAsync(
+ Path.Combine(TestData.SamplesDir, "responses", "policy.response.json")))!;
+ var propertyNames = new[]
+ {
+ "Interactive",
+ "SkipHashCheck",
+ "PreRelease",
+ "HasCustomParameters",
+ "HasCustomInstallLocation",
+ "HasPrePostCommands",
+ "HasKillBeforeOperation",
+ "HasUninstallPrevious",
+ };
+
+ foreach (var propertyName in propertyNames)
+ {
+ foreach (var validValue in new JsonNode?[] { null, false, true })
+ {
+ var document = template.DeepClone();
+ document["Policy"]!["Rules"]![0]!["Match"]![propertyName] = validValue?.DeepClone();
+ Assert.Empty(schema.Validate(document.ToJsonString()));
+ }
+
+ foreach (var invalidValue in new[] { "[]", "[false]", "[true]", "[false,true]", "\"true\"", "0", "{}" })
+ {
+ var document = template.DeepClone();
+ document["Policy"]!["Rules"]![0]!["Match"]![propertyName] = JsonNode.Parse(invalidValue);
+ Assert.NotEmpty(schema.Validate(document.ToJsonString()));
+ }
+
+ var omitted = template.DeepClone();
+ omitted["Policy"]!["Rules"]![0]!["Match"]!.AsObject().Remove(propertyName);
+ Assert.Empty(schema.Validate(omitted.ToJsonString()));
+ }
+ }
+
[Fact]
public async Task Invalid_request_is_rejected_by_schema()
{
diff --git a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs
index ce0fa99..3c419de 100644
--- a/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs
+++ b/policies/dotnet/Devolutions.Now.Policy.Model.Tests/PolicyTests.cs
@@ -1,6 +1,7 @@
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
using NJsonSchema;
@@ -22,6 +23,12 @@ public class PolicyTests
public static IEnumerable