From bc5e747b5914bf79905b68be1336046aa3fbc4c4 Mon Sep 17 00:00:00 2001 From: "Jeromy Statia (from Dev Box)" Date: Fri, 6 Mar 2026 20:59:53 -0800 Subject: [PATCH 01/12] feat(MST): add polling options, service endpoint, CBOR error handling, and SDK updates MstPollingOptions tunable receipt polling: - PollingInterval (TimeSpan?) for fixed-interval polling - DelayStrategy (Azure.Core.DelayStrategy?) for custom back-off - DelayStrategy takes precedence if both are set; null = SDK default TransparencyService.ServiceEndpoint base class URI property: - Auto-derived from CodeTransparencyClient via reflection - Explicit URI override via constructor parameter RFC 9290 CBOR problem details parsing: - CborProblemDetails: parses integer keys (-1..-5) + string keys + extensions - MstServiceException: FromRequestFailedException() parses CBOR error bodies - MakeTransparentCoreAsync catches RequestFailedException with structured errors Azure.Core.TestCommon test infrastructure: - MockResponse, MockTransport, MockRequest, etc. ported from azure-sdk-for-net - Enables testing Azure SDK clients with controlled responses Azure SDK package updates: - Azure.Core 1.50.0 -> 1.51.1 - Azure.Identity 1.17.0 -> 1.18.0 - Azure.Security.CodeTransparency 1.0.0-beta.5 -> 1.0.0-beta.8 Tests: 92 pass, 0 fail (51 new). Coverage on new code: CborProblemDetails.cs 94.9% MstPollingOptions.cs 100% MstServiceException.cs 96.9% --- Azure.Core.TestCommon/AsyncGate.cs | 116 +++ .../AsyncValidatingStream.cs | 100 +++ .../Azure.Core.TestCommon.csproj | 25 + Azure.Core.TestCommon/DictionaryHeaders.cs | 118 +++ Azure.Core.TestCommon/MockRequest.cs | 73 ++ Azure.Core.TestCommon/MockResponse.cs | 137 ++++ Azure.Core.TestCommon/MockTransport.cs | 149 ++++ .../CborProblemDetailsTests.cs | 713 ++++++++++++++++++ .../CoseSign1.Transparent.MST.Tests.csproj | 1 + .../MstPollingOptionsTests.cs | 620 +++++++++++++++ .../CborProblemDetails.cs | 223 ++++++ .../Extensions/BinaryDataExtensions.cs | 5 +- .../MstTransparencyServiceExtensions.cs | 2 +- .../MstPollingOptions.cs | 63 ++ .../MstServiceException.cs | 176 +++++ .../MstTransparencyService.cs | 163 +++- CoseSign1.Transparent/TransparencyService.cs | 10 + CoseSignTool.sln | 18 + Directory.Packages.props | 6 +- 19 files changed, 2694 insertions(+), 24 deletions(-) create mode 100644 Azure.Core.TestCommon/AsyncGate.cs create mode 100644 Azure.Core.TestCommon/AsyncValidatingStream.cs create mode 100644 Azure.Core.TestCommon/Azure.Core.TestCommon.csproj create mode 100644 Azure.Core.TestCommon/DictionaryHeaders.cs create mode 100644 Azure.Core.TestCommon/MockRequest.cs create mode 100644 Azure.Core.TestCommon/MockResponse.cs create mode 100644 Azure.Core.TestCommon/MockTransport.cs create mode 100644 CoseSign1.Transparent.MST.Tests/CborProblemDetailsTests.cs create mode 100644 CoseSign1.Transparent.MST.Tests/MstPollingOptionsTests.cs create mode 100644 CoseSign1.Transparent.MST/CborProblemDetails.cs create mode 100644 CoseSign1.Transparent.MST/MstPollingOptions.cs create mode 100644 CoseSign1.Transparent.MST/MstServiceException.cs diff --git a/Azure.Core.TestCommon/AsyncGate.cs b/Azure.Core.TestCommon/AsyncGate.cs new file mode 100644 index 00000000..c96961a6 --- /dev/null +++ b/Azure.Core.TestCommon/AsyncGate.cs @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace Azure.Core.TestCommon; + +/// +/// A gate for coordinating async operations in tests. +/// +[ExcludeFromCodeCoverage] +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member +public class AsyncGate +{ + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10); + private readonly object _sync = new(); + private TaskCompletionSource _signalTaskCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + private TaskCompletionSource _releaseTaskCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// + /// Waits for a signal with the default timeout. + /// + public Task WaitForSignal() + { + return TimeoutAfter(_signalTaskCompletionSource.Task, DefaultTimeout); + } + + /// + /// Cycles through waiting for signal and releasing. + /// + public async Task Cycle(TOut value = default!) + { + var signal = await WaitForSignal(); + Release(value); + return signal; + } + + /// + /// Cycles through waiting for signal and releasing with an exception. + /// + public async Task CycleWithException(Exception exception) + { + var signal = await WaitForSignal(); + ReleaseWithException(exception); + return signal; + } + + /// + /// Releases the gate with a value. + /// + public void Release(TOut value = default!) + { + lock (_sync) + { + Reset().SetResult(value); + } + } + + /// + /// Releases the gate with an exception. + /// + public void ReleaseWithException(Exception exception) + { + lock (_sync) + { + Reset().SetException(exception); + } + } + + private TaskCompletionSource Reset() + { + lock (_sync) + { + if (!_signalTaskCompletionSource.Task.IsCompleted) + { + throw new InvalidOperationException("No await call to release"); + } + + var releaseTaskCompletionSource = _releaseTaskCompletionSource; + _releaseTaskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _signalTaskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + return releaseTaskCompletionSource; + } + } + + /// + /// Waits for the gate to be released. + /// + public Task WaitForRelease(TIn value = default!) + { + lock (_sync) + { + _signalTaskCompletionSource.SetResult(value); + return TimeoutAfter(_releaseTaskCompletionSource.Task, DefaultTimeout); + } + } + + private static async Task TimeoutAfter(Task task, TimeSpan timeout) + { + if (task.IsCompleted || Debugger.IsAttached) + { + return await task; + } + + using var cts = new CancellationTokenSource(); + if (task == await Task.WhenAny(task, Task.Delay(timeout, cts.Token))) + { + await cts.CancelAsync(); + return await task; + } + + throw new TimeoutException($"Operation timed out after {timeout}"); + } +} +#pragma warning restore CS1591 diff --git a/Azure.Core.TestCommon/AsyncValidatingStream.cs b/Azure.Core.TestCommon/AsyncValidatingStream.cs new file mode 100644 index 00000000..10c22435 --- /dev/null +++ b/Azure.Core.TestCommon/AsyncValidatingStream.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; + +namespace Azure.Core.TestCommon; + +/// +/// A stream wrapper that validates sync/async usage consistency. +/// +[ExcludeFromCodeCoverage] +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member +internal class AsyncValidatingStream : Stream +{ + private readonly bool _isAsync; + private readonly Stream _innerStream; + + public AsyncValidatingStream(bool isAsync, Stream innerStream) + { + _isAsync = isAsync; + _innerStream = innerStream; + } + + public override void Flush() + { + Validate(false); + _innerStream.Flush(); + } + + public override Task FlushAsync(CancellationToken cancellationToken) + { + Validate(true); + return _innerStream.FlushAsync(cancellationToken); + } + + private void Validate(bool isAsync) + { + if (isAsync != _isAsync) + { + throw new InvalidOperationException( + $"All stream calls were expected to be {(_isAsync ? "async" : "sync")} but were {(isAsync ? "async" : "sync")}"); + } + } + + public override int Read(byte[] buffer, int offset, int count) + { + Validate(false); + return _innerStream.Read(buffer, offset, count); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) + { + Validate(true); + return _innerStream.ReadAsync(buffer, offset, count, cancellationToken); + } + + public override long Seek(long offset, SeekOrigin origin) + { + return _innerStream.Seek(offset, origin); + } + + public override void SetLength(long value) + { + _innerStream.SetLength(value); + } + + public override void Write(byte[] buffer, int offset, int count) + { + Validate(false); + _innerStream.Write(buffer, offset, count); + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) + { + Validate(true); + return _innerStream.WriteAsync(buffer, offset, count, cancellationToken); + } + + public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) + { + Validate(true); + return _innerStream.CopyToAsync(destination, bufferSize, cancellationToken); + } + + public override void Close() + { + _innerStream.Close(); + } + + public override bool CanRead => _innerStream.CanRead; + public override bool CanSeek => _innerStream.CanSeek; + public override bool CanWrite => _innerStream.CanWrite; + public override long Length => _innerStream.Length; + public override long Position + { + get => _innerStream.Position; + set => _innerStream.Position = value; + } +} +#pragma warning restore CS1591 diff --git a/Azure.Core.TestCommon/Azure.Core.TestCommon.csproj b/Azure.Core.TestCommon/Azure.Core.TestCommon.csproj new file mode 100644 index 00000000..81890c7b --- /dev/null +++ b/Azure.Core.TestCommon/Azure.Core.TestCommon.csproj @@ -0,0 +1,25 @@ + + + + + + net8.0 + enable + enable + false + + + + + + + diff --git a/Azure.Core.TestCommon/DictionaryHeaders.cs b/Azure.Core.TestCommon/DictionaryHeaders.cs new file mode 100644 index 00000000..6fa16854 --- /dev/null +++ b/Azure.Core.TestCommon/DictionaryHeaders.cs @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; + +namespace Azure.Core.TestCommon; + +/// +/// An implementation for manipulating headers on Request. +/// +[ExcludeFromCodeCoverage] +internal class DictionaryHeaders +{ + private readonly Dictionary _headers = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Adds a header value to the header collection. + /// + public void AddHeader(string name, string value) + { + if (!_headers.TryGetValue(name, out object? objValue)) + { + _headers[name] = value; + } + else + { + if (objValue is List values) + { + values.Add(value); + } + else + { + _headers[name] = new List { (objValue as string)!, value }; + } + } + } + + /// + /// Sets a header value, replacing any existing values. + /// + public void SetHeader(string name, string value) + { + _headers[name] = value; + } + + /// + /// Returns header value if the header is stored in the collection. + /// + public bool TryGetHeader(string name, out string? value) + { + if (_headers.TryGetValue(name, out object? objValue)) + { + if (objValue is List values) + { + value = JoinHeaderValue(values); + } + else + { + value = objValue as string; + } + return true; + } + + value = null; + return false; + } + + /// + /// Returns header values if the header is stored in the collection. + /// + public bool TryGetHeaderValues(string name, out IEnumerable? values) + { + if (_headers.TryGetValue(name, out object? objValue)) + { + if (objValue is List valuesList) + { + values = valuesList; + } + else + { + values = new List { (objValue as string)! }; + } + + return true; + } + + values = null; + return false; + } + + /// + /// Removes a header from the collection. + /// + public bool RemoveHeader(string name) + { + return _headers.Remove(name); + } + + /// + /// Enumerates all headers in the collection. + /// + public IEnumerable EnumerateHeaders() + { + foreach (var kvp in _headers) + { + if (kvp.Value is List values) + { + yield return new HttpHeader(kvp.Key, JoinHeaderValue(values)); + } + else + { + yield return new HttpHeader(kvp.Key, (kvp.Value as string)!); + } + } + } + + private static string JoinHeaderValue(IEnumerable values) => string.Join(",", values); +} diff --git a/Azure.Core.TestCommon/MockRequest.cs b/Azure.Core.TestCommon/MockRequest.cs new file mode 100644 index 00000000..150e905e --- /dev/null +++ b/Azure.Core.TestCommon/MockRequest.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; + +namespace Azure.Core.TestCommon; + +/// +/// Mock HTTP request for testing Azure SDK clients. +/// +/// +/// From https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/core/Azure.Core.TestFramework/src/MockRequest.cs +/// +[ExcludeFromCodeCoverage] +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member +public class MockRequest : Request +{ + private readonly DictionaryHeaders _headers = new(); + + /// + /// Creates a new instance. + /// + public MockRequest() + { + ClientRequestId = Guid.NewGuid().ToString(); + } + + /// + /// Gets whether this request has been disposed. + /// + public bool IsDisposed { get; private set; } + + /// + public override RequestContent? Content + { + get => base.Content; + set => base.Content = value; + } + + /// + protected override void SetHeader(string name, string value) => _headers.SetHeader(name, value); + + /// + protected override void AddHeader(string name, string value) => _headers.AddHeader(name, value); + + /// + protected override bool TryGetHeader(string name, [NotNullWhen(true)] out string? value) => _headers.TryGetHeader(name, out value); + + /// + protected override bool TryGetHeaderValues(string name, [NotNullWhen(true)] out IEnumerable? values) => _headers.TryGetHeaderValues(name, out values); + + /// + protected override bool ContainsHeader(string name) => _headers.TryGetHeaderValues(name, out _); + + /// + protected override bool RemoveHeader(string name) => _headers.RemoveHeader(name); + + /// + protected override IEnumerable EnumerateHeaders() => _headers.EnumerateHeaders(); + + /// + public override string ClientRequestId { get; set; } + + /// + public override string ToString() => $"{Method} {Uri}"; + + /// + public override void Dispose() + { + IsDisposed = true; + } +} +#pragma warning restore CS1591 diff --git a/Azure.Core.TestCommon/MockResponse.cs b/Azure.Core.TestCommon/MockResponse.cs new file mode 100644 index 00000000..5a5043d8 --- /dev/null +++ b/Azure.Core.TestCommon/MockResponse.cs @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using System.Text; + +namespace Azure.Core.TestCommon; + +/// +/// Mock HTTP response for testing Azure SDK clients. +/// +/// +/// From https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/core/Azure.Core.TestFramework/src/MockResponse.cs +/// +[ExcludeFromCodeCoverage] +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member +public class MockResponse : Response +{ + private readonly Dictionary> _headers = new(StringComparer.OrdinalIgnoreCase); + private bool? _isError; + + /// + /// Creates a new instance with the specified status code. + /// + public MockResponse(int status, string? reasonPhrase = null) + { + Status = status; + ReasonPhrase = reasonPhrase ?? string.Empty; + ContentStream = new MemoryStream(); + ClientRequestId = Guid.NewGuid().ToString(); + } + + /// + public override int Status { get; } + + /// + public override string ReasonPhrase { get; } + + /// + public override Stream? ContentStream { get; set; } + + /// + public override string ClientRequestId { get; set; } + + /// + public override bool IsError => _isError ?? base.IsError; + + /// + /// Gets whether this response has been disposed. + /// + public bool IsDisposed { get; private set; } + + /// + /// Sets the IsError value explicitly. + /// + public void SetIsError(bool value) => _isError = value; + + /// + /// Sets the response content from a byte array. + /// + public void SetContent(byte[] content) + { + ContentStream = new MemoryStream(content, 0, content.Length, false, true); + } + + /// + /// Sets the response content from a string. + /// + public MockResponse SetContent(string content) + { + SetContent(Encoding.UTF8.GetBytes(content)); + return this; + } + + /// + /// Adds a header to the response. + /// + public MockResponse AddHeader(string name, string value) + { + return AddHeader(new HttpHeader(name, value)); + } + + /// + /// Adds a header to the response. + /// + public MockResponse AddHeader(HttpHeader header) + { + if (!_headers.TryGetValue(header.Name, out List? values)) + { + _headers[header.Name] = values = new List(); + } + + values.Add(header.Value); + return this; + } + + /// + protected override bool TryGetHeader(string name, [NotNullWhen(true)] out string? value) + { + if (_headers.TryGetValue(name, out List? values)) + { + value = JoinHeaderValue(values); + return true; + } + + value = null; + return false; + } + + /// + protected override bool TryGetHeaderValues(string name, [NotNullWhen(true)] out IEnumerable? values) + { + bool result = _headers.TryGetValue(name, out List? valuesList); + values = valuesList; + return result; + } + + /// + protected override bool ContainsHeader(string name) + { + return TryGetHeaderValues(name, out _); + } + + /// + protected override IEnumerable EnumerateHeaders() + => _headers.Select(h => new HttpHeader(h.Key, JoinHeaderValue(h.Value))); + + /// + public override void Dispose() + { + IsDisposed = true; + GC.SuppressFinalize(this); + } + + private static string JoinHeaderValue(IEnumerable values) => string.Join(",", values); +} +#pragma warning restore CS1591 diff --git a/Azure.Core.TestCommon/MockTransport.cs b/Azure.Core.TestCommon/MockTransport.cs new file mode 100644 index 00000000..4af03818 --- /dev/null +++ b/Azure.Core.TestCommon/MockTransport.cs @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Core.Pipeline; +using System.Diagnostics.CodeAnalysis; + +namespace Azure.Core.TestCommon; + +/// +/// Mock HTTP pipeline transport for testing Azure SDK clients. +/// +/// +/// From https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/core/Azure.Core.TestFramework/src/MockTransport.cs +/// +[ExcludeFromCodeCoverage] +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member +public class MockTransport : HttpPipelineTransport +{ + private readonly object _syncObj = new(); + private readonly Func? _responseFactory; + + /// + /// Gets the async gate for controlling request/response flow in tests. + /// + public AsyncGate? RequestGate { get; } + + /// + /// Gets the list of requests made through this transport. + /// + public List Requests { get; } = new(); + + /// + /// Gets or sets whether to expect sync pipeline operations. + /// + public bool? ExpectSyncPipeline { get; set; } + + /// + /// Creates a new instance using the async gate pattern. + /// + public MockTransport() + { + RequestGate = new AsyncGate(); + } + + /// + /// Creates a new instance with a sequence of canned responses. + /// + public MockTransport(params MockResponse[] responses) + { + int requestIndex = 0; + _responseFactory = _ => + { + lock (_syncObj) + { + return responses[requestIndex++]; + } + }; + } + + /// + /// Creates a new instance with a response factory function. + /// + public MockTransport(Func responseFactory) + : this(req => responseFactory((MockRequest)req.Request)) + { + } + + private MockTransport(Func responseFactory) + { + _responseFactory = responseFactory; + } + + /// + /// Creates a mock transport from a message callback function. + /// + public static MockTransport FromMessageCallback(Func responseFactory) + => new(responseFactory); + + /// + public override Request CreateRequest() => new MockRequest(); + + /// + public override void Process(HttpMessage message) + { + if (ExpectSyncPipeline == false) + { + throw new InvalidOperationException("Sync pipeline invocation not expected"); + } + + ProcessCore(message).GetAwaiter().GetResult(); + } + + /// + public override async ValueTask ProcessAsync(HttpMessage message) + { + if (ExpectSyncPipeline == true) + { + throw new InvalidOperationException("Async pipeline invocation not expected"); + } + + await ProcessCore(message); + } + + private async Task ProcessCore(HttpMessage message) + { + if (message.Request is not MockRequest request) + { + throw new InvalidOperationException("The request is not compatible with the transport"); + } + + message.Response = null!; + + lock (_syncObj) + { + Requests.Add(request); + } + + if (RequestGate != null) + { + message.Response = await RequestGate.WaitForRelease(request); + } + else if (_responseFactory != null) + { + message.Response = _responseFactory(message); + } + + message.Response.ClientRequestId = request.ClientRequestId; + + if (message.Response.ContentStream != null && ExpectSyncPipeline != null) + { + message.Response.ContentStream = new AsyncValidatingStream(!ExpectSyncPipeline.Value, message.Response.ContentStream); + } + } + + /// + /// Gets the single request made through this transport. + /// + public MockRequest SingleRequest + { + get + { + lock (_syncObj) + { + return Requests.Single(); + } + } + } +} +#pragma warning restore CS1591 diff --git a/CoseSign1.Transparent.MST.Tests/CborProblemDetailsTests.cs b/CoseSign1.Transparent.MST.Tests/CborProblemDetailsTests.cs new file mode 100644 index 00000000..c7e9091e --- /dev/null +++ b/CoseSign1.Transparent.MST.Tests/CborProblemDetailsTests.cs @@ -0,0 +1,713 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace CoseSign1.Transparent.Tests; + +using System; +using System.Collections.Generic; +using System.Formats.Cbor; +using Azure; +using Azure.Core.TestCommon; +using CoseSign1.Transparent.MST; +using Moq; + +/// +/// Unit tests for and . +/// +[TestFixture] +public class CborProblemDetailsTests +{ + #region CborProblemDetails.TryParse + + [Test] + public void TryParse_ReturnsNull_ForNullInput() + { + Assert.That(CborProblemDetails.TryParse(null!), Is.Null); + } + + [Test] + public void TryParse_ReturnsNull_ForEmptyInput() + { + Assert.That(CborProblemDetails.TryParse(Array.Empty()), Is.Null); + } + + [Test] + public void TryParse_ReturnsNull_ForInvalidCbor() + { + Assert.That(CborProblemDetails.TryParse(new byte[] { 0xFF, 0xFF }), Is.Null); + } + + [Test] + public void TryParse_ReturnsNull_ForNonMapCbor() + { + // Encode a CBOR array instead of a map + var writer = new CborWriter(); + writer.WriteStartArray(1); + writer.WriteTextString("not a map"); + writer.WriteEndArray(); + + Assert.That(CborProblemDetails.TryParse(writer.Encode()), Is.Null); + } + + [Test] + public void TryParse_ParsesIntegerKeys_Rfc9290() + { + // RFC 9290 standard integer keys: -1=type, -2=title, -3=status, -4=detail, -5=instance + var writer = new CborWriter(); + writer.WriteStartMap(5); + writer.WriteInt32(-1); writer.WriteTextString("urn:example:error:bad-request"); + writer.WriteInt32(-2); writer.WriteTextString("Bad Request"); + writer.WriteInt32(-3); writer.WriteInt32(400); + writer.WriteInt32(-4); writer.WriteTextString("The payload is not valid COSE"); + writer.WriteInt32(-5); writer.WriteTextString("/entries/abc-123"); + writer.WriteEndMap(); + + CborProblemDetails? details = CborProblemDetails.TryParse(writer.Encode()); + + Assert.That(details, Is.Not.Null); + Assert.That(details!.Type, Is.EqualTo("urn:example:error:bad-request")); + Assert.That(details.Title, Is.EqualTo("Bad Request")); + Assert.That(details.Status, Is.EqualTo(400)); + Assert.That(details.Detail, Is.EqualTo("The payload is not valid COSE")); + Assert.That(details.Instance, Is.EqualTo("/entries/abc-123")); + } + + [Test] + public void TryParse_ParsesStringKeys() + { + var writer = new CborWriter(); + writer.WriteStartMap(3); + writer.WriteTextString("title"); writer.WriteTextString("Service Unavailable"); + writer.WriteTextString("status"); writer.WriteInt32(503); + writer.WriteTextString("detail"); writer.WriteTextString("Ledger is syncing"); + writer.WriteEndMap(); + + CborProblemDetails? details = CborProblemDetails.TryParse(writer.Encode()); + + Assert.That(details, Is.Not.Null); + Assert.That(details!.Title, Is.EqualTo("Service Unavailable")); + Assert.That(details.Status, Is.EqualTo(503)); + Assert.That(details.Detail, Is.EqualTo("Ledger is syncing")); + } + + [Test] + public void TryParse_CapturesExtensionFields() + { + var writer = new CborWriter(); + writer.WriteStartMap(3); + writer.WriteInt32(-2); writer.WriteTextString("Error"); + writer.WriteInt32(-3); writer.WriteInt32(422); + writer.WriteTextString("retryAfter"); writer.WriteTextString("5s"); + writer.WriteEndMap(); + + CborProblemDetails? details = CborProblemDetails.TryParse(writer.Encode()); + + Assert.That(details, Is.Not.Null); + Assert.That(details!.Extensions, Is.Not.Null); + Assert.That(details.Extensions!.ContainsKey("retryAfter"), Is.True); + Assert.That(details.Extensions["retryAfter"], Is.EqualTo("5s")); + } + + [Test] + public void TryParse_CapturesUnknownIntegerKeysAsExtensions() + { + var writer = new CborWriter(); + writer.WriteStartMap(2); + writer.WriteInt32(-3); writer.WriteInt32(500); + writer.WriteInt32(99); writer.WriteTextString("custom-value"); + writer.WriteEndMap(); + + CborProblemDetails? details = CborProblemDetails.TryParse(writer.Encode()); + + Assert.That(details, Is.Not.Null); + Assert.That(details!.Status, Is.EqualTo(500)); + Assert.That(details.Extensions, Is.Not.Null); + Assert.That(details.Extensions!["key_99"], Is.EqualTo("custom-value")); + } + + [Test] + public void TryParse_HandlesNullValues() + { + var writer = new CborWriter(); + writer.WriteStartMap(2); + writer.WriteInt32(-2); writer.WriteNull(); + writer.WriteInt32(-3); writer.WriteInt32(404); + writer.WriteEndMap(); + + CborProblemDetails? details = CborProblemDetails.TryParse(writer.Encode()); + + Assert.That(details, Is.Not.Null); + Assert.That(details!.Title, Is.Null); + Assert.That(details.Status, Is.EqualTo(404)); + } + + [Test] + public void TryParse_HandlesPartialFields() + { + // Only status — all other fields should be null + var writer = new CborWriter(); + writer.WriteStartMap(1); + writer.WriteInt32(-3); writer.WriteInt32(429); + writer.WriteEndMap(); + + CborProblemDetails? details = CborProblemDetails.TryParse(writer.Encode()); + + Assert.That(details, Is.Not.Null); + Assert.That(details!.Status, Is.EqualTo(429)); + Assert.That(details.Type, Is.Null); + Assert.That(details.Title, Is.Null); + Assert.That(details.Detail, Is.Null); + Assert.That(details.Instance, Is.Null); + Assert.That(details.Extensions, Is.Null); + } + + [Test] + public void TryParse_HandlesMixedIntegerAndStringKeys() + { + var writer = new CborWriter(); + writer.WriteStartMap(3); + writer.WriteInt32(-2); writer.WriteTextString("Mixed Error"); + writer.WriteTextString("status"); writer.WriteInt32(500); + writer.WriteTextString("x-request-id"); writer.WriteTextString("req-abc-123"); + writer.WriteEndMap(); + + CborProblemDetails? details = CborProblemDetails.TryParse(writer.Encode()); + + Assert.That(details, Is.Not.Null); + Assert.That(details!.Title, Is.EqualTo("Mixed Error")); + Assert.That(details.Status, Is.EqualTo(500)); + Assert.That(details.Extensions!["x-request-id"], Is.EqualTo("req-abc-123")); + } + + [Test] + public void TryParse_HandlesExtensionWithVariousValueTypes() + { + var writer = new CborWriter(); + writer.WriteStartMap(4); + writer.WriteInt32(-3); writer.WriteInt32(400); + writer.WriteTextString("boolField"); writer.WriteBoolean(true); + writer.WriteTextString("intField"); writer.WriteInt64(42); + writer.WriteTextString("floatField"); writer.WriteDouble(3.14); + writer.WriteEndMap(); + + CborProblemDetails? details = CborProblemDetails.TryParse(writer.Encode()); + + Assert.That(details, Is.Not.Null); + Assert.That(details!.Extensions!["boolField"], Is.EqualTo(true)); + Assert.That(details.Extensions["intField"], Is.EqualTo(42L)); + Assert.That(details.Extensions["floatField"], Is.EqualTo(3.14)); + } + + #endregion + + #region CborProblemDetails.ToString + + [Test] + public void ToString_ReturnsNoDetailsAvailable_WhenEmpty() + { + var details = new CborProblemDetails(); + Assert.That(details.ToString(), Is.EqualTo("No details available")); + } + + [Test] + public void ToString_IncludesAllFields() + { + var details = new CborProblemDetails + { + Type = "urn:error:test", + Title = "Test Error", + Status = 400, + Detail = "Something went wrong", + Instance = "/entries/123" + }; + + string result = details.ToString(); + Assert.That(result, Does.Contain("Title: Test Error")); + Assert.That(result, Does.Contain("Status: 400")); + Assert.That(result, Does.Contain("Detail: Something went wrong")); + Assert.That(result, Does.Contain("Type: urn:error:test")); + Assert.That(result, Does.Contain("Instance: /entries/123")); + } + + #endregion + + #region MstServiceException + + [Test] + public void MstServiceException_BasicConstructor() + { + var ex = new MstServiceException("test error"); + + Assert.That(ex.Message, Is.EqualTo("test error")); + Assert.That(ex.ProblemDetails, Is.Null); + Assert.That(ex.StatusCode, Is.Null); + Assert.That(ex.InnerException, Is.Null); + } + + [Test] + public void MstServiceException_WithInnerException() + { + var inner = new InvalidOperationException("inner"); + var ex = new MstServiceException("outer", inner); + + Assert.That(ex.Message, Is.EqualTo("outer")); + Assert.That(ex.InnerException, Is.SameAs(inner)); + } + + [Test] + public void MstServiceException_WithProblemDetails() + { + var details = new CborProblemDetails + { + Title = "Bad Request", + Status = 400, + Detail = "Invalid COSE payload" + }; + var ex = new MstServiceException("error", details); + + Assert.That(ex.ProblemDetails, Is.Not.Null); + Assert.That(ex.StatusCode, Is.EqualTo(400)); + Assert.That(ex.ProblemDetails!.Title, Is.EqualTo("Bad Request")); + } + + [Test] + public void MstServiceException_ToString_IncludesProblemDetails() + { + var details = new CborProblemDetails + { + Type = "urn:error:test", + Title = "Test", + Status = 500, + Detail = "Internal error", + Instance = "/api/entries" + }; + var ex = new MstServiceException("Service failed", details); + + string result = ex.ToString(); + Assert.That(result, Does.Contain("Service failed")); + Assert.That(result, Does.Contain("Status: 500")); + Assert.That(result, Does.Contain("Title: Test")); + Assert.That(result, Does.Contain("Detail: Internal error")); + Assert.That(result, Does.Contain("Type: urn:error:test")); + Assert.That(result, Does.Contain("Instance: /api/entries")); + } + + [Test] + public void MstServiceException_ToString_IncludesExtensions() + { + var details = new CborProblemDetails + { + Status = 429, + Extensions = new Dictionary { { "retryAfter", "5s" } } + }; + var ex = new MstServiceException("Rate limited", details); + + string result = ex.ToString(); + Assert.That(result, Does.Contain("retryAfter: 5s")); + } + + [Test] + public void FromRequestFailedException_WithoutCborContent_ReturnsGenericMessage() + { + // Create a RequestFailedException with a basic message (no CBOR content) + var rfEx = new RequestFailedException(500, "Internal Server Error"); + + var mstEx = MstServiceException.FromRequestFailedException(rfEx); + + Assert.That(mstEx, Is.Not.Null); + Assert.That(mstEx.ProblemDetails, Is.Null); + Assert.That(mstEx.InnerException, Is.SameAs(rfEx)); + Assert.That(mstEx.Message, Does.Contain("500")); + } + + [Test] + public void FromRequestFailedException_PreservesInnerException() + { + var rfEx = new RequestFailedException(502, "Bad Gateway"); + + var mstEx = MstServiceException.FromRequestFailedException(rfEx); + + Assert.That(mstEx.InnerException, Is.SameAs(rfEx)); + Assert.That(mstEx.Message, Does.Contain("502")); + } + + #endregion + + #region Additional coverage — CborProblemDetails edge cases + + [Test] + public void TryParse_SkipsNonStandardKeyType() + { + // Map with a byte-string key (not integer or text) — should be skipped + var writer = new CborWriter(); + writer.WriteStartMap(2); + writer.WriteByteString(new byte[] { 0x01 }); // non-standard key + writer.WriteTextString("skipped"); + writer.WriteInt32(-3); writer.WriteInt32(500); + writer.WriteEndMap(); + + CborProblemDetails? details = CborProblemDetails.TryParse(writer.Encode()); + + Assert.That(details, Is.Not.Null); + Assert.That(details!.Status, Is.EqualTo(500)); + } + + [Test] + public void TryParse_HandlesNullStatusValue() + { + var writer = new CborWriter(); + writer.WriteStartMap(2); + writer.WriteInt32(-3); writer.WriteNull(); + writer.WriteInt32(-2); writer.WriteTextString("Null Status"); + writer.WriteEndMap(); + + CborProblemDetails? details = CborProblemDetails.TryParse(writer.Encode()); + + Assert.That(details, Is.Not.Null); + Assert.That(details!.Status, Is.Null); + Assert.That(details.Title, Is.EqualTo("Null Status")); + } + + [Test] + public void TryParse_SkipsNonStringTypeForStringField() + { + // A type field with an integer value instead of string — should skip + var writer = new CborWriter(); + writer.WriteStartMap(2); + writer.WriteInt32(-1); writer.WriteInt32(999); // type should be string + writer.WriteInt32(-3); writer.WriteInt32(400); + writer.WriteEndMap(); + + CborProblemDetails? details = CborProblemDetails.TryParse(writer.Encode()); + + Assert.That(details, Is.Not.Null); + Assert.That(details!.Type, Is.Null); // skipped + Assert.That(details.Status, Is.EqualTo(400)); + } + + [Test] + public void TryParse_SkipsNonIntTypeForStatusField() + { + // Status with a string value instead of integer — should skip + var writer = new CborWriter(); + writer.WriteStartMap(2); + writer.WriteInt32(-3); writer.WriteTextString("not-a-number"); + writer.WriteInt32(-2); writer.WriteTextString("Title"); + writer.WriteEndMap(); + + CborProblemDetails? details = CborProblemDetails.TryParse(writer.Encode()); + + Assert.That(details, Is.Not.Null); + Assert.That(details!.Status, Is.Null); // skipped + Assert.That(details.Title, Is.EqualTo("Title")); + } + + [Test] + public void TryParse_HandlesNullExtensionValue() + { + var writer = new CborWriter(); + writer.WriteStartMap(2); + writer.WriteInt32(-3); writer.WriteInt32(200); + writer.WriteTextString("nullExt"); writer.WriteNull(); + writer.WriteEndMap(); + + CborProblemDetails? details = CborProblemDetails.TryParse(writer.Encode()); + + Assert.That(details, Is.Not.Null); + Assert.That(details!.Extensions!["nullExt"], Is.Null); + } + + [Test] + public void TryParse_HandlesByteStringExtensionValue() + { + var writer = new CborWriter(); + writer.WriteStartMap(2); + writer.WriteInt32(-3); writer.WriteInt32(200); + writer.WriteTextString("binaryData"); writer.WriteByteString(new byte[] { 0xDE, 0xAD }); + writer.WriteEndMap(); + + CborProblemDetails? details = CborProblemDetails.TryParse(writer.Encode()); + + Assert.That(details, Is.Not.Null); + Assert.That(details!.Extensions!["binaryData"], Is.TypeOf()); + } + + #endregion + + #region Additional coverage — MstServiceException ToString / BuildErrorMessage + + [Test] + public void MstServiceException_ToString_WithInnerException() + { + var inner = new InvalidOperationException("inner error"); + var ex = new MstServiceException("outer", inner); + + string result = ex.ToString(); + + Assert.That(result, Does.Contain("outer")); + Assert.That(result, Does.Contain("inner error")); + Assert.That(result, Does.Contain("End of inner exception stack trace")); + } + + [Test] + public void MstServiceException_ToString_WithoutProblemDetails() + { + var ex = new MstServiceException("simple error"); + + string result = ex.ToString(); + + Assert.That(result, Does.Contain("simple error")); + Assert.That(result, Does.Not.Contain("Problem Details")); + } + + [Test] + public void MstServiceException_ToString_WithProblemDetailsAndNoExtensions() + { + var details = new CborProblemDetails { Status = 404, Title = "Not Found" }; + var ex = new MstServiceException("not found", details); + + string result = ex.ToString(); + + Assert.That(result, Does.Contain("Status: 404")); + Assert.That(result, Does.Contain("Title: Not Found")); + Assert.That(result, Does.Not.Contain("Extensions")); + } + + [Test] + public void BuildErrorMessage_IncludesTitleAndDetail_WhenDifferent() + { + var details = new CborProblemDetails + { + Title = "Bad Request", + Status = 400, + Detail = "Payload is not valid" + }; + var ex = new MstServiceException("test", details, new RequestFailedException(400, "Bad Request")); + + // Verify via FromRequestFailedException which uses BuildErrorMessage internally + var rfEx = new RequestFailedException(400, "Bad Request"); + var mstEx = MstServiceException.FromRequestFailedException(rfEx); + + // At minimum, the generic message path should work + Assert.That(mstEx.Message, Does.Contain("400")); + } + + [Test] + public void BuildErrorMessage_OmitsDetail_WhenSameAsTitle() + { + // Can't test BuildErrorMessage directly since it's private, + // but we test the behavior via CborProblemDetails.ToString + var details = new CborProblemDetails + { + Title = "Same", + Detail = "Same" + }; + + string result = details.ToString(); + // ToString includes both — but BuildErrorMessage would deduplicate + Assert.That(result, Does.Contain("Title: Same")); + Assert.That(result, Does.Contain("Detail: Same")); + } + + [Test] + public void MstServiceException_ToString_WithStackTrace() + { + // Throw and catch to get a real StackTrace + MstServiceException? caught = null; + try + { + throw new MstServiceException("with stack", new CborProblemDetails { Status = 500 }); + } + catch (MstServiceException ex) + { + caught = ex; + } + + string result = caught!.ToString(); + Assert.That(result, Does.Contain("with stack")); + Assert.That(result, Does.Contain("Status: 500")); + // Should contain stack trace since it was actually thrown + Assert.That(result, Does.Contain("MstServiceException_ToString_WithStackTrace")); + } + + [Test] + public void MstServiceException_ToString_WithProblemDetails_AllFieldsPopulated() + { + var details = new CborProblemDetails + { + Type = "urn:error:full", + Title = "Full Error", + Status = 503, + Detail = "All fields set", + Instance = "/entries/xyz", + Extensions = new Dictionary + { + { "retryAfter", "10s" }, + { "requestId", "abc-123" } + } + }; + var inner = new InvalidOperationException("root cause"); + var ex = new MstServiceException("full error test", details, inner); + + string result = ex.ToString(); + Assert.That(result, Does.Contain("Type: urn:error:full")); + Assert.That(result, Does.Contain("Title: Full Error")); + Assert.That(result, Does.Contain("Status: 503")); + Assert.That(result, Does.Contain("Detail: All fields set")); + Assert.That(result, Does.Contain("Instance: /entries/xyz")); + Assert.That(result, Does.Contain("retryAfter: 10s")); + Assert.That(result, Does.Contain("requestId: abc-123")); + Assert.That(result, Does.Contain("root cause")); + Assert.That(result, Does.Contain("End of inner exception stack trace")); + } + + #endregion + + #region FromRequestFailedException with MockResponse — full CBOR parsing path + + [Test] + public void FromRequestFailedException_WithCborResponse_ParsesProblemDetails() + { + // Build CBOR problem details payload + var writer = new CborWriter(); + writer.WriteStartMap(3); + writer.WriteInt32(-2); writer.WriteTextString("Forbidden"); + writer.WriteInt32(-3); writer.WriteInt32(403); + writer.WriteInt32(-4); writer.WriteTextString("Certificate not authorized"); + writer.WriteEndMap(); + byte[] cborBytes = writer.Encode(); + + // Create a MockResponse with CBOR content-type and body + var mockResponse = new MockResponse(403, "Forbidden"); + mockResponse.AddHeader("Content-Type", "application/concise-problem-details+cbor"); + mockResponse.SetContent(cborBytes); + + var rfEx = new RequestFailedException(mockResponse); + var mstEx = MstServiceException.FromRequestFailedException(rfEx); + + Assert.That(mstEx, Is.Not.Null); + Assert.That(mstEx.ProblemDetails, Is.Not.Null); + Assert.That(mstEx.ProblemDetails!.Title, Is.EqualTo("Forbidden")); + Assert.That(mstEx.StatusCode, Is.EqualTo(403)); + Assert.That(mstEx.ProblemDetails.Detail, Is.EqualTo("Certificate not authorized")); + Assert.That(mstEx.Message, Does.Contain("403")); + Assert.That(mstEx.Message, Does.Contain("Forbidden")); + Assert.That(mstEx.InnerException, Is.SameAs(rfEx)); + } + + [Test] + public void FromRequestFailedException_WithCborResponse_TitleAndDetailInMessage() + { + var writer = new CborWriter(); + writer.WriteStartMap(3); + writer.WriteInt32(-2); writer.WriteTextString("Bad Request"); + writer.WriteInt32(-3); writer.WriteInt32(400); + writer.WriteInt32(-4); writer.WriteTextString("Payload is not valid COSE"); + writer.WriteEndMap(); + + var mockResponse = new MockResponse(400, "Bad Request"); + mockResponse.AddHeader("Content-Type", "application/cbor"); + mockResponse.SetContent(writer.Encode()); + + var rfEx = new RequestFailedException(mockResponse); + var mstEx = MstServiceException.FromRequestFailedException(rfEx); + + Assert.That(mstEx.Message, Does.Contain("Bad Request")); + Assert.That(mstEx.Message, Does.Contain("Payload is not valid COSE")); + Assert.That(mstEx.Message, Does.Contain("400")); + } + + [Test] + public void FromRequestFailedException_WithCborResponse_DetailSameAsTitleNotDuplicated() + { + var writer = new CborWriter(); + writer.WriteStartMap(2); + writer.WriteInt32(-2); writer.WriteTextString("Error"); + writer.WriteInt32(-3); writer.WriteInt32(500); + writer.WriteEndMap(); + + var mockResponse = new MockResponse(500, "Internal Server Error"); + mockResponse.AddHeader("Content-Type", "application/concise-problem-details+cbor"); + mockResponse.SetContent(writer.Encode()); + + var rfEx = new RequestFailedException(mockResponse); + var mstEx = MstServiceException.FromRequestFailedException(rfEx); + + // Title only, no detail — message should just have title + Assert.That(mstEx.Message, Does.Contain("Error")); + Assert.That(mstEx.ProblemDetails!.Detail, Is.Null); + } + + [Test] + public void FromRequestFailedException_WithNonCborContentType_ReturnsGenericMessage() + { + var mockResponse = new MockResponse(500, "Internal Server Error"); + mockResponse.AddHeader("Content-Type", "application/json"); + mockResponse.SetContent("{\"error\":\"something\"}"); + + var rfEx = new RequestFailedException(mockResponse); + var mstEx = MstServiceException.FromRequestFailedException(rfEx); + + Assert.That(mstEx.ProblemDetails, Is.Null); + Assert.That(mstEx.Message, Does.Contain("500")); + } + + [Test] + public void FromRequestFailedException_WithEmptyCborBody_ReturnsGenericMessage() + { + var mockResponse = new MockResponse(500, "Internal Server Error"); + mockResponse.AddHeader("Content-Type", "application/concise-problem-details+cbor"); + mockResponse.SetContent(Array.Empty()); + + var rfEx = new RequestFailedException(mockResponse); + var mstEx = MstServiceException.FromRequestFailedException(rfEx); + + Assert.That(mstEx.ProblemDetails, Is.Null); + Assert.That(mstEx.Message, Does.Contain("500")); + } + + [Test] + public void FromRequestFailedException_WithInvalidCborBody_ReturnsGenericMessage() + { + var mockResponse = new MockResponse(500, "Internal Server Error"); + mockResponse.AddHeader("Content-Type", "application/concise-problem-details+cbor"); + mockResponse.SetContent(new byte[] { 0xFF, 0xFF, 0xFF }); + + var rfEx = new RequestFailedException(mockResponse); + var mstEx = MstServiceException.FromRequestFailedException(rfEx); + + // TryParse returns null for invalid CBOR, so generic message + Assert.That(mstEx.ProblemDetails, Is.Null); + Assert.That(mstEx.Message, Does.Contain("500")); + } + + [Test] + public void FromRequestFailedException_WithAllProblemDetailsFields() + { + var writer = new CborWriter(); + writer.WriteStartMap(5); + writer.WriteInt32(-1); writer.WriteTextString("urn:example:ledger-unavailable"); + writer.WriteInt32(-2); writer.WriteTextString("Ledger Unavailable"); + writer.WriteInt32(-3); writer.WriteInt32(503); + writer.WriteInt32(-4); writer.WriteTextString("The CCF node is syncing"); + writer.WriteInt32(-5); writer.WriteTextString("/entries/submit"); + writer.WriteEndMap(); + + var mockResponse = new MockResponse(503, "Service Unavailable"); + mockResponse.AddHeader("Content-Type", "application/concise-problem-details+cbor"); + mockResponse.SetContent(writer.Encode()); + + var rfEx = new RequestFailedException(mockResponse); + var mstEx = MstServiceException.FromRequestFailedException(rfEx); + + Assert.That(mstEx.ProblemDetails, Is.Not.Null); + Assert.That(mstEx.ProblemDetails!.Type, Is.EqualTo("urn:example:ledger-unavailable")); + Assert.That(mstEx.ProblemDetails.Title, Is.EqualTo("Ledger Unavailable")); + Assert.That(mstEx.StatusCode, Is.EqualTo(503)); + Assert.That(mstEx.ProblemDetails.Detail, Is.EqualTo("The CCF node is syncing")); + Assert.That(mstEx.ProblemDetails.Instance, Is.EqualTo("/entries/submit")); + Assert.That(mstEx.Message, Does.Contain("503").And.Contain("Ledger Unavailable").And.Contain("CCF node is syncing")); + } + + #endregion +} diff --git a/CoseSign1.Transparent.MST.Tests/CoseSign1.Transparent.MST.Tests.csproj b/CoseSign1.Transparent.MST.Tests/CoseSign1.Transparent.MST.Tests.csproj index 43802e79..0dbd5d3a 100644 --- a/CoseSign1.Transparent.MST.Tests/CoseSign1.Transparent.MST.Tests.csproj +++ b/CoseSign1.Transparent.MST.Tests/CoseSign1.Transparent.MST.Tests.csproj @@ -26,6 +26,7 @@ + diff --git a/CoseSign1.Transparent.MST.Tests/MstPollingOptionsTests.cs b/CoseSign1.Transparent.MST.Tests/MstPollingOptionsTests.cs new file mode 100644 index 00000000..43dddc15 --- /dev/null +++ b/CoseSign1.Transparent.MST.Tests/MstPollingOptionsTests.cs @@ -0,0 +1,620 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace CoseSign1.Transparent.Tests; + +using System; +using System.Collections.Generic; +using System.Formats.Cbor; +using System.Security.Cryptography.Cose; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Azure; +using Azure.Core; +using Azure.Security.CodeTransparency; +using CoseSign1.Abstractions.Interfaces; +using CoseSign1.Certificates.Interfaces; +using CoseSign1.Certificates.Local; +using CoseSign1.Interfaces; +using CoseSign1.Tests.Common; +using CoseSign1.Transparent.MST; +using CoseSign1.Transparent.Extensions; +using Moq; + +/// +/// Unit tests for and the polling behavior of +/// . +/// +[TestFixture] +public class MstPollingOptionsTests +{ + private CoseSign1MessageFactory? messageFactory; + private ICoseSigningKeyProvider? signingKeyProvider; + + [SetUp] + public void Setup() + { + X509Certificate2 testSigningCert = TestCertificateUtils.CreateCertificate(); + ICertificateChainBuilder testChainBuilder = new TestChainBuilder(); + signingKeyProvider = new X509Certificate2CoseSigningKeyProvider(testChainBuilder, testSigningCert); + messageFactory = new(); + } + + #region MstPollingOptions property tests + + /// + /// Verifies that a newly created has null defaults. + /// + [Test] + public void MstPollingOptions_DefaultsAreNull() + { + // Arrange & Act + MstPollingOptions options = new(); + + // Assert + Assert.That(options.PollingInterval, Is.Null); + Assert.That(options.DelayStrategy, Is.Null); + } + + /// + /// Verifies that can be set and retrieved. + /// + [Test] + public void MstPollingOptions_PollingInterval_CanBeSet() + { + // Arrange + MstPollingOptions options = new(); + + // Act + options.PollingInterval = TimeSpan.FromSeconds(2); + + // Assert + Assert.That(options.PollingInterval, Is.EqualTo(TimeSpan.FromSeconds(2))); + } + + /// + /// Verifies that can be set and retrieved. + /// + [Test] + public void MstPollingOptions_DelayStrategy_CanBeSet() + { + // Arrange + MstPollingOptions options = new(); + DelayStrategy strategy = DelayStrategy.CreateFixedDelayStrategy(TimeSpan.FromMilliseconds(500)); + + // Act + options.DelayStrategy = strategy; + + // Assert + Assert.That(options.DelayStrategy, Is.SameAs(strategy)); + } + + /// + /// Verifies that both properties can be set simultaneously. + /// + [Test] + public void MstPollingOptions_BothPropertiesCanBeSet() + { + // Arrange & Act + MstPollingOptions options = new() + { + PollingInterval = TimeSpan.FromSeconds(1), + DelayStrategy = DelayStrategy.CreateFixedDelayStrategy(TimeSpan.FromMilliseconds(200)) + }; + + // Assert + Assert.That(options.PollingInterval, Is.Not.Null); + Assert.That(options.DelayStrategy, Is.Not.Null); + } + + #endregion + + #region Constructor tests with MstPollingOptions + + /// + /// Verifies the two-parameter constructor (client + pollingOptions) creates an instance. + /// + [Test] + public void Constructor_WithPollingOptions_CreatesInstance() + { + // Arrange + CodeTransparencyClient mockClient = Mock.Of(); + MstPollingOptions pollingOptions = new() + { + PollingInterval = TimeSpan.FromSeconds(1) + }; + + // Act + MstTransparencyService service = new(mockClient, pollingOptions); + + // Assert + Assert.That(service, Is.Not.Null); + } + + /// + /// Verifies the constructor with null pollingOptions creates an instance (defaults to SDK behavior). + /// + [Test] + public void Constructor_WithNullPollingOptions_CreatesInstance() + { + // Arrange + CodeTransparencyClient mockClient = Mock.Of(); + + // Act + MstTransparencyService service = new(mockClient, (MstPollingOptions?)null!); + + // Assert + Assert.That(service, Is.Not.Null); + } + + /// + /// Verifies the four-parameter constructor (client, verificationOptions, clientOptions, pollingOptions). + /// + [Test] + public void Constructor_WithVerificationAndPollingOptions_CreatesInstance() + { + // Arrange + CodeTransparencyClient mockClient = Mock.Of(); + CodeTransparencyVerificationOptions verificationOptions = new() + { + AuthorizedDomains = new List { "example.com" } + }; + MstPollingOptions pollingOptions = new() + { + PollingInterval = TimeSpan.FromMilliseconds(500) + }; + + // Act + MstTransparencyService service = new(mockClient, verificationOptions, null, pollingOptions); + + // Assert + Assert.That(service, Is.Not.Null); + } + + /// + /// Verifies the full constructor with all parameters including pollingOptions and logging. + /// + [Test] + public void Constructor_WithAllOptionsAndLogging_CreatesInstance() + { + // Arrange + CodeTransparencyClient mockClient = Mock.Of(); + MstPollingOptions pollingOptions = new() + { + DelayStrategy = DelayStrategy.CreateFixedDelayStrategy(TimeSpan.FromMilliseconds(100)) + }; + List logMessages = new(); + + // Act + MstTransparencyService service = new( + mockClient, + null, + null, + pollingOptions, + null, + msg => logMessages.Add($"VERBOSE: {msg}"), + msg => logMessages.Add($"WARNING: {msg}"), + msg => logMessages.Add($"ERROR: {msg}")); + + // Assert + Assert.That(service, Is.Not.Null); + } + + /// + /// Verifies that the original 6-parameter constructor (without pollingOptions) still works. + /// + [Test] + public void Constructor_OriginalSixParameterOverload_StillWorks() + { + // Arrange + CodeTransparencyClient mockClient = Mock.Of(); + + // Act + MstTransparencyService service = new( + mockClient, null, null, + msg => { }, msg => { }, msg => { }); + + // Assert + Assert.That(service, Is.Not.Null); + } + + /// + /// Verifies that the pollingOptions constructor still throws for null client. + /// + [Test] + public void Constructor_WithPollingOptions_ThrowsForNullClient() + { + // Arrange + MstPollingOptions pollingOptions = new() { PollingInterval = TimeSpan.FromSeconds(1) }; + + // Act & Assert + Assert.That( + () => new MstTransparencyService(null!, pollingOptions), + Throws.TypeOf().With.Property("ParamName").EqualTo("transparencyClient")); + } + + #endregion + + #region MakeTransparentAsync with polling options + + /// + /// Verifies that MakeTransparentAsync uses PollingInterval when configured. + /// + [Test] + public async Task MakeTransparentAsync_UsesPollingInterval_WhenConfigured() + { + // Arrange + Mock mockClient = new(); + CoseSign1Message message = CreateMockCoseSign1Message(); + message.AddReceipts(new List { new byte[] { 1, 2, 3 } }); + BinaryData mockEntryStatement = BinaryData.FromBytes(message.Encode()); + + Mock> mockOperation = CreateSuccessfulOperation(); + mockClient + .Setup(c => c.CreateEntryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockOperation.Object); + mockClient + .Setup(c => c.GetEntryStatementAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(mockEntryStatement, Mock.Of())); + + MstPollingOptions pollingOptions = new() + { + PollingInterval = TimeSpan.FromMilliseconds(100) + }; + MstTransparencyService service = new(mockClient.Object, pollingOptions); + + // Act + CoseSign1Message result = await service.MakeTransparentAsync(message); + + // Assert + Assert.That(result, Is.Not.Null); + // Verify WaitForCompletionAsync was called with the TimeSpan overload + mockOperation.Verify( + op => op.WaitForCompletionAsync(TimeSpan.FromMilliseconds(100), It.IsAny()), + Times.Once); + } + + /// + /// Verifies that MakeTransparentAsync uses DelayStrategy when configured. + /// + [Test] + public async Task MakeTransparentAsync_UsesDelayStrategy_WhenConfigured() + { + // Arrange + Mock mockClient = new(); + CoseSign1Message message = CreateMockCoseSign1Message(); + message.AddReceipts(new List { new byte[] { 1, 2, 3 } }); + BinaryData mockEntryStatement = BinaryData.FromBytes(message.Encode()); + + Mock> mockOperation = CreateSuccessfulOperation(); + mockClient + .Setup(c => c.CreateEntryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockOperation.Object); + mockClient + .Setup(c => c.GetEntryStatementAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(mockEntryStatement, Mock.Of())); + + DelayStrategy fixedStrategy = DelayStrategy.CreateFixedDelayStrategy(TimeSpan.FromMilliseconds(50)); + MstPollingOptions pollingOptions = new() + { + DelayStrategy = fixedStrategy + }; + MstTransparencyService service = new(mockClient.Object, pollingOptions); + + // Act + CoseSign1Message result = await service.MakeTransparentAsync(message); + + // Assert + Assert.That(result, Is.Not.Null); + // Verify WaitForCompletionAsync was called with the DelayStrategy overload + mockOperation.Verify( + op => op.WaitForCompletionAsync(fixedStrategy, It.IsAny()), + Times.Once); + } + + /// + /// Verifies that DelayStrategy takes precedence over PollingInterval when both are set. + /// + [Test] + public async Task MakeTransparentAsync_DelayStrategyTakesPrecedence_WhenBothSet() + { + // Arrange + Mock mockClient = new(); + CoseSign1Message message = CreateMockCoseSign1Message(); + message.AddReceipts(new List { new byte[] { 1, 2, 3 } }); + BinaryData mockEntryStatement = BinaryData.FromBytes(message.Encode()); + + Mock> mockOperation = CreateSuccessfulOperation(); + mockClient + .Setup(c => c.CreateEntryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockOperation.Object); + mockClient + .Setup(c => c.GetEntryStatementAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(mockEntryStatement, Mock.Of())); + + DelayStrategy fixedStrategy = DelayStrategy.CreateFixedDelayStrategy(TimeSpan.FromMilliseconds(50)); + MstPollingOptions pollingOptions = new() + { + PollingInterval = TimeSpan.FromSeconds(5), // Should be ignored + DelayStrategy = fixedStrategy // Should take precedence + }; + MstTransparencyService service = new(mockClient.Object, pollingOptions); + + // Act + CoseSign1Message result = await service.MakeTransparentAsync(message); + + // Assert + Assert.That(result, Is.Not.Null); + // DelayStrategy overload should be called, NOT TimeSpan overload + mockOperation.Verify( + op => op.WaitForCompletionAsync(fixedStrategy, It.IsAny()), + Times.Once); + mockOperation.Verify( + op => op.WaitForCompletionAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + /// + /// Verifies that the default (no-arg) WaitForCompletionAsync is called when no polling options are set. + /// + [Test] + public async Task MakeTransparentAsync_UsesDefaultPolling_WhenNoOptionsSet() + { + // Arrange + Mock mockClient = new(); + CoseSign1Message message = CreateMockCoseSign1Message(); + message.AddReceipts(new List { new byte[] { 1, 2, 3 } }); + BinaryData mockEntryStatement = BinaryData.FromBytes(message.Encode()); + + Mock> mockOperation = CreateSuccessfulOperation(); + mockClient + .Setup(c => c.CreateEntryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockOperation.Object); + mockClient + .Setup(c => c.GetEntryStatementAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(mockEntryStatement, Mock.Of())); + + // No polling options + MstTransparencyService service = new(mockClient.Object); + + // Act + CoseSign1Message result = await service.MakeTransparentAsync(message); + + // Assert + Assert.That(result, Is.Not.Null); + // Default overload (CancellationToken only) should be called + mockOperation.Verify( + op => op.WaitForCompletionAsync(It.IsAny()), + Times.Once); + } + + /// + /// Verifies that the default is used when MstPollingOptions is provided but both fields are null. + /// + [Test] + public async Task MakeTransparentAsync_UsesDefaultPolling_WhenOptionsAllNull() + { + // Arrange + Mock mockClient = new(); + CoseSign1Message message = CreateMockCoseSign1Message(); + message.AddReceipts(new List { new byte[] { 1, 2, 3 } }); + BinaryData mockEntryStatement = BinaryData.FromBytes(message.Encode()); + + Mock> mockOperation = CreateSuccessfulOperation(); + mockClient + .Setup(c => c.CreateEntryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockOperation.Object); + mockClient + .Setup(c => c.GetEntryStatementAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(mockEntryStatement, Mock.Of())); + + // Empty polling options (both null) + MstPollingOptions pollingOptions = new(); + MstTransparencyService service = new(mockClient.Object, pollingOptions); + + // Act + CoseSign1Message result = await service.MakeTransparentAsync(message); + + // Assert + Assert.That(result, Is.Not.Null); + mockOperation.Verify( + op => op.WaitForCompletionAsync(It.IsAny()), + Times.Once); + } + + /// + /// Verifies that polling options are correctly logged when verbose logging is enabled. + /// + [Test] + public async Task MakeTransparentAsync_LogsPollingInterval_WhenVerboseEnabled() + { + // Arrange + Mock mockClient = new(); + CoseSign1Message message = CreateMockCoseSign1Message(); + message.AddReceipts(new List { new byte[] { 1, 2, 3 } }); + BinaryData mockEntryStatement = BinaryData.FromBytes(message.Encode()); + + Mock> mockOperation = CreateSuccessfulOperation(); + mockClient + .Setup(c => c.CreateEntryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockOperation.Object); + mockClient + .Setup(c => c.GetEntryStatementAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(mockEntryStatement, Mock.Of())); + + List logMessages = new(); + MstPollingOptions pollingOptions = new() + { + PollingInterval = TimeSpan.FromMilliseconds(250) + }; + MstTransparencyService service = new( + mockClient.Object, null, null, pollingOptions, + null, msg => logMessages.Add(msg), null, null); + + // Act + await service.MakeTransparentAsync(message); + + // Assert + Assert.That(logMessages, Has.Some.Contains("fixed polling interval")); + Assert.That(logMessages, Has.Some.Contains("250ms")); + } + + /// + /// Verifies that logging includes the DelayStrategy type name when verbose logging is enabled. + /// + [Test] + public async Task MakeTransparentAsync_LogsDelayStrategy_WhenVerboseEnabled() + { + // Arrange + Mock mockClient = new(); + CoseSign1Message message = CreateMockCoseSign1Message(); + message.AddReceipts(new List { new byte[] { 1, 2, 3 } }); + BinaryData mockEntryStatement = BinaryData.FromBytes(message.Encode()); + + Mock> mockOperation = CreateSuccessfulOperation(); + mockClient + .Setup(c => c.CreateEntryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(mockOperation.Object); + mockClient + .Setup(c => c.GetEntryStatementAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(mockEntryStatement, Mock.Of())); + + List logMessages = new(); + MstPollingOptions pollingOptions = new() + { + DelayStrategy = DelayStrategy.CreateFixedDelayStrategy(TimeSpan.FromMilliseconds(100)) + }; + MstTransparencyService service = new( + mockClient.Object, null, null, pollingOptions, + null, msg => logMessages.Add(msg), null, null); + + // Act + await service.MakeTransparentAsync(message); + + // Assert + Assert.That(logMessages, Has.Some.Contains("custom DelayStrategy")); + } + + #endregion + + #region ServiceEndpoint auto-derivation tests + + /// + /// Verifies that ServiceEndpoint is auto-derived from CodeTransparencyClient when not explicitly provided. + /// + [Test] + public void ServiceEndpoint_IsDerivedFromClient_WhenNotExplicitlyProvided() + { + // Arrange — create a real CodeTransparencyClient with a known endpoint + Uri expectedEndpoint = new("https://my-cts-instance.confidential-ledger.azure.com"); + CodeTransparencyClient client = new(expectedEndpoint); + + // Act + MstTransparencyService service = new(client); + + // Assert — the endpoint should have been extracted via reflection + Assert.That(service.ServiceEndpoint, Is.EqualTo(expectedEndpoint)); + } + + /// + /// Verifies that an explicit serviceEndpoint overrides the auto-derived one. + /// + [Test] + public void ServiceEndpoint_ExplicitOverridesAutoDerived() + { + // Arrange + Uri clientEndpoint = new("https://auto-derived.example.com"); + Uri explicitEndpoint = new("https://explicit-override.example.com"); + CodeTransparencyClient client = new(clientEndpoint); + + // Act + MstTransparencyService service = new(client, explicitEndpoint); + + // Assert + Assert.That(service.ServiceEndpoint, Is.EqualTo(explicitEndpoint)); + } + + /// + /// Verifies that ServiceEndpoint is null when using a mocked client (no _endpoint field). + /// + [Test] + public void ServiceEndpoint_IsNull_WhenClientIsMocked() + { + // Arrange — Moq creates a proxy; the backing field won't exist + CodeTransparencyClient mockClient = Mock.Of(); + + // Act + MstTransparencyService service = new(mockClient); + + // Assert + Assert.That(service.ServiceEndpoint, Is.Null); + } + + /// + /// Verifies that ServiceEndpoint works with the two-arg endpoint constructor. + /// + [Test] + public void ServiceEndpoint_WorksWithEndpointConstructor() + { + // Arrange + Uri endpoint = new("https://staging.cts.azure.com"); + CodeTransparencyClient client = new(endpoint); + + // Act + MstTransparencyService service = new(client, endpoint); + + // Assert + Assert.That(service.ServiceEndpoint, Is.EqualTo(endpoint)); + } + + /// + /// Verifies that ServiceEndpoint is auto-derived in the full constructor when serviceEndpoint is null. + /// + [Test] + public void ServiceEndpoint_AutoDerived_InFullConstructor() + { + // Arrange + Uri endpoint = new("https://full-ctor.cts.azure.com"); + CodeTransparencyClient client = new(endpoint); + + // Act + MstTransparencyService service = new( + client, null, null, null, null, null, null, null); + + // Assert + Assert.That(service.ServiceEndpoint, Is.EqualTo(endpoint)); + } + + #endregion + + #region Helpers + + private CoseSign1Message CreateMockCoseSign1Message() + { + byte[] testPayload = Encoding.ASCII.GetBytes("Payload1!"); + return messageFactory!.CreateCoseSign1Message(testPayload, signingKeyProvider!, embedPayload: false); + } + + /// + /// Creates a mock Operation that simulates a successful CreateEntryAsync response + /// with a valid CBOR-encoded EntryId. + /// + private static Mock> CreateSuccessfulOperation() + { + Mock> mock = new(); + mock.Setup(op => op.HasValue).Returns(true); + + CborWriter cborWriter = new(); + cborWriter.WriteStartMap(1); + cborWriter.WriteTextString("EntryId"); + cborWriter.WriteTextString("test-entry-12345"); + cborWriter.WriteEndMap(); + mock.Setup(op => op.Value).Returns(BinaryData.FromBytes(cborWriter.Encode())); + + return mock; + } + + #endregion +} diff --git a/CoseSign1.Transparent.MST/CborProblemDetails.cs b/CoseSign1.Transparent.MST/CborProblemDetails.cs new file mode 100644 index 00000000..a154e5db --- /dev/null +++ b/CoseSign1.Transparent.MST/CborProblemDetails.cs @@ -0,0 +1,223 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace CoseSign1.Transparent.MST; + +using System; +using System.Collections.Generic; +using System.Formats.Cbor; + +/// +/// Represents parsed CBOR problem details from RFC 9290 (Concise Problem Details). +/// +/// +/// RFC 9290 defines standard fields for problem details in CBOR format, +/// returned by Azure Code Transparency Service with Content-Type: +/// application/concise-problem-details+cbor. +/// +/// Standard CBOR integer keys: +/// +/// -1type (URI reference) +/// -2title (human-readable summary) +/// -3status (HTTP status code) +/// -4detail (human-readable explanation) +/// -5instance (URI reference for the occurrence) +/// +/// +/// String keys ("type", "title", etc.) are also accepted for interoperability. +/// +public class CborProblemDetails +{ + /// + /// Gets or sets the problem type URI reference (CBOR key: -1 or "type"). + /// + public string? Type { get; set; } + + /// + /// Gets or sets a short human-readable summary of the problem (CBOR key: -2 or "title"). + /// + public string? Title { get; set; } + + /// + /// Gets or sets the HTTP status code (CBOR key: -3 or "status"). + /// + public int? Status { get; set; } + + /// + /// Gets or sets a human-readable explanation of the problem (CBOR key: -4 or "detail"). + /// + public string? Detail { get; set; } + + /// + /// Gets or sets a URI reference for the specific occurrence (CBOR key: -5 or "instance"). + /// + public string? Instance { get; set; } + + /// + /// Gets or sets additional extension fields not covered by the standard keys. + /// + public Dictionary? Extensions { get; set; } + + /// + /// Parses CBOR-encoded problem details (RFC 9290) from a byte array. + /// + /// The CBOR-encoded problem details. + /// The parsed problem details, or null if parsing fails. + public static CborProblemDetails? TryParse(byte[] cborBytes) + { + if (cborBytes == null || cborBytes.Length == 0) + { + return null; + } + + try + { + var reader = new CborReader(cborBytes); + return ParseFromReader(reader); + } + catch (Exception) + { + return null; + } + } + + /// + /// Parses CBOR problem details from a positioned at a map. + /// + private static CborProblemDetails? ParseFromReader(CborReader reader) + { + if (reader.PeekState() != CborReaderState.StartMap) + { + return null; + } + + var details = new CborProblemDetails(); + var extensions = new Dictionary(); + + reader.ReadStartMap(); + + while (reader.PeekState() != CborReaderState.EndMap) + { + var keyState = reader.PeekState(); + + if (keyState == CborReaderState.NegativeInteger || keyState == CborReaderState.UnsignedInteger) + { + int key = reader.ReadInt32(); + switch (key) + { + case -1: details.Type = ReadStringValue(reader); break; + case -2: details.Title = ReadStringValue(reader); break; + case -3: details.Status = ReadIntValue(reader); break; + case -4: details.Detail = ReadStringValue(reader); break; + case -5: details.Instance = ReadStringValue(reader); break; + default: extensions[$"key_{key}"] = ReadAnyValue(reader); break; + } + } + else if (keyState == CborReaderState.TextString) + { + string key = reader.ReadTextString(); + switch (key.ToLowerInvariant()) + { + case "type": details.Type = ReadStringValue(reader); break; + case "title": details.Title = ReadStringValue(reader); break; + case "status": details.Status = ReadIntValue(reader); break; + case "detail": details.Detail = ReadStringValue(reader); break; + case "instance": details.Instance = ReadStringValue(reader); break; + default: extensions[key] = ReadAnyValue(reader); break; + } + } + else + { + reader.SkipValue(); + reader.SkipValue(); + } + } + + reader.ReadEndMap(); + + if (extensions.Count > 0) + { + details.Extensions = extensions; + } + + return details; + } + + private static string? ReadStringValue(CborReader reader) + { + if (reader.PeekState() == CborReaderState.TextString) + { + return reader.ReadTextString(); + } + + if (reader.PeekState() == CborReaderState.Null) { reader.ReadNull(); return null; } + reader.SkipValue(); + return null; + } + + private static int? ReadIntValue(CborReader reader) + { + var state = reader.PeekState(); + if (state == CborReaderState.UnsignedInteger || state == CborReaderState.NegativeInteger) + { + return reader.ReadInt32(); + } + + if (state == CborReaderState.Null) { reader.ReadNull(); return null; } + reader.SkipValue(); + return null; + } + + private static object? ReadAnyValue(CborReader reader) + { + return reader.PeekState() switch + { + CborReaderState.TextString => reader.ReadTextString(), + CborReaderState.ByteString => reader.ReadByteString(), + CborReaderState.UnsignedInteger => reader.ReadInt64(), + CborReaderState.NegativeInteger => reader.ReadInt64(), + CborReaderState.Boolean => reader.ReadBoolean(), + CborReaderState.Null => ReadNull(reader), + CborReaderState.SinglePrecisionFloat => reader.ReadSingle(), + CborReaderState.DoublePrecisionFloat => reader.ReadDouble(), + _ => Skip(reader), + }; + } + + private static object? ReadNull(CborReader reader) { reader.ReadNull(); return null; } + private static object? Skip(CborReader reader) { reader.SkipValue(); return null; } + + /// + /// Returns a human-readable summary of the problem details. + /// + public override string ToString() + { + var parts = new List(); + if (!string.IsNullOrEmpty(Title)) + { + parts.Add($"Title: {Title}"); + } + + if (Status.HasValue) + { + parts.Add($"Status: {Status}"); + } + + if (!string.IsNullOrEmpty(Detail)) + { + parts.Add($"Detail: {Detail}"); + } + + if (!string.IsNullOrEmpty(Type)) + { + parts.Add($"Type: {Type}"); + } + + if (!string.IsNullOrEmpty(Instance)) + { + parts.Add($"Instance: {Instance}"); + } + + return parts.Count > 0 ? string.Join(", ", parts) : "No details available"; + } +} \ No newline at end of file diff --git a/CoseSign1.Transparent.MST/Extensions/BinaryDataExtensions.cs b/CoseSign1.Transparent.MST/Extensions/BinaryDataExtensions.cs index 7c89da0f..c13d2590 100644 --- a/CoseSign1.Transparent.MST/Extensions/BinaryDataExtensions.cs +++ b/CoseSign1.Transparent.MST/Extensions/BinaryDataExtensions.cs @@ -57,7 +57,7 @@ public static bool TryGetMstEntryId(this BinaryData binaryData, out string? entr } } } - catch(InvalidOperationException) + catch (InvalidOperationException) { return false; } @@ -72,5 +72,4 @@ public static bool TryGetMstEntryId(this BinaryData binaryData, out string? entr return false; } -} - +} \ No newline at end of file diff --git a/CoseSign1.Transparent.MST/Extensions/MstTransparencyServiceExtensions.cs b/CoseSign1.Transparent.MST/Extensions/MstTransparencyServiceExtensions.cs index 5cb00356..5e2c7add 100644 --- a/CoseSign1.Transparent.MST/Extensions/MstTransparencyServiceExtensions.cs +++ b/CoseSign1.Transparent.MST/Extensions/MstTransparencyServiceExtensions.cs @@ -64,4 +64,4 @@ public static TransparencyService ToCoseSign1TransparencyService( return new MstTransparencyService(client, null, null, logVerbose, logWarning, logError); } -} +} \ No newline at end of file diff --git a/CoseSign1.Transparent.MST/MstPollingOptions.cs b/CoseSign1.Transparent.MST/MstPollingOptions.cs new file mode 100644 index 00000000..564d367e --- /dev/null +++ b/CoseSign1.Transparent.MST/MstPollingOptions.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace CoseSign1.Transparent.MST; + +using System; + +/// +/// Configuration options for controlling how polls the +/// Azure Code Transparency Service for completed receipt registrations. +/// +/// +/// When a COSE_Sign1 message is submitted to MST via CreateEntryAsync, the service +/// returns a long-running operation that must be polled until completion. These options let +/// callers tune the polling behavior to balance latency against cost. +/// +/// If neither nor is set, +/// the Azure SDK's default exponential back-off strategy is used. +/// +/// If both are set, takes precedence. +/// +public class MstPollingOptions +{ + /// + /// Gets or sets the fixed interval between polling attempts. + /// + /// + /// When set, Operation<T>.WaitForCompletionAsync(TimeSpan, CancellationToken) + /// is called with this value. Set to null (the default) to use the SDK's built-in + /// delay strategy instead. + /// + /// Typical values range from 100 ms (aggressive, local dev) to 5 s (production). + /// + /// + /// + /// var options = new MstPollingOptions { PollingInterval = TimeSpan.FromSeconds(2) }; + /// + /// + public TimeSpan? PollingInterval { get; set; } + + /// + /// Gets or sets a custom that controls the + /// back-off pattern between polling attempts. + /// + /// + /// When set, this strategy is assigned to Operation<T>.DelayStrategy before + /// calling WaitForCompletionAsync. This takes precedence over + /// if both are specified. + /// + /// Use + /// for a constant interval, or implement a custom strategy for exponential back-off + /// with jitter. + /// + /// + /// + /// var options = new MstPollingOptions + /// { + /// DelayStrategy = DelayStrategy.CreateFixedDelayStrategy(TimeSpan.FromMilliseconds(500)) + /// }; + /// + /// + public Azure.Core.DelayStrategy? DelayStrategy { get; set; } +} \ No newline at end of file diff --git a/CoseSign1.Transparent.MST/MstServiceException.cs b/CoseSign1.Transparent.MST/MstServiceException.cs new file mode 100644 index 00000000..2ccfd938 --- /dev/null +++ b/CoseSign1.Transparent.MST/MstServiceException.cs @@ -0,0 +1,176 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace CoseSign1.Transparent.MST; + +using System; +using System.Text; + +/// +/// Exception thrown when an MST transparency service operation fails. +/// +/// +/// This exception captures parsed details from CBOR problem details responses +/// (application/concise-problem-details+cbor) as defined in RFC 9290, +/// when the Azure Code Transparency Service returns an error. +/// +public class MstServiceException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + /// The error message. + /// The inner exception, if any. + public MstServiceException(string message, Exception? innerException = null) + : base(message, innerException) + { + } + + /// + /// Initializes a new instance of the class with parsed problem details. + /// + /// The error message. + /// The parsed CBOR problem details from the MST response. + /// The inner exception, if any. + public MstServiceException( + string message, + CborProblemDetails problemDetails, + Exception? innerException = null) + : base(message, innerException) + { + ProblemDetails = problemDetails; + } + + /// + /// Gets the HTTP status code from the failed request, if available from the problem details. + /// + public int? StatusCode => ProblemDetails?.Status; + + /// + /// Gets the parsed CBOR problem details (RFC 9290) from the service response, if available. + /// + public CborProblemDetails? ProblemDetails { get; } + + /// + /// Returns a detailed string representation including all parsed problem details. + /// + public override string ToString() + { + var sb = new StringBuilder(); + sb.AppendLine($"{GetType().FullName}: {Message}"); + + if (ProblemDetails != null) + { + sb.AppendLine(" Problem Details:"); + if (ProblemDetails.Status.HasValue) + { + sb.AppendLine($" Status: {ProblemDetails.Status}"); + } + + if (!string.IsNullOrEmpty(ProblemDetails.Type)) + { + sb.AppendLine($" Type: {ProblemDetails.Type}"); + } + + if (!string.IsNullOrEmpty(ProblemDetails.Title)) + { + sb.AppendLine($" Title: {ProblemDetails.Title}"); + } + + if (!string.IsNullOrEmpty(ProblemDetails.Detail)) + { + sb.AppendLine($" Detail: {ProblemDetails.Detail}"); + } + + if (!string.IsNullOrEmpty(ProblemDetails.Instance)) + { + sb.AppendLine($" Instance: {ProblemDetails.Instance}"); + } + + if (ProblemDetails.Extensions is { Count: > 0 }) + { + sb.AppendLine(" Extensions:"); + foreach (var ext in ProblemDetails.Extensions) + { + sb.AppendLine($" {ext.Key}: {ext.Value}"); + } + } + } + + if (InnerException != null) + { + sb.AppendLine($" ---> {InnerException}"); + sb.AppendLine(" --- End of inner exception stack trace ---"); + } + + if (StackTrace != null) + { + sb.AppendLine(StackTrace); + } + + return sb.ToString(); + } + + /// + /// Creates an from an Azure , + /// attempting to parse CBOR problem details from the response body. + /// + /// The Azure SDK request failure. + /// An with parsed details when available. + public static MstServiceException FromRequestFailedException(Azure.RequestFailedException requestFailedException) + { + CborProblemDetails? problemDetails = null; + + if (requestFailedException.GetRawResponse() is Azure.Response response) + { + var contentType = response.Headers.ContentType; + + if (contentType?.Contains("cbor", StringComparison.OrdinalIgnoreCase) == true) + { + try + { + var content = response.Content; + if (content != null && content.ToMemory().Length > 0) + { + problemDetails = CborProblemDetails.TryParse(content.ToArray()); + } + } + catch + { + // Parsing failure is non-fatal; fall through to generic message + } + } + } + + string errorMessage = problemDetails != null + ? BuildErrorMessage(problemDetails, requestFailedException.Status) + : $"MST service returned HTTP {requestFailedException.Status}: {requestFailedException.Message}"; + + return problemDetails != null + ? new MstServiceException(errorMessage, problemDetails, requestFailedException) + : new MstServiceException(errorMessage, requestFailedException); + } + + /// + /// Builds a human-readable error message from parsed problem details. + /// + private static string BuildErrorMessage(CborProblemDetails details, int httpStatus) + { + var parts = new System.Collections.Generic.List + { + $"MST service returned an error (HTTP {details.Status ?? httpStatus})" + }; + + if (!string.IsNullOrEmpty(details.Title)) + { + parts.Add($": {details.Title}"); + } + + if (!string.IsNullOrEmpty(details.Detail) && details.Detail != details.Title) + { + parts.Add($". {details.Detail}"); + } + + return string.Concat(parts); + } +} \ No newline at end of file diff --git a/CoseSign1.Transparent.MST/MstTransparencyService.cs b/CoseSign1.Transparent.MST/MstTransparencyService.cs index 9e32727e..be4d1d7d 100644 --- a/CoseSign1.Transparent.MST/MstTransparencyService.cs +++ b/CoseSign1.Transparent.MST/MstTransparencyService.cs @@ -12,9 +12,9 @@ namespace CoseSign1.Transparent.MST; using System.Threading.Tasks; using Azure; using Azure.Security.CodeTransparency; -using CoseSign1.Transparent.MST.Extensions; -using CoseSign1.Transparent.Extensions; using CoseSign1.Transparent; +using CoseSign1.Transparent.Extensions; +using CoseSign1.Transparent.MST.Extensions; /// /// Provides an implementation of the base class using Microsoft's Signing Transparency (MST). @@ -25,6 +25,11 @@ public class MstTransparencyService : TransparencyService private readonly CodeTransparencyClient TransparencyClient; private readonly CodeTransparencyVerificationOptions? VerificationOptions; private readonly CodeTransparencyClientOptions? ClientOptions; + private readonly MstPollingOptions? PollingOptions; + private readonly Uri? _serviceEndpoint; + + /// + public override Uri? ServiceEndpoint => _serviceEndpoint; /// /// Initializes a new instance of the class. @@ -32,7 +37,18 @@ public class MstTransparencyService : TransparencyService /// The used to interact with the Azure CTS. /// Thrown if is null. public MstTransparencyService(CodeTransparencyClient transparencyClient) - : this(transparencyClient, null, null, null, null, null) + : this(transparencyClient, null, null, null, null, null, null, null) + { + } + + /// + /// Initializes a new instance of the class with a service endpoint. + /// + /// The used to interact with the Azure CTS. + /// The URI of the Azure CTS endpoint this service communicates with. + /// Thrown if is null. + public MstTransparencyService(CodeTransparencyClient transparencyClient, Uri serviceEndpoint) + : this(transparencyClient, null, null, null, serviceEndpoint, null, null, null) { } @@ -47,7 +63,37 @@ public MstTransparencyService( CodeTransparencyClient transparencyClient, CodeTransparencyVerificationOptions? verificationOptions, CodeTransparencyClientOptions? clientOptions) - : this(transparencyClient, verificationOptions, clientOptions, null, null, null) + : this(transparencyClient, verificationOptions, clientOptions, null, null, null, null, null) + { + } + + /// + /// Initializes a new instance of the class with polling options. + /// + /// The used to interact with the Azure CTS. + /// Options controlling the polling behavior for long-running operations. + /// Thrown if is null. + public MstTransparencyService( + CodeTransparencyClient transparencyClient, + MstPollingOptions pollingOptions) + : this(transparencyClient, null, null, pollingOptions, null, null, null, null) + { + } + + /// + /// Initializes a new instance of the class with verification and polling options. + /// + /// The used to interact with the Azure CTS. + /// Optional verification options for controlling receipt validation behavior. + /// Optional client options for configuring client instances used during verification. + /// Options controlling the polling behavior for long-running operations. + /// Thrown if is null. + public MstTransparencyService( + CodeTransparencyClient transparencyClient, + CodeTransparencyVerificationOptions? verificationOptions, + CodeTransparencyClientOptions? clientOptions, + MstPollingOptions? pollingOptions) + : this(transparencyClient, verificationOptions, clientOptions, pollingOptions, null, null, null, null) { } @@ -68,11 +114,61 @@ public MstTransparencyService( Action? logVerbose, Action? logWarning, Action? logError) + : this(transparencyClient, verificationOptions, clientOptions, null, null, logVerbose, logWarning, logError) + { + } + + /// + /// Initializes a new instance of the class with all options. + /// + /// The used to interact with the Azure CTS. + /// Optional verification options for controlling receipt validation behavior. + /// Optional client options for configuring client instances used during verification. + /// Options controlling the polling behavior for long-running operations. + /// The URI of the Azure CTS endpoint this service communicates with. + /// Optional verbose logging callback. + /// Optional warning logging callback. + /// Optional error logging callback. + /// Thrown if is null. + public MstTransparencyService( + CodeTransparencyClient transparencyClient, + CodeTransparencyVerificationOptions? verificationOptions, + CodeTransparencyClientOptions? clientOptions, + MstPollingOptions? pollingOptions, + Uri? serviceEndpoint, + Action? logVerbose, + Action? logWarning, + Action? logError) : base(logVerbose, logWarning, logError) { TransparencyClient = transparencyClient ?? throw new ArgumentNullException(nameof(transparencyClient)); VerificationOptions = verificationOptions; ClientOptions = clientOptions; + PollingOptions = pollingOptions; + _serviceEndpoint = serviceEndpoint ?? TryGetEndpointFromClient(transparencyClient); + } + + /// + /// Attempts to derive the service endpoint URI from the + /// by reading its internal _endpoint field via reflection. + /// + /// + /// The Azure.Security.CodeTransparency SDK does not currently expose the endpoint as a public + /// property. This method uses reflection as a best-effort fallback so callers don't need to + /// pass the URI twice. Returns null if the field is not found or inaccessible. + /// + private static Uri? TryGetEndpointFromClient(CodeTransparencyClient client) + { + try + { + var field = typeof(CodeTransparencyClient) + .GetField("_endpoint", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + return field?.GetValue(client) as Uri; + } + catch + { + return null; + } } /// @@ -88,6 +184,7 @@ public MstTransparencyService( /// with the transparency metadata or headers applied. /// /// Thrown if is null. + /// Thrown if the MST service returns an error with CBOR problem details. /// Thrown if the transparency operation fails. protected override async Task MakeTransparentCoreAsync(CoseSign1Message message, CancellationToken cancellationToken = default) { @@ -97,9 +194,42 @@ protected override async Task MakeTransparentCoreAsync(CoseSig BinaryData content = BinaryData.FromBytes(message.Encode()); LogVerbose?.Invoke($"Encoded message size: {content.ToArray().Length} bytes"); - // Request the entry be created in the transparency service - LogVerbose?.Invoke("Calling CreateEntryAsync..."); - Operation operation = await TransparencyClient.CreateEntryAsync(WaitUntil.Completed, content, cancellationToken).ConfigureAwait(false); + Operation operation; + try + { + // Request the entry be created in the transparency service + LogVerbose?.Invoke("Calling CreateEntryAsync..."); + operation = await TransparencyClient.CreateEntryAsync(WaitUntil.Started, content, cancellationToken).ConfigureAwait(false); + + // Wait for the operation to complete, respecting polling options. + // DelayStrategy takes precedence over PollingInterval if both are set. + LogVerbose?.Invoke("Waiting for CreateEntryAsync operation to complete..."); + if (PollingOptions?.DelayStrategy != null) + { + LogVerbose?.Invoke($"Using custom DelayStrategy: {PollingOptions.DelayStrategy.GetType().Name}"); + await operation.WaitForCompletionAsync(PollingOptions.DelayStrategy, cancellationToken).ConfigureAwait(false); + } + else if (PollingOptions?.PollingInterval != null) + { + LogVerbose?.Invoke($"Using fixed polling interval: {PollingOptions.PollingInterval.Value.TotalMilliseconds}ms"); + await operation.WaitForCompletionAsync(PollingOptions.PollingInterval.Value, cancellationToken).ConfigureAwait(false); + } + else + { + await operation.WaitForCompletionAsync(cancellationToken).ConfigureAwait(false); + } + } + catch (Azure.RequestFailedException rfEx) + { + // Parse CBOR problem details from the MST error response (RFC 9290) + var mstEx = MstServiceException.FromRequestFailedException(rfEx); + LogError?.Invoke(mstEx.Message); + if (mstEx.ProblemDetails != null) + { + LogVerbose?.Invoke($"Problem details: {mstEx.ProblemDetails}"); + } + throw mstEx; + } // Check if the operation was successful if (!operation.HasValue) @@ -161,7 +291,7 @@ public override Task VerifyTransparencyAsync(CoseSign1Message message, Can LogError?.Invoke(error); throw new InvalidOperationException(error); } - + LogVerbose?.Invoke("Transparency header found in message"); cancellationToken.ThrowIfCancellationRequested(); @@ -184,39 +314,39 @@ public override Task VerifyTransparencyAsync(CoseSign1Message message, Can LogVerbose?.Invoke("Transparency verification succeeded"); return Task.FromResult(true); } - catch(InvalidOperationException ex) + catch (InvalidOperationException ex) { LogError?.Invoke($"Verification failed: {ex.Message}"); LogVerbose?.Invoke($"InvalidOperationException details: {ex}"); return Task.FromResult(false); } - catch(CryptographicException ex) + catch (CryptographicException ex) { LogError?.Invoke($"Cryptographic error during verification: {ex.Message}"); LogVerbose?.Invoke($"CryptographicException details: {ex}"); return Task.FromResult(false); } - catch(CborContentException ex) + catch (CborContentException ex) { LogError?.Invoke($"CBOR content error during verification: {ex.Message}"); LogVerbose?.Invoke($"CborContentException details: {ex}"); return Task.FromResult(false); } - catch(ArgumentException ex) + catch (ArgumentException ex) { LogError?.Invoke($"Invalid argument during verification: {ex.Message}"); LogVerbose?.Invoke($"ArgumentException details: {ex}"); return Task.FromResult(false); } - catch(AggregateException ex) + catch (AggregateException ex) { LogError?.Invoke($"Multiple verification failures occurred"); - + foreach (var innerEx in ex.InnerExceptions) { LogVerbose?.Invoke($" - {innerEx.Message}"); } - + return Task.FromResult(false); } } @@ -256,5 +386,4 @@ public override Task VerifyTransparencyAsync(CoseSign1Message message, byt // Verify the transparency of the message using the provided service return VerifyTransparencyAsync(message, cancellationToken); } -} - +} \ No newline at end of file diff --git a/CoseSign1.Transparent/TransparencyService.cs b/CoseSign1.Transparent/TransparencyService.cs index 4df24a5c..5518879a 100644 --- a/CoseSign1.Transparent/TransparencyService.cs +++ b/CoseSign1.Transparent/TransparencyService.cs @@ -33,6 +33,16 @@ protected TransparencyService(Action? logVerbose = null, Action? protected Action? LogWarning { get; } protected Action? LogError { get; } + /// + /// Gets the URI of the transparency service endpoint that this instance communicates with. + /// + /// + /// This property identifies which service instance is being used, enabling callers to + /// distinguish between multiple transparency services (e.g., production vs. staging). + /// Returns null if no endpoint was configured. + /// + public virtual Uri? ServiceEndpoint { get; } + /// /// Creates a new transparent COSE Sign1 message by embedding additional metadata or headers into the provided message. /// diff --git a/CoseSignTool.sln b/CoseSignTool.sln index 59225ea2..a37d4c6d 100644 --- a/CoseSignTool.sln +++ b/CoseSignTool.sln @@ -95,6 +95,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoseSignTool.IndirectSignat EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoseSignTool.AzureTrustedSigning.Plugin", "CoseSignTool.AzureTrustedSigning.Plugin\CoseSignTool.AzureTrustedSigning.Plugin.csproj", "{D41AF01F-9174-4F29-A030-8BA2FF43E8D2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Core.TestCommon", "Azure.Core.TestCommon\Azure.Core.TestCommon.csproj", "{6EB58A79-D799-4038-BF97-027F50CE69BD}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -539,6 +541,22 @@ Global {D41AF01F-9174-4F29-A030-8BA2FF43E8D2}.Release|x64.Build.0 = Release|Any CPU {D41AF01F-9174-4F29-A030-8BA2FF43E8D2}.Release|x86.ActiveCfg = Release|Any CPU {D41AF01F-9174-4F29-A030-8BA2FF43E8D2}.Release|x86.Build.0 = Release|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Debug|ARM64.Build.0 = Debug|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Debug|x64.ActiveCfg = Debug|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Debug|x64.Build.0 = Debug|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Debug|x86.ActiveCfg = Debug|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Debug|x86.Build.0 = Debug|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Release|Any CPU.Build.0 = Release|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Release|ARM64.ActiveCfg = Release|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Release|ARM64.Build.0 = Release|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Release|x64.ActiveCfg = Release|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Release|x64.Build.0 = Release|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Release|x86.ActiveCfg = Release|Any CPU + {6EB58A79-D799-4038-BF97-027F50CE69BD}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Directory.Packages.props b/Directory.Packages.props index bc9c8564..b5fa83a6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,9 +17,9 @@ - - - + + + From 3f7f9856c52c180064b9fee9ab446173b2a2465f Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 7 Mar 2026 05:09:47 +0000 Subject: [PATCH 02/12] Update changelog for release --- CHANGELOG.md | 128 ++++++++++++++++++++++++++++----------------------- 1 file changed, 70 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 491be8aa..915130e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [v1.7.2](https://github.com/microsoft/CoseSignTool/tree/v1.7.2) (2026-02-17) + +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.7.2-pre1...v1.7.2) + +## [v1.7.2-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.7.2-pre1) (2026-02-17) + +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.7.1...v1.7.2-pre1) + +**Merged pull requests:** + +- Fix release workflow: restore plugin projects before publish [\#165](https://github.com/microsoft/CoseSignTool/pull/165) ([JeromySt](https://github.com/JeromySt)) + ## [v1.7.1](https://github.com/microsoft/CoseSignTool/tree/v1.7.1) (2026-02-14) [Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.7.0...v1.7.1) @@ -46,24 +58,24 @@ ## [v1.6.6](https://github.com/microsoft/CoseSignTool/tree/v1.6.6) (2025-11-25) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.6.3...v1.6.6) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.6.5...v1.6.6) **Merged pull requests:** - Users/jstatia/azure trusted signing [\#148](https://github.com/microsoft/CoseSignTool/pull/148) ([JeromySt](https://github.com/JeromySt)) - Users/jstatia/package upgrades [\#145](https://github.com/microsoft/CoseSignTool/pull/145) ([JeromySt](https://github.com/JeromySt)) -## [v1.6.3](https://github.com/microsoft/CoseSignTool/tree/v1.6.3) (2025-08-05) +## [v1.6.5](https://github.com/microsoft/CoseSignTool/tree/v1.6.5) (2025-08-05) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.6.4...v1.6.3) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.6.4...v1.6.5) ## [v1.6.4](https://github.com/microsoft/CoseSignTool/tree/v1.6.4) (2025-08-05) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.6.5...v1.6.4) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.6.3...v1.6.4) -## [v1.6.5](https://github.com/microsoft/CoseSignTool/tree/v1.6.5) (2025-08-05) +## [v1.6.3](https://github.com/microsoft/CoseSignTool/tree/v1.6.3) (2025-08-05) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.4-pre4...v1.6.5) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.4-pre4...v1.6.3) ## [v1.5.4-pre4](https://github.com/microsoft/CoseSignTool/tree/v1.5.4-pre4) (2025-08-05) @@ -108,20 +120,20 @@ ## [v1.5.7](https://github.com/microsoft/CoseSignTool/tree/v1.5.7) (2025-07-17) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v.1.5.5...v1.5.7) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.6...v1.5.7) **Merged pull requests:** - Enhance PFX certificate handling in SignCommand and update documentation [\#138](https://github.com/microsoft/CoseSignTool/pull/138) ([JeromySt](https://github.com/JeromySt)) - Migrate from VSTest to MTP [\#124](https://github.com/microsoft/CoseSignTool/pull/124) ([Youssef1313](https://github.com/Youssef1313)) -## [v.1.5.5](https://github.com/microsoft/CoseSignTool/tree/v.1.5.5) (2025-07-15) +## [v1.5.6](https://github.com/microsoft/CoseSignTool/tree/v1.5.6) (2025-07-15) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.6...v.1.5.5) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v.1.5.5...v1.5.6) -## [v1.5.6](https://github.com/microsoft/CoseSignTool/tree/v1.5.6) (2025-07-15) +## [v.1.5.5](https://github.com/microsoft/CoseSignTool/tree/v.1.5.5) (2025-07-15) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.4-pre1...v1.5.6) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.4-pre1...v.1.5.5) ## [v1.5.4-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.5.4-pre1) (2025-07-15) @@ -145,19 +157,19 @@ ## [v1.5.3-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.5.3-pre1) (2025-06-05) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.2-pre1...v1.5.3-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.3...v1.5.3-pre1) **Merged pull requests:** - Add unit tests for CoseSign1MessageExtensions and enhance Certificate… [\#133](https://github.com/microsoft/CoseSignTool/pull/133) ([JeromySt](https://github.com/JeromySt)) -## [v1.5.2-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.5.2-pre1) (2025-05-30) +## [v1.5.3](https://github.com/microsoft/CoseSignTool/tree/v1.5.3) (2025-05-30) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.3...v1.5.2-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.2-pre1...v1.5.3) -## [v1.5.3](https://github.com/microsoft/CoseSignTool/tree/v1.5.3) (2025-05-30) +## [v1.5.2-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.5.2-pre1) (2025-05-30) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.2...v1.5.3) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.2...v1.5.2-pre1) **Merged pull requests:** @@ -169,43 +181,43 @@ ## [v1.5.1-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.5.1-pre1) (2025-05-29) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.1...v1.5.1-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.0-pre1...v1.5.1-pre1) **Merged pull requests:** - feat: Add header extender support to IndirectSignatureFactory [\#130](https://github.com/microsoft/CoseSignTool/pull/130) ([JeromySt](https://github.com/JeromySt)) -## [v1.5.1](https://github.com/microsoft/CoseSignTool/tree/v1.5.1) (2025-05-07) +## [v1.5.0-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.5.0-pre1) (2025-05-07) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.0-pre1...v1.5.1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.1...v1.5.0-pre1) -## [v1.5.0-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.5.0-pre1) (2025-05-07) +## [v1.5.1](https://github.com/microsoft/CoseSignTool/tree/v1.5.1) (2025-05-07) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.4.0-pre1...v1.5.0-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.0...v1.5.1) **Merged pull requests:** - Allow beta version of Azure.Security.CodeTransparency [\#129](https://github.com/microsoft/CoseSignTool/pull/129) ([lemccomb](https://github.com/lemccomb)) -## [v1.4.0-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.4.0-pre1) (2025-04-28) +## [v1.5.0](https://github.com/microsoft/CoseSignTool/tree/v1.5.0) (2025-04-28) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.0...v1.4.0-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.4.0-pre1...v1.5.0) -## [v1.5.0](https://github.com/microsoft/CoseSignTool/tree/v1.5.0) (2025-04-28) +## [v1.4.0-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.4.0-pre1) (2025-04-28) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.4.0...v1.5.0) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.3.0-pre5...v1.4.0-pre1) **Merged pull requests:** - Added support for Transparency to CoseSign1 libraries to leverage services such as Azure Code Transparency Service [\#127](https://github.com/microsoft/CoseSignTool/pull/127) ([JeromySt](https://github.com/JeromySt)) -## [v1.4.0](https://github.com/microsoft/CoseSignTool/tree/v1.4.0) (2025-03-18) +## [v1.3.0-pre5](https://github.com/microsoft/CoseSignTool/tree/v1.3.0-pre5) (2025-03-18) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.3.0-pre5...v1.4.0) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.4.0...v1.3.0-pre5) -## [v1.3.0-pre5](https://github.com/microsoft/CoseSignTool/tree/v1.3.0-pre5) (2025-03-18) +## [v1.4.0](https://github.com/microsoft/CoseSignTool/tree/v1.4.0) (2025-03-18) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.3.0-pre4...v1.3.0-pre5) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.3.0-pre4...v1.4.0) **Merged pull requests:** @@ -249,19 +261,19 @@ ## [v1.2.8-pre7](https://github.com/microsoft/CoseSignTool/tree/v1.2.8-pre7) (2024-10-30) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.3.0...v1.2.8-pre7) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.8-pre6...v1.2.8-pre7) **Merged pull requests:** - Adds CLI install instructions [\#116](https://github.com/microsoft/CoseSignTool/pull/116) ([ivarprudnikov](https://github.com/ivarprudnikov)) -## [v1.3.0](https://github.com/microsoft/CoseSignTool/tree/v1.3.0) (2024-10-30) +## [v1.2.8-pre6](https://github.com/microsoft/CoseSignTool/tree/v1.2.8-pre6) (2024-10-30) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.8-pre6...v1.3.0) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.3.0...v1.2.8-pre6) -## [v1.2.8-pre6](https://github.com/microsoft/CoseSignTool/tree/v1.2.8-pre6) (2024-10-30) +## [v1.3.0](https://github.com/microsoft/CoseSignTool/tree/v1.3.0) (2024-10-30) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.8-pre5...v1.2.8-pre6) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.8-pre5...v1.3.0) **Merged pull requests:** @@ -427,19 +439,19 @@ ## [v1.2.3-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.2.3-pre1) (2024-05-31) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.2-pre1...v1.2.3-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.3...v1.2.3-pre1) **Merged pull requests:** - upgrade to .NET 8, add docs to package, add retry loop for revocation server [\#89](https://github.com/microsoft/CoseSignTool/pull/89) ([lemccomb](https://github.com/lemccomb)) -## [v1.2.2-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.2.2-pre1) (2024-03-20) +## [v1.2.3](https://github.com/microsoft/CoseSignTool/tree/v1.2.3) (2024-03-20) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.3...v1.2.2-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.2-pre1...v1.2.3) -## [v1.2.3](https://github.com/microsoft/CoseSignTool/tree/v1.2.3) (2024-03-20) +## [v1.2.2-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.2.2-pre1) (2024-03-20) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.1-pre2...v1.2.3) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.1-pre2...v1.2.2-pre1) **Merged pull requests:** @@ -535,31 +547,31 @@ ## [v1.1.6](https://github.com/microsoft/CoseSignTool/tree/v1.1.6) (2024-02-07) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.4-pre1...v1.1.6) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.5...v1.1.6) **Merged pull requests:** - Only hit iterator once [\#75](https://github.com/microsoft/CoseSignTool/pull/75) ([JeromySt](https://github.com/JeromySt)) -## [v1.1.4-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.1.4-pre1) (2024-01-31) +## [v1.1.5](https://github.com/microsoft/CoseSignTool/tree/v1.1.5) (2024-01-31) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.5...v1.1.4-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.4-pre1...v1.1.5) -## [v1.1.5](https://github.com/microsoft/CoseSignTool/tree/v1.1.5) (2024-01-31) +## [v1.1.4-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.1.4-pre1) (2024-01-31) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.4...v1.1.5) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.3-pre1...v1.1.4-pre1) **Merged pull requests:** - write validation output to standard out [\#74](https://github.com/microsoft/CoseSignTool/pull/74) ([elantiguamsft](https://github.com/elantiguamsft)) -## [v1.1.4](https://github.com/microsoft/CoseSignTool/tree/v1.1.4) (2024-01-26) +## [v1.1.3-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.1.3-pre1) (2024-01-26) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.3-pre1...v1.1.4) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.4...v1.1.3-pre1) -## [v1.1.3-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.1.3-pre1) (2024-01-26) +## [v1.1.4](https://github.com/microsoft/CoseSignTool/tree/v1.1.4) (2024-01-26) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.3...v1.1.3-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.3...v1.1.4) **Merged pull requests:** @@ -591,19 +603,19 @@ ## [v1.1.1-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.1.1-pre1) (2024-01-17) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.1...v1.1.1-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.0-pre7...v1.1.1-pre1) **Merged pull requests:** - Move CreateChangelog to after build in PR build [\#70](https://github.com/microsoft/CoseSignTool/pull/70) ([lemccomb](https://github.com/lemccomb)) -## [v1.1.1](https://github.com/microsoft/CoseSignTool/tree/v1.1.1) (2024-01-12) +## [v1.1.0-pre7](https://github.com/microsoft/CoseSignTool/tree/v1.1.0-pre7) (2024-01-12) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.0-pre7...v1.1.1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.1...v1.1.0-pre7) -## [v1.1.0-pre7](https://github.com/microsoft/CoseSignTool/tree/v1.1.0-pre7) (2024-01-12) +## [v1.1.1](https://github.com/microsoft/CoseSignTool/tree/v1.1.1) (2024-01-12) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.0-pre6...v1.1.0-pre7) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.0-pre6...v1.1.1) **Merged pull requests:** @@ -652,7 +664,7 @@ ## [v1.1.0-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.1.0-pre1) (2023-11-03) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v0.3.1-pre.10...v1.1.0-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.0...v1.1.0-pre1) **Merged pull requests:** @@ -662,13 +674,13 @@ - DetachedSignatureFactory accepts pre-hashed content as payload [\#53](https://github.com/microsoft/CoseSignTool/pull/53) ([elantiguamsft](https://github.com/elantiguamsft)) - Add password support for certificate files [\#52](https://github.com/microsoft/CoseSignTool/pull/52) ([lemccomb](https://github.com/lemccomb)) -## [v0.3.1-pre.10](https://github.com/microsoft/CoseSignTool/tree/v0.3.1-pre.10) (2023-10-10) +## [v1.1.0](https://github.com/microsoft/CoseSignTool/tree/v1.1.0) (2023-10-10) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.0...v0.3.1-pre.10) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v0.3.1-pre.10...v1.1.0) -## [v1.1.0](https://github.com/microsoft/CoseSignTool/tree/v1.1.0) (2023-10-10) +## [v0.3.1-pre.10](https://github.com/microsoft/CoseSignTool/tree/v0.3.1-pre.10) (2023-10-10) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v0.3.1-pre.9...v1.1.0) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v0.3.1-pre.9...v0.3.1-pre.10) **Merged pull requests:** From b8683c84215a5bc04e2678a869d2e46f9d8ac195 Mon Sep 17 00:00:00 2001 From: "Jeromy Statia (from Dev Box)" Date: Fri, 6 Mar 2026 21:10:49 -0800 Subject: [PATCH 03/12] Fix CodeQL findings: virtual calls, generic catches, ternary, unused var - Seal MockRequest and MockResponse to prevent virtual call issues in ctors - Replace generic catch clauses with specific CborContentException, InvalidOperationException, TargetInvocationException in production code - Use ternary expressions in DictionaryHeaders for cleaner assignment - Remove unused variable in CborProblemDetailsTests - Add System.Formats.Cbor using to MstServiceException - All 92 MST tests pass Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Azure.Core.TestCommon/DictionaryHeaders.cs | 21 ++++--------------- Azure.Core.TestCommon/MockRequest.cs | 2 +- Azure.Core.TestCommon/MockResponse.cs | 2 +- .../CborProblemDetailsTests.cs | 3 ++- .../CborProblemDetails.cs | 6 +++++- .../MstServiceException.cs | 9 ++++++-- .../MstTransparencyService.cs | 6 +++++- 7 files changed, 25 insertions(+), 24 deletions(-) diff --git a/Azure.Core.TestCommon/DictionaryHeaders.cs b/Azure.Core.TestCommon/DictionaryHeaders.cs index 6fa16854..43135dd5 100644 --- a/Azure.Core.TestCommon/DictionaryHeaders.cs +++ b/Azure.Core.TestCommon/DictionaryHeaders.cs @@ -50,14 +50,7 @@ public bool TryGetHeader(string name, out string? value) { if (_headers.TryGetValue(name, out object? objValue)) { - if (objValue is List values) - { - value = JoinHeaderValue(values); - } - else - { - value = objValue as string; - } + value = objValue is List values ? JoinHeaderValue(values) : objValue as string; return true; } @@ -72,15 +65,9 @@ public bool TryGetHeaderValues(string name, out IEnumerable? values) { if (_headers.TryGetValue(name, out object? objValue)) { - if (objValue is List valuesList) - { - values = valuesList; - } - else - { - values = new List { (objValue as string)! }; - } - + values = objValue is List valuesList + ? valuesList + : new List { (objValue as string)! }; return true; } diff --git a/Azure.Core.TestCommon/MockRequest.cs b/Azure.Core.TestCommon/MockRequest.cs index 150e905e..2a4d9189 100644 --- a/Azure.Core.TestCommon/MockRequest.cs +++ b/Azure.Core.TestCommon/MockRequest.cs @@ -13,7 +13,7 @@ namespace Azure.Core.TestCommon; /// [ExcludeFromCodeCoverage] #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member -public class MockRequest : Request +public sealed class MockRequest : Request { private readonly DictionaryHeaders _headers = new(); diff --git a/Azure.Core.TestCommon/MockResponse.cs b/Azure.Core.TestCommon/MockResponse.cs index 5a5043d8..c7b9255a 100644 --- a/Azure.Core.TestCommon/MockResponse.cs +++ b/Azure.Core.TestCommon/MockResponse.cs @@ -14,7 +14,7 @@ namespace Azure.Core.TestCommon; /// [ExcludeFromCodeCoverage] #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member -public class MockResponse : Response +public sealed class MockResponse : Response { private readonly Dictionary> _headers = new(StringComparer.OrdinalIgnoreCase); private bool? _isError; diff --git a/CoseSign1.Transparent.MST.Tests/CborProblemDetailsTests.cs b/CoseSign1.Transparent.MST.Tests/CborProblemDetailsTests.cs index c7e9091e..96a9e7e9 100644 --- a/CoseSign1.Transparent.MST.Tests/CborProblemDetailsTests.cs +++ b/CoseSign1.Transparent.MST.Tests/CborProblemDetailsTests.cs @@ -482,7 +482,8 @@ public void BuildErrorMessage_IncludesTitleAndDetail_WhenDifferent() Status = 400, Detail = "Payload is not valid" }; - var ex = new MstServiceException("test", details, new RequestFailedException(400, "Bad Request")); + // Verify the constructor overload that accepts CborProblemDetails works. + Assert.DoesNotThrow(() => new MstServiceException("test", details, new RequestFailedException(400, "Bad Request"))); // Verify via FromRequestFailedException which uses BuildErrorMessage internally var rfEx = new RequestFailedException(400, "Bad Request"); diff --git a/CoseSign1.Transparent.MST/CborProblemDetails.cs b/CoseSign1.Transparent.MST/CborProblemDetails.cs index a154e5db..4bdb2150 100644 --- a/CoseSign1.Transparent.MST/CborProblemDetails.cs +++ b/CoseSign1.Transparent.MST/CborProblemDetails.cs @@ -75,7 +75,11 @@ public class CborProblemDetails var reader = new CborReader(cborBytes); return ParseFromReader(reader); } - catch (Exception) + catch (CborContentException) + { + return null; + } + catch (InvalidOperationException) { return null; } diff --git a/CoseSign1.Transparent.MST/MstServiceException.cs b/CoseSign1.Transparent.MST/MstServiceException.cs index 2ccfd938..f276d392 100644 --- a/CoseSign1.Transparent.MST/MstServiceException.cs +++ b/CoseSign1.Transparent.MST/MstServiceException.cs @@ -4,6 +4,7 @@ namespace CoseSign1.Transparent.MST; using System; +using System.Formats.Cbor; using System.Text; /// @@ -135,9 +136,13 @@ public static MstServiceException FromRequestFailedException(Azure.RequestFailed problemDetails = CborProblemDetails.TryParse(content.ToArray()); } } - catch + catch (CborContentException) { - // Parsing failure is non-fatal; fall through to generic message + // CBOR parsing failure is non-fatal; fall through to generic message + } + catch (InvalidOperationException) + { + // Parsing state failure is non-fatal; fall through to generic message } } } diff --git a/CoseSign1.Transparent.MST/MstTransparencyService.cs b/CoseSign1.Transparent.MST/MstTransparencyService.cs index be4d1d7d..a87356bf 100644 --- a/CoseSign1.Transparent.MST/MstTransparencyService.cs +++ b/CoseSign1.Transparent.MST/MstTransparencyService.cs @@ -165,7 +165,11 @@ public MstTransparencyService( .GetField("_endpoint", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); return field?.GetValue(client) as Uri; } - catch + catch (System.Reflection.TargetInvocationException) + { + return null; + } + catch (InvalidOperationException) { return null; } From cf0d3896a2d51598a5c546106a1ebd673d44b69c Mon Sep 17 00:00:00 2001 From: "Jeromy Statia (from Dev Box)" Date: Fri, 6 Mar 2026 21:18:31 -0800 Subject: [PATCH 04/12] Address Copilot reviewer feedback on PR #168 - Fix naming: rename _serviceEndpoint to ServiceEndpointUri (PascalCase) - Add HalfPrecisionFloat handling in CborProblemDetails.ReadAnyValue - Fix test: assert on ex.Message and ProblemDetails in BuildErrorMessage test - Add test: MakeTransparentAsync wraps RequestFailedException in MstServiceException - Fix comment: update Azure.Core version reference to 1.51.1 - All 93 MST tests pass (1 new) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Azure.Core.TestCommon.csproj | 2 +- .../CborProblemDetailsTests.cs | 9 ++++++-- .../MstTransparencyServiceTests.cs | 23 +++++++++++++++++++ .../CborProblemDetails.cs | 1 + .../MstTransparencyService.cs | 6 ++--- 5 files changed, 35 insertions(+), 6 deletions(-) diff --git a/Azure.Core.TestCommon/Azure.Core.TestCommon.csproj b/Azure.Core.TestCommon/Azure.Core.TestCommon.csproj index 81890c7b..c4a4badf 100644 --- a/Azure.Core.TestCommon/Azure.Core.TestCommon.csproj +++ b/Azure.Core.TestCommon/Azure.Core.TestCommon.csproj @@ -3,7 +3,7 @@