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
2 changes: 2 additions & 0 deletions policies/dotnet/Devolutions.Now.Policy.Api/BrokerApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ public static class BrokerApi

public const string PackageRequestKind = "PackageRequest";
public const string StatusRequestKind = "StatusRequest";
public const string CancelRequestKind = "CancelRequest";

public const string HealthResponseKind = "HealthResponse";
public const string CapabilitiesResponseKind = "CapabilitiesResponse";
public const string EvaluationResponseKind = "EvaluationResponse";
public const string ExecutionResponseKind = "ExecutionResponse";
public const string StatusResponseKind = "StatusResponse";
public const string CancelResponseKind = "CancelResponse";
public const string ErrorResponseKind = "ErrorResponse";

internal static string ValidateMessageKind(string? value, string expected, string propertyName)
Expand Down
8 changes: 8 additions & 0 deletions policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,22 +40,26 @@ public static string Serialize<T>(T value) =>
private static JsonTypeInfo<T> TypeInfo<T>() =>
typeof(T) == typeof(PackageRequest) ? Cast<T>(BrokerJsonSerializerContext.Default.PackageRequest) :
typeof(T) == typeof(StatusRequest) ? Cast<T>(BrokerJsonSerializerContext.Default.StatusRequest) :
typeof(T) == typeof(CancelRequest) ? Cast<T>(BrokerJsonSerializerContext.Default.CancelRequest) :
typeof(T) == typeof(HealthResponse) ? Cast<T>(BrokerJsonSerializerContext.Default.HealthResponse) :
typeof(T) == typeof(CapabilitiesResponse) ? Cast<T>(BrokerJsonSerializerContext.Default.CapabilitiesResponse) :
typeof(T) == typeof(EvaluationResponse) ? Cast<T>(BrokerJsonSerializerContext.Default.EvaluationResponse) :
typeof(T) == typeof(ExecutionResponse) ? Cast<T>(BrokerJsonSerializerContext.Default.ExecutionResponse) :
typeof(T) == typeof(StatusResponse) ? Cast<T>(BrokerJsonSerializerContext.Default.StatusResponse) :
typeof(T) == typeof(CancelResponse) ? Cast<T>(BrokerJsonSerializerContext.Default.CancelResponse) :
typeof(T) == typeof(ErrorResponse) ? Cast<T>(BrokerJsonSerializerContext.Default.ErrorResponse) :
throw new NotSupportedException($"Broker JSON serialization for {typeof(T).FullName} is not source-generated.");

private static JsonTypeInfo<T> StrictTypeInfo<T>() =>
typeof(T) == typeof(PackageRequest) ? Cast<T>(BrokerJsonStrictSerializerContext.Default.PackageRequest) :
typeof(T) == typeof(StatusRequest) ? Cast<T>(BrokerJsonStrictSerializerContext.Default.StatusRequest) :
typeof(T) == typeof(CancelRequest) ? Cast<T>(BrokerJsonStrictSerializerContext.Default.CancelRequest) :
typeof(T) == typeof(HealthResponse) ? Cast<T>(BrokerJsonStrictSerializerContext.Default.HealthResponse) :
typeof(T) == typeof(CapabilitiesResponse) ? Cast<T>(BrokerJsonStrictSerializerContext.Default.CapabilitiesResponse) :
typeof(T) == typeof(EvaluationResponse) ? Cast<T>(BrokerJsonStrictSerializerContext.Default.EvaluationResponse) :
typeof(T) == typeof(ExecutionResponse) ? Cast<T>(BrokerJsonStrictSerializerContext.Default.ExecutionResponse) :
typeof(T) == typeof(StatusResponse) ? Cast<T>(BrokerJsonStrictSerializerContext.Default.StatusResponse) :
typeof(T) == typeof(CancelResponse) ? Cast<T>(BrokerJsonStrictSerializerContext.Default.CancelResponse) :
typeof(T) == typeof(ErrorResponse) ? Cast<T>(BrokerJsonStrictSerializerContext.Default.ErrorResponse) :
throw new NotSupportedException($"Strict broker JSON deserialization for {typeof(T).FullName} is not source-generated.");

