From 0c17ad04540c70557c453635f199a17920825520 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:25:31 +0000 Subject: [PATCH 01/13] Initial plan From cda4322f293fe3355f7c8b9ef22ac88d759ddd8e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:35:24 +0000 Subject: [PATCH 02/13] feat: add framework-neutral transport-aware trigger identity validation (#217) - Add ConnectorTriggerTransport (Body stream + Headers dictionary, BCL-only) - Add ConnectorTriggerIdentity sealed record (ConnectorName, OperationName) - Add ConnectorTriggerIdentityMismatchException with safe diagnostics - Add ConnectorTriggerHeaderNames provisional header name constants - Add ReadAsync(ConnectorTriggerTransport, ConnectorTriggerIdentity, ...) overload to ConnectorTriggerPayload; validates identity headers before deserialization - Add ConnectorTriggerPayloadTransportTests (16 new tests) - Update docs/triggers.md with transport overload guide and provisional header table" --- docs/triggers.md | 62 +++ .../ConnectorTriggerHeaderNames.cs | 54 ++ .../ConnectorTriggerIdentity.cs | 24 + ...nnectorTriggerIdentityMismatchException.cs | 103 ++++ .../ConnectorTriggerPayload.cs | 153 ++++++ .../ConnectorTriggerTransport.cs | 54 ++ .../ConnectorTriggerPayloadTransportTests.cs | 482 ++++++++++++++++++ 7 files changed, 932 insertions(+) create mode 100644 src/Azure.Connectors.Sdk/ConnectorTriggerHeaderNames.cs create mode 100644 src/Azure.Connectors.Sdk/ConnectorTriggerIdentity.cs create mode 100644 src/Azure.Connectors.Sdk/ConnectorTriggerIdentityMismatchException.cs create mode 100644 src/Azure.Connectors.Sdk/ConnectorTriggerTransport.cs create mode 100644 tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTransportTests.cs diff --git a/docs/triggers.md b/docs/triggers.md index 304c740..663dd8d 100644 --- a/docs/triggers.md +++ b/docs/triggers.md @@ -141,6 +141,68 @@ byte[]? fileBytes = await ConnectorTriggerPayload If a binary-content (string) body is read into a metadata payload type, deserialization throws an actionable `JsonException` that points to the binary-content helpers. +### Transport-aware identity validation + +The body-only overloads above remain the simplest choice when the trigger configuration is a compile-time constant (a single function handles exactly one connector/operation). For scenarios where the same endpoint handles multiple connectors, or for defence-in-depth against routing mistakes, use the transport-aware overload that validates trigger identity headers before deserialization: + +```csharp +// 1. Build a framework-neutral transport from the host-specific request. +// This adapter pattern keeps Azure.Connectors.Sdk free of Functions/ASP.NET Core references. +var transport = new ConnectorTriggerTransport +{ + Body = request.Body, + Headers = request.Headers.ToDictionary( + h => h.Key, + h => h.Value, + StringComparer.OrdinalIgnoreCase), +}; + +// 2. Declare the expected identity — use the generated constants for compile-time safety. +var identity = new ConnectorTriggerIdentity( + ConnectorName: ConnectorNames.OneDriveForBusiness, + OperationName: OneDriveForBusinessTriggerOperations.OnNewFiles); + +// 3. Read and validate in one call. +var payload = await ConnectorTriggerPayload + .ReadAsync( + transport, + identity, + cancellationToken: cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); +``` + +If the identity headers do not match `expectedIdentity`, a `ConnectorTriggerIdentityMismatchException` is thrown **before** any JSON deserialization. The exception exposes only safe diagnostics — expected vs. actual connector/operation names, which identity headers were present, and the correlation ID — and never exposes authorization headers, callback URLs, payload content, or lock tokens. + +```csharp +catch (ConnectorTriggerIdentityMismatchException ex) +{ + // Safe to log: only identity metadata and correlation ID are exposed. + logger.LogError( + "Trigger identity mismatch. Expected {Connector}/{Operation}, " + + "got {ActualConnector}/{ActualOperation}. CorrelationId: {CorrelationId}. " + + "Present identity headers: {PresentHeaders}.", + ex.ExpectedConnectorName, ex.ExpectedOperationName, + ex.ActualConnectorName, ex.ActualOperationName, + ex.CorrelationId, + string.Join(", ", ex.PresentIdentityHeaderNames)); + throw; +} +``` + +#### Identity header names (provisional service contract) + +The header names validated by the transport overload are defined in `ConnectorTriggerHeaderNames`: + +| Constant | Header | Value example | +|----------|--------|---------------| +| `ConnectorTriggerHeaderNames.ConnectorName` | `x-ms-gateway-resource-name` | `office365` | +| `ConnectorTriggerHeaderNames.OperationName` | `x-ms-trigger-name` | `OnNewEmailV3` | +| `ConnectorTriggerHeaderNames.CorrelationId` | `x-ms-client-request-id` | `abc-123` | + +> **Important — provisional contract:** These header names reflect the current Connector Namespace webhook implementation and have not yet been formally agreed as a stable, versioned API surface with the Connector Namespace service team. They may change before reaching general availability. The names are isolated behind `ConnectorTriggerHeaderNames` constants so a future update is a single-line change. + +Header-name lookup and value comparison both use `StringComparison.OrdinalIgnoreCase`. + ## Trigger Operation Constants Each connector exposes a `{Connector}TriggerOperations` static class with the operation name strings: diff --git a/src/Azure.Connectors.Sdk/ConnectorTriggerHeaderNames.cs b/src/Azure.Connectors.Sdk/ConnectorTriggerHeaderNames.cs new file mode 100644 index 0000000..6bd6c29 --- /dev/null +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerHeaderNames.cs @@ -0,0 +1,54 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Azure.Connectors.Sdk; + +/// +/// HTTP header name constants used by the Connector Namespace service when delivering +/// trigger callbacks to application endpoints. +/// +/// +/// +/// Important — provisional service contract: The header names defined here +/// reflect the current Connector Namespace webhook implementation. They have not yet been +/// formally agreed as a stable, versioned API surface with the Connector Namespace service team. +/// Do not treat these constants as immutable across SDK versions. The service team will document +/// a finalized, versioned header contract before identity validation is enabled by default. +/// +/// +/// Use these constants with +/// +/// to validate trigger identity before payload deserialization. +/// +/// +public static class ConnectorTriggerHeaderNames +{ + /// + /// The header that carries the connector's API name (for example office365). + /// + /// + /// Provisional — not yet finalized as a stable Connector Namespace service contract. + /// Compare against constants from . + /// + public const string ConnectorName = "x-ms-gateway-resource-name"; + + /// + /// The header that carries the trigger operation name (for example OnNewEmailV3). + /// + /// + /// Provisional — not yet finalized as a stable Connector Namespace service contract. + /// Compare against constants from the connector's {Connector}TriggerOperations class. + /// + public const string OperationName = "x-ms-trigger-name"; + + /// + /// The header that carries the per-request correlation identifier, when present. + /// + /// + /// Provisional — not yet finalized as a stable Connector Namespace service contract. + /// When present, this value is included in + /// to assist with call tracing. + /// + public const string CorrelationId = "x-ms-client-request-id"; +} diff --git a/src/Azure.Connectors.Sdk/ConnectorTriggerIdentity.cs b/src/Azure.Connectors.Sdk/ConnectorTriggerIdentity.cs new file mode 100644 index 0000000..5f093b7 --- /dev/null +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerIdentity.cs @@ -0,0 +1,24 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Azure.Connectors.Sdk; + +/// +/// Identifies the expected connector and operation for a trigger callback. +/// +/// +/// The connector API name (for example office365). +/// Use constants from for IntelliSense and compile-time validation. +/// +/// +/// The trigger operation name (for example OnNewEmailV3). +/// Use constants from the connector's {Connector}TriggerOperations class. +/// +/// +/// Pass this to +/// +/// to validate that the callback originates from the expected connector trigger before +/// payload deserialization. +/// +public sealed record ConnectorTriggerIdentity(string ConnectorName, string OperationName); diff --git a/src/Azure.Connectors.Sdk/ConnectorTriggerIdentityMismatchException.cs b/src/Azure.Connectors.Sdk/ConnectorTriggerIdentityMismatchException.cs new file mode 100644 index 0000000..865971e --- /dev/null +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerIdentityMismatchException.cs @@ -0,0 +1,103 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +using System; +using System.Collections.Generic; + +namespace Azure.Connectors.Sdk; + +/// +/// Thrown when the Connector Namespace trigger identity headers delivered with a callback +/// do not match the expected connector and operation identity. +/// +/// +/// +/// This exception is thrown by +/// +/// when one or more identity headers are absent or their values do not match the +/// supplied by the caller. +/// +/// +/// The exception message and all properties intentionally exclude authorization headers, +/// callback URLs, raw payload content, queue lock tokens, and other secrets. Only expected +/// and actual identity values, the names of present identity headers, and the correlation +/// identifier are exposed for safe diagnostic use. +/// +/// +public sealed class ConnectorTriggerIdentityMismatchException : InvalidOperationException +{ + /// + /// Initializes a new instance of the class. + /// + /// A human-readable message describing the mismatch. + /// The connector name the caller expected. + /// The operation name the caller expected. + /// + /// The connector name read from the callback headers, or when the header + /// was absent or carried an empty value. + /// + /// + /// The operation name read from the callback headers, or when the header + /// was absent or carried an empty value. + /// + /// + /// The names of the known identity headers that were present (and non-empty) in the callback. + /// + /// + /// The per-request correlation identifier from the callback headers, or + /// when not present. + /// + internal ConnectorTriggerIdentityMismatchException( + string message, + string expectedConnectorName, + string expectedOperationName, + string? actualConnectorName, + string? actualOperationName, + IReadOnlyCollection presentIdentityHeaderNames, + string? correlationId) + : base(message) + { + this.ExpectedConnectorName = expectedConnectorName; + this.ExpectedOperationName = expectedOperationName; + this.ActualConnectorName = actualConnectorName; + this.ActualOperationName = actualOperationName; + this.PresentIdentityHeaderNames = presentIdentityHeaderNames; + this.CorrelationId = correlationId; + } + + /// + /// Gets the connector name the caller expected. + /// + public string ExpectedConnectorName { get; } + + /// + /// Gets the operation name the caller expected. + /// + public string ExpectedOperationName { get; } + + /// + /// Gets the connector name read from the callback headers, or when the + /// header was absent or carried an empty value. + /// + public string? ActualConnectorName { get; } + + /// + /// Gets the operation name read from the callback headers, or when the + /// header was absent or carried an empty value. + /// + public string? ActualOperationName { get; } + + /// + /// Gets the names of the known identity headers that were present (and non-empty) in the callback. + /// Use this to distinguish a fully-absent set of headers (possible routing mistake or non-Connector + /// Namespace caller) from headers that were delivered but carried unexpected values. + /// + public IReadOnlyCollection PresentIdentityHeaderNames { get; } + + /// + /// Gets the per-request correlation identifier from the callback headers, or + /// when not present. + /// + public string? CorrelationId { get; } +} diff --git a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs index e59557a..74ba255 100644 --- a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs @@ -4,7 +4,9 @@ using System; using System.Buffers; +using System.Collections.Generic; using System.IO; +using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -141,6 +143,63 @@ public static class ConnectorTriggerPayload .ConfigureAwait(continueOnCapturedContext: false); } + /// + /// Reads a metadata trigger callback from the framework-neutral , + /// validates the trigger identity headers against , and + /// deserializes the body into its typed payload. + /// + /// + /// The connector-specific payload type, a subclass of + /// (for example Office365OnNewEmailTriggerPayload). + /// + /// + /// The framework-neutral callback representation. Carry the body stream and the HTTP headers + /// forwarded from the host (Azure Functions, ASP.NET Core, etc.) using a host-local adapter. + /// + /// + /// The expected connector and operation identity. When the callback headers do not match, + /// a is thrown before deserialization. + /// + /// + /// The maximum number of bytes to read from the body before failing. + /// Defaults to . + /// + /// The cancellation token. + /// The deserialized payload, or when the body is JSON null. + /// + /// or is . + /// + /// is not greater than zero. + /// + /// The identity headers in the callback are absent or do not match . + /// + /// The body exceeded . + /// + /// The body was a base64 string (a binary-content trigger) rather than a metadata object. + /// + /// + /// Identity validation uses the header names defined in , + /// which are provisional and subject to change until the Connector Namespace service team publishes + /// a finalized header contract. Header-name lookup and value comparison are both + /// . + /// + public static async ValueTask ReadAsync( + ConnectorTriggerTransport transport, + ConnectorTriggerIdentity expectedIdentity, + long maxBodySizeBytes = ConnectorTriggerPayload.DefaultMaxBodySizeBytes, + CancellationToken cancellationToken = default) + where TPayload : class + { + ArgumentNullException.ThrowIfNull(transport); + ArgumentNullException.ThrowIfNull(expectedIdentity); + + ConnectorTriggerPayload.ValidateIdentity(transport.Headers, expectedIdentity); + + return await ConnectorTriggerPayload + .ReadAsync(transport.Body, maxBodySizeBytes, cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + } + /// /// Attempts to read a binary-content trigger callback (for example OneDrive OnNewFileV2), /// whose wire shape is {"body":"<base64>"}, into the decoded file bytes. @@ -179,6 +238,100 @@ public static bool TryReadBinaryContent(string json, out byte[] content) } } + /// + /// Validates the trigger identity headers against the expected connector and operation. + /// Throws when any header is absent or mismatches. + /// + /// The request headers from the callback. + /// The expected connector and operation identity. + private static void ValidateIdentity( + IReadOnlyDictionary> headers, + ConnectorTriggerIdentity expectedIdentity) + { + string? actualConnectorName = ConnectorTriggerPayload.GetFirstHeaderValue(headers, ConnectorTriggerHeaderNames.ConnectorName); + string? actualOperationName = ConnectorTriggerPayload.GetFirstHeaderValue(headers, ConnectorTriggerHeaderNames.OperationName); + string? correlationId = ConnectorTriggerPayload.GetFirstHeaderValue(headers, ConnectorTriggerHeaderNames.CorrelationId); + + // Track which identity headers were present (non-empty) to aid diagnostics. + var presentHeaders = new List(capacity: 2); + if (actualConnectorName is not null) + { + presentHeaders.Add(ConnectorTriggerHeaderNames.ConnectorName); + } + + if (actualOperationName is not null) + { + presentHeaders.Add(ConnectorTriggerHeaderNames.OperationName); + } + + bool connectorMatch = string.Equals(actualConnectorName, expectedIdentity.ConnectorName, StringComparison.OrdinalIgnoreCase); + bool operationMatch = string.Equals(actualOperationName, expectedIdentity.OperationName, StringComparison.OrdinalIgnoreCase); + + if (connectorMatch && operationMatch) + { + return; + } + + // Build a descriptive message that exposes only safe diagnostic data. + var message = new StringBuilder("Trigger identity mismatch."); + + if (actualConnectorName is null) + { + message.Append($" Connector identity header '{ConnectorTriggerHeaderNames.ConnectorName}' was absent or empty."); + } + else if (!connectorMatch) + { + message.Append($" Expected connector '{expectedIdentity.ConnectorName}', got '{actualConnectorName}'."); + } + + if (actualOperationName is null) + { + message.Append($" Operation identity header '{ConnectorTriggerHeaderNames.OperationName}' was absent or empty."); + } + else if (!operationMatch) + { + message.Append($" Expected operation '{expectedIdentity.OperationName}', got '{actualOperationName}'."); + } + + if (correlationId is not null) + { + message.Append($" Correlation ID: '{correlationId}'."); + } + + throw new ConnectorTriggerIdentityMismatchException( + message: message.ToString(), + expectedConnectorName: expectedIdentity.ConnectorName, + expectedOperationName: expectedIdentity.OperationName, + actualConnectorName: actualConnectorName, + actualOperationName: actualOperationName, + presentIdentityHeaderNames: presentHeaders, + correlationId: correlationId); + } + + /// + /// Returns the first non-empty value for in , + /// or when the header is absent or all its values are empty. + /// + private static string? GetFirstHeaderValue( + IReadOnlyDictionary> headers, + string headerName) + { + if (!headers.TryGetValue(headerName, out IEnumerable? values)) + { + return null; + } + + foreach (string value in values) + { + if (!string.IsNullOrEmpty(value)) + { + return value; + } + } + + return null; + } + /// /// Decodes the base64 body string of a parsed binary-content trigger callback into bytes. /// diff --git a/src/Azure.Connectors.Sdk/ConnectorTriggerTransport.cs b/src/Azure.Connectors.Sdk/ConnectorTriggerTransport.cs new file mode 100644 index 0000000..d2b0802 --- /dev/null +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerTransport.cs @@ -0,0 +1,54 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +using System.Collections.Generic; +using System.IO; + +namespace Azure.Connectors.Sdk; + +/// +/// A framework-neutral representation of an incoming trigger callback request, carrying +/// the raw body stream and the HTTP headers needed for identity validation. +/// +/// +/// +/// This type uses only BCL abstractions (, +/// ) so that the core SDK has no dependency on +/// Azure Functions, ASP.NET Core, or any other host-specific framework. +/// Host adapters are the caller's responsibility and stay within the application or optional +/// integration packages. +/// +/// +/// Example — adapting an Azure Functions isolated-worker HttpRequestData: +/// +/// +/// var transport = new ConnectorTriggerTransport +/// { +/// Body = request.Body, +/// Headers = request.Headers +/// .ToDictionary( +/// h => h.Key, +/// h => h.Value, +/// StringComparer.OrdinalIgnoreCase) +/// }; +/// +/// +public sealed class ConnectorTriggerTransport +{ + /// + /// Gets the callback body stream. The caller retains ownership; the SDK reads but does not close the stream. + /// + public required Stream Body { get; init; } + + /// + /// Gets the request headers used for trigger identity validation. + /// + /// + /// The dictionary should use for + /// case-insensitive header-name lookup. When not provided, defaults to an empty + /// case-insensitive dictionary (all identity headers will be treated as absent). + /// + public IReadOnlyDictionary> Headers { get; init; } + = new Dictionary>(System.StringComparer.OrdinalIgnoreCase); +} diff --git a/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTransportTests.cs b/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTransportTests.cs new file mode 100644 index 0000000..f660eee --- /dev/null +++ b/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTransportTests.cs @@ -0,0 +1,482 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Azure.Connectors.Sdk.OneDriveForBusiness.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.Connectors.Sdk.Tests +{ + /// + /// Tests for the transport-aware overload of , + /// covering identity validation, safe diagnostics, and backward compatibility. + /// + [TestClass] + public class ConnectorTriggerPayloadTransportTests + { + private const string ExpectedConnectorName = "onedriveforbusiness"; + + private const string ExpectedOperationName = "OnNewFilesV2"; + + private const string MetadataPayload = """ + {"body":{"value":[{"Id":"01ABC","Name":"report.docx","Path":"/Documents/report.docx","Size":1234,"IsFolder":false}]}} + """; + + // ------------------------------------------------------------------ // + // Helpers // + // ------------------------------------------------------------------ // + + private static ConnectorTriggerTransport CreateTransport( + string body, + string connectorName = ConnectorTriggerPayloadTransportTests.ExpectedConnectorName, + string operationName = ConnectorTriggerPayloadTransportTests.ExpectedOperationName, + string? correlationId = null, + IDictionary>? extraHeaders = null) + { + var headers = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + [ConnectorTriggerHeaderNames.ConnectorName] = new[] { connectorName }, + [ConnectorTriggerHeaderNames.OperationName] = new[] { operationName }, + }; + + if (correlationId is not null) + { + headers[ConnectorTriggerHeaderNames.CorrelationId] = new[] { correlationId }; + } + + if (extraHeaders is not null) + { + foreach (var header in extraHeaders) + { + headers[header.Key] = header.Value; + } + } + + return new ConnectorTriggerTransport + { + Body = new MemoryStream(Encoding.UTF8.GetBytes(body)), + Headers = headers, + }; + } + + private static ConnectorTriggerIdentity ExpectedIdentity => new( + ConnectorTriggerPayloadTransportTests.ExpectedConnectorName, + ConnectorTriggerPayloadTransportTests.ExpectedOperationName); + + // ------------------------------------------------------------------ // + // Happy path // + // ------------------------------------------------------------------ // + + [TestMethod] + public async Task ReadAsync_Transport_MatchingIdentity_ReturnsPayload() + { + // Arrange + var transport = ConnectorTriggerPayloadTransportTests.CreateTransport( + ConnectorTriggerPayloadTransportTests.MetadataPayload); + + // Act + var payload = await ConnectorTriggerPayload + .ReadAsync( + transport, + ConnectorTriggerPayloadTransportTests.ExpectedIdentity) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsNotNull(payload); + Assert.IsNotNull(payload.Body); + Assert.IsNotNull(payload.Body.Value); + Assert.AreEqual(1, payload.Body.Value.Count); + Assert.AreEqual("report.docx", payload.Body.Value[0].Name); + } + + // ------------------------------------------------------------------ // + // Case-insensitive header-name and header-value matching // + // ------------------------------------------------------------------ // + + [TestMethod] + public async Task ReadAsync_Transport_HeaderNameCaseInsensitive_Validates() + { + // Arrange — headers keyed with upper-case names in a plain Dictionary; the + // ConnectorTriggerTransport default comparer normalises lookup. + var headers = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["X-MS-GATEWAY-RESOURCE-NAME"] = new[] { ConnectorTriggerPayloadTransportTests.ExpectedConnectorName }, + ["X-MS-TRIGGER-NAME"] = new[] { ConnectorTriggerPayloadTransportTests.ExpectedOperationName }, + }; + + var transport = new ConnectorTriggerTransport + { + Body = new MemoryStream(Encoding.UTF8.GetBytes(ConnectorTriggerPayloadTransportTests.MetadataPayload)), + Headers = headers, + }; + + // Act — should not throw + var payload = await ConnectorTriggerPayload + .ReadAsync( + transport, + ConnectorTriggerPayloadTransportTests.ExpectedIdentity) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsNotNull(payload); + } + + [TestMethod] + public async Task ReadAsync_Transport_HeaderValueCaseInsensitive_Validates() + { + // Arrange — connector name in uppercase; validation must be case-insensitive. + var transport = ConnectorTriggerPayloadTransportTests.CreateTransport( + ConnectorTriggerPayloadTransportTests.MetadataPayload, + connectorName: ConnectorTriggerPayloadTransportTests.ExpectedConnectorName.ToUpperInvariant(), + operationName: ConnectorTriggerPayloadTransportTests.ExpectedOperationName.ToUpperInvariant()); + + // Act — should not throw + var payload = await ConnectorTriggerPayload + .ReadAsync( + transport, + ConnectorTriggerPayloadTransportTests.ExpectedIdentity) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsNotNull(payload); + } + + // ------------------------------------------------------------------ // + // Missing identity headers // + // ------------------------------------------------------------------ // + + [TestMethod] + public async Task ReadAsync_Transport_ConnectorNameHeaderMissing_ThrowsIdentityMismatch() + { + // Arrange — only the operation header is present. + var headers = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + [ConnectorTriggerHeaderNames.OperationName] = new[] { ConnectorTriggerPayloadTransportTests.ExpectedOperationName }, + }; + + var transport = new ConnectorTriggerTransport + { + Body = new MemoryStream(Encoding.UTF8.GetBytes(ConnectorTriggerPayloadTransportTests.MetadataPayload)), + Headers = headers, + }; + + // Act & Assert + var exception = await Assert.ThrowsExactlyAsync( + async () => await ConnectorTriggerPayload + .ReadAsync( + transport, + ConnectorTriggerPayloadTransportTests.ExpectedIdentity) + .ConfigureAwait(continueOnCapturedContext: false)) + .ConfigureAwait(continueOnCapturedContext: false); + + Assert.IsNull(exception.ActualConnectorName); + Assert.AreEqual(ConnectorTriggerPayloadTransportTests.ExpectedConnectorName, exception.ExpectedConnectorName); + Assert.AreEqual(ConnectorTriggerPayloadTransportTests.ExpectedOperationName, exception.ExpectedOperationName); + StringAssert.Contains(exception.Message, ConnectorTriggerHeaderNames.ConnectorName); + } + + [TestMethod] + public async Task ReadAsync_Transport_OperationNameHeaderMissing_ThrowsIdentityMismatch() + { + // Arrange — only the connector header is present. + var headers = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + [ConnectorTriggerHeaderNames.ConnectorName] = new[] { ConnectorTriggerPayloadTransportTests.ExpectedConnectorName }, + }; + + var transport = new ConnectorTriggerTransport + { + Body = new MemoryStream(Encoding.UTF8.GetBytes(ConnectorTriggerPayloadTransportTests.MetadataPayload)), + Headers = headers, + }; + + // Act & Assert + var exception = await Assert.ThrowsExactlyAsync( + async () => await ConnectorTriggerPayload + .ReadAsync( + transport, + ConnectorTriggerPayloadTransportTests.ExpectedIdentity) + .ConfigureAwait(continueOnCapturedContext: false)) + .ConfigureAwait(continueOnCapturedContext: false); + + Assert.IsNull(exception.ActualOperationName); + StringAssert.Contains(exception.Message, ConnectorTriggerHeaderNames.OperationName); + } + + [TestMethod] + public async Task ReadAsync_Transport_BothHeadersMissing_ThrowsIdentityMismatch() + { + // Arrange — no identity headers at all (e.g. routing mistake or non-Connector Namespace caller). + var transport = new ConnectorTriggerTransport + { + Body = new MemoryStream(Encoding.UTF8.GetBytes(ConnectorTriggerPayloadTransportTests.MetadataPayload)), + Headers = new Dictionary>(StringComparer.OrdinalIgnoreCase), + }; + + // Act & Assert + var exception = await Assert.ThrowsExactlyAsync( + async () => await ConnectorTriggerPayload + .ReadAsync( + transport, + ConnectorTriggerPayloadTransportTests.ExpectedIdentity) + .ConfigureAwait(continueOnCapturedContext: false)) + .ConfigureAwait(continueOnCapturedContext: false); + + Assert.IsNull(exception.ActualConnectorName); + Assert.IsNull(exception.ActualOperationName); + Assert.AreEqual(0, exception.PresentIdentityHeaderNames.Count); + StringAssert.Contains(exception.Message, ConnectorTriggerHeaderNames.ConnectorName); + StringAssert.Contains(exception.Message, ConnectorTriggerHeaderNames.OperationName); + } + + // ------------------------------------------------------------------ // + // Value mismatches // + // ------------------------------------------------------------------ // + + [TestMethod] + public async Task ReadAsync_Transport_ConnectorNameMismatch_ThrowsIdentityMismatch() + { + // Arrange — wrong connector, correct operation. + var transport = ConnectorTriggerPayloadTransportTests.CreateTransport( + ConnectorTriggerPayloadTransportTests.MetadataPayload, + connectorName: "sharepoint", + operationName: ConnectorTriggerPayloadTransportTests.ExpectedOperationName); + + // Act & Assert + var exception = await Assert.ThrowsExactlyAsync( + async () => await ConnectorTriggerPayload + .ReadAsync( + transport, + ConnectorTriggerPayloadTransportTests.ExpectedIdentity) + .ConfigureAwait(continueOnCapturedContext: false)) + .ConfigureAwait(continueOnCapturedContext: false); + + Assert.AreEqual("sharepoint", exception.ActualConnectorName); + Assert.AreEqual(ConnectorTriggerPayloadTransportTests.ExpectedConnectorName, exception.ExpectedConnectorName); + Assert.IsTrue(exception.PresentIdentityHeaderNames.Contains(ConnectorTriggerHeaderNames.ConnectorName)); + StringAssert.Contains(exception.Message, "sharepoint"); + StringAssert.Contains(exception.Message, ConnectorTriggerPayloadTransportTests.ExpectedConnectorName); + } + + [TestMethod] + public async Task ReadAsync_Transport_OperationNameMismatch_ThrowsIdentityMismatch() + { + // Arrange — correct connector, wrong operation. + var transport = ConnectorTriggerPayloadTransportTests.CreateTransport( + ConnectorTriggerPayloadTransportTests.MetadataPayload, + connectorName: ConnectorTriggerPayloadTransportTests.ExpectedConnectorName, + operationName: "OnUpdatedFilesV2"); + + // Act & Assert + var exception = await Assert.ThrowsExactlyAsync( + async () => await ConnectorTriggerPayload + .ReadAsync( + transport, + ConnectorTriggerPayloadTransportTests.ExpectedIdentity) + .ConfigureAwait(continueOnCapturedContext: false)) + .ConfigureAwait(continueOnCapturedContext: false); + + Assert.AreEqual("OnUpdatedFilesV2", exception.ActualOperationName); + Assert.AreEqual(ConnectorTriggerPayloadTransportTests.ExpectedOperationName, exception.ExpectedOperationName); + StringAssert.Contains(exception.Message, "OnUpdatedFilesV2"); + } + + // ------------------------------------------------------------------ // + // Correlation ID // + // ------------------------------------------------------------------ // + + [TestMethod] + public async Task ReadAsync_Transport_WithCorrelationId_IncludedInException() + { + // Arrange + const string correlationId = "abc-123-def"; + var transport = ConnectorTriggerPayloadTransportTests.CreateTransport( + ConnectorTriggerPayloadTransportTests.MetadataPayload, + connectorName: "wrong-connector", + correlationId: correlationId); + + // Act & Assert + var exception = await Assert.ThrowsExactlyAsync( + async () => await ConnectorTriggerPayload + .ReadAsync( + transport, + ConnectorTriggerPayloadTransportTests.ExpectedIdentity) + .ConfigureAwait(continueOnCapturedContext: false)) + .ConfigureAwait(continueOnCapturedContext: false); + + Assert.AreEqual(correlationId, exception.CorrelationId); + StringAssert.Contains(exception.Message, correlationId); + } + + [TestMethod] + public async Task ReadAsync_Transport_MatchingIdentityWithCorrelationId_DoesNotThrow() + { + // Arrange + var transport = ConnectorTriggerPayloadTransportTests.CreateTransport( + ConnectorTriggerPayloadTransportTests.MetadataPayload, + correlationId: "trace-xyz"); + + // Act — correlation ID must not interfere with successful validation. + var payload = await ConnectorTriggerPayload + .ReadAsync( + transport, + ConnectorTriggerPayloadTransportTests.ExpectedIdentity) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsNotNull(payload); + } + + // ------------------------------------------------------------------ // + // Safe diagnostics — no secrets in exception // + // ------------------------------------------------------------------ // + + [TestMethod] + public async Task ReadAsync_Transport_IdentityMismatch_ExceptionContainsNoSecrets() + { + // Arrange — add sensitive headers that must never appear in the exception. + const string secretAuthToken = "******"; + const string secretCallbackUrl = "https://secret-callback.example.com/run?code=very-secret"; + const string secretLockToken = "lock-token-sensitive-value"; + + var sensitiveHeaders = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["Authorization"] = new[] { secretAuthToken }, + ["x-ms-callback-url"] = new[] { secretCallbackUrl }, + ["x-ms-lock-token"] = new[] { secretLockToken }, + }; + + var transport = ConnectorTriggerPayloadTransportTests.CreateTransport( + ConnectorTriggerPayloadTransportTests.MetadataPayload, + connectorName: "wrong-connector", + extraHeaders: sensitiveHeaders); + + // Act + var exception = await Assert.ThrowsExactlyAsync( + async () => await ConnectorTriggerPayload + .ReadAsync( + transport, + ConnectorTriggerPayloadTransportTests.ExpectedIdentity) + .ConfigureAwait(continueOnCapturedContext: false)) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert — sensitive header values must not appear in the exception message. + Assert.IsFalse(exception.Message.Contains(secretAuthToken, StringComparison.Ordinal)); + Assert.IsFalse(exception.Message.Contains(secretCallbackUrl, StringComparison.Ordinal)); + Assert.IsFalse(exception.Message.Contains(secretLockToken, StringComparison.Ordinal)); + } + + // ------------------------------------------------------------------ // + // PresentIdentityHeaderNames diagnostics // + // ------------------------------------------------------------------ // + + [TestMethod] + public async Task ReadAsync_Transport_BothHeadersPresent_PresentHeadersListedInException() + { + // Arrange — both headers present but values are wrong. + var transport = ConnectorTriggerPayloadTransportTests.CreateTransport( + ConnectorTriggerPayloadTransportTests.MetadataPayload, + connectorName: "wrong-connector", + operationName: "WrongOperation"); + + // Act & Assert + var exception = await Assert.ThrowsExactlyAsync( + async () => await ConnectorTriggerPayload + .ReadAsync( + transport, + ConnectorTriggerPayloadTransportTests.ExpectedIdentity) + .ConfigureAwait(continueOnCapturedContext: false)) + .ConfigureAwait(continueOnCapturedContext: false); + + Assert.IsTrue(exception.PresentIdentityHeaderNames.Contains(ConnectorTriggerHeaderNames.ConnectorName)); + Assert.IsTrue(exception.PresentIdentityHeaderNames.Contains(ConnectorTriggerHeaderNames.OperationName)); + } + + // ------------------------------------------------------------------ // + // Null argument guards // + // ------------------------------------------------------------------ // + + [TestMethod] + public async Task ReadAsync_Transport_NullTransport_ThrowsArgumentNull() + { + // Act & Assert + await Assert.ThrowsExactlyAsync( + async () => await ConnectorTriggerPayload + .ReadAsync( + transport: null!, + expectedIdentity: ConnectorTriggerPayloadTransportTests.ExpectedIdentity) + .ConfigureAwait(continueOnCapturedContext: false)) + .ConfigureAwait(continueOnCapturedContext: false); + } + + [TestMethod] + public async Task ReadAsync_Transport_NullExpectedIdentity_ThrowsArgumentNull() + { + // Arrange + var transport = ConnectorTriggerPayloadTransportTests.CreateTransport( + ConnectorTriggerPayloadTransportTests.MetadataPayload); + + // Act & Assert + await Assert.ThrowsExactlyAsync( + async () => await ConnectorTriggerPayload + .ReadAsync( + transport, + expectedIdentity: null!) + .ConfigureAwait(continueOnCapturedContext: false)) + .ConfigureAwait(continueOnCapturedContext: false); + } + + // ------------------------------------------------------------------ // + // Default headers (empty) — all identity headers treated as absent // + // ------------------------------------------------------------------ // + + [TestMethod] + public async Task ReadAsync_Transport_DefaultEmptyHeaders_ThrowsIdentityMismatch() + { + // Arrange — Transport with no Headers set (default empty dictionary). + var transport = new ConnectorTriggerTransport + { + Body = new MemoryStream(Encoding.UTF8.GetBytes(ConnectorTriggerPayloadTransportTests.MetadataPayload)), + }; + + // Act & Assert — both identity headers absent. + var exception = await Assert.ThrowsExactlyAsync( + async () => await ConnectorTriggerPayload + .ReadAsync( + transport, + ConnectorTriggerPayloadTransportTests.ExpectedIdentity) + .ConfigureAwait(continueOnCapturedContext: false)) + .ConfigureAwait(continueOnCapturedContext: false); + + Assert.IsNull(exception.ActualConnectorName); + Assert.IsNull(exception.ActualOperationName); + Assert.IsNull(exception.CorrelationId); + } + + // ------------------------------------------------------------------ // + // Backward compatibility — existing Stream overloads still work // + // ------------------------------------------------------------------ // + + [TestMethod] + public async Task ReadAsync_Stream_ExistingOverload_StillWorks() + { + // Arrange + using var stream = new MemoryStream( + Encoding.UTF8.GetBytes(ConnectorTriggerPayloadTransportTests.MetadataPayload)); + + // Act — the body-only overload must remain unchanged. + var payload = await ConnectorTriggerPayload + .ReadAsync(stream) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsNotNull(payload); + Assert.AreEqual("report.docx", payload.Body?.Value?[0].Name); + } + } +} From e3ed49f0f2e5ec0aafc3ef039d8b05b7734870c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:00:11 +0000 Subject: [PATCH 03/13] fix: resolve trigger identity via trigger config --- docs/triggers.md | 69 ++- .../ConnectorNamespaceTriggerConfig.cs | 12 + ...amespaceTriggerConfigManagementResolver.cs | 246 ++++++++++ ...eTriggerConfigManagementResolverOptions.cs | 44 ++ ...rNamespaceTriggerConfigResourceIdentity.cs | 18 + ...TriggerConfigurationResolutionException.cs | 55 +++ .../ConnectorTriggerHeaderNames.cs | 32 +- .../ConnectorTriggerIdentity.cs | 4 +- ...nnectorTriggerIdentityMismatchException.cs | 54 +-- .../ConnectorTriggerPayload.cs | 293 ++++++++--- ...nnectorTriggerResourceIdentityException.cs | 45 ++ .../ConnectorTriggerTransport.cs | 6 +- ...ConnectorNamespaceTriggerConfigResolver.cs | 24 + ...aceTriggerConfigManagementResolverTests.cs | 238 +++++++++ .../ConnectorTriggerPayloadTransportTests.cs | 454 ++++++++---------- 15 files changed, 1221 insertions(+), 373 deletions(-) create mode 100644 src/Azure.Connectors.Sdk/ConnectorNamespaceTriggerConfig.cs create mode 100644 src/Azure.Connectors.Sdk/ConnectorNamespaceTriggerConfigManagementResolver.cs create mode 100644 src/Azure.Connectors.Sdk/ConnectorNamespaceTriggerConfigManagementResolverOptions.cs create mode 100644 src/Azure.Connectors.Sdk/ConnectorNamespaceTriggerConfigResourceIdentity.cs create mode 100644 src/Azure.Connectors.Sdk/ConnectorTriggerConfigurationResolutionException.cs create mode 100644 src/Azure.Connectors.Sdk/ConnectorTriggerResourceIdentityException.cs create mode 100644 src/Azure.Connectors.Sdk/IConnectorNamespaceTriggerConfigResolver.cs create mode 100644 tests/Azure.Connectors.Sdk.Tests/ConnectorNamespaceTriggerConfigManagementResolverTests.cs diff --git a/docs/triggers.md b/docs/triggers.md index 663dd8d..ccf24ca 100644 --- a/docs/triggers.md +++ b/docs/triggers.md @@ -162,46 +162,85 @@ var identity = new ConnectorTriggerIdentity( ConnectorName: ConnectorNames.OneDriveForBusiness, OperationName: OneDriveForBusinessTriggerOperations.OnNewFiles); -// 3. Read and validate in one call. +// 3. Configure the authoritative trigger-config resolver. +var resolver = new ConnectorNamespaceTriggerConfigManagementResolver( + credential: new DefaultAzureCredential(), + options: new ConnectorNamespaceTriggerConfigManagementResolverOptions + { + ManagementEndpoint = new Uri("https://management.azure.com"), + Audience = "https://management.azure.com", + ApiVersion = "2026-05-01-preview", + }); + +// 4. Read and validate in one call. var payload = await ConnectorTriggerPayload .ReadAsync( transport, identity, + resolver, cancellationToken: cancellationToken) .ConfigureAwait(continueOnCapturedContext: false); ``` -If the identity headers do not match `expectedIdentity`, a `ConnectorTriggerIdentityMismatchException` is thrown **before** any JSON deserialization. The exception exposes only safe diagnostics — expected vs. actual connector/operation names, which identity headers were present, and the correlation ID — and never exposes authorization headers, callback URLs, payload content, or lock tokens. +The validating overload does **not** treat `x-ms-gateway-resource-name` and `x-ms-trigger-name` as connector/operation metadata. Instead it: + +1. Reads the four resource-context headers from the callback. +2. Builds a `ConnectorNamespaceTriggerConfigResourceIdentity`. +3. Uses `IConnectorNamespaceTriggerConfigResolver` to GET the authoritative trigger config. +4. Compares `properties.connectionDetails.connectorName` and `properties.operationName` to the caller-selected `ConnectorTriggerIdentity`. +5. Only then delegates to the existing bounded stream deserializer. + +If the resolved trigger configuration does not match `expectedIdentity`, a `ConnectorTriggerIdentityMismatchException` is thrown **before** any JSON deserialization. The exception exposes only safe diagnostics — expected vs. resolved connector/operation names, the trigger-config resource identity, and the correlation ID — and never exposes authorization headers, callback URLs, payload content, lock tokens, or response bodies. ```csharp +catch (ConnectorTriggerResourceIdentityException ex) +{ + logger.LogError( + "Trigger resource identity headers were invalid. CorrelationId: {CorrelationId}. Present headers: {PresentHeaders}.", + ex.CorrelationId, + string.Join(", ", ex.PresentResourceIdentityHeaderNames)); + throw; +} +catch (ConnectorTriggerConfigurationResolutionException ex) +{ + logger.LogError( + "Failed to resolve trigger config for {Namespace}/{Trigger}. Status: {Status}. CorrelationId: {CorrelationId}.", + ex.ResourceIdentity.ConnectorNamespaceName, + ex.ResourceIdentity.TriggerConfigName, + ex.Status, + ex.CorrelationId); + throw; +} catch (ConnectorTriggerIdentityMismatchException ex) { - // Safe to log: only identity metadata and correlation ID are exposed. logger.LogError( "Trigger identity mismatch. Expected {Connector}/{Operation}, " + - "got {ActualConnector}/{ActualOperation}. CorrelationId: {CorrelationId}. " + - "Present identity headers: {PresentHeaders}.", + "resolved {ResolvedConnector}/{ResolvedOperation}. CorrelationId: {CorrelationId}. " + + "Resource: {Namespace}/{Trigger}.", ex.ExpectedConnectorName, ex.ExpectedOperationName, - ex.ActualConnectorName, ex.ActualOperationName, + ex.ResolvedConnectorName, ex.ResolvedOperationName, ex.CorrelationId, - string.Join(", ", ex.PresentIdentityHeaderNames)); + ex.ResourceIdentity.ConnectorNamespaceName, + ex.ResourceIdentity.TriggerConfigName); throw; } ``` -#### Identity header names (provisional service contract) +#### Resource-context header names (provisional service contract) -The header names validated by the transport overload are defined in `ConnectorTriggerHeaderNames`: +The header names consumed by the transport overload are defined in `ConnectorTriggerHeaderNames`: -| Constant | Header | Value example | -|----------|--------|---------------| -| `ConnectorTriggerHeaderNames.ConnectorName` | `x-ms-gateway-resource-name` | `office365` | -| `ConnectorTriggerHeaderNames.OperationName` | `x-ms-trigger-name` | `OnNewEmailV3` | -| `ConnectorTriggerHeaderNames.CorrelationId` | `x-ms-client-request-id` | `abc-123` | +| Constant | Header | Value example | Meaning | +|----------|--------|---------------|---------| +| `ConnectorTriggerHeaderNames.SubscriptionId` | `x-ms-subscription-id` | `00000000-0000-0000-0000-000000000000` | Subscription that owns the Connector Namespace | +| `ConnectorTriggerHeaderNames.ResourceGroupName` | `x-ms-resource-group` | `prod-connectors-rg` | Resource group that owns the Connector Namespace | +| `ConnectorTriggerHeaderNames.ConnectorNamespaceName` | `x-ms-gateway-resource-name` | `my-gateway` | Connector Namespace resource name | +| `ConnectorTriggerHeaderNames.TriggerConfigName` | `x-ms-trigger-name` | `email-trigger` | Trigger-config resource name | +| `ConnectorTriggerHeaderNames.CorrelationId` | `x-ms-client-request-id` | `abc-123` | Callback correlation identifier | > **Important — provisional contract:** These header names reflect the current Connector Namespace webhook implementation and have not yet been formally agreed as a stable, versioned API surface with the Connector Namespace service team. They may change before reaching general availability. The names are isolated behind `ConnectorTriggerHeaderNames` constants so a future update is a single-line change. -Header-name lookup and value comparison both use `StringComparison.OrdinalIgnoreCase`. +Header-name lookup is implemented by the SDK with `StringComparison.OrdinalIgnoreCase`, so callers do not need to supply a case-insensitive dictionary comparer for correctness. ## Trigger Operation Constants diff --git a/src/Azure.Connectors.Sdk/ConnectorNamespaceTriggerConfig.cs b/src/Azure.Connectors.Sdk/ConnectorNamespaceTriggerConfig.cs new file mode 100644 index 0000000..fa68391 --- /dev/null +++ b/src/Azure.Connectors.Sdk/ConnectorNamespaceTriggerConfig.cs @@ -0,0 +1,12 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Azure.Connectors.Sdk; + +/// +/// The authoritative connector and operation identity resolved from a Connector Namespace trigger config. +/// +/// The connector API name (for example office365). +/// The trigger operation name (for example OnNewEmailV3). +public sealed record ConnectorNamespaceTriggerConfig(string ConnectorName, string OperationName); diff --git a/src/Azure.Connectors.Sdk/ConnectorNamespaceTriggerConfigManagementResolver.cs b/src/Azure.Connectors.Sdk/ConnectorNamespaceTriggerConfigManagementResolver.cs new file mode 100644 index 0000000..85e0308 --- /dev/null +++ b/src/Azure.Connectors.Sdk/ConnectorNamespaceTriggerConfigManagementResolver.cs @@ -0,0 +1,246 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using global::Azure; +using global::Azure.Core; +using global::Azure.Core.Pipeline; + +namespace Azure.Connectors.Sdk; + +/// +/// Resolves Connector Namespace trigger configuration through the management API using . +/// +public sealed class ConnectorNamespaceTriggerConfigManagementResolver : IConnectorNamespaceTriggerConfigResolver +{ + private const string ConnectorNamePropertyName = "connectorName"; + private const string ConnectionDetailsPropertyName = "connectionDetails"; + private const string MicrosoftWebProviderName = "Microsoft.Web"; + private const string OperationNamePropertyName = "operationName"; + private const string PropertiesPropertyName = "properties"; + private const string ResourceGroupsSegmentName = "resourceGroups"; + private const string SubscriptionsSegmentName = "subscriptions"; + private const string TriggerConfigsSegmentName = "triggerconfigs"; + + private readonly string _apiVersion; + private readonly string[] _audienceScopes; + private readonly Uri _managementEndpoint; + private readonly HttpPipeline _pipeline; + + /// + /// Initializes a new instance of the class. + /// + /// The credential used for management-plane authentication. + /// Optional resolver options for endpoint, audience, API version, retry, and transport. + public ConnectorNamespaceTriggerConfigManagementResolver( + TokenCredential credential, + ConnectorNamespaceTriggerConfigManagementResolverOptions? options = null) + { + ArgumentNullException.ThrowIfNull(credential); + + options ??= new ConnectorNamespaceTriggerConfigManagementResolverOptions(); + ArgumentNullException.ThrowIfNull(options.ManagementEndpoint); + + if (!options.ManagementEndpoint.IsAbsoluteUri) + { + throw new ArgumentException( + message: $"The management endpoint '{options.ManagementEndpoint}' must be an absolute URI.", + paramName: nameof(options)); + } + + if (string.IsNullOrWhiteSpace(options.Audience)) + { + throw new ArgumentException( + message: "The management audience cannot be null or whitespace.", + paramName: nameof(options)); + } + + if (string.IsNullOrWhiteSpace(options.ApiVersion)) + { + throw new ArgumentException( + message: "The management API version cannot be null or whitespace.", + paramName: nameof(options)); + } + + this._managementEndpoint = options.ManagementEndpoint; + this._apiVersion = options.ApiVersion; + this._audienceScopes = [$"{options.Audience.TrimEnd('/')}/.default"]; + this._pipeline = HttpPipelineBuilder.Build( + options, + perRetryPolicies: new HttpPipelinePolicy[] + { + new BearerTokenAuthenticationPolicy(credential, this._audienceScopes) + }); + } + + /// + public async ValueTask GetTriggerConfigAsync( + ConnectorNamespaceTriggerConfigResourceIdentity resourceIdentity, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(resourceIdentity); + + using var message = this._pipeline.CreateMessage(); + var request = message.Request; + request.Method = RequestMethod.Get; + request.Uri.Reset(this.BuildRequestUri(resourceIdentity)); + request.Headers.Add("Accept", "application/json"); + + try + { + await this._pipeline + .SendAsync(message, cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (!ex.IsFatal()) + { + throw ConnectorNamespaceTriggerConfigManagementResolver.CreateResolutionException( + resourceIdentity: resourceIdentity, + status: ex is RequestFailedException requestFailedException && requestFailedException.Status > 0 + ? requestFailedException.Status + : null, + correlationId: null, + detail: "The management request failed before a trigger configuration could be resolved.", + innerException: ex); + } + + var response = message.Response; + if (response.IsError) + { + throw ConnectorNamespaceTriggerConfigManagementResolver.CreateResolutionException( + resourceIdentity: resourceIdentity, + status: response.Status, + correlationId: null, + detail: "The management API returned an unsuccessful status while resolving the trigger configuration."); + } + + string responseContent = response.Content.ToString(); + if (string.IsNullOrWhiteSpace(responseContent)) + { + throw ConnectorNamespaceTriggerConfigManagementResolver.CreateResolutionException( + resourceIdentity: resourceIdentity, + status: response.Status, + correlationId: null, + detail: "The management API returned an empty trigger configuration response."); + } + + try + { + using var document = JsonDocument.Parse(responseContent); + return ConnectorNamespaceTriggerConfigManagementResolver.ParseTriggerConfig(document, resourceIdentity, response.Status); + } + catch (JsonException ex) + { + throw ConnectorNamespaceTriggerConfigManagementResolver.CreateResolutionException( + resourceIdentity: resourceIdentity, + status: response.Status, + correlationId: null, + detail: "The management API returned malformed trigger configuration JSON.", + innerException: ex); + } + } + + private static ConnectorTriggerConfigurationResolutionException CreateResolutionException( + ConnectorNamespaceTriggerConfigResourceIdentity resourceIdentity, + int? status, + string? correlationId, + string detail, + Exception? innerException = null) + { + var message = + $"{detail} Subscription '{resourceIdentity.SubscriptionId}', resource group '{resourceIdentity.ResourceGroupName}', " + + $"Connector Namespace '{resourceIdentity.ConnectorNamespaceName}', trigger config '{resourceIdentity.TriggerConfigName}'."; + + if (status.HasValue) + { + message += $" Status: '{status.Value}'."; + } + + if (correlationId is not null) + { + message += $" Correlation ID: '{correlationId}'."; + } + + return new ConnectorTriggerConfigurationResolutionException( + message: message, + resourceIdentity: resourceIdentity, + status: status, + correlationId: correlationId, + innerException: innerException); + } + + private static string EscapePathSegment(string value) + { + return Uri.EscapeDataString(value); + } + + private static ConnectorNamespaceTriggerConfig ParseTriggerConfig( + JsonDocument document, + ConnectorNamespaceTriggerConfigResourceIdentity resourceIdentity, + int status) + { + if (document.RootElement.ValueKind != JsonValueKind.Object || + !document.RootElement.TryGetProperty(ConnectorNamespaceTriggerConfigManagementResolver.PropertiesPropertyName, out JsonElement propertiesElement) || + propertiesElement.ValueKind != JsonValueKind.Object || + !propertiesElement.TryGetProperty(ConnectorNamespaceTriggerConfigManagementResolver.ConnectionDetailsPropertyName, out JsonElement connectionDetailsElement) || + connectionDetailsElement.ValueKind != JsonValueKind.Object || + !connectionDetailsElement.TryGetProperty(ConnectorNamespaceTriggerConfigManagementResolver.ConnectorNamePropertyName, out JsonElement connectorNameElement) || + connectorNameElement.ValueKind != JsonValueKind.String || + !propertiesElement.TryGetProperty(ConnectorNamespaceTriggerConfigManagementResolver.OperationNamePropertyName, out JsonElement operationNameElement) || + operationNameElement.ValueKind != JsonValueKind.String) + { + throw ConnectorNamespaceTriggerConfigManagementResolver.CreateResolutionException( + resourceIdentity: resourceIdentity, + status: status, + correlationId: null, + detail: "The management API response did not contain required trigger configuration properties."); + } + + string connectorName = connectorNameElement.GetString() ?? string.Empty; + string operationName = operationNameElement.GetString() ?? string.Empty; + + if (string.IsNullOrWhiteSpace(connectorName) || + string.IsNullOrWhiteSpace(operationName)) + { + throw ConnectorNamespaceTriggerConfigManagementResolver.CreateResolutionException( + resourceIdentity: resourceIdentity, + status: status, + correlationId: null, + detail: "The management API response contained empty trigger configuration identity values."); + } + + return new ConnectorNamespaceTriggerConfig( + ConnectorName: connectorName, + OperationName: operationName); + } + + private Uri BuildRequestUri(ConnectorNamespaceTriggerConfigResourceIdentity resourceIdentity) + { + string managementPath = + $"{this._managementEndpoint.AbsolutePath.TrimEnd('/')}/" + + $"{ConnectorNamespaceTriggerConfigManagementResolver.SubscriptionsSegmentName}/" + + $"{ConnectorNamespaceTriggerConfigManagementResolver.EscapePathSegment(resourceIdentity.SubscriptionId)}/" + + $"{ConnectorNamespaceTriggerConfigManagementResolver.ResourceGroupsSegmentName}/" + + $"{ConnectorNamespaceTriggerConfigManagementResolver.EscapePathSegment(resourceIdentity.ResourceGroupName)}/" + + $"providers/{ConnectorNamespaceTriggerConfigManagementResolver.MicrosoftWebProviderName}/connectorGateways/" + + $"{ConnectorNamespaceTriggerConfigManagementResolver.EscapePathSegment(resourceIdentity.ConnectorNamespaceName)}/" + + $"{ConnectorNamespaceTriggerConfigManagementResolver.TriggerConfigsSegmentName}/" + + $"{ConnectorNamespaceTriggerConfigManagementResolver.EscapePathSegment(resourceIdentity.TriggerConfigName)}"; + + var builder = new UriBuilder(this._managementEndpoint) + { + Path = managementPath, + Query = $"api-version={Uri.EscapeDataString(this._apiVersion)}", + }; + + return builder.Uri; + } +} diff --git a/src/Azure.Connectors.Sdk/ConnectorNamespaceTriggerConfigManagementResolverOptions.cs b/src/Azure.Connectors.Sdk/ConnectorNamespaceTriggerConfigManagementResolverOptions.cs new file mode 100644 index 0000000..422ea46 --- /dev/null +++ b/src/Azure.Connectors.Sdk/ConnectorNamespaceTriggerConfigManagementResolverOptions.cs @@ -0,0 +1,44 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +using System; +using global::Azure.Core; + +namespace Azure.Connectors.Sdk; + +/// +/// Options for . +/// +public sealed class ConnectorNamespaceTriggerConfigManagementResolverOptions : ClientOptions +{ + /// + /// The default Connector Namespace management API version. + /// + public const string DefaultApiVersion = "2026-05-01-preview"; + + /// + /// The default ARM management endpoint. + /// + public static readonly Uri DefaultManagementEndpoint = new("https://management.azure.com"); + + /// + /// The default token audience used to acquire management-plane access tokens. + /// + public const string DefaultAudience = "https://management.azure.com"; + + /// + /// Gets or sets the management endpoint used for trigger-config GET requests. + /// + public Uri ManagementEndpoint { get; set; } = ConnectorNamespaceTriggerConfigManagementResolverOptions.DefaultManagementEndpoint; + + /// + /// Gets or sets the token audience used to acquire management-plane access tokens. + /// + public string Audience { get; set; } = ConnectorNamespaceTriggerConfigManagementResolverOptions.DefaultAudience; + + /// + /// Gets or sets the Connector Namespace management API version. + /// + public string ApiVersion { get; set; } = ConnectorNamespaceTriggerConfigManagementResolverOptions.DefaultApiVersion; +} diff --git a/src/Azure.Connectors.Sdk/ConnectorNamespaceTriggerConfigResourceIdentity.cs b/src/Azure.Connectors.Sdk/ConnectorNamespaceTriggerConfigResourceIdentity.cs new file mode 100644 index 0000000..f57be0d --- /dev/null +++ b/src/Azure.Connectors.Sdk/ConnectorNamespaceTriggerConfigResourceIdentity.cs @@ -0,0 +1,18 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Azure.Connectors.Sdk; + +/// +/// Identifies the Connector Namespace trigger-config resource that delivered a callback. +/// +/// The Azure subscription identifier. +/// The Azure resource group name. +/// The Connector Namespace resource name. +/// The trigger-config resource name. +public sealed record ConnectorNamespaceTriggerConfigResourceIdentity( + string SubscriptionId, + string ResourceGroupName, + string ConnectorNamespaceName, + string TriggerConfigName); diff --git a/src/Azure.Connectors.Sdk/ConnectorTriggerConfigurationResolutionException.cs b/src/Azure.Connectors.Sdk/ConnectorTriggerConfigurationResolutionException.cs new file mode 100644 index 0000000..cd4245f --- /dev/null +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerConfigurationResolutionException.cs @@ -0,0 +1,55 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +using System; + +namespace Azure.Connectors.Sdk; + +/// +/// Thrown when the SDK cannot resolve the Connector Namespace trigger configuration for a callback. +/// +/// +/// The exception message and properties intentionally exclude authorization headers, callback URLs, +/// response bodies, and other secrets. Only resource identity, status, and correlation diagnostics +/// are exposed. +/// +public sealed class ConnectorTriggerConfigurationResolutionException : InvalidOperationException +{ + /// + /// Initializes a new instance of the class. + /// + /// A human-readable message describing the resolution failure. + /// The Connector Namespace trigger-config resource being resolved. + /// The HTTP status code returned by the management API, when available. + /// The callback correlation identifier, when present. + /// The underlying failure, when available. + internal ConnectorTriggerConfigurationResolutionException( + string message, + ConnectorNamespaceTriggerConfigResourceIdentity resourceIdentity, + int? status, + string? correlationId, + Exception? innerException = null) + : base(message, innerException) + { + this.ResourceIdentity = resourceIdentity; + this.Status = status; + this.CorrelationId = correlationId; + } + + /// + /// Gets the Connector Namespace trigger-config resource identity being resolved. + /// + public ConnectorNamespaceTriggerConfigResourceIdentity ResourceIdentity { get; } + + /// + /// Gets the HTTP status code returned by the management API, when available. + /// + public int? Status { get; } + + /// + /// Gets the per-request correlation identifier from the callback headers, or + /// when not present. + /// + public string? CorrelationId { get; } +} diff --git a/src/Azure.Connectors.Sdk/ConnectorTriggerHeaderNames.cs b/src/Azure.Connectors.Sdk/ConnectorTriggerHeaderNames.cs index 6bd6c29..fdf6656 100644 --- a/src/Azure.Connectors.Sdk/ConnectorTriggerHeaderNames.cs +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerHeaderNames.cs @@ -18,29 +18,45 @@ namespace Azure.Connectors.Sdk; /// /// /// Use these constants with -/// -/// to validate trigger identity before payload deserialization. +/// +/// to build the Connector Namespace trigger-config resource identity before payload deserialization. /// /// public static class ConnectorTriggerHeaderNames { /// - /// The header that carries the connector's API name (for example office365). + /// The header that carries the Azure subscription identifier that owns the Connector Namespace resource. /// /// /// Provisional — not yet finalized as a stable Connector Namespace service contract. - /// Compare against constants from . /// - public const string ConnectorName = "x-ms-gateway-resource-name"; + public const string SubscriptionId = "x-ms-subscription-id"; /// - /// The header that carries the trigger operation name (for example OnNewEmailV3). + /// The header that carries the Azure resource group name that owns the Connector Namespace resource. /// /// /// Provisional — not yet finalized as a stable Connector Namespace service contract. - /// Compare against constants from the connector's {Connector}TriggerOperations class. /// - public const string OperationName = "x-ms-trigger-name"; + public const string ResourceGroupName = "x-ms-resource-group"; + + /// + /// The header that carries the Connector Namespace resource name. + /// + /// + /// Provisional — not yet finalized as a stable Connector Namespace service contract. + /// This is the Connector Namespace resource name, not the connector API name. + /// + public const string ConnectorNamespaceName = "x-ms-gateway-resource-name"; + + /// + /// The header that carries the trigger-config resource name. + /// + /// + /// Provisional — not yet finalized as a stable Connector Namespace service contract. + /// This is the trigger-config resource name, not the Swagger trigger operation name. + /// + public const string TriggerConfigName = "x-ms-trigger-name"; /// /// The header that carries the per-request correlation identifier, when present. diff --git a/src/Azure.Connectors.Sdk/ConnectorTriggerIdentity.cs b/src/Azure.Connectors.Sdk/ConnectorTriggerIdentity.cs index 5f093b7..d311265 100644 --- a/src/Azure.Connectors.Sdk/ConnectorTriggerIdentity.cs +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerIdentity.cs @@ -17,8 +17,8 @@ namespace Azure.Connectors.Sdk; /// /// /// Pass this to -/// -/// to validate that the callback originates from the expected connector trigger before +/// +/// to validate that the resolved trigger configuration matches the expected connector trigger before /// payload deserialization. /// public sealed record ConnectorTriggerIdentity(string ConnectorName, string OperationName); diff --git a/src/Azure.Connectors.Sdk/ConnectorTriggerIdentityMismatchException.cs b/src/Azure.Connectors.Sdk/ConnectorTriggerIdentityMismatchException.cs index 865971e..067cbb9 100644 --- a/src/Azure.Connectors.Sdk/ConnectorTriggerIdentityMismatchException.cs +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerIdentityMismatchException.cs @@ -3,25 +3,23 @@ //------------------------------------------------------------ using System; -using System.Collections.Generic; - namespace Azure.Connectors.Sdk; /// -/// Thrown when the Connector Namespace trigger identity headers delivered with a callback -/// do not match the expected connector and operation identity. +/// Thrown when the Connector Namespace trigger configuration resolved for a callback +/// does not match the expected connector and operation identity. /// /// /// /// This exception is thrown by -/// -/// when one or more identity headers are absent or their values do not match the +/// +/// when the resolved Connector Namespace trigger configuration does not match the /// supplied by the caller. /// /// /// The exception message and all properties intentionally exclude authorization headers, /// callback URLs, raw payload content, queue lock tokens, and other secrets. Only expected -/// and actual identity values, the names of present identity headers, and the correlation +/// and resolved identity values, the trigger-config resource identity, and the correlation /// identifier are exposed for safe diagnostic use. /// /// @@ -33,16 +31,14 @@ public sealed class ConnectorTriggerIdentityMismatchException : InvalidOperation /// A human-readable message describing the mismatch. /// The connector name the caller expected. /// The operation name the caller expected. - /// - /// The connector name read from the callback headers, or when the header - /// was absent or carried an empty value. + /// + /// The connector name resolved from the Connector Namespace trigger configuration. /// - /// - /// The operation name read from the callback headers, or when the header - /// was absent or carried an empty value. + /// + /// The operation name resolved from the Connector Namespace trigger configuration. /// - /// - /// The names of the known identity headers that were present (and non-empty) in the callback. + /// + /// The Connector Namespace trigger-config resource identity resolved from the callback headers. /// /// /// The per-request correlation identifier from the callback headers, or @@ -52,17 +48,17 @@ internal ConnectorTriggerIdentityMismatchException( string message, string expectedConnectorName, string expectedOperationName, - string? actualConnectorName, - string? actualOperationName, - IReadOnlyCollection presentIdentityHeaderNames, + string resolvedConnectorName, + string resolvedOperationName, + ConnectorNamespaceTriggerConfigResourceIdentity resourceIdentity, string? correlationId) : base(message) { this.ExpectedConnectorName = expectedConnectorName; this.ExpectedOperationName = expectedOperationName; - this.ActualConnectorName = actualConnectorName; - this.ActualOperationName = actualOperationName; - this.PresentIdentityHeaderNames = presentIdentityHeaderNames; + this.ResolvedConnectorName = resolvedConnectorName; + this.ResolvedOperationName = resolvedOperationName; + this.ResourceIdentity = resourceIdentity; this.CorrelationId = correlationId; } @@ -77,23 +73,19 @@ internal ConnectorTriggerIdentityMismatchException( public string ExpectedOperationName { get; } /// - /// Gets the connector name read from the callback headers, or when the - /// header was absent or carried an empty value. + /// Gets the connector name resolved from the Connector Namespace trigger configuration. /// - public string? ActualConnectorName { get; } + public string ResolvedConnectorName { get; } /// - /// Gets the operation name read from the callback headers, or when the - /// header was absent or carried an empty value. + /// Gets the operation name resolved from the Connector Namespace trigger configuration. /// - public string? ActualOperationName { get; } + public string ResolvedOperationName { get; } /// - /// Gets the names of the known identity headers that were present (and non-empty) in the callback. - /// Use this to distinguish a fully-absent set of headers (possible routing mistake or non-Connector - /// Namespace caller) from headers that were delivered but carried unexpected values. + /// Gets the Connector Namespace trigger-config resource identity resolved from the callback headers. /// - public IReadOnlyCollection PresentIdentityHeaderNames { get; } + public ConnectorNamespaceTriggerConfigResourceIdentity ResourceIdentity { get; } /// /// Gets the per-request correlation identifier from the callback headers, or diff --git a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs index 74ba255..5b0b1ad 100644 --- a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs @@ -145,7 +145,7 @@ public static class ConnectorTriggerPayload /// /// Reads a metadata trigger callback from the framework-neutral , - /// validates the trigger identity headers against , and + /// validates the resolved trigger configuration against , and /// deserializes the body into its typed payload. /// /// @@ -157,8 +157,11 @@ public static class ConnectorTriggerPayload /// forwarded from the host (Azure Functions, ASP.NET Core, etc.) using a host-local adapter. /// /// - /// The expected connector and operation identity. When the callback headers do not match, - /// a is thrown before deserialization. + /// The expected connector and operation identity. When the resolved trigger configuration does not + /// match, a is thrown before deserialization. + /// + /// + /// Resolves the authoritative Connector Namespace trigger configuration identified by the callback headers. /// /// /// The maximum number of bytes to read from the body before failing. @@ -167,33 +170,83 @@ public static class ConnectorTriggerPayload /// The cancellation token. /// The deserialized payload, or when the body is JSON null. /// - /// or is . + /// , , or + /// is . /// /// is not greater than zero. + /// + /// The callback did not contain the required Connector Namespace resource-context headers. + /// + /// + /// The SDK could not resolve the authoritative Connector Namespace trigger configuration. + /// /// - /// The identity headers in the callback are absent or do not match . + /// The resolved Connector Namespace trigger configuration does not match . /// /// The body exceeded . /// /// The body was a base64 string (a binary-content trigger) rather than a metadata object. /// /// - /// Identity validation uses the header names defined in , - /// which are provisional and subject to change until the Connector Namespace service team publishes - /// a finalized header contract. Header-name lookup and value comparison are both - /// . + /// Validation uses the resource-context header names defined in , + /// resolves the authoritative trigger configuration through , + /// and compares the resolved connector and operation to . + /// Header-name lookup and value comparison are both . /// public static async ValueTask ReadAsync( ConnectorTriggerTransport transport, ConnectorTriggerIdentity expectedIdentity, + IConnectorNamespaceTriggerConfigResolver triggerConfigResolver, long maxBodySizeBytes = ConnectorTriggerPayload.DefaultMaxBodySizeBytes, CancellationToken cancellationToken = default) where TPayload : class { ArgumentNullException.ThrowIfNull(transport); ArgumentNullException.ThrowIfNull(expectedIdentity); + ArgumentNullException.ThrowIfNull(triggerConfigResolver); + + string? correlationId = ConnectorTriggerPayload.GetFirstHeaderValue(transport.Headers, ConnectorTriggerHeaderNames.CorrelationId); + var resourceIdentity = ConnectorTriggerPayload.GetResourceIdentity(transport.Headers, correlationId); + + ConnectorNamespaceTriggerConfig resolvedTriggerConfig; + try + { + resolvedTriggerConfig = await triggerConfigResolver + .GetTriggerConfigAsync(resourceIdentity, cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + } + catch (OperationCanceledException) + { + throw; + } + catch (ConnectorTriggerConfigurationResolutionException) + { + throw; + } + catch (Exception ex) when (!ex.IsFatal()) + { + throw ConnectorTriggerPayload.CreateConfigurationResolutionException( + resourceIdentity: resourceIdentity, + correlationId: correlationId, + detail: "The trigger configuration resolver failed to return an authoritative Connector Namespace trigger configuration.", + status: null, + innerException: ex); + } - ConnectorTriggerPayload.ValidateIdentity(transport.Headers, expectedIdentity); + if (resolvedTriggerConfig is null) + { + throw ConnectorTriggerPayload.CreateConfigurationResolutionException( + resourceIdentity: resourceIdentity, + correlationId: correlationId, + detail: "The trigger configuration resolver returned a null trigger configuration.", + status: null); + } + + ConnectorTriggerPayload.ValidateIdentity( + expectedIdentity: expectedIdentity, + resolvedTriggerConfig: resolvedTriggerConfig, + resourceIdentity: resourceIdentity, + correlationId: correlationId); return await ConnectorTriggerPayload .ReadAsync(transport.Body, maxBodySizeBytes, cancellationToken) @@ -239,58 +292,132 @@ public static bool TryReadBinaryContent(string json, out byte[] content) } /// - /// Validates the trigger identity headers against the expected connector and operation. - /// Throws when any header is absent or mismatches. + /// Creates a configuration-resolution exception with safe diagnostics. /// - /// The request headers from the callback. - /// The expected connector and operation identity. - private static void ValidateIdentity( - IReadOnlyDictionary> headers, - ConnectorTriggerIdentity expectedIdentity) + private static ConnectorTriggerConfigurationResolutionException CreateConfigurationResolutionException( + ConnectorNamespaceTriggerConfigResourceIdentity resourceIdentity, + string? correlationId, + string detail, + int? status, + Exception? innerException = null) { - string? actualConnectorName = ConnectorTriggerPayload.GetFirstHeaderValue(headers, ConnectorTriggerHeaderNames.ConnectorName); - string? actualOperationName = ConnectorTriggerPayload.GetFirstHeaderValue(headers, ConnectorTriggerHeaderNames.OperationName); - string? correlationId = ConnectorTriggerPayload.GetFirstHeaderValue(headers, ConnectorTriggerHeaderNames.CorrelationId); + var message = new StringBuilder(detail); + message.Append( + $" Subscription '{resourceIdentity.SubscriptionId}', resource group '{resourceIdentity.ResourceGroupName}', " + + $"Connector Namespace '{resourceIdentity.ConnectorNamespaceName}', trigger config '{resourceIdentity.TriggerConfigName}'."); - // Track which identity headers were present (non-empty) to aid diagnostics. - var presentHeaders = new List(capacity: 2); - if (actualConnectorName is not null) + if (status.HasValue) { - presentHeaders.Add(ConnectorTriggerHeaderNames.ConnectorName); + message.Append($" Status: '{status.Value}'."); } - if (actualOperationName is not null) + if (correlationId is not null) { - presentHeaders.Add(ConnectorTriggerHeaderNames.OperationName); + message.Append($" Correlation ID: '{correlationId}'."); } - bool connectorMatch = string.Equals(actualConnectorName, expectedIdentity.ConnectorName, StringComparison.OrdinalIgnoreCase); - bool operationMatch = string.Equals(actualOperationName, expectedIdentity.OperationName, StringComparison.OrdinalIgnoreCase); + return new ConnectorTriggerConfigurationResolutionException( + message: message.ToString(), + resourceIdentity: resourceIdentity, + status: status, + correlationId: correlationId, + innerException: innerException); + } - if (connectorMatch && operationMatch) + /// + /// Returns the first non-empty value for in , + /// or when the header is absent or all its values are empty. + /// + private static string? GetFirstHeaderValue( + IReadOnlyDictionary>? headers, + string headerName) + { + if (headers is null) { - return; + return null; } - // Build a descriptive message that exposes only safe diagnostic data. - var message = new StringBuilder("Trigger identity mismatch."); + if (headers.TryGetValue(headerName, out IEnumerable? values)) + { + return ConnectorTriggerPayload.GetFirstNonEmptyHeaderValue(values); + } + + foreach (KeyValuePair> header in headers) + { + if (string.Equals(header.Key, headerName, StringComparison.OrdinalIgnoreCase)) + { + return ConnectorTriggerPayload.GetFirstNonEmptyHeaderValue(header.Value); + } + } - if (actualConnectorName is null) + return null; + } + + /// + /// Returns the Connector Namespace trigger-config resource identity resolved from callback headers. + /// + private static ConnectorNamespaceTriggerConfigResourceIdentity GetResourceIdentity( + IReadOnlyDictionary>? headers, + string? correlationId) + { + string? subscriptionId = ConnectorTriggerPayload.GetFirstHeaderValue(headers, ConnectorTriggerHeaderNames.SubscriptionId); + string? resourceGroupName = ConnectorTriggerPayload.GetFirstHeaderValue(headers, ConnectorTriggerHeaderNames.ResourceGroupName); + string? connectorNamespaceName = ConnectorTriggerPayload.GetFirstHeaderValue(headers, ConnectorTriggerHeaderNames.ConnectorNamespaceName); + string? triggerConfigName = ConnectorTriggerPayload.GetFirstHeaderValue(headers, ConnectorTriggerHeaderNames.TriggerConfigName); + + var presentHeaders = new List(capacity: 4); + if (subscriptionId is not null) { - message.Append($" Connector identity header '{ConnectorTriggerHeaderNames.ConnectorName}' was absent or empty."); + presentHeaders.Add(ConnectorTriggerHeaderNames.SubscriptionId); } - else if (!connectorMatch) + + if (resourceGroupName is not null) { - message.Append($" Expected connector '{expectedIdentity.ConnectorName}', got '{actualConnectorName}'."); + presentHeaders.Add(ConnectorTriggerHeaderNames.ResourceGroupName); } - if (actualOperationName is null) + if (connectorNamespaceName is not null) { - message.Append($" Operation identity header '{ConnectorTriggerHeaderNames.OperationName}' was absent or empty."); + presentHeaders.Add(ConnectorTriggerHeaderNames.ConnectorNamespaceName); } - else if (!operationMatch) + + if (triggerConfigName is not null) { - message.Append($" Expected operation '{expectedIdentity.OperationName}', got '{actualOperationName}'."); + presentHeaders.Add(ConnectorTriggerHeaderNames.TriggerConfigName); + } + + if (subscriptionId is not null && + resourceGroupName is not null && + connectorNamespaceName is not null && + triggerConfigName is not null) + { + return new ConnectorNamespaceTriggerConfigResourceIdentity( + SubscriptionId: subscriptionId, + ResourceGroupName: resourceGroupName, + ConnectorNamespaceName: connectorNamespaceName, + TriggerConfigName: triggerConfigName); + } + + var message = new StringBuilder("Trigger resource identity headers were missing or empty."); + + if (subscriptionId is null) + { + message.Append($" Required header '{ConnectorTriggerHeaderNames.SubscriptionId}' was absent or empty."); + } + + if (resourceGroupName is null) + { + message.Append($" Required header '{ConnectorTriggerHeaderNames.ResourceGroupName}' was absent or empty."); + } + + if (connectorNamespaceName is null) + { + message.Append($" Required header '{ConnectorTriggerHeaderNames.ConnectorNamespaceName}' was absent or empty."); + } + + if (triggerConfigName is null) + { + message.Append($" Required header '{ConnectorTriggerHeaderNames.TriggerConfigName}' was absent or empty."); } if (correlationId is not null) @@ -298,40 +425,98 @@ private static void ValidateIdentity( message.Append($" Correlation ID: '{correlationId}'."); } - throw new ConnectorTriggerIdentityMismatchException( + throw new ConnectorTriggerResourceIdentityException( message: message.ToString(), - expectedConnectorName: expectedIdentity.ConnectorName, - expectedOperationName: expectedIdentity.OperationName, - actualConnectorName: actualConnectorName, - actualOperationName: actualOperationName, - presentIdentityHeaderNames: presentHeaders, + presentResourceIdentityHeaderNames: ConnectorTriggerPayload.GetReadOnlyHeaderNames(presentHeaders), correlationId: correlationId); } /// - /// Returns the first non-empty value for in , - /// or when the header is absent or all its values are empty. + /// Returns the first non-empty, trimmed value in , when present. /// - private static string? GetFirstHeaderValue( - IReadOnlyDictionary> headers, - string headerName) + private static string? GetFirstNonEmptyHeaderValue(IEnumerable? values) { - if (!headers.TryGetValue(headerName, out IEnumerable? values)) + if (values is null) { return null; } foreach (string value in values) { - if (!string.IsNullOrEmpty(value)) + string trimmedValue = value?.Trim() ?? string.Empty; + if (trimmedValue.Length > 0) { - return value; + return trimmedValue; } } return null; } + /// + /// Creates an immutable view over header names captured for diagnostics. + /// + private static IReadOnlyList GetReadOnlyHeaderNames(List headerNames) + { + return Array.AsReadOnly(headerNames.ToArray()); + } + + /// + /// Validates the resolved trigger identity against the caller-selected expected identity. + /// + private static void ValidateIdentity( + ConnectorTriggerIdentity expectedIdentity, + ConnectorNamespaceTriggerConfig resolvedTriggerConfig, + ConnectorNamespaceTriggerConfigResourceIdentity resourceIdentity, + string? correlationId) + { + bool connectorMatch = string.Equals( + resolvedTriggerConfig.ConnectorName, + expectedIdentity.ConnectorName, + StringComparison.OrdinalIgnoreCase); + bool operationMatch = string.Equals( + resolvedTriggerConfig.OperationName, + expectedIdentity.OperationName, + StringComparison.OrdinalIgnoreCase); + + if (connectorMatch && operationMatch) + { + return; + } + + var message = new StringBuilder("Trigger identity mismatch."); + + if (!connectorMatch) + { + message.Append( + $" Expected connector '{expectedIdentity.ConnectorName}', resolved connector '{resolvedTriggerConfig.ConnectorName}'."); + } + + if (!operationMatch) + { + message.Append( + $" Expected operation '{expectedIdentity.OperationName}', resolved operation '{resolvedTriggerConfig.OperationName}'."); + } + + message.Append( + $" Subscription '{resourceIdentity.SubscriptionId}', resource group '{resourceIdentity.ResourceGroupName}', " + + $"Connector Namespace '{resourceIdentity.ConnectorNamespaceName}', trigger config '{resourceIdentity.TriggerConfigName}'."); + + if (correlationId is not null) + { + message.Append($" Correlation ID: '{correlationId}'."); + } + + throw new ConnectorTriggerIdentityMismatchException( + message: message.ToString(), + expectedConnectorName: expectedIdentity.ConnectorName, + expectedOperationName: expectedIdentity.OperationName, + resolvedConnectorName: resolvedTriggerConfig.ConnectorName, + resolvedOperationName: resolvedTriggerConfig.OperationName, + resourceIdentity: resourceIdentity, + correlationId: correlationId); + } + /// /// Decodes the base64 body string of a parsed binary-content trigger callback into bytes. /// diff --git a/src/Azure.Connectors.Sdk/ConnectorTriggerResourceIdentityException.cs b/src/Azure.Connectors.Sdk/ConnectorTriggerResourceIdentityException.cs new file mode 100644 index 0000000..d4c5c4e --- /dev/null +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerResourceIdentityException.cs @@ -0,0 +1,45 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +using System; +using System.Collections.Generic; + +namespace Azure.Connectors.Sdk; + +/// +/// Thrown when a callback does not contain the resource-context headers required to resolve its trigger config. +/// +/// +/// The exception message and properties expose only safe diagnostics: which known resource-identity +/// headers were present and the callback correlation identifier, when available. +/// +public sealed class ConnectorTriggerResourceIdentityException : InvalidOperationException +{ + /// + /// Initializes a new instance of the class. + /// + /// A human-readable message describing the missing or malformed headers. + /// The known resource-identity header names that were present. + /// The callback correlation identifier, when present. + internal ConnectorTriggerResourceIdentityException( + string message, + IReadOnlyList presentResourceIdentityHeaderNames, + string? correlationId) + : base(message) + { + this.PresentResourceIdentityHeaderNames = presentResourceIdentityHeaderNames; + this.CorrelationId = correlationId; + } + + /// + /// Gets the names of the known resource-identity headers that were present and non-empty in the callback. + /// + public IReadOnlyList PresentResourceIdentityHeaderNames { get; } + + /// + /// Gets the per-request correlation identifier from the callback headers, or + /// when not present. + /// + public string? CorrelationId { get; } +} diff --git a/src/Azure.Connectors.Sdk/ConnectorTriggerTransport.cs b/src/Azure.Connectors.Sdk/ConnectorTriggerTransport.cs index d2b0802..0d9e1c5 100644 --- a/src/Azure.Connectors.Sdk/ConnectorTriggerTransport.cs +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerTransport.cs @@ -46,8 +46,10 @@ public sealed class ConnectorTriggerTransport /// /// /// The dictionary should use for - /// case-insensitive header-name lookup. When not provided, defaults to an empty - /// case-insensitive dictionary (all identity headers will be treated as absent). + /// predictable behavior when callers inspect it directly. The SDK performs its own + /// header-name matching even when the + /// provided dictionary uses a case-sensitive comparer. When not provided, defaults to an empty + /// case-insensitive dictionary (all resource-context headers will be treated as absent). /// public IReadOnlyDictionary> Headers { get; init; } = new Dictionary>(System.StringComparer.OrdinalIgnoreCase); diff --git a/src/Azure.Connectors.Sdk/IConnectorNamespaceTriggerConfigResolver.cs b/src/Azure.Connectors.Sdk/IConnectorNamespaceTriggerConfigResolver.cs new file mode 100644 index 0000000..c968110 --- /dev/null +++ b/src/Azure.Connectors.Sdk/IConnectorNamespaceTriggerConfigResolver.cs @@ -0,0 +1,24 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +using System.Threading; +using System.Threading.Tasks; + +namespace Azure.Connectors.Sdk; + +/// +/// Resolves authoritative Connector Namespace trigger configuration for a callback. +/// +public interface IConnectorNamespaceTriggerConfigResolver +{ + /// + /// Retrieves the trigger configuration for the specified Connector Namespace trigger-config resource. + /// + /// The resource identity resolved from the callback headers. + /// The cancellation token. + /// The resolved connector and operation identity from the trigger configuration. + ValueTask GetTriggerConfigAsync( + ConnectorNamespaceTriggerConfigResourceIdentity resourceIdentity, + CancellationToken cancellationToken = default); +} diff --git a/tests/Azure.Connectors.Sdk.Tests/ConnectorNamespaceTriggerConfigManagementResolverTests.cs b/tests/Azure.Connectors.Sdk.Tests/ConnectorNamespaceTriggerConfigManagementResolverTests.cs new file mode 100644 index 0000000..1dfc176 --- /dev/null +++ b/tests/Azure.Connectors.Sdk.Tests/ConnectorNamespaceTriggerConfigManagementResolverTests.cs @@ -0,0 +1,238 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using global::Azure.Core; +using global::Azure.Core.Pipeline; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using Moq.Protected; + +namespace Azure.Connectors.Sdk.Tests +{ + [TestClass] + public class ConnectorNamespaceTriggerConfigManagementResolverTests + { + private static readonly AccessToken TestAccessToken = new( + token: "mock-token", + expiresOn: new DateTimeOffset(2099, 1, 1, 0, 0, 0, TimeSpan.Zero)); + + private static ConnectorNamespaceTriggerConfigResourceIdentity CreateResourceIdentity() + { + return new ConnectorNamespaceTriggerConfigResourceIdentity( + SubscriptionId: "11111111-2222-3333-4444-555555555555", + ResourceGroupName: "prod-connectors-rg", + ConnectorNamespaceName: "my-gateway", + TriggerConfigName: "email-trigger"); + } + + [TestMethod] + public async Task GetTriggerConfigAsync_ValidResponse_ReturnsTriggerConfigAndBuildsExpectedRequest() + { + // Arrange + const string responseJson = """ + { + "properties": { + "operationName": "OnNewFilesV2", + "connectionDetails": { + "connectorName": "onedriveforbusiness" + } + } + } + """; + + var resourceIdentity = ConnectorNamespaceTriggerConfigManagementResolverTests.CreateResourceIdentity(); + var (resolver, getLastRequest) = ConnectorNamespaceTriggerConfigManagementResolverTests.CreateResolver( + responseFactory: () => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseJson), + }); + + // Act + var triggerConfig = await resolver + .GetTriggerConfigAsync(resourceIdentity) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.AreEqual("onedriveforbusiness", triggerConfig.ConnectorName); + Assert.AreEqual("OnNewFilesV2", triggerConfig.OperationName); + + var request = getLastRequest(); + Assert.IsNotNull(request); + Assert.AreEqual(HttpMethod.Get, request.Method); + Assert.AreEqual( + "https://management.azure.com/subscriptions/11111111-2222-3333-4444-555555555555/resourceGroups/prod-connectors-rg/providers/Microsoft.Web/connectorGateways/my-gateway/triggerconfigs/email-trigger?api-version=2026-05-01-preview", + request.RequestUri?.AbsoluteUri); + Assert.AreEqual("Bearer", request.Headers.Authorization?.Scheme); + Assert.AreEqual("mock-token", request.Headers.Authorization?.Parameter); + } + + [TestMethod] + public async Task GetTriggerConfigAsync_NotFound_ThrowsConfigurationResolutionException() + { + // Arrange + const string secretBody = "{\"error\":\"secret-value\"}"; + var resourceIdentity = ConnectorNamespaceTriggerConfigManagementResolverTests.CreateResourceIdentity(); + var (resolver, _) = ConnectorNamespaceTriggerConfigManagementResolverTests.CreateResolver( + responseFactory: () => new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent(secretBody), + }); + + // Act + var exception = await Assert.ThrowsExactlyAsync( + async () => await resolver + .GetTriggerConfigAsync(resourceIdentity) + .ConfigureAwait(continueOnCapturedContext: false)) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.AreEqual(404, exception.Status); + Assert.IsFalse(exception.Message.Contains(secretBody, StringComparison.Ordinal)); + StringAssert.Contains(exception.Message, "404"); + } + + [TestMethod] + public async Task GetTriggerConfigAsync_Unauthorized_ThrowsConfigurationResolutionException() + { + // Arrange + var resourceIdentity = ConnectorNamespaceTriggerConfigManagementResolverTests.CreateResourceIdentity(); + var (resolver, _) = ConnectorNamespaceTriggerConfigManagementResolverTests.CreateResolver( + responseFactory: () => new HttpResponseMessage(HttpStatusCode.Unauthorized)); + + // Act + var exception = await Assert.ThrowsExactlyAsync( + async () => await resolver + .GetTriggerConfigAsync(resourceIdentity) + .ConfigureAwait(continueOnCapturedContext: false)) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.AreEqual(401, exception.Status); + } + + [TestMethod] + public async Task GetTriggerConfigAsync_MalformedResponse_ThrowsConfigurationResolutionException() + { + // Arrange + const string malformedJson = "{\"properties\":{\"connectionDetails\":{}}}"; + var resourceIdentity = ConnectorNamespaceTriggerConfigManagementResolverTests.CreateResourceIdentity(); + var (resolver, _) = ConnectorNamespaceTriggerConfigManagementResolverTests.CreateResolver( + responseFactory: () => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(malformedJson), + }); + + // Act + var exception = await Assert.ThrowsExactlyAsync( + async () => await resolver + .GetTriggerConfigAsync(resourceIdentity) + .ConfigureAwait(continueOnCapturedContext: false)) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.AreEqual(200, exception.Status); + StringAssert.Contains(exception.Message, "required trigger configuration properties"); + } + + [TestMethod] + public async Task GetTriggerConfigAsync_TransportFailure_ThrowsConfigurationResolutionException() + { + // Arrange + var resourceIdentity = ConnectorNamespaceTriggerConfigManagementResolverTests.CreateResourceIdentity(); + var credential = ConnectorNamespaceTriggerConfigManagementResolverTests.CreateCredential(); + var handler = new Mock(); + handler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ThrowsAsync(new HttpRequestException("Connection refused")); + + var options = new ConnectorNamespaceTriggerConfigManagementResolverOptions(); + options.Transport = new HttpClientTransport(new HttpClient(handler.Object)); + options.Retry.MaxRetries = 0; + var resolver = new ConnectorNamespaceTriggerConfigManagementResolver(credential.Object, options); + + // Act + var exception = await Assert.ThrowsExactlyAsync( + async () => await resolver + .GetTriggerConfigAsync(resourceIdentity) + .ConfigureAwait(continueOnCapturedContext: false)) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsNotNull(exception.InnerException); + StringAssert.Contains(exception.Message, "management request failed"); + } + + [TestMethod] + public async Task GetTriggerConfigAsync_Cancelled_ThrowsOperationCanceledException() + { + // Arrange + var resourceIdentity = ConnectorNamespaceTriggerConfigManagementResolverTests.CreateResourceIdentity(); + var credential = ConnectorNamespaceTriggerConfigManagementResolverTests.CreateCredential(); + var handler = new Mock(); + handler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Returns((request, cancellationToken) => Task.FromCanceled(cancellationToken)); + + var options = new ConnectorNamespaceTriggerConfigManagementResolverOptions(); + options.Transport = new HttpClientTransport(new HttpClient(handler.Object)); + options.Retry.MaxRetries = 0; + var resolver = new ConnectorNamespaceTriggerConfigManagementResolver(credential.Object, options); + using var cancellationSource = new CancellationTokenSource(); + await cancellationSource.CancelAsync().ConfigureAwait(continueOnCapturedContext: false); + + // Act & Assert + await Assert.ThrowsExactlyAsync( + async () => await resolver + .GetTriggerConfigAsync(resourceIdentity, cancellationSource.Token) + .ConfigureAwait(continueOnCapturedContext: false)) + .ConfigureAwait(continueOnCapturedContext: false); + } + + private static Mock CreateCredential() + { + var credential = new Mock(); + credential + .Setup(mock => mock.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(ConnectorNamespaceTriggerConfigManagementResolverTests.TestAccessToken); + return credential; + } + + private static (ConnectorNamespaceTriggerConfigManagementResolver Resolver, Func GetLastRequest) CreateResolver( + Func responseFactory) + { + var credential = ConnectorNamespaceTriggerConfigManagementResolverTests.CreateCredential(); + HttpRequestMessage? lastRequest = null; + var handler = new Mock(); + handler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((request, cancellationToken) => lastRequest = request) + .Returns(() => Task.FromResult(responseFactory())); + + var options = new ConnectorNamespaceTriggerConfigManagementResolverOptions(); + options.Transport = new HttpClientTransport(new HttpClient(handler.Object)); + options.Retry.MaxRetries = 0; + + return ( + new ConnectorNamespaceTriggerConfigManagementResolver(credential.Object, options), + () => lastRequest); + } + } +} diff --git a/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTransportTests.cs b/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTransportTests.cs index f660eee..d798054 100644 --- a/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTransportTests.cs +++ b/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTransportTests.cs @@ -5,7 +5,9 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; +using System.Threading; using System.Threading.Tasks; using Azure.Connectors.Sdk.OneDriveForBusiness.Models; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -13,35 +15,44 @@ namespace Azure.Connectors.Sdk.Tests { /// - /// Tests for the transport-aware overload of , - /// covering identity validation, safe diagnostics, and backward compatibility. + /// Tests for the transport-aware overload of . /// [TestClass] public class ConnectorTriggerPayloadTransportTests { private const string ExpectedConnectorName = "onedriveforbusiness"; - private const string ExpectedOperationName = "OnNewFilesV2"; - + private const string SubscriptionId = "11111111-2222-3333-4444-555555555555"; + private const string ResourceGroupName = "prod-connectors-rg"; + private const string ConnectorNamespaceName = "my-gateway"; + private const string TriggerConfigName = "email-trigger"; private const string MetadataPayload = """ {"body":{"value":[{"Id":"01ABC","Name":"report.docx","Path":"/Documents/report.docx","Size":1234,"IsFolder":false}]}} """; - // ------------------------------------------------------------------ // - // Helpers // - // ------------------------------------------------------------------ // + private static ConnectorTriggerIdentity ExpectedIdentity => new( + ConnectorTriggerPayloadTransportTests.ExpectedConnectorName, + ConnectorTriggerPayloadTransportTests.ExpectedOperationName); + + private static ConnectorNamespaceTriggerConfig MatchingTriggerConfig => new( + ConnectorTriggerPayloadTransportTests.ExpectedConnectorName, + ConnectorTriggerPayloadTransportTests.ExpectedOperationName); private static ConnectorTriggerTransport CreateTransport( string body, - string connectorName = ConnectorTriggerPayloadTransportTests.ExpectedConnectorName, - string operationName = ConnectorTriggerPayloadTransportTests.ExpectedOperationName, + string subscriptionId = ConnectorTriggerPayloadTransportTests.SubscriptionId, + string resourceGroupName = ConnectorTriggerPayloadTransportTests.ResourceGroupName, + string connectorNamespaceName = ConnectorTriggerPayloadTransportTests.ConnectorNamespaceName, + string triggerConfigName = ConnectorTriggerPayloadTransportTests.TriggerConfigName, string? correlationId = null, IDictionary>? extraHeaders = null) { - var headers = new Dictionary>(StringComparer.OrdinalIgnoreCase) + var headers = new Dictionary> { - [ConnectorTriggerHeaderNames.ConnectorName] = new[] { connectorName }, - [ConnectorTriggerHeaderNames.OperationName] = new[] { operationName }, + [ConnectorTriggerHeaderNames.SubscriptionId] = new[] { subscriptionId }, + [ConnectorTriggerHeaderNames.ResourceGroupName] = new[] { resourceGroupName }, + [ConnectorTriggerHeaderNames.ConnectorNamespaceName] = new[] { connectorNamespaceName }, + [ConnectorTriggerHeaderNames.TriggerConfigName] = new[] { triggerConfigName }, }; if (correlationId is not null) @@ -64,49 +75,44 @@ private static ConnectorTriggerTransport CreateTransport( }; } - private static ConnectorTriggerIdentity ExpectedIdentity => new( - ConnectorTriggerPayloadTransportTests.ExpectedConnectorName, - ConnectorTriggerPayloadTransportTests.ExpectedOperationName); - - // ------------------------------------------------------------------ // - // Happy path // - // ------------------------------------------------------------------ // - [TestMethod] - public async Task ReadAsync_Transport_MatchingIdentity_ReturnsPayload() + public async Task ReadAsync_Transport_ResolvedIdentityMatches_ReturnsPayload() { // Arrange var transport = ConnectorTriggerPayloadTransportTests.CreateTransport( ConnectorTriggerPayloadTransportTests.MetadataPayload); + var resolver = new StubTriggerConfigResolver( + ConnectorTriggerPayloadTransportTests.MatchingTriggerConfig); // Act var payload = await ConnectorTriggerPayload .ReadAsync( transport, - ConnectorTriggerPayloadTransportTests.ExpectedIdentity) + ConnectorTriggerPayloadTransportTests.ExpectedIdentity, + resolver) .ConfigureAwait(continueOnCapturedContext: false); // Assert Assert.IsNotNull(payload); - Assert.IsNotNull(payload.Body); - Assert.IsNotNull(payload.Body.Value); - Assert.AreEqual(1, payload.Body.Value.Count); - Assert.AreEqual("report.docx", payload.Body.Value[0].Name); + Assert.AreEqual("report.docx", payload.Body?.Value?[0].Name); + Assert.IsNotNull(resolver.LastRequestedResourceIdentity); + var requestedResourceIdentity = resolver.LastRequestedResourceIdentity!; + Assert.AreEqual(ConnectorTriggerPayloadTransportTests.SubscriptionId, requestedResourceIdentity.SubscriptionId); + Assert.AreEqual(ConnectorTriggerPayloadTransportTests.ResourceGroupName, requestedResourceIdentity.ResourceGroupName); + Assert.AreEqual(ConnectorTriggerPayloadTransportTests.ConnectorNamespaceName, requestedResourceIdentity.ConnectorNamespaceName); + Assert.AreEqual(ConnectorTriggerPayloadTransportTests.TriggerConfigName, requestedResourceIdentity.TriggerConfigName); } - // ------------------------------------------------------------------ // - // Case-insensitive header-name and header-value matching // - // ------------------------------------------------------------------ // - [TestMethod] public async Task ReadAsync_Transport_HeaderNameCaseInsensitive_Validates() { - // Arrange — headers keyed with upper-case names in a plain Dictionary; the - // ConnectorTriggerTransport default comparer normalises lookup. - var headers = new Dictionary>(StringComparer.OrdinalIgnoreCase) + // Arrange — the SDK, not the transport, performs OrdinalIgnoreCase lookup. + var headers = new Dictionary> { - ["X-MS-GATEWAY-RESOURCE-NAME"] = new[] { ConnectorTriggerPayloadTransportTests.ExpectedConnectorName }, - ["X-MS-TRIGGER-NAME"] = new[] { ConnectorTriggerPayloadTransportTests.ExpectedOperationName }, + ["X-MS-SUBSCRIPTION-ID"] = new[] { ConnectorTriggerPayloadTransportTests.SubscriptionId }, + ["X-MS-RESOURCE-GROUP"] = new[] { ConnectorTriggerPayloadTransportTests.ResourceGroupName }, + ["X-MS-GATEWAY-RESOURCE-NAME"] = new[] { ConnectorTriggerPayloadTransportTests.ConnectorNamespaceName }, + ["X-MS-TRIGGER-NAME"] = new[] { ConnectorTriggerPayloadTransportTests.TriggerConfigName }, }; var transport = new ConnectorTriggerTransport @@ -115,11 +121,15 @@ public async Task ReadAsync_Transport_HeaderNameCaseInsensitive_Validates() Headers = headers, }; - // Act — should not throw + var resolver = new StubTriggerConfigResolver( + ConnectorTriggerPayloadTransportTests.MatchingTriggerConfig); + + // Act var payload = await ConnectorTriggerPayload .ReadAsync( transport, - ConnectorTriggerPayloadTransportTests.ExpectedIdentity) + ConnectorTriggerPayloadTransportTests.ExpectedIdentity, + resolver) .ConfigureAwait(continueOnCapturedContext: false); // Assert @@ -127,223 +137,124 @@ public async Task ReadAsync_Transport_HeaderNameCaseInsensitive_Validates() } [TestMethod] - public async Task ReadAsync_Transport_HeaderValueCaseInsensitive_Validates() + public async Task ReadAsync_Transport_SubscriptionHeaderMissing_ThrowsResourceIdentityException() { - // Arrange — connector name in uppercase; validation must be case-insensitive. - var transport = ConnectorTriggerPayloadTransportTests.CreateTransport( - ConnectorTriggerPayloadTransportTests.MetadataPayload, - connectorName: ConnectorTriggerPayloadTransportTests.ExpectedConnectorName.ToUpperInvariant(), - operationName: ConnectorTriggerPayloadTransportTests.ExpectedOperationName.ToUpperInvariant()); - - // Act — should not throw - var payload = await ConnectorTriggerPayload - .ReadAsync( - transport, - ConnectorTriggerPayloadTransportTests.ExpectedIdentity) + await this.AssertMissingHeaderThrowsAsync(ConnectorTriggerHeaderNames.SubscriptionId) .ConfigureAwait(continueOnCapturedContext: false); - - // Assert - Assert.IsNotNull(payload); } - // ------------------------------------------------------------------ // - // Missing identity headers // - // ------------------------------------------------------------------ // - [TestMethod] - public async Task ReadAsync_Transport_ConnectorNameHeaderMissing_ThrowsIdentityMismatch() + public async Task ReadAsync_Transport_ResourceGroupHeaderMissing_ThrowsResourceIdentityException() { - // Arrange — only the operation header is present. - var headers = new Dictionary>(StringComparer.OrdinalIgnoreCase) - { - [ConnectorTriggerHeaderNames.OperationName] = new[] { ConnectorTriggerPayloadTransportTests.ExpectedOperationName }, - }; - - var transport = new ConnectorTriggerTransport - { - Body = new MemoryStream(Encoding.UTF8.GetBytes(ConnectorTriggerPayloadTransportTests.MetadataPayload)), - Headers = headers, - }; - - // Act & Assert - var exception = await Assert.ThrowsExactlyAsync( - async () => await ConnectorTriggerPayload - .ReadAsync( - transport, - ConnectorTriggerPayloadTransportTests.ExpectedIdentity) - .ConfigureAwait(continueOnCapturedContext: false)) + await this.AssertMissingHeaderThrowsAsync(ConnectorTriggerHeaderNames.ResourceGroupName) .ConfigureAwait(continueOnCapturedContext: false); - - Assert.IsNull(exception.ActualConnectorName); - Assert.AreEqual(ConnectorTriggerPayloadTransportTests.ExpectedConnectorName, exception.ExpectedConnectorName); - Assert.AreEqual(ConnectorTriggerPayloadTransportTests.ExpectedOperationName, exception.ExpectedOperationName); - StringAssert.Contains(exception.Message, ConnectorTriggerHeaderNames.ConnectorName); } [TestMethod] - public async Task ReadAsync_Transport_OperationNameHeaderMissing_ThrowsIdentityMismatch() + public async Task ReadAsync_Transport_ConnectorNamespaceHeaderMissing_ThrowsResourceIdentityException() { - // Arrange — only the connector header is present. - var headers = new Dictionary>(StringComparer.OrdinalIgnoreCase) - { - [ConnectorTriggerHeaderNames.ConnectorName] = new[] { ConnectorTriggerPayloadTransportTests.ExpectedConnectorName }, - }; - - var transport = new ConnectorTriggerTransport - { - Body = new MemoryStream(Encoding.UTF8.GetBytes(ConnectorTriggerPayloadTransportTests.MetadataPayload)), - Headers = headers, - }; - - // Act & Assert - var exception = await Assert.ThrowsExactlyAsync( - async () => await ConnectorTriggerPayload - .ReadAsync( - transport, - ConnectorTriggerPayloadTransportTests.ExpectedIdentity) - .ConfigureAwait(continueOnCapturedContext: false)) + await this.AssertMissingHeaderThrowsAsync(ConnectorTriggerHeaderNames.ConnectorNamespaceName) .ConfigureAwait(continueOnCapturedContext: false); - - Assert.IsNull(exception.ActualOperationName); - StringAssert.Contains(exception.Message, ConnectorTriggerHeaderNames.OperationName); } [TestMethod] - public async Task ReadAsync_Transport_BothHeadersMissing_ThrowsIdentityMismatch() + public async Task ReadAsync_Transport_TriggerConfigHeaderMissing_ThrowsResourceIdentityException() { - // Arrange — no identity headers at all (e.g. routing mistake or non-Connector Namespace caller). - var transport = new ConnectorTriggerTransport - { - Body = new MemoryStream(Encoding.UTF8.GetBytes(ConnectorTriggerPayloadTransportTests.MetadataPayload)), - Headers = new Dictionary>(StringComparer.OrdinalIgnoreCase), - }; - - // Act & Assert - var exception = await Assert.ThrowsExactlyAsync( - async () => await ConnectorTriggerPayload - .ReadAsync( - transport, - ConnectorTriggerPayloadTransportTests.ExpectedIdentity) - .ConfigureAwait(continueOnCapturedContext: false)) + await this.AssertMissingHeaderThrowsAsync(ConnectorTriggerHeaderNames.TriggerConfigName) .ConfigureAwait(continueOnCapturedContext: false); - - Assert.IsNull(exception.ActualConnectorName); - Assert.IsNull(exception.ActualOperationName); - Assert.AreEqual(0, exception.PresentIdentityHeaderNames.Count); - StringAssert.Contains(exception.Message, ConnectorTriggerHeaderNames.ConnectorName); - StringAssert.Contains(exception.Message, ConnectorTriggerHeaderNames.OperationName); } - // ------------------------------------------------------------------ // - // Value mismatches // - // ------------------------------------------------------------------ // - [TestMethod] - public async Task ReadAsync_Transport_ConnectorNameMismatch_ThrowsIdentityMismatch() + public async Task ReadAsync_Transport_ResolvedConnectorMismatch_ThrowsIdentityMismatch() { - // Arrange — wrong connector, correct operation. + // Arrange + const string correlationId = "abc-123-def"; var transport = ConnectorTriggerPayloadTransportTests.CreateTransport( ConnectorTriggerPayloadTransportTests.MetadataPayload, - connectorName: "sharepoint", - operationName: ConnectorTriggerPayloadTransportTests.ExpectedOperationName); + correlationId: correlationId); + var resolver = new StubTriggerConfigResolver( + new ConnectorNamespaceTriggerConfig( + ConnectorName: "sharepointonline", + OperationName: ConnectorTriggerPayloadTransportTests.ExpectedOperationName)); - // Act & Assert + // Act var exception = await Assert.ThrowsExactlyAsync( async () => await ConnectorTriggerPayload .ReadAsync( transport, - ConnectorTriggerPayloadTransportTests.ExpectedIdentity) + ConnectorTriggerPayloadTransportTests.ExpectedIdentity, + resolver) .ConfigureAwait(continueOnCapturedContext: false)) .ConfigureAwait(continueOnCapturedContext: false); - Assert.AreEqual("sharepoint", exception.ActualConnectorName); - Assert.AreEqual(ConnectorTriggerPayloadTransportTests.ExpectedConnectorName, exception.ExpectedConnectorName); - Assert.IsTrue(exception.PresentIdentityHeaderNames.Contains(ConnectorTriggerHeaderNames.ConnectorName)); - StringAssert.Contains(exception.Message, "sharepoint"); - StringAssert.Contains(exception.Message, ConnectorTriggerPayloadTransportTests.ExpectedConnectorName); + // Assert + Assert.AreEqual("sharepointonline", exception.ResolvedConnectorName); + Assert.AreEqual(ConnectorTriggerPayloadTransportTests.ExpectedOperationName, exception.ResolvedOperationName); + Assert.AreEqual(correlationId, exception.CorrelationId); + Assert.AreEqual(ConnectorTriggerPayloadTransportTests.ConnectorNamespaceName, exception.ResourceIdentity.ConnectorNamespaceName); + StringAssert.Contains(exception.Message, "sharepointonline"); } [TestMethod] - public async Task ReadAsync_Transport_OperationNameMismatch_ThrowsIdentityMismatch() + public async Task ReadAsync_Transport_ResolvedOperationMismatch_ThrowsIdentityMismatch() { - // Arrange — correct connector, wrong operation. + // Arrange var transport = ConnectorTriggerPayloadTransportTests.CreateTransport( - ConnectorTriggerPayloadTransportTests.MetadataPayload, - connectorName: ConnectorTriggerPayloadTransportTests.ExpectedConnectorName, - operationName: "OnUpdatedFilesV2"); + ConnectorTriggerPayloadTransportTests.MetadataPayload); + var resolver = new StubTriggerConfigResolver( + new ConnectorNamespaceTriggerConfig( + ConnectorName: ConnectorTriggerPayloadTransportTests.ExpectedConnectorName, + OperationName: "OnUpdatedFilesV2")); - // Act & Assert + // Act var exception = await Assert.ThrowsExactlyAsync( async () => await ConnectorTriggerPayload .ReadAsync( transport, - ConnectorTriggerPayloadTransportTests.ExpectedIdentity) + ConnectorTriggerPayloadTransportTests.ExpectedIdentity, + resolver) .ConfigureAwait(continueOnCapturedContext: false)) .ConfigureAwait(continueOnCapturedContext: false); - Assert.AreEqual("OnUpdatedFilesV2", exception.ActualOperationName); - Assert.AreEqual(ConnectorTriggerPayloadTransportTests.ExpectedOperationName, exception.ExpectedOperationName); + // Assert + Assert.AreEqual(ConnectorTriggerPayloadTransportTests.ExpectedConnectorName, exception.ResolvedConnectorName); + Assert.AreEqual("OnUpdatedFilesV2", exception.ResolvedOperationName); StringAssert.Contains(exception.Message, "OnUpdatedFilesV2"); } - // ------------------------------------------------------------------ // - // Correlation ID // - // ------------------------------------------------------------------ // - [TestMethod] - public async Task ReadAsync_Transport_WithCorrelationId_IncludedInException() + public async Task ReadAsync_Transport_ResolverFailure_ThrowsConfigurationResolutionException() { // Arrange - const string correlationId = "abc-123-def"; var transport = ConnectorTriggerPayloadTransportTests.CreateTransport( ConnectorTriggerPayloadTransportTests.MetadataPayload, - connectorName: "wrong-connector", - correlationId: correlationId); + correlationId: "trace-xyz"); + var resolver = new StubTriggerConfigResolver( + new InvalidOperationException(message: "boom")); - // Act & Assert - var exception = await Assert.ThrowsExactlyAsync( + // Act + var exception = await Assert.ThrowsExactlyAsync( async () => await ConnectorTriggerPayload .ReadAsync( transport, - ConnectorTriggerPayloadTransportTests.ExpectedIdentity) + ConnectorTriggerPayloadTransportTests.ExpectedIdentity, + resolver) .ConfigureAwait(continueOnCapturedContext: false)) .ConfigureAwait(continueOnCapturedContext: false); - Assert.AreEqual(correlationId, exception.CorrelationId); - StringAssert.Contains(exception.Message, correlationId); - } - - [TestMethod] - public async Task ReadAsync_Transport_MatchingIdentityWithCorrelationId_DoesNotThrow() - { - // Arrange - var transport = ConnectorTriggerPayloadTransportTests.CreateTransport( - ConnectorTriggerPayloadTransportTests.MetadataPayload, - correlationId: "trace-xyz"); - - // Act — correlation ID must not interfere with successful validation. - var payload = await ConnectorTriggerPayload - .ReadAsync( - transport, - ConnectorTriggerPayloadTransportTests.ExpectedIdentity) - .ConfigureAwait(continueOnCapturedContext: false); - // Assert - Assert.IsNotNull(payload); + Assert.IsInstanceOfType(exception.InnerException); + Assert.AreEqual("trace-xyz", exception.CorrelationId); + Assert.AreEqual(ConnectorTriggerPayloadTransportTests.TriggerConfigName, exception.ResourceIdentity.TriggerConfigName); } - // ------------------------------------------------------------------ // - // Safe diagnostics — no secrets in exception // - // ------------------------------------------------------------------ // - [TestMethod] public async Task ReadAsync_Transport_IdentityMismatch_ExceptionContainsNoSecrets() { - // Arrange — add sensitive headers that must never appear in the exception. - const string secretAuthToken = "******"; + // Arrange + const string secretAuthToken = "secret-auth-token"; const string secretCallbackUrl = "https://secret-callback.example.com/run?code=very-secret"; const string secretLockToken = "lock-token-sensitive-value"; - var sensitiveHeaders = new Dictionary>(StringComparer.OrdinalIgnoreCase) { ["Authorization"] = new[] { secretAuthToken }, @@ -353,130 +264,151 @@ public async Task ReadAsync_Transport_IdentityMismatch_ExceptionContainsNoSecret var transport = ConnectorTriggerPayloadTransportTests.CreateTransport( ConnectorTriggerPayloadTransportTests.MetadataPayload, - connectorName: "wrong-connector", extraHeaders: sensitiveHeaders); + var resolver = new StubTriggerConfigResolver( + new ConnectorNamespaceTriggerConfig( + ConnectorName: "sharepointonline", + OperationName: "WrongOperation")); // Act var exception = await Assert.ThrowsExactlyAsync( async () => await ConnectorTriggerPayload .ReadAsync( transport, - ConnectorTriggerPayloadTransportTests.ExpectedIdentity) + ConnectorTriggerPayloadTransportTests.ExpectedIdentity, + resolver) .ConfigureAwait(continueOnCapturedContext: false)) .ConfigureAwait(continueOnCapturedContext: false); - // Assert — sensitive header values must not appear in the exception message. + // Assert Assert.IsFalse(exception.Message.Contains(secretAuthToken, StringComparison.Ordinal)); Assert.IsFalse(exception.Message.Contains(secretCallbackUrl, StringComparison.Ordinal)); Assert.IsFalse(exception.Message.Contains(secretLockToken, StringComparison.Ordinal)); } - // ------------------------------------------------------------------ // - // PresentIdentityHeaderNames diagnostics // - // ------------------------------------------------------------------ // - [TestMethod] - public async Task ReadAsync_Transport_BothHeadersPresent_PresentHeadersListedInException() + public async Task ReadAsync_Transport_CancelledResolver_ThrowsOperationCanceledException() { - // Arrange — both headers present but values are wrong. + // Arrange var transport = ConnectorTriggerPayloadTransportTests.CreateTransport( - ConnectorTriggerPayloadTransportTests.MetadataPayload, - connectorName: "wrong-connector", - operationName: "WrongOperation"); + ConnectorTriggerPayloadTransportTests.MetadataPayload); + using var cancellationSource = new CancellationTokenSource(); + await cancellationSource.CancelAsync().ConfigureAwait(continueOnCapturedContext: false); + var resolver = new StubTriggerConfigResolver(cancellationSource.Token); // Act & Assert - var exception = await Assert.ThrowsExactlyAsync( + await Assert.ThrowsExactlyAsync( async () => await ConnectorTriggerPayload .ReadAsync( transport, - ConnectorTriggerPayloadTransportTests.ExpectedIdentity) - .ConfigureAwait(continueOnCapturedContext: false)) - .ConfigureAwait(continueOnCapturedContext: false); - - Assert.IsTrue(exception.PresentIdentityHeaderNames.Contains(ConnectorTriggerHeaderNames.ConnectorName)); - Assert.IsTrue(exception.PresentIdentityHeaderNames.Contains(ConnectorTriggerHeaderNames.OperationName)); - } - - // ------------------------------------------------------------------ // - // Null argument guards // - // ------------------------------------------------------------------ // - - [TestMethod] - public async Task ReadAsync_Transport_NullTransport_ThrowsArgumentNull() - { - // Act & Assert - await Assert.ThrowsExactlyAsync( - async () => await ConnectorTriggerPayload - .ReadAsync( - transport: null!, - expectedIdentity: ConnectorTriggerPayloadTransportTests.ExpectedIdentity) + ConnectorTriggerPayloadTransportTests.ExpectedIdentity, + resolver, + cancellationToken: cancellationSource.Token) .ConfigureAwait(continueOnCapturedContext: false)) .ConfigureAwait(continueOnCapturedContext: false); } [TestMethod] - public async Task ReadAsync_Transport_NullExpectedIdentity_ThrowsArgumentNull() + public async Task ReadAsync_Stream_ExistingOverload_StillWorks() { // Arrange - var transport = ConnectorTriggerPayloadTransportTests.CreateTransport( - ConnectorTriggerPayloadTransportTests.MetadataPayload); + using var stream = new MemoryStream( + Encoding.UTF8.GetBytes(ConnectorTriggerPayloadTransportTests.MetadataPayload)); - // Act & Assert - await Assert.ThrowsExactlyAsync( - async () => await ConnectorTriggerPayload - .ReadAsync( - transport, - expectedIdentity: null!) - .ConfigureAwait(continueOnCapturedContext: false)) + // Act + var payload = await ConnectorTriggerPayload + .ReadAsync(stream) .ConfigureAwait(continueOnCapturedContext: false); - } - // ------------------------------------------------------------------ // - // Default headers (empty) — all identity headers treated as absent // - // ------------------------------------------------------------------ // + // Assert + Assert.IsNotNull(payload); + Assert.AreEqual("report.docx", payload.Body?.Value?[0].Name); + } - [TestMethod] - public async Task ReadAsync_Transport_DefaultEmptyHeaders_ThrowsIdentityMismatch() + private static ConnectorTriggerTransport CreateTransportWithoutHeader(string headerName) { - // Arrange — Transport with no Headers set (default empty dictionary). - var transport = new ConnectorTriggerTransport + var headers = new Dictionary> + { + [ConnectorTriggerHeaderNames.SubscriptionId] = new[] { ConnectorTriggerPayloadTransportTests.SubscriptionId }, + [ConnectorTriggerHeaderNames.ResourceGroupName] = new[] { ConnectorTriggerPayloadTransportTests.ResourceGroupName }, + [ConnectorTriggerHeaderNames.ConnectorNamespaceName] = new[] { ConnectorTriggerPayloadTransportTests.ConnectorNamespaceName }, + [ConnectorTriggerHeaderNames.TriggerConfigName] = new[] { ConnectorTriggerPayloadTransportTests.TriggerConfigName }, + }; + + headers.Remove(headerName); + + return new ConnectorTriggerTransport { Body = new MemoryStream(Encoding.UTF8.GetBytes(ConnectorTriggerPayloadTransportTests.MetadataPayload)), + Headers = headers, }; + } - // Act & Assert — both identity headers absent. - var exception = await Assert.ThrowsExactlyAsync( + private async Task AssertMissingHeaderThrowsAsync(string headerName) + { + // Arrange + var transport = ConnectorTriggerPayloadTransportTests.CreateTransportWithoutHeader(headerName); + var resolver = new StubTriggerConfigResolver( + ConnectorTriggerPayloadTransportTests.MatchingTriggerConfig); + + // Act + var exception = await Assert.ThrowsExactlyAsync( async () => await ConnectorTriggerPayload .ReadAsync( transport, - ConnectorTriggerPayloadTransportTests.ExpectedIdentity) + ConnectorTriggerPayloadTransportTests.ExpectedIdentity, + resolver) .ConfigureAwait(continueOnCapturedContext: false)) .ConfigureAwait(continueOnCapturedContext: false); - Assert.IsNull(exception.ActualConnectorName); - Assert.IsNull(exception.ActualOperationName); - Assert.IsNull(exception.CorrelationId); + // Assert + StringAssert.Contains(exception.Message, headerName); + Assert.IsFalse(exception.PresentResourceIdentityHeaderNames.Contains(headerName)); } - // ------------------------------------------------------------------ // - // Backward compatibility — existing Stream overloads still work // - // ------------------------------------------------------------------ // - - [TestMethod] - public async Task ReadAsync_Stream_ExistingOverload_StillWorks() + private sealed class StubTriggerConfigResolver : IConnectorNamespaceTriggerConfigResolver { - // Arrange - using var stream = new MemoryStream( - Encoding.UTF8.GetBytes(ConnectorTriggerPayloadTransportTests.MetadataPayload)); + private readonly ConnectorNamespaceTriggerConfig? _triggerConfig; + private readonly Exception? _exception; + private readonly CancellationToken _cancelledToken; + private readonly bool _throwCancellation; - // Act — the body-only overload must remain unchanged. - var payload = await ConnectorTriggerPayload - .ReadAsync(stream) - .ConfigureAwait(continueOnCapturedContext: false); + public StubTriggerConfigResolver(ConnectorNamespaceTriggerConfig triggerConfig) + { + this._triggerConfig = triggerConfig; + } - // Assert - Assert.IsNotNull(payload); - Assert.AreEqual("report.docx", payload.Body?.Value?[0].Name); + public StubTriggerConfigResolver(Exception exception) + { + this._exception = exception; + } + + public StubTriggerConfigResolver(CancellationToken cancelledToken) + { + this._cancelledToken = cancelledToken; + this._throwCancellation = true; + } + + public ConnectorNamespaceTriggerConfigResourceIdentity? LastRequestedResourceIdentity { get; private set; } + + public ValueTask GetTriggerConfigAsync( + ConnectorNamespaceTriggerConfigResourceIdentity resourceIdentity, + CancellationToken cancellationToken = default) + { + this.LastRequestedResourceIdentity = resourceIdentity; + + if (this._throwCancellation) + { + return ValueTask.FromCanceled(this._cancelledToken); + } + + if (this._exception is not null) + { + return ValueTask.FromException(this._exception); + } + + return ValueTask.FromResult(this._triggerConfig!); + } } } } From b7ce8cabf832c84cd8e57529e5a6c29aafafb925 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:01:01 +0000 Subject: [PATCH 04/13] chore: address trigger validation review feedback --- src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs index 5b0b1ad..d330fb6 100644 --- a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs @@ -441,12 +441,11 @@ connectorNamespaceName is not null && return null; } - foreach (string value in values) + foreach (string? value in values) { - string trimmedValue = value?.Trim() ?? string.Empty; - if (trimmedValue.Length > 0) + if (!string.IsNullOrWhiteSpace(value)) { - return trimmedValue; + return value.Trim(); } } From 4b1ad43baaccb893a72a9e6891a70294a5ac2075 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:02:02 +0000 Subject: [PATCH 05/13] chore: polish trigger validation follow-ups --- .../ConnectorNamespaceTriggerConfigManagementResolver.cs | 9 ++++++--- src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs | 2 ++ ...ectorNamespaceTriggerConfigManagementResolverTests.cs | 4 +++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/Azure.Connectors.Sdk/ConnectorNamespaceTriggerConfigManagementResolver.cs b/src/Azure.Connectors.Sdk/ConnectorNamespaceTriggerConfigManagementResolver.cs index 85e0308..34a695e 100644 --- a/src/Azure.Connectors.Sdk/ConnectorNamespaceTriggerConfigManagementResolver.cs +++ b/src/Azure.Connectors.Sdk/ConnectorNamespaceTriggerConfigManagementResolver.cs @@ -102,11 +102,14 @@ await this._pipeline } catch (Exception ex) when (!ex.IsFatal()) { + int? requestFailedStatus = ex is RequestFailedException requestFailedException && + requestFailedException.Status > 0 + ? requestFailedException.Status + : null; + throw ConnectorNamespaceTriggerConfigManagementResolver.CreateResolutionException( resourceIdentity: resourceIdentity, - status: ex is RequestFailedException requestFailedException && requestFailedException.Status > 0 - ? requestFailedException.Status - : null, + status: requestFailedStatus, correlationId: null, detail: "The management request failed before a trigger configuration could be resolved.", innerException: ex); diff --git a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs index d330fb6..b4f640f 100644 --- a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs @@ -342,6 +342,8 @@ private static ConnectorTriggerConfigurationResolutionException CreateConfigurat return ConnectorTriggerPayload.GetFirstNonEmptyHeaderValue(values); } + // The SDK promises OrdinalIgnoreCase header-name matching even when callers pass a + // case-sensitive dictionary, so fall back to a manual scan when TryGetValue misses. foreach (KeyValuePair> header in headers) { if (string.Equals(header.Key, headerName, StringComparison.OrdinalIgnoreCase)) diff --git a/tests/Azure.Connectors.Sdk.Tests/ConnectorNamespaceTriggerConfigManagementResolverTests.cs b/tests/Azure.Connectors.Sdk.Tests/ConnectorNamespaceTriggerConfigManagementResolverTests.cs index 1dfc176..a81a8f2 100644 --- a/tests/Azure.Connectors.Sdk.Tests/ConnectorNamespaceTriggerConfigManagementResolverTests.cs +++ b/tests/Azure.Connectors.Sdk.Tests/ConnectorNamespaceTriggerConfigManagementResolverTests.cs @@ -18,9 +18,11 @@ namespace Azure.Connectors.Sdk.Tests [TestClass] public class ConnectorNamespaceTriggerConfigManagementResolverTests { + private static readonly DateTimeOffset FarFutureExpiry = new(2099, 1, 1, 0, 0, 0, TimeSpan.Zero); + private static readonly AccessToken TestAccessToken = new( token: "mock-token", - expiresOn: new DateTimeOffset(2099, 1, 1, 0, 0, 0, TimeSpan.Zero)); + expiresOn: ConnectorNamespaceTriggerConfigManagementResolverTests.FarFutureExpiry); private static ConnectorNamespaceTriggerConfigResourceIdentity CreateResourceIdentity() { From e49371521e95648b686ab6b25aab1f09f7a525fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:02:50 +0000 Subject: [PATCH 06/13] chore: finalize trigger validation cleanup --- .../ConnectorNamespaceTriggerConfigManagementResolver.cs | 2 +- src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Azure.Connectors.Sdk/ConnectorNamespaceTriggerConfigManagementResolver.cs b/src/Azure.Connectors.Sdk/ConnectorNamespaceTriggerConfigManagementResolver.cs index 34a695e..a065db7 100644 --- a/src/Azure.Connectors.Sdk/ConnectorNamespaceTriggerConfigManagementResolver.cs +++ b/src/Azure.Connectors.Sdk/ConnectorNamespaceTriggerConfigManagementResolver.cs @@ -68,7 +68,7 @@ public ConnectorNamespaceTriggerConfigManagementResolver( this._managementEndpoint = options.ManagementEndpoint; this._apiVersion = options.ApiVersion; - this._audienceScopes = [$"{options.Audience.TrimEnd('/')}/.default"]; + this._audienceScopes = new[] { $"{options.Audience.TrimEnd('/')}/.default" }; this._pipeline = HttpPipelineBuilder.Build( options, perRetryPolicies: new HttpPipelinePolicy[] diff --git a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs index b4f640f..e50565f 100644 --- a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs @@ -447,7 +447,10 @@ connectorNamespaceName is not null && { if (!string.IsNullOrWhiteSpace(value)) { - return value.Trim(); + return char.IsWhiteSpace(value[0]) || + char.IsWhiteSpace(value[value.Length - 1]) + ? value.Trim() + : value; } } @@ -459,7 +462,7 @@ connectorNamespaceName is not null && /// private static IReadOnlyList GetReadOnlyHeaderNames(List headerNames) { - return Array.AsReadOnly(headerNames.ToArray()); + return headerNames.AsReadOnly(); } /// From 847b7ebe77f04a617b75072a061928d73cb7b72e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:35:31 +0000 Subject: [PATCH 07/13] fix: avoid redundant trigger payload catch rethrow --- src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs index e50565f..b7251c4 100644 --- a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs @@ -215,15 +215,12 @@ public static class ConnectorTriggerPayload .GetTriggerConfigAsync(resourceIdentity, cancellationToken) .ConfigureAwait(continueOnCapturedContext: false); } - catch (OperationCanceledException) - { - throw; - } catch (ConnectorTriggerConfigurationResolutionException) { throw; } - catch (Exception ex) when (!ex.IsFatal()) + catch (Exception ex) when (!ex.IsFatal() && + ex is not OperationCanceledException) { throw ConnectorTriggerPayload.CreateConfigurationResolutionException( resourceIdentity: resourceIdentity, From aec6b206b18d309fc4a9658caf16a6d071ce8c22 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:36:19 +0000 Subject: [PATCH 08/13] fix: remove redundant trigger resolution rethrow --- src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs index b7251c4..406ee1b 100644 --- a/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs @@ -215,12 +215,9 @@ public static class ConnectorTriggerPayload .GetTriggerConfigAsync(resourceIdentity, cancellationToken) .ConfigureAwait(continueOnCapturedContext: false); } - catch (ConnectorTriggerConfigurationResolutionException) - { - throw; - } catch (Exception ex) when (!ex.IsFatal() && - ex is not OperationCanceledException) + ex is not OperationCanceledException && + ex is not ConnectorTriggerConfigurationResolutionException) { throw ConnectorTriggerPayload.CreateConfigurationResolutionException( resourceIdentity: resourceIdentity, From 0c7512934f5588cbeaf2fca44ffa258c1076d6ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:15:43 +0000 Subject: [PATCH 09/13] fix: unblock trigger validation test build --- ...paceTriggerConfigManagementResolverTests.cs | 18 ++++++++++++------ .../ConnectorTriggerPayloadTransportTests.cs | 14 ++++++++++---- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/tests/Azure.Connectors.Sdk.Tests/ConnectorNamespaceTriggerConfigManagementResolverTests.cs b/tests/Azure.Connectors.Sdk.Tests/ConnectorNamespaceTriggerConfigManagementResolverTests.cs index a81a8f2..b188af5 100644 --- a/tests/Azure.Connectors.Sdk.Tests/ConnectorNamespaceTriggerConfigManagementResolverTests.cs +++ b/tests/Azure.Connectors.Sdk.Tests/ConnectorNamespaceTriggerConfigManagementResolverTests.cs @@ -21,8 +21,8 @@ public class ConnectorNamespaceTriggerConfigManagementResolverTests private static readonly DateTimeOffset FarFutureExpiry = new(2099, 1, 1, 0, 0, 0, TimeSpan.Zero); private static readonly AccessToken TestAccessToken = new( - token: "mock-token", - expiresOn: ConnectorNamespaceTriggerConfigManagementResolverTests.FarFutureExpiry); + "mock-token", + ConnectorNamespaceTriggerConfigManagementResolverTests.FarFutureExpiry); private static ConnectorNamespaceTriggerConfigResourceIdentity CreateResourceIdentity() { @@ -197,11 +197,17 @@ public async Task GetTriggerConfigAsync_Cancelled_ThrowsOperationCanceledExcepti await cancellationSource.CancelAsync().ConfigureAwait(continueOnCapturedContext: false); // Act & Assert - await Assert.ThrowsExactlyAsync( - async () => await resolver + try + { + await resolver .GetTriggerConfigAsync(resourceIdentity, cancellationSource.Token) - .ConfigureAwait(continueOnCapturedContext: false)) - .ConfigureAwait(continueOnCapturedContext: false); + .ConfigureAwait(continueOnCapturedContext: false); + Assert.Fail("Expected an OperationCanceledException."); + } + catch (OperationCanceledException ex) + { + Assert.IsNotNull(ex); + } } private static Mock CreateCredential() diff --git a/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTransportTests.cs b/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTransportTests.cs index d798054..5706ed0 100644 --- a/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTransportTests.cs +++ b/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTransportTests.cs @@ -297,15 +297,21 @@ public async Task ReadAsync_Transport_CancelledResolver_ThrowsOperationCanceledE var resolver = new StubTriggerConfigResolver(cancellationSource.Token); // Act & Assert - await Assert.ThrowsExactlyAsync( - async () => await ConnectorTriggerPayload + try + { + await ConnectorTriggerPayload .ReadAsync( transport, ConnectorTriggerPayloadTransportTests.ExpectedIdentity, resolver, cancellationToken: cancellationSource.Token) - .ConfigureAwait(continueOnCapturedContext: false)) - .ConfigureAwait(continueOnCapturedContext: false); + .ConfigureAwait(continueOnCapturedContext: false); + Assert.Fail("Expected an OperationCanceledException."); + } + catch (OperationCanceledException ex) + { + Assert.IsNotNull(ex); + } } [TestMethod] From 25d25a91f64835fdc3bc5e4db83d9e5b1781fc65 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:17:59 +0000 Subject: [PATCH 10/13] fix: stabilize trigger validation cancellation tests --- ...nnectorNamespaceTriggerConfigManagementResolverTests.cs | 7 +++---- .../ConnectorTriggerPayloadTransportTests.cs | 3 +-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/Azure.Connectors.Sdk.Tests/ConnectorNamespaceTriggerConfigManagementResolverTests.cs b/tests/Azure.Connectors.Sdk.Tests/ConnectorNamespaceTriggerConfigManagementResolverTests.cs index b188af5..8d956c9 100644 --- a/tests/Azure.Connectors.Sdk.Tests/ConnectorNamespaceTriggerConfigManagementResolverTests.cs +++ b/tests/Azure.Connectors.Sdk.Tests/ConnectorNamespaceTriggerConfigManagementResolverTests.cs @@ -21,8 +21,8 @@ public class ConnectorNamespaceTriggerConfigManagementResolverTests private static readonly DateTimeOffset FarFutureExpiry = new(2099, 1, 1, 0, 0, 0, TimeSpan.Zero); private static readonly AccessToken TestAccessToken = new( - "mock-token", - ConnectorNamespaceTriggerConfigManagementResolverTests.FarFutureExpiry); + accessToken: "mock-token", + expiresOn: ConnectorNamespaceTriggerConfigManagementResolverTests.FarFutureExpiry); private static ConnectorNamespaceTriggerConfigResourceIdentity CreateResourceIdentity() { @@ -204,9 +204,8 @@ await resolver .ConfigureAwait(continueOnCapturedContext: false); Assert.Fail("Expected an OperationCanceledException."); } - catch (OperationCanceledException ex) + catch (OperationCanceledException) { - Assert.IsNotNull(ex); } } diff --git a/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTransportTests.cs b/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTransportTests.cs index 5706ed0..14c5602 100644 --- a/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTransportTests.cs +++ b/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTransportTests.cs @@ -308,9 +308,8 @@ await ConnectorTriggerPayload .ConfigureAwait(continueOnCapturedContext: false); Assert.Fail("Expected an OperationCanceledException."); } - catch (OperationCanceledException ex) + catch (OperationCanceledException) { - Assert.IsNotNull(ex); } } From 4dd36f5a344a39c6e0ea0b50f3873a892a5c683f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:19:47 +0000 Subject: [PATCH 11/13] fix: clarify trigger validation cancellation assertions --- .../ConnectorNamespaceTriggerConfigManagementResolverTests.cs | 4 ++++ .../ConnectorTriggerPayloadTransportTests.cs | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/tests/Azure.Connectors.Sdk.Tests/ConnectorNamespaceTriggerConfigManagementResolverTests.cs b/tests/Azure.Connectors.Sdk.Tests/ConnectorNamespaceTriggerConfigManagementResolverTests.cs index 8d956c9..1d429a8 100644 --- a/tests/Azure.Connectors.Sdk.Tests/ConnectorNamespaceTriggerConfigManagementResolverTests.cs +++ b/tests/Azure.Connectors.Sdk.Tests/ConnectorNamespaceTriggerConfigManagementResolverTests.cs @@ -195,6 +195,7 @@ public async Task GetTriggerConfigAsync_Cancelled_ThrowsOperationCanceledExcepti var resolver = new ConnectorNamespaceTriggerConfigManagementResolver(credential.Object, options); using var cancellationSource = new CancellationTokenSource(); await cancellationSource.CancelAsync().ConfigureAwait(continueOnCapturedContext: false); + var threwOperationCanceledException = false; // Act & Assert try @@ -206,7 +207,10 @@ await resolver } catch (OperationCanceledException) { + threwOperationCanceledException = true; } + + Assert.IsTrue(threwOperationCanceledException); } private static Mock CreateCredential() diff --git a/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTransportTests.cs b/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTransportTests.cs index 14c5602..3d16239 100644 --- a/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTransportTests.cs +++ b/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTransportTests.cs @@ -295,6 +295,7 @@ public async Task ReadAsync_Transport_CancelledResolver_ThrowsOperationCanceledE using var cancellationSource = new CancellationTokenSource(); await cancellationSource.CancelAsync().ConfigureAwait(continueOnCapturedContext: false); var resolver = new StubTriggerConfigResolver(cancellationSource.Token); + var threwOperationCanceledException = false; // Act & Assert try @@ -310,7 +311,10 @@ await ConnectorTriggerPayload } catch (OperationCanceledException) { + threwOperationCanceledException = true; } + + Assert.IsTrue(threwOperationCanceledException); } [TestMethod] From 515677713c557b5710a0ca04a6a21e57d1d0c849 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:22:24 +0000 Subject: [PATCH 12/13] fix: use non-strict cancellation assertions --- ...spaceTriggerConfigManagementResolverTests.cs | 17 ++++------------- .../ConnectorTriggerPayloadTransportTests.cs | 17 ++++------------- 2 files changed, 8 insertions(+), 26 deletions(-) diff --git a/tests/Azure.Connectors.Sdk.Tests/ConnectorNamespaceTriggerConfigManagementResolverTests.cs b/tests/Azure.Connectors.Sdk.Tests/ConnectorNamespaceTriggerConfigManagementResolverTests.cs index 1d429a8..3a7ca53 100644 --- a/tests/Azure.Connectors.Sdk.Tests/ConnectorNamespaceTriggerConfigManagementResolverTests.cs +++ b/tests/Azure.Connectors.Sdk.Tests/ConnectorNamespaceTriggerConfigManagementResolverTests.cs @@ -195,22 +195,13 @@ public async Task GetTriggerConfigAsync_Cancelled_ThrowsOperationCanceledExcepti var resolver = new ConnectorNamespaceTriggerConfigManagementResolver(credential.Object, options); using var cancellationSource = new CancellationTokenSource(); await cancellationSource.CancelAsync().ConfigureAwait(continueOnCapturedContext: false); - var threwOperationCanceledException = false; // Act & Assert - try - { - await resolver + await Assert.ThrowsAsync( + async () => await resolver .GetTriggerConfigAsync(resourceIdentity, cancellationSource.Token) - .ConfigureAwait(continueOnCapturedContext: false); - Assert.Fail("Expected an OperationCanceledException."); - } - catch (OperationCanceledException) - { - threwOperationCanceledException = true; - } - - Assert.IsTrue(threwOperationCanceledException); + .ConfigureAwait(continueOnCapturedContext: false)) + .ConfigureAwait(continueOnCapturedContext: false); } private static Mock CreateCredential() diff --git a/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTransportTests.cs b/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTransportTests.cs index 3d16239..08eb615 100644 --- a/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTransportTests.cs +++ b/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTransportTests.cs @@ -295,26 +295,17 @@ public async Task ReadAsync_Transport_CancelledResolver_ThrowsOperationCanceledE using var cancellationSource = new CancellationTokenSource(); await cancellationSource.CancelAsync().ConfigureAwait(continueOnCapturedContext: false); var resolver = new StubTriggerConfigResolver(cancellationSource.Token); - var threwOperationCanceledException = false; // Act & Assert - try - { - await ConnectorTriggerPayload + await Assert.ThrowsAsync( + async () => await ConnectorTriggerPayload .ReadAsync( transport, ConnectorTriggerPayloadTransportTests.ExpectedIdentity, resolver, cancellationToken: cancellationSource.Token) - .ConfigureAwait(continueOnCapturedContext: false); - Assert.Fail("Expected an OperationCanceledException."); - } - catch (OperationCanceledException) - { - threwOperationCanceledException = true; - } - - Assert.IsTrue(threwOperationCanceledException); + .ConfigureAwait(continueOnCapturedContext: false)) + .ConfigureAwait(continueOnCapturedContext: false); } [TestMethod] From 614c4efc5412717dacf8d07fa08b9689d7b7776a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:39:40 +0000 Subject: [PATCH 13/13] test: avoid delegate request capture in resolver tests --- ...NamespaceTriggerConfigManagementResolverTests.cs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/Azure.Connectors.Sdk.Tests/ConnectorNamespaceTriggerConfigManagementResolverTests.cs b/tests/Azure.Connectors.Sdk.Tests/ConnectorNamespaceTriggerConfigManagementResolverTests.cs index 3a7ca53..5eee0b2 100644 --- a/tests/Azure.Connectors.Sdk.Tests/ConnectorNamespaceTriggerConfigManagementResolverTests.cs +++ b/tests/Azure.Connectors.Sdk.Tests/ConnectorNamespaceTriggerConfigManagementResolverTests.cs @@ -5,6 +5,7 @@ using System; using System.Net; using System.Net.Http; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using global::Azure.Core; @@ -49,7 +50,7 @@ public async Task GetTriggerConfigAsync_ValidResponse_ReturnsTriggerConfigAndBui """; var resourceIdentity = ConnectorNamespaceTriggerConfigManagementResolverTests.CreateResourceIdentity(); - var (resolver, getLastRequest) = ConnectorNamespaceTriggerConfigManagementResolverTests.CreateResolver( + var (resolver, lastRequest) = ConnectorNamespaceTriggerConfigManagementResolverTests.CreateResolver( responseFactory: () => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(responseJson), @@ -64,7 +65,7 @@ public async Task GetTriggerConfigAsync_ValidResponse_ReturnsTriggerConfigAndBui Assert.AreEqual("onedriveforbusiness", triggerConfig.ConnectorName); Assert.AreEqual("OnNewFilesV2", triggerConfig.OperationName); - var request = getLastRequest(); + var request = lastRequest.Value; Assert.IsNotNull(request); Assert.AreEqual(HttpMethod.Get, request.Method); Assert.AreEqual( @@ -213,11 +214,11 @@ private static Mock CreateCredential() return credential; } - private static (ConnectorNamespaceTriggerConfigManagementResolver Resolver, Func GetLastRequest) CreateResolver( + private static (ConnectorNamespaceTriggerConfigManagementResolver Resolver, StrongBox LastRequest) CreateResolver( Func responseFactory) { var credential = ConnectorNamespaceTriggerConfigManagementResolverTests.CreateCredential(); - HttpRequestMessage? lastRequest = null; + var lastRequest = new StrongBox(); var handler = new Mock(); handler .Protected() @@ -225,7 +226,7 @@ private static (ConnectorNamespaceTriggerConfigManagementResolver Resolver, Func "SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) - .Callback((request, cancellationToken) => lastRequest = request) + .Callback((request, cancellationToken) => lastRequest.Value = request) .Returns(() => Task.FromResult(responseFactory())); var options = new ConnectorNamespaceTriggerConfigManagementResolverOptions(); @@ -234,7 +235,7 @@ private static (ConnectorNamespaceTriggerConfigManagementResolver Resolver, Func return ( new ConnectorNamespaceTriggerConfigManagementResolver(credential.Object, options), - () => lastRequest); + lastRequest); } } }