diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs
index 387db94..1c2e5f3 100644
--- a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs
+++ b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs
@@ -19,6 +19,7 @@ public static class BrokerApi
public const string ExecutionResponseKind = "ExecutionResponse";
public const string StatusResponseKind = "StatusResponse";
public const string CancelResponseKind = "CancelResponse";
+ public const string PolicyResponseKind = "PolicyResponse";
public const string ErrorResponseKind = "ErrorResponse";
internal static string ValidateMessageKind(string? value, string expected, string propertyName)
diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs
index 5d87806..7afcb4d 100644
--- a/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs
+++ b/policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs
@@ -3,6 +3,8 @@
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
+using Devolutions.Now.Policy.Model;
+
namespace Devolutions.Now.Policy.Api;
/// Canonical schema URI used in the $schema field of policy documents.
@@ -19,14 +21,9 @@ public static class BrokerJson
/// (via explicit [JsonPropertyName] attributes), PascalCase enum values, and
/// null optionals omitted (mirroring the Rust skip_serializing_if = "Option::is_none").
///
- public static readonly JsonSerializerOptions Options = new(BrokerJsonSerializerContext.Default.Options)
- {
- };
+ public static readonly JsonSerializerOptions Options = CreateOptions(writeIndented: false);
- public static readonly JsonSerializerOptions PrettyOptions = new(Options)
- {
- WriteIndented = true,
- };
+ public static readonly JsonSerializerOptions PrettyOptions = CreateOptions(writeIndented: true);
public static string Serialize(T value) =>
JsonSerializer.Serialize(value, TypeInfo());
@@ -34,8 +31,16 @@ public static string Serialize(T value) =>
public static T? Deserialize(string json) =>
JsonSerializer.Deserialize(json, TypeInfo());
- public static T? DeserializeStrict(string json) =>
- JsonSerializer.Deserialize(json, StrictTypeInfo());
+ public static T? DeserializeStrict(string json)
+ {
+ var value = JsonSerializer.Deserialize(json, StrictTypeInfo());
+ if (value is PolicyResponse response)
+ {
+ PolicyJson.ValidateRequiredCollectionElements(response.Policy);
+ }
+
+ return value;
+ }
private static JsonTypeInfo TypeInfo() =>
typeof(T) == typeof(PackageRequest) ? Cast(BrokerJsonSerializerContext.Default.PackageRequest) :
@@ -43,6 +48,7 @@ private static JsonTypeInfo TypeInfo() =>
typeof(T) == typeof(CancelRequest) ? Cast(BrokerJsonSerializerContext.Default.CancelRequest) :
typeof(T) == typeof(HealthResponse) ? Cast(BrokerJsonSerializerContext.Default.HealthResponse) :
typeof(T) == typeof(CapabilitiesResponse) ? Cast(BrokerJsonSerializerContext.Default.CapabilitiesResponse) :
+ typeof(T) == typeof(PolicyResponse) ? Cast(BrokerPolicyJsonSerializerContext.Default.PolicyResponse) :
typeof(T) == typeof(EvaluationResponse) ? Cast(BrokerJsonSerializerContext.Default.EvaluationResponse) :
typeof(T) == typeof(ExecutionResponse) ? Cast(BrokerJsonSerializerContext.Default.ExecutionResponse) :
typeof(T) == typeof(StatusResponse) ? Cast(BrokerJsonSerializerContext.Default.StatusResponse) :
@@ -56,6 +62,7 @@ private static JsonTypeInfo StrictTypeInfo() =>
typeof(T) == typeof(CancelRequest) ? Cast(BrokerJsonStrictSerializerContext.Default.CancelRequest) :
typeof(T) == typeof(HealthResponse) ? Cast(BrokerJsonStrictSerializerContext.Default.HealthResponse) :
typeof(T) == typeof(CapabilitiesResponse) ? Cast(BrokerJsonStrictSerializerContext.Default.CapabilitiesResponse) :
+ typeof(T) == typeof(PolicyResponse) ? Cast(BrokerPolicyJsonStrictSerializerContext.Default.PolicyResponse) :
typeof(T) == typeof(EvaluationResponse) ? Cast(BrokerJsonStrictSerializerContext.Default.EvaluationResponse) :
typeof(T) == typeof(ExecutionResponse) ? Cast(BrokerJsonStrictSerializerContext.Default.ExecutionResponse) :
typeof(T) == typeof(StatusResponse) ? Cast(BrokerJsonStrictSerializerContext.Default.StatusResponse) :
@@ -65,10 +72,20 @@ private static JsonTypeInfo StrictTypeInfo() =>
private static JsonTypeInfo Cast(JsonTypeInfo jsonTypeInfo) =>
(JsonTypeInfo)jsonTypeInfo;
+
+ private static JsonSerializerOptions CreateOptions(bool writeIndented) =>
+ new(BrokerJsonSerializerContext.Default.Options)
+ {
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(
+ BrokerJsonSerializerContext.Default,
+ BrokerPolicyJsonSerializerContext.Default),
+ WriteIndented = writeIndented,
+ };
}
[JsonSourceGenerationOptions(
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ RespectNullableAnnotations = true,
WriteIndented = false)]
[JsonSerializable(typeof(PackageRequest))]
[JsonSerializable(typeof(StatusRequest))]
@@ -87,6 +104,7 @@ internal sealed partial class BrokerJsonSerializerContext : JsonSerializerContex
[JsonSourceGenerationOptions(
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ RespectNullableAnnotations = true,
WriteIndented = false,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow)]
[JsonSerializable(typeof(PackageRequest))]
@@ -102,4 +120,21 @@ internal sealed partial class BrokerJsonSerializerContext : JsonSerializerContex
[JsonSerializable(typeof(JsonNode))]
[JsonSerializable(typeof(JsonObject))]
[JsonSerializable(typeof(JsonArray))]
-internal sealed partial class BrokerJsonStrictSerializerContext : JsonSerializerContext;
\ No newline at end of file
+internal sealed partial class BrokerJsonStrictSerializerContext : JsonSerializerContext;
+
+[JsonSourceGenerationOptions(
+ Converters = new[] { typeof(ExactCaseTransportConverter) },
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ RespectNullableAnnotations = true,
+ WriteIndented = false)]
+[JsonSerializable(typeof(PolicyResponse))]
+internal sealed partial class BrokerPolicyJsonSerializerContext : JsonSerializerContext;
+
+[JsonSourceGenerationOptions(
+ Converters = new[] { typeof(ExactCaseTransportConverter) },
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ RespectNullableAnnotations = true,
+ WriteIndented = false,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow)]
+[JsonSerializable(typeof(PolicyResponse))]
+internal sealed partial class BrokerPolicyJsonStrictSerializerContext : JsonSerializerContext;
\ No newline at end of file
diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs b/policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs
index 4959a6c..38fe46e 100644
--- a/policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs
+++ b/policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs
@@ -1,3 +1,4 @@
+using System.Text.Json;
using System.Text.Json.Serialization;
namespace Devolutions.Now.Policy.Api;
@@ -5,6 +6,37 @@ namespace Devolutions.Now.Policy.Api;
// Enum members are spelled exactly as they appear on the wire (PascalCase), so the
// default JsonStringEnumConverter round-trips them without a naming policy.
+internal class ExactCaseStringEnumConverter : JsonConverter
+ where TEnum : struct, Enum
+{
+ public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType != JsonTokenType.String)
+ {
+ throw new JsonException($"Expected a string value for {typeof(TEnum).Name}.");
+ }
+
+ var name = reader.GetString();
+ if (name is null ||
+ !Enum.TryParse(name, ignoreCase: false, out var value) ||
+ Enum.GetName(value) != name)
+ {
+ throw new JsonException($"'{name}' is not a canonical {typeof(TEnum).Name} value.");
+ }
+
+ return value;
+ }
+
+ public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options)
+ {
+ var name = Enum.GetName(value)
+ ?? throw new JsonException($"'{value}' is not a defined {typeof(TEnum).Name} value.");
+ writer.WriteStringValue(name);
+ }
+}
+
+internal sealed class ExactCaseTransportConverter : ExactCaseStringEnumConverter;
+
/// Package operation type.
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum Operation
diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/README.md b/policies/dotnet/Devolutions.Now.Policy.Api/README.md
index 3898aa8..4180214 100644
--- a/policies/dotnet/Devolutions.Now.Policy.Api/README.md
+++ b/policies/dotnet/Devolutions.Now.Policy.Api/README.md
@@ -6,7 +6,7 @@ Devolutions NOW package broker API for .NET
Purpose
-------
-This package contains request, response, status, health, capabilities, and error DTOs for package broker clients and implementations. It does not perform HTTP transport, named-pipe I/O, policy evaluation, or package-manager execution.
+This package contains request, response, status, health, capabilities, active-policy inspection, and error DTOs for package broker clients and implementations. It does not perform HTTP transport, named-pipe I/O, policy evaluation, or package-manager execution.
Top-level request DTOs carry `RequestKind` and `RequestVersion`; top-level response DTOs carry
`ResponseKind` and `ResponseVersion`. Kind properties are fixed discriminators that serialize
@@ -17,7 +17,7 @@ the wire schema.
The DTOs are used to:
- serialize package broker requests from .NET clients;
-- deserialize broker health, capability, evaluation, execution, status, and error responses;
+- deserialize broker health, capability, active-policy, evaluation, execution, status, and error responses;
- share the same JSON wire shape as the Rust source-of-truth model;
- provide compatibility conversions between package broker API enums and the `Devolutions.Now.Policy.Model` policy enums.
@@ -25,11 +25,11 @@ Architecture
------------
- `RequestModels.cs` defines `PackageRequest` and request context/options.
-- `ResponseModels.cs` defines evaluation and execution responses plus shared response context, summaries, decisions, policy info, diagnostics, and operation submission.
+- `ResponseModels.cs` defines active-policy, evaluation, and execution responses plus shared response context, summaries, decisions, policy info, diagnostics, and operation submission. `PolicyResponse` embeds the canonical `Devolutions.Now.Policy.Model.PolicyDocument`.
- `StatusModels.cs` defines status query request/response DTOs.
- `MetaModels.cs` defines health, capabilities, manager capability, and error DTOs.
- `Enums.cs` defines package broker API enums and JSON string enum converters.
-- `BrokerJson.cs` defines serializer options for the broker wire format.
+- `BrokerJson.cs` defines source-generated serializer options for the broker wire format. Public `BrokerJson.Options` and `BrokerJson.PrettyOptions` support every broker DTO, including the embedded policy model, without reflection and reject JSON null for non-nullable contract members.
- `PolicyCompatibility.cs` maps compatible API enums to and from `Devolutions.Now.Policy.Model` enums.
OpenAPI relationship
@@ -44,7 +44,7 @@ policies\rust\now-policy-api\openapi\now-policy-api.yaml
Regenerate it with:
```powershell
-cargo run -p now-policy-server-template --bin generate-now-policy-api-openapi --locked
+cargo run -p now-policy-server-template --features policy-compat --bin generate-now-policy-api-openapi --locked
```
After schema changes, run the .NET client tests to verify these DTOs still match the Rust contract.
diff --git a/policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs b/policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs
index d86dd57..3d59925 100644
--- a/policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs
+++ b/policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs
@@ -1,7 +1,36 @@
using System.Text.Json.Serialization;
+using Devolutions.Now.Policy.Model;
+
namespace Devolutions.Now.Policy.Api;
+/// Response containing the broker's active parsed policy document.
+public sealed class PolicyResponse
+{
+ private const string Kind = BrokerApi.PolicyResponseKind;
+ private string _responseKind = Kind;
+
+ [JsonPropertyName("ResponseKind")]
+ [JsonRequired]
+ public string ResponseKind
+ {
+ get => _responseKind;
+ set => _responseKind = BrokerApi.ValidateMessageKind(value, Kind, nameof(ResponseKind));
+ }
+
+ [JsonPropertyName("ResponseVersion")]
+ [JsonRequired]
+ public string ResponseVersion { get; set; } = BrokerApi.Version;
+
+ [JsonPropertyName("Server")]
+ [JsonRequired]
+ public ServerContext Server { get; set; } = new();
+
+ [JsonPropertyName("Policy")]
+ [JsonRequired]
+ public PolicyDocument Policy { get; set; } = new();
+}
+
/// Canonical response returned by the broker after evaluating a request.
public sealed class EvaluationResponse
{
@@ -95,9 +124,11 @@ public string ResponseKind
public sealed class ServerContext
{
[JsonPropertyName("ServerVersion")]
+ [JsonRequired]
public string ServerVersion { get; set; } = "";
[JsonPropertyName("Transport")]
+ [JsonRequired]
public Transport Transport { get; set; }
}
diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs
index 8cd59ea..00b57e6 100644
--- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs
+++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs
@@ -1,4 +1,5 @@
using System.Text.Json;
+using System.Text.Json.Nodes;
using Devolutions.Now.Policy.Client;
@@ -299,6 +300,182 @@ public async Task GetHealth_throws_typed_error_for_broker_error_response()
Assert.Contains("mock failure", exception.Message);
}
+ [Fact]
+ public async Task GetPolicy_sends_json_get_and_deserializes_response()
+ {
+ var body = await File.ReadAllTextAsync(Path.Combine(TestData.SamplesDir, "responses", "policy.response.json"));
+ var transport = new FakeBrokerTransport(body);
+ var client = CreateClient(transport);
+
+ var response = await client.GetPolicy();
+
+ var request = Assert.Single(transport.Requests);
+ Assert.Equal("GET", request.Method);
+ Assert.Equal("/v1/policy", request.Path);
+ Assert.Null(request.Body);
+ Assert.Equal("application/json", request.Headers["Accept"]);
+ Assert.Equal(BrokerApi.PolicyResponseKind, response.ResponseKind);
+ Assert.Equal("contoso.desktop.standard-allowlist", response.Policy.Metadata.Id);
+ Assert.Equal(4u, response.Policy.Metadata.Revision);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("Policy.Metadata")]
+ public async Task GetPolicy_rejects_unmapped_property(string objectPath)
+ {
+ 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");
+ var target = string.IsNullOrEmpty(objectPath) ? document : ResolveNode(document, objectPath);
+ target.AsObject()["Unexpected"] = true;
+
+ await AssertInvalidPolicyResponse(document);
+ }
+
+ [Fact]
+ public async Task GetPolicy_rejects_integer_policy_enum_token()
+ {
+ 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");
+ ResolveNode(document, "Policy.Rules.0.Match.Operations").AsArray()[0] = 0;
+
+ await AssertInvalidPolicyResponse(document);
+ }
+
+ [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)
+ {
+ 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");
+ SetPropertyValue(document, propertyPath, value);
+
+ await AssertInvalidPolicyResponse(document);
+ }
+
+ [Theory]
+ [InlineData("Policy.Rules.0")]
+ [InlineData("Policy.Rules.3.Match.Sources.0")]
+ public async Task GetPolicy_rejects_null_collection_element(string elementPath)
+ {
+ 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");
+ SetPropertyToNull(document, elementPath);
+
+ await AssertInvalidPolicyResponse(document);
+ }
+
+ [Fact]
+ public async Task GetPolicy_propagates_cancellation()
+ {
+ var transport = new FakeBrokerTransport(Array.Empty());
+ var client = CreateClient(transport);
+ using var cancellation = new CancellationTokenSource();
+ cancellation.Cancel();
+
+ await Assert.ThrowsAnyAsync(() => client.GetPolicy(cancellation.Token));
+ Assert.Empty(transport.Requests);
+ }
+
+ [Theory]
+ [InlineData("HttpNamedPipe")]
+ [InlineData("httpnamedpipe")]
+ public async Task GetPolicy_preserves_structured_unsupported_error(string transportValue)
+ {
+ var transport = new FakeBrokerTransport(new BrokerTransportResponse
+ {
+ StatusCode = 404,
+ Body = """
+ {"ResponseKind":"ErrorResponse","ResponseVersion":"1.0","Server":{"ServerVersion":"mock","Transport":"HttpNamedPipe"},"Code":"NotFound","Message":"active policy inspection is not supported"}
+ """.Replace("HttpNamedPipe", transportValue, StringComparison.Ordinal),
+ });
+ var client = CreateClient(transport);
+
+ var exception = await Assert.ThrowsAsync(() => client.GetPolicy());
+
+ Assert.Equal(BrokerClientErrorKind.BrokerError, exception.Kind);
+ Assert.Equal("/v1/policy", exception.Endpoint);
+ Assert.Equal(404, exception.StatusCode);
+ Assert.Equal(ErrorCode.NotFound, exception.BrokerError?.Code);
+ }
+
+ [Theory]
+ [InlineData("ResponseVersion")]
+ [InlineData("Server")]
+ [InlineData("Server.ServerVersion")]
+ [InlineData("Server.Transport")]
+ [InlineData("Policy.$schema")]
+ [InlineData("Policy.Metadata.Id")]
+ [InlineData("Policy.Enforcement.DefaultDecision")]
+ [InlineData("Policy.Rules")]
+ [InlineData("Policy.Rules.0.Match")]
+ public async Task GetPolicy_rejects_missing_required_property(string propertyPath)
+ {
+ 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");
+ RemoveProperty(document, propertyPath);
+ var client = CreateClient(new FakeBrokerTransport(document.ToJsonString()));
+
+ var exception = await Assert.ThrowsAsync(() => client.GetPolicy());
+
+ Assert.Equal(BrokerClientErrorKind.InvalidResponse, exception.Kind);
+ Assert.Equal("/v1/policy", exception.Endpoint);
+ Assert.Equal(200, exception.StatusCode);
+ }
+
+ [Theory]
+ [InlineData("ResponseVersion")]
+ [InlineData("Server")]
+ [InlineData("Server.ServerVersion")]
+ [InlineData("Policy")]
+ [InlineData("Policy.Metadata")]
+ [InlineData("Policy.Metadata.Id")]
+ [InlineData("Policy.Enforcement.DefaultDecision")]
+ [InlineData("Policy.Rules")]
+ [InlineData("Policy.Rules.0.Match")]
+ [InlineData("Policy.Rules.0.Match.Operations")]
+ public async Task GetPolicy_rejects_null_non_nullable_property(string propertyPath)
+ {
+ 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");
+ SetPropertyToNull(document, propertyPath);
+ var client = CreateClient(new FakeBrokerTransport(document.ToJsonString()));
+
+ var exception = await Assert.ThrowsAsync(() => client.GetPolicy());
+
+ Assert.Equal(BrokerClientErrorKind.InvalidResponse, exception.Kind);
+ Assert.Equal("/v1/policy", exception.Endpoint);
+ Assert.Equal(200, exception.StatusCode);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("not found")]
+ public async Task GetPolicy_identifies_legacy_unstructured_not_found(string body)
+ {
+ var transport = new FakeBrokerTransport(new BrokerTransportResponse { StatusCode = 404, Body = body });
+ var client = CreateClient(transport);
+
+ var exception = await Assert.ThrowsAsync(() => client.GetPolicy());
+
+ Assert.True(
+ exception.Kind is BrokerClientErrorKind.EmptyResponse or BrokerClientErrorKind.BrokerError,
+ $"unexpected legacy 404 error kind: {exception.Kind}");
+ Assert.Equal("/v1/policy", exception.Endpoint);
+ Assert.Equal(404, exception.StatusCode);
+ Assert.Null(exception.BrokerError);
+ }
+
[Fact]
public void Constructor_can_resolve_effective_user_automatically()
{
@@ -319,6 +496,70 @@ public void Constructor_can_resolve_effective_user_automatically()
ClientVersion = "9.8.7",
});
+ private static async Task AssertInvalidPolicyResponse(JsonNode document)
+ {
+ var client = CreateClient(new FakeBrokerTransport(document.ToJsonString()));
+
+ var exception = await Assert.ThrowsAsync(() => client.GetPolicy());
+
+ Assert.Equal(BrokerClientErrorKind.InvalidResponse, exception.Kind);
+ Assert.Equal("/v1/policy", exception.Endpoint);
+ Assert.Equal(200, exception.StatusCode);
+ }
+
+ private static void RemoveProperty(JsonNode document, string propertyPath)
+ {
+ var segments = propertyPath.Split('.');
+ var parent = document;
+ foreach (var segment in segments[..^1])
+ {
+ parent = int.TryParse(segment, out var index)
+ ? parent.AsArray()[index]!
+ : parent[segment]!;
+ }
+
+ Assert.True(parent.AsObject().Remove(segments[^1]), $"missing fixture property {propertyPath}");
+ }
+
+ private static void SetPropertyToNull(JsonNode document, string propertyPath)
+ => SetPropertyValue(document, propertyPath, null);
+
+ private static void SetPropertyValue(JsonNode document, string propertyPath, JsonNode? value)
+ {
+ var segments = propertyPath.Split('.');
+ var parent = document;
+ foreach (var segment in segments[..^1])
+ {
+ parent = int.TryParse(segment, out var index)
+ ? parent.AsArray()[index]!
+ : parent[segment]!;
+ }
+
+ if (int.TryParse(segments[^1], out var finalIndex))
+ {
+ Assert.NotNull(parent.AsArray()[finalIndex]);
+ parent.AsArray()[finalIndex] = value;
+ }
+ else
+ {
+ Assert.NotNull(parent.AsObject()[segments[^1]]);
+ parent.AsObject()[segments[^1]] = value;
+ }
+ }
+
+ private static JsonNode ResolveNode(JsonNode document, string propertyPath)
+ {
+ var node = document;
+ foreach (var segment in propertyPath.Split('.'))
+ {
+ node = int.TryParse(segment, out var index)
+ ? node.AsArray()[index]!
+ : node[segment]!;
+ }
+
+ return node;
+ }
+
private sealed class FakeBrokerTransport : IBrokerTransport
{
private readonly Queue _responses;
@@ -340,6 +581,7 @@ public FakeBrokerTransport(params BrokerTransportResponse[] responses)
public Task Send(BrokerTransportRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
+ cancellationToken.ThrowIfCancellationRequested();
Requests.Add(request);
if (_responses.Count == 0)
{
diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/DtoRoundTripTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/DtoRoundTripTests.cs
index 8bac24d..6cfd91c 100644
--- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/DtoRoundTripTests.cs
+++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/DtoRoundTripTests.cs
@@ -57,6 +57,11 @@ public async Task HealthResponse_round_trips_and_validates(string path)
public async Task CapabilitiesResponse_round_trips_and_validates(string path)
=> await AssertRoundTrip(path, await TestData.SchemaAsync("CapabilitiesResponse"));
+ [Theory]
+ [MemberData(nameof(TestData.PolicyResponseSamples), MemberType = typeof(TestData))]
+ public async Task PolicyResponse_round_trips_and_validates(string path)
+ => await AssertRoundTrip(path, await TestData.SchemaAsync("PolicyResponse"));
+
private static async Task AssertRoundTrip(string samplePath, JsonSchema schema)
{
var original = await File.ReadAllTextAsync(samplePath);
diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs
index 38e6249..dfd4539 100644
--- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs
+++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/MetaModelTests.cs
@@ -1,4 +1,7 @@
using System.Text.Json;
+using System.Text.Json.Nodes;
+
+using Devolutions.Now.Policy.Model;
using Xunit;
@@ -37,6 +40,124 @@ public void ResponseKind_rejects_wrong_value_on_deserialization()
Assert.Throws(() => BrokerJson.DeserializeStrict(json));
}
+ [Fact]
+ public void PolicyResponseKind_rejects_wrong_value_on_deserialization()
+ {
+ var json = File.ReadAllText(Path.Combine(TestData.SamplesDir, "responses", "policy.response.json"))
+ .Replace(BrokerApi.PolicyResponseKind, BrokerApi.ErrorResponseKind, StringComparison.Ordinal);
+
+ Assert.Throws(() => BrokerJson.DeserializeStrict(json));
+ }
+
+ [Fact]
+ public void PolicyResponse_requires_policy_on_deserialization()
+ {
+ const string json =
+ """
+ {"ResponseKind":"PolicyResponse","ResponseVersion":"1.0","Server":{"ServerVersion":"mock","Transport":"HttpNamedPipe"}}
+ """;
+
+ Assert.Throws(() => BrokerJson.Deserialize(json));
+ }
+
+ [Theory]
+ [InlineData("ResponseVersion")]
+ [InlineData("Server")]
+ [InlineData("Server.ServerVersion")]
+ [InlineData("Policy")]
+ [InlineData("Policy.Metadata")]
+ [InlineData("Policy.Metadata.Id")]
+ [InlineData("Policy.Enforcement.DefaultDecision")]
+ [InlineData("Policy.Rules")]
+ [InlineData("Policy.Rules.0.Match")]
+ [InlineData("Policy.Rules.0.Match.Operations")]
+ public void PolicyResponse_rejects_null_non_nullable_property(string propertyPath)
+ {
+ var path = Path.Combine(TestData.SamplesDir, "responses", "policy.response.json");
+ var document = JsonNode.Parse(File.ReadAllText(path))
+ ?? throw new InvalidOperationException("policy response sample should parse");
+ SetPropertyToNull(document, propertyPath);
+ var json = document.ToJsonString();
+
+ Assert.Throws(() => BrokerJson.Deserialize(json));
+ Assert.Throws(() => BrokerJson.DeserializeStrict(json));
+ Assert.Throws(() => JsonSerializer.Deserialize(json, BrokerJson.Options));
+ }
+
+ [Theory]
+ [InlineData("Policy.Rules.0")]
+ [InlineData("Policy.Rules.3.Match.Sources.0")]
+ public void Strict_policy_response_rejects_null_collection_element(string elementPath)
+ {
+ var path = Path.Combine(TestData.SamplesDir, "responses", "policy.response.json");
+ var document = JsonNode.Parse(File.ReadAllText(path))
+ ?? throw new InvalidOperationException("policy response sample should parse");
+ SetPropertyToNull(document, elementPath);
+
+ Assert.Throws(() => BrokerJson.DeserializeStrict(document.ToJsonString()));
+ }
+
+ [Fact]
+ public void Public_json_options_source_generate_all_broker_dtos()
+ {
+ Type[] dtoTypes =
+ [
+ typeof(PackageRequest),
+ typeof(RequestSource),
+ typeof(RequestPackage),
+ typeof(RequestOptions),
+ typeof(ClientContext),
+ typeof(PolicyResponse),
+ typeof(EvaluationResponse),
+ typeof(ExecutionResponse),
+ typeof(ServerContext),
+ typeof(RequestSummary),
+ typeof(DecisionInfo),
+ typeof(ResponsePolicyInfo),
+ typeof(OperationDiagnostics),
+ typeof(OperationSubmission),
+ typeof(StatusRequest),
+ typeof(StatusResponse),
+ typeof(CancelRequest),
+ typeof(CancelResponse),
+ typeof(HealthResponse),
+ typeof(CapabilitiesResponse),
+ typeof(ManagerCapability),
+ typeof(ErrorResponse),
+ typeof(ErrorDetail),
+ typeof(EventChannel),
+ typeof(PolicyDocument),
+ typeof(PolicyMetadata),
+ typeof(PolicyEnforcement),
+ typeof(PolicyRule),
+ typeof(PolicyMatch),
+ typeof(VersionRange),
+ typeof(PolicyConstraints),
+ ];
+
+ foreach (var dtoType in dtoTypes)
+ {
+ Assert.NotNull(BrokerJson.Options.GetTypeInfo(dtoType));
+ Assert.NotNull(BrokerJson.PrettyOptions.GetTypeInfo(dtoType));
+ }
+ }
+
+ [Fact]
+ public void Public_json_options_round_trip_policy_response_without_reflection()
+ {
+ var json = File.ReadAllText(Path.Combine(TestData.SamplesDir, "responses", "policy.response.json"));
+ var response = JsonSerializer.Deserialize(json, BrokerJson.Options);
+
+ Assert.NotNull(response);
+
+ var compact = JsonSerializer.Serialize(response, BrokerJson.Options);
+ var pretty = JsonSerializer.Serialize(response, BrokerJson.PrettyOptions);
+
+ Assert.NotNull(JsonSerializer.Deserialize(compact, BrokerJson.Options));
+ Assert.NotNull(JsonSerializer.Deserialize(pretty, BrokerJson.PrettyOptions));
+ Assert.Contains(Environment.NewLine, pretty);
+ }
+
[Fact]
public async Task ErrorResponse_serializes_to_schema_valid_output()
{
@@ -73,6 +194,29 @@ public async Task ErrorResponse_serializes_to_schema_valid_output()
Transport = Transport.HttpNamedPipe,
};
+ private static void SetPropertyToNull(JsonNode document, string propertyPath)
+ {
+ var segments = propertyPath.Split('.');
+ var parent = document;
+ foreach (var segment in segments[..^1])
+ {
+ parent = int.TryParse(segment, out var index)
+ ? parent.AsArray()[index]!
+ : parent[segment]!;
+ }
+
+ if (int.TryParse(segments[^1], out var finalIndex))
+ {
+ Assert.NotNull(parent.AsArray()[finalIndex]);
+ parent.AsArray()[finalIndex] = null;
+ }
+ else
+ {
+ Assert.NotNull(parent.AsObject()[segments[^1]]);
+ parent.AsObject()[segments[^1]] = null;
+ }
+ }
+
private static async Task AssertSerializesValid(T dto, string componentName)
{
var schema = await TestData.SchemaAsync(componentName);
diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/SchemaValidationTests.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/SchemaValidationTests.cs
index d10c73b..e01d110 100644
--- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/SchemaValidationTests.cs
+++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/SchemaValidationTests.cs
@@ -32,6 +32,8 @@ public async Task Broker_api_version_matches_openapi_and_message_versions()
Assert.Equal(BrokerApi.Version, new HealthResponse().ResponseVersion);
Assert.Equal(BrokerApi.CapabilitiesResponseKind, new CapabilitiesResponse().ResponseKind);
Assert.Equal(BrokerApi.Version, new CapabilitiesResponse().ResponseVersion);
+ Assert.Equal(BrokerApi.PolicyResponseKind, new PolicyResponse().ResponseKind);
+ Assert.Equal(BrokerApi.Version, new PolicyResponse().ResponseVersion);
Assert.Equal(BrokerApi.ErrorResponseKind, new ErrorResponse().ResponseKind);
Assert.Equal(BrokerApi.Version, new ErrorResponse().ResponseVersion);
}
@@ -71,6 +73,11 @@ public async Task Health_response_samples_are_schema_valid(string path)
public async Task Capabilities_response_samples_are_schema_valid(string path)
=> await AssertValid(path, await TestData.SchemaAsync("CapabilitiesResponse"));
+ [Theory]
+ [MemberData(nameof(TestData.PolicyResponseSamples), MemberType = typeof(TestData))]
+ public async Task Policy_response_samples_are_schema_valid(string path)
+ => await AssertValid(path, await TestData.SchemaAsync("PolicyResponse"));
+
[Fact]
public async Task Invalid_request_is_rejected_by_schema()
{
diff --git a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/TestData.cs b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/TestData.cs
index bc6007f..df115cc 100644
--- a/policies/dotnet/Devolutions.Now.Policy.Client.Tests/TestData.cs
+++ b/policies/dotnet/Devolutions.Now.Policy.Client.Tests/TestData.cs
@@ -163,6 +163,7 @@ public static IEnumerable