Expand All @@ -68,11 +72,13 @@ private static JsonTypeInfo<T> Cast<T>(JsonTypeInfo jsonTypeInfo) =>
WriteIndented = false)]
[JsonSerializable(typeof(PackageRequest))]
[JsonSerializable(typeof(StatusRequest))]
[JsonSerializable(typeof(CancelRequest))]
[JsonSerializable(typeof(HealthResponse))]
[JsonSerializable(typeof(CapabilitiesResponse))]
[JsonSerializable(typeof(EvaluationResponse))]
[JsonSerializable(typeof(ExecutionResponse))]
[JsonSerializable(typeof(StatusResponse))]
[JsonSerializable(typeof(CancelResponse))]
[JsonSerializable(typeof(ErrorResponse))]
[JsonSerializable(typeof(JsonNode))]
[JsonSerializable(typeof(JsonObject))]
Expand All @@ -85,11 +91,13 @@ internal sealed partial class BrokerJsonSerializerContext : JsonSerializerContex
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow)]
[JsonSerializable(typeof(PackageRequest))]
[JsonSerializable(typeof(StatusRequest))]
[JsonSerializable(typeof(CancelRequest))]
[JsonSerializable(typeof(HealthResponse))]
[JsonSerializable(typeof(CapabilitiesResponse))]
[JsonSerializable(typeof(EvaluationResponse))]
[JsonSerializable(typeof(ExecutionResponse))]
[JsonSerializable(typeof(StatusResponse))]
[JsonSerializable(typeof(CancelResponse))]
[JsonSerializable(typeof(ErrorResponse))]
[JsonSerializable(typeof(JsonNode))]
[JsonSerializable(typeof(JsonObject))]
Expand Down
69 changes: 69 additions & 0 deletions policies/dotnet/Devolutions.Now.Policy.Api/CancelModels.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System.Text.Json.Serialization;

namespace Devolutions.Now.Policy.Api;

/// <summary>Request body for canceling a previously submitted operation.</summary>
public sealed class CancelRequest
{
private const string Kind = BrokerApi.CancelRequestKind;
private string _requestKind = Kind;

[JsonPropertyName("RequestKind")]
[JsonRequired]
public string RequestKind
{
get => _requestKind;
set => _requestKind = BrokerApi.ValidateMessageKind(value, Kind, nameof(RequestKind));
}

[JsonPropertyName("RequestVersion")]
public string RequestVersion { get; set; } = BrokerApi.Version;

[JsonPropertyName("OperationId")]
public string OperationId { get; set; } = "";

[JsonPropertyName("Client")]
public ClientContext Client { get; set; } = new();
}

/// <summary>Response to a cancel request.</summary>
/// <remarks>
/// Cancelation is asynchronous and idempotent: the broker acknowledges the request by moving a
/// non-terminal operation to <see cref="OperationStatus.Canceling"/> and reports the resulting
/// status. Clients should poll the status endpoint until the operation reaches a terminal status
/// (<see cref="OperationStatus.Canceled"/>, or <see cref="OperationStatus.Completed"/> /
/// <see cref="OperationStatus.Failed"/> when the process ends first).
/// </remarks>
public sealed class CancelResponse
{
private const string Kind = BrokerApi.CancelResponseKind;
private string _responseKind = Kind;

[JsonPropertyName("ResponseKind")]
[JsonRequired]
public string ResponseKind
{
get => _responseKind;
set => _responseKind = BrokerApi.ValidateMessageKind(value, Kind, nameof(ResponseKind));
}

[JsonPropertyName("ResponseVersion")]
public string ResponseVersion { get; set; } = BrokerApi.Version;

[JsonPropertyName("Server")]
public ServerContext Server { get; set; } = new();

[JsonPropertyName("OperationId")]
public string OperationId { get; set; } = "";

[JsonPropertyName("RequestId")]
public string RequestId { get; set; } = "";

/// <summary>Status of the operation after the cancel request was applied.</summary>
[JsonPropertyName("Status")]
public OperationStatus Status { get; set; }

/// <summary>Human-readable message about the cancelation outcome.</summary>
[JsonPropertyName("Message")]
public string? Message { get; set; }
}
2 changes: 2 additions & 0 deletions policies/dotnet/Devolutions.Now.Policy.Api/Enums.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ public enum OperationStatus
Running,
Completed,
Failed,
Canceling,
Canceled,
}

/// <summary>Broker readiness state reported by the health endpoint.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,94 @@ public async Task QueryStatus_populates_client_context()
Assert.False(clientContext.TryGetProperty("ApiVersion", out _));
}

[Fact]
public async Task Cancel_populates_client_context()
{
var transport = new FakeBrokerTransport(
CapabilitiesResponse,
"""
{"ResponseKind":"CancelResponse","ResponseVersion": "1.0","Server":{"ServerVersion":"mock","Transport":"HttpNamedPipe"},"OperationId":"operation:123","RequestId":"b6cd88d1-9e32-49dd-b53f-e9dad34ad915","Status":"Canceling","Message":"cancelation requested"}
""");
var client = CreateClient(transport);

var response = await client.Cancel(new OperationCancelQuery { OperationId = "operation:123" });
var sent = transport.Requests[1];
using var sentBody = JsonDocument.Parse(sent.Body!);

Assert.Equal("/v1/capabilities", transport.Requests[0].Path);
Assert.Equal("/v1/package-operations/cancel", sent.Path);
Assert.Equal(BrokerApi.CancelRequestKind, sentBody.RootElement.GetProperty("RequestKind").GetString());
Assert.Equal(BrokerApi.Version, sentBody.RootElement.GetProperty("RequestVersion").GetString());
Assert.Equal("operation:123", sentBody.RootElement.GetProperty("OperationId").GetString());
Assert.Equal(OperationStatus.Canceling, response.Status);

var clientContext = sentBody.RootElement.GetProperty("Client");
Assert.Equal("HttpNamedPipe", clientContext.GetProperty("Transport").GetString());
Assert.Equal("DEVOLUTIONS\\bob", clientContext.GetProperty("EffectiveUser").GetString());
}

[Fact]
public async Task ExecuteAndWait_treats_canceled_status_as_terminal()
{
var transport = new FakeBrokerTransport(
CapabilitiesResponse,
"""
{"ResponseKind":"ExecutionResponse","ResponseVersion": "1.0","Server":{"ServerVersion":"mock","Transport":"HttpNamedPipe"},"RequestId":"6f8f1f54-8c42-4773-932a-ff7c7c9f58f1","ReceivedAt":"2026-06-29T12:00:00Z","CompletedAt":"2026-06-29T12:00:01Z","Request":{},"Decision":{"Decision":"Allow","RuleId":"<default>","Reason":"allowed"},"Policy":{"Id":"mock.policy","Revision":1,"PolicyVersion":"1.0.0"},"Operation":{"OperationId":"operation:123","Status":"Starting","SubmittedAt":"2026-06-29T12:00:02Z"}}
""",
"""
{"ResponseKind":"StatusResponse","ResponseVersion": "1.0","Server":{"ServerVersion":"mock","Transport":"HttpNamedPipe"},"OperationId":"operation:123","RequestId":"6f8f1f54-8c42-4773-932a-ff7c7c9f58f1","Status":"Canceled","Message":"operation was canceled"}
""");
var client = CreateClient(transport);

var status = await client.ExecuteAndWait(
new PackageOperationRequest
{
Operation = Operation.Install,
Manager = ManagerName.Winget,
Source = new RequestSource { Name = "winget" },
Package = new RequestPackage { Id = "Microsoft.VisualStudioCode" },
},
pollIntervalMs: 1);

Assert.Equal(OperationStatus.Canceled, status.Status);
}

[Fact]
public async Task ExecuteAndWait_requests_broker_cancelation_when_token_is_canceled()
{
var transport = new FakeBrokerTransport(
CapabilitiesResponse,
"""
{"ResponseKind":"ExecutionResponse","ResponseVersion": "1.0","Server":{"ServerVersion":"mock","Transport":"HttpNamedPipe"},"RequestId":"6f8f1f54-8c42-4773-932a-ff7c7c9f58f1","ReceivedAt":"2026-06-29T12:00:00Z","CompletedAt":"2026-06-29T12:00:01Z","Request":{},"Decision":{"Decision":"Allow","RuleId":"<default>","Reason":"allowed"},"Policy":{"Id":"mock.policy","Revision":1,"PolicyVersion":"1.0.0"},"Operation":{"OperationId":"operation:123","Status":"Starting","SubmittedAt":"2026-06-29T12:00:02Z"}}
""",
"""
{"ResponseKind":"CancelResponse","ResponseVersion": "1.0","Server":{"ServerVersion":"mock","Transport":"HttpNamedPipe"},"OperationId":"operation:123","RequestId":"6f8f1f54-8c42-4773-932a-ff7c7c9f58f1","Status":"Canceling"}
""");
var client = CreateClient(transport);
using var cts = new CancellationTokenSource();

var pending = client.ExecuteAndWait(
new PackageOperationRequest
{
Operation = Operation.Install,
Manager = ManagerName.Winget,
Source = new RequestSource { Name = "winget" },
Package = new RequestPackage { Id = "Microsoft.VisualStudioCode" },
},
cts.Token,
pollIntervalMs: 300_000);

// Wait for the execute request to be sent, then cancel while the client is between polls.
while (transport.Requests.Count < 2)
{
await Task.Delay(1);
}
cts.Cancel();

await Assert.ThrowsAnyAsync<OperationCanceledException>(() => pending);
Assert.Equal("/v1/package-operations/cancel", transport.Requests[^1].Path);
}

[Fact]
public async Task Evaluate_rejects_unsupported_capability_before_operation_request()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ public async Task PackageRequest_round_trips_and_validates(string path)
public async Task StatusRequest_round_trips_and_validates(string path)
=> await AssertRoundTrip<StatusRequest>(path, await TestData.SchemaAsync("StatusRequest"));

[Theory]
[MemberData(nameof(TestData.CancelRequestSamples), MemberType = typeof(TestData))]
public async Task CancelRequest_round_trips_and_validates(string path)
=> await AssertRoundTrip<CancelRequest>(path, await TestData.SchemaAsync("CancelRequest"));

[Theory]
[MemberData(nameof(TestData.ResponseSamples), MemberType = typeof(TestData))]
public async Task EvaluationResponse_round_trips_and_validates(string path)
Expand All @@ -37,6 +42,11 @@ public async Task ExecutionResponse_round_trips_and_validates(string path)
public async Task StatusResponse_round_trips_and_validates(string path)
=> await AssertRoundTrip<StatusResponse>(path, await TestData.SchemaAsync("StatusResponse"));

[Theory]
[MemberData(nameof(TestData.CancelResponseSamples), MemberType = typeof(TestData))]
public async Task CancelResponse_round_trips_and_validates(string path)
=> await AssertRoundTrip<CancelResponse>(path, await TestData.SchemaAsync("CancelResponse"));

[Theory]
[MemberData(nameof(TestData.HealthResponseSamples), MemberType = typeof(TestData))]
public async Task HealthResponse_round_trips_and_validates(string path)
Expand Down
12 changes: 12 additions & 0 deletions policies/dotnet/Devolutions.Now.Policy.Client.Tests/TestData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ _ when double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture,
public static IEnumerable<object[]> RequestSamples() =>
JsonFiles(Path.Combine(SamplesDir, "requests"))
.Where(f => !Path.GetFileName(f).StartsWith("status-", StringComparison.Ordinal))
.Where(f => !Path.GetFileName(f).StartsWith("cancel-", StringComparison.Ordinal))
.Where(f => !IsInvalidRequestSample(f))
.Select(f => new object[] { f });

Expand All @@ -150,9 +151,15 @@ public static IEnumerable<object[]> StatusRequestSamples() =>
.Where(f => Path.GetFileName(f).StartsWith("status-", StringComparison.Ordinal))
.Select(f => new object[] { f });

public static IEnumerable<object[]> CancelRequestSamples() =>
JsonFiles(Path.Combine(SamplesDir, "requests"))
.Where(f => Path.GetFileName(f).StartsWith("cancel-", StringComparison.Ordinal))
.Select(f => new object[] { f });

public static IEnumerable<object[]> ResponseSamples() =>
JsonFiles(Path.Combine(SamplesDir, "responses"))
.Where(f => !Path.GetFileName(f).StartsWith("status-", StringComparison.Ordinal))
.Where(f => !Path.GetFileName(f).StartsWith("cancel-", StringComparison.Ordinal))
.Where(f => !Path.GetFileName(f).StartsWith("execution-", StringComparison.Ordinal))
.Where(f => !Path.GetFileName(f).StartsWith("health-", StringComparison.Ordinal))
.Where(f => !Path.GetFileName(f).StartsWith("capabilities", StringComparison.Ordinal))
Expand All @@ -168,6 +175,11 @@ public static IEnumerable<object[]> StatusResponseSamples() =>
.Where(f => Path.GetFileName(f).StartsWith("status-", StringComparison.Ordinal))
.Select(f => new object[] { f });

public static IEnumerable<object[]> CancelResponseSamples() =>
JsonFiles(Path.Combine(SamplesDir, "responses"))
.Where(f => Path.GetFileName(f).StartsWith("cancel-", StringComparison.Ordinal))
.Select(f => new object[] { f });

public static IEnumerable<object[]> HealthResponseSamples() =>
JsonFiles(Path.Combine(SamplesDir, "responses"))
.Where(f => Path.GetFileName(f).StartsWith("health-", StringComparison.Ordinal))
Expand Down
Loading