diff --git a/docs/triggers.md b/docs/triggers.md index 304c740..ccf24ca 100644 --- a/docs/triggers.md +++ b/docs/triggers.md @@ -141,6 +141,107 @@ 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. 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); +``` + +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) +{ + logger.LogError( + "Trigger identity mismatch. Expected {Connector}/{Operation}, " + + "resolved {ResolvedConnector}/{ResolvedOperation}. CorrelationId: {CorrelationId}. " + + "Resource: {Namespace}/{Trigger}.", + ex.ExpectedConnectorName, ex.ExpectedOperationName, + ex.ResolvedConnectorName, ex.ResolvedOperationName, + ex.CorrelationId, + ex.ResourceIdentity.ConnectorNamespaceName, + ex.ResourceIdentity.TriggerConfigName); + throw; +} +``` + +#### Resource-context header names (provisional service contract) + +The header names consumed by the transport overload are defined in `ConnectorTriggerHeaderNames`: + +| 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 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 Each connector exposes a `{Connector}TriggerOperations` static class with the operation name strings: 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..a065db7 --- /dev/null +++ b/src/Azure.Connectors.Sdk/ConnectorNamespaceTriggerConfigManagementResolver.cs @@ -0,0 +1,249 @@ +//------------------------------------------------------------ +// 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 = new[] { $"{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()) + { + int? requestFailedStatus = ex is RequestFailedException requestFailedException && + requestFailedException.Status > 0 + ? requestFailedException.Status + : null; + + throw ConnectorNamespaceTriggerConfigManagementResolver.CreateResolutionException( + resourceIdentity: resourceIdentity, + status: requestFailedStatus, + 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 new file mode 100644 index 0000000..fdf6656 --- /dev/null +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerHeaderNames.cs @@ -0,0 +1,70 @@ +//------------------------------------------------------------ +// 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 build the Connector Namespace trigger-config resource identity before payload deserialization. +/// +/// +public static class ConnectorTriggerHeaderNames +{ + /// + /// 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. + /// + public const string SubscriptionId = "x-ms-subscription-id"; + + /// + /// 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. + /// + 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. + /// + /// + /// 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..d311265 --- /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 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 new file mode 100644 index 0000000..067cbb9 --- /dev/null +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerIdentityMismatchException.cs @@ -0,0 +1,95 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +using System; +namespace Azure.Connectors.Sdk; + +/// +/// 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 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 resolved identity values, the trigger-config resource identity, 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 resolved from the Connector Namespace trigger configuration. + /// + /// + /// The operation name resolved from the Connector Namespace trigger configuration. + /// + /// + /// The Connector Namespace trigger-config resource identity resolved from the callback headers. + /// + /// + /// The per-request correlation identifier from the callback headers, or + /// when not present. + /// + internal ConnectorTriggerIdentityMismatchException( + string message, + string expectedConnectorName, + string expectedOperationName, + string resolvedConnectorName, + string resolvedOperationName, + ConnectorNamespaceTriggerConfigResourceIdentity resourceIdentity, + string? correlationId) + : base(message) + { + this.ExpectedConnectorName = expectedConnectorName; + this.ExpectedOperationName = expectedOperationName; + this.ResolvedConnectorName = resolvedConnectorName; + this.ResolvedOperationName = resolvedOperationName; + this.ResourceIdentity = resourceIdentity; + 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 resolved from the Connector Namespace trigger configuration. + /// + public string ResolvedConnectorName { get; } + + /// + /// Gets the operation name resolved from the Connector Namespace trigger configuration. + /// + public string ResolvedOperationName { get; } + + /// + /// Gets the Connector Namespace trigger-config resource identity resolved from the callback headers. + /// + public ConnectorNamespaceTriggerConfigResourceIdentity ResourceIdentity { 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..406ee1b 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,110 @@ public static class ConnectorTriggerPayload .ConfigureAwait(continueOnCapturedContext: false); } + /// + /// Reads a metadata trigger callback from the framework-neutral , + /// validates the resolved trigger configuration 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 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. + /// Defaults to . + /// + /// The cancellation token. + /// The deserialized payload, or when the body is JSON null. + /// + /// , , 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 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. + /// + /// + /// 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 (Exception ex) when (!ex.IsFatal() && + ex is not OperationCanceledException && + ex is not ConnectorTriggerConfigurationResolutionException) + { + 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); + } + + 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) + .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 +285,236 @@ public static bool TryReadBinaryContent(string json, out byte[] content) } } + /// + /// Creates a configuration-resolution exception with safe diagnostics. + /// + private static ConnectorTriggerConfigurationResolutionException CreateConfigurationResolutionException( + ConnectorNamespaceTriggerConfigResourceIdentity resourceIdentity, + string? correlationId, + string detail, + int? status, + Exception? innerException = null) + { + var message = new StringBuilder(detail); + message.Append( + $" Subscription '{resourceIdentity.SubscriptionId}', resource group '{resourceIdentity.ResourceGroupName}', " + + $"Connector Namespace '{resourceIdentity.ConnectorNamespaceName}', trigger config '{resourceIdentity.TriggerConfigName}'."); + + if (status.HasValue) + { + message.Append($" Status: '{status.Value}'."); + } + + if (correlationId is not null) + { + message.Append($" Correlation ID: '{correlationId}'."); + } + + return new ConnectorTriggerConfigurationResolutionException( + message: message.ToString(), + resourceIdentity: resourceIdentity, + status: status, + correlationId: correlationId, + innerException: innerException); + } + + /// + /// 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 null; + } + + if (headers.TryGetValue(headerName, out IEnumerable? values)) + { + 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)) + { + return ConnectorTriggerPayload.GetFirstNonEmptyHeaderValue(header.Value); + } + } + + 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) + { + presentHeaders.Add(ConnectorTriggerHeaderNames.SubscriptionId); + } + + if (resourceGroupName is not null) + { + presentHeaders.Add(ConnectorTriggerHeaderNames.ResourceGroupName); + } + + if (connectorNamespaceName is not null) + { + presentHeaders.Add(ConnectorTriggerHeaderNames.ConnectorNamespaceName); + } + + if (triggerConfigName is not null) + { + 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) + { + message.Append($" Correlation ID: '{correlationId}'."); + } + + throw new ConnectorTriggerResourceIdentityException( + message: message.ToString(), + presentResourceIdentityHeaderNames: ConnectorTriggerPayload.GetReadOnlyHeaderNames(presentHeaders), + correlationId: correlationId); + } + + /// + /// Returns the first non-empty, trimmed value in , when present. + /// + private static string? GetFirstNonEmptyHeaderValue(IEnumerable? values) + { + if (values is null) + { + return null; + } + + foreach (string? value in values) + { + if (!string.IsNullOrWhiteSpace(value)) + { + return char.IsWhiteSpace(value[0]) || + char.IsWhiteSpace(value[value.Length - 1]) + ? value.Trim() + : value; + } + } + + return null; + } + + /// + /// Creates an immutable view over header names captured for diagnostics. + /// + private static IReadOnlyList GetReadOnlyHeaderNames(List headerNames) + { + return headerNames.AsReadOnly(); + } + + /// + /// 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 new file mode 100644 index 0000000..0d9e1c5 --- /dev/null +++ b/src/Azure.Connectors.Sdk/ConnectorTriggerTransport.cs @@ -0,0 +1,56 @@ +//------------------------------------------------------------ +// 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 + /// 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..5eee0b2 --- /dev/null +++ b/tests/Azure.Connectors.Sdk.Tests/ConnectorNamespaceTriggerConfigManagementResolverTests.cs @@ -0,0 +1,241 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +using System; +using System.Net; +using System.Net.Http; +using System.Runtime.CompilerServices; +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 DateTimeOffset FarFutureExpiry = new(2099, 1, 1, 0, 0, 0, TimeSpan.Zero); + + private static readonly AccessToken TestAccessToken = new( + accessToken: "mock-token", + expiresOn: ConnectorNamespaceTriggerConfigManagementResolverTests.FarFutureExpiry); + + 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, lastRequest) = 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 = lastRequest.Value; + 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.ThrowsAsync( + 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, StrongBox LastRequest) CreateResolver( + Func responseFactory) + { + var credential = ConnectorNamespaceTriggerConfigManagementResolverTests.CreateCredential(); + var lastRequest = new StrongBox(); + var handler = new Mock(); + handler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((request, cancellationToken) => lastRequest.Value = 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 new file mode 100644 index 0000000..08eb615 --- /dev/null +++ b/tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTransportTests.cs @@ -0,0 +1,414 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +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; + +namespace Azure.Connectors.Sdk.Tests +{ + /// + /// 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}]}} + """; + + 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 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> + { + [ConnectorTriggerHeaderNames.SubscriptionId] = new[] { subscriptionId }, + [ConnectorTriggerHeaderNames.ResourceGroupName] = new[] { resourceGroupName }, + [ConnectorTriggerHeaderNames.ConnectorNamespaceName] = new[] { connectorNamespaceName }, + [ConnectorTriggerHeaderNames.TriggerConfigName] = new[] { triggerConfigName }, + }; + + 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, + }; + } + + [TestMethod] + 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, + resolver) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsNotNull(payload); + 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); + } + + [TestMethod] + public async Task ReadAsync_Transport_HeaderNameCaseInsensitive_Validates() + { + // Arrange — the SDK, not the transport, performs OrdinalIgnoreCase lookup. + var headers = new Dictionary> + { + ["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 + { + Body = new MemoryStream(Encoding.UTF8.GetBytes(ConnectorTriggerPayloadTransportTests.MetadataPayload)), + Headers = headers, + }; + + var resolver = new StubTriggerConfigResolver( + ConnectorTriggerPayloadTransportTests.MatchingTriggerConfig); + + // Act + var payload = await ConnectorTriggerPayload + .ReadAsync( + transport, + ConnectorTriggerPayloadTransportTests.ExpectedIdentity, + resolver) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsNotNull(payload); + } + + [TestMethod] + public async Task ReadAsync_Transport_SubscriptionHeaderMissing_ThrowsResourceIdentityException() + { + await this.AssertMissingHeaderThrowsAsync(ConnectorTriggerHeaderNames.SubscriptionId) + .ConfigureAwait(continueOnCapturedContext: false); + } + + [TestMethod] + public async Task ReadAsync_Transport_ResourceGroupHeaderMissing_ThrowsResourceIdentityException() + { + await this.AssertMissingHeaderThrowsAsync(ConnectorTriggerHeaderNames.ResourceGroupName) + .ConfigureAwait(continueOnCapturedContext: false); + } + + [TestMethod] + public async Task ReadAsync_Transport_ConnectorNamespaceHeaderMissing_ThrowsResourceIdentityException() + { + await this.AssertMissingHeaderThrowsAsync(ConnectorTriggerHeaderNames.ConnectorNamespaceName) + .ConfigureAwait(continueOnCapturedContext: false); + } + + [TestMethod] + public async Task ReadAsync_Transport_TriggerConfigHeaderMissing_ThrowsResourceIdentityException() + { + await this.AssertMissingHeaderThrowsAsync(ConnectorTriggerHeaderNames.TriggerConfigName) + .ConfigureAwait(continueOnCapturedContext: false); + } + + [TestMethod] + public async Task ReadAsync_Transport_ResolvedConnectorMismatch_ThrowsIdentityMismatch() + { + // Arrange + const string correlationId = "abc-123-def"; + var transport = ConnectorTriggerPayloadTransportTests.CreateTransport( + ConnectorTriggerPayloadTransportTests.MetadataPayload, + correlationId: correlationId); + var resolver = new StubTriggerConfigResolver( + new ConnectorNamespaceTriggerConfig( + ConnectorName: "sharepointonline", + OperationName: ConnectorTriggerPayloadTransportTests.ExpectedOperationName)); + + // Act + var exception = await Assert.ThrowsExactlyAsync( + async () => await ConnectorTriggerPayload + .ReadAsync( + transport, + ConnectorTriggerPayloadTransportTests.ExpectedIdentity, + resolver) + .ConfigureAwait(continueOnCapturedContext: false)) + .ConfigureAwait(continueOnCapturedContext: false); + + // 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_ResolvedOperationMismatch_ThrowsIdentityMismatch() + { + // Arrange + var transport = ConnectorTriggerPayloadTransportTests.CreateTransport( + ConnectorTriggerPayloadTransportTests.MetadataPayload); + var resolver = new StubTriggerConfigResolver( + new ConnectorNamespaceTriggerConfig( + ConnectorName: ConnectorTriggerPayloadTransportTests.ExpectedConnectorName, + OperationName: "OnUpdatedFilesV2")); + + // Act + var exception = await Assert.ThrowsExactlyAsync( + async () => await ConnectorTriggerPayload + .ReadAsync( + transport, + ConnectorTriggerPayloadTransportTests.ExpectedIdentity, + resolver) + .ConfigureAwait(continueOnCapturedContext: false)) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.AreEqual(ConnectorTriggerPayloadTransportTests.ExpectedConnectorName, exception.ResolvedConnectorName); + Assert.AreEqual("OnUpdatedFilesV2", exception.ResolvedOperationName); + StringAssert.Contains(exception.Message, "OnUpdatedFilesV2"); + } + + [TestMethod] + public async Task ReadAsync_Transport_ResolverFailure_ThrowsConfigurationResolutionException() + { + // Arrange + var transport = ConnectorTriggerPayloadTransportTests.CreateTransport( + ConnectorTriggerPayloadTransportTests.MetadataPayload, + correlationId: "trace-xyz"); + var resolver = new StubTriggerConfigResolver( + new InvalidOperationException(message: "boom")); + + // Act + var exception = await Assert.ThrowsExactlyAsync( + async () => await ConnectorTriggerPayload + .ReadAsync( + transport, + ConnectorTriggerPayloadTransportTests.ExpectedIdentity, + resolver) + .ConfigureAwait(continueOnCapturedContext: false)) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsInstanceOfType(exception.InnerException); + Assert.AreEqual("trace-xyz", exception.CorrelationId); + Assert.AreEqual(ConnectorTriggerPayloadTransportTests.TriggerConfigName, exception.ResourceIdentity.TriggerConfigName); + } + + [TestMethod] + public async Task ReadAsync_Transport_IdentityMismatch_ExceptionContainsNoSecrets() + { + // 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 }, + ["x-ms-callback-url"] = new[] { secretCallbackUrl }, + ["x-ms-lock-token"] = new[] { secretLockToken }, + }; + + var transport = ConnectorTriggerPayloadTransportTests.CreateTransport( + ConnectorTriggerPayloadTransportTests.MetadataPayload, + 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, + resolver) + .ConfigureAwait(continueOnCapturedContext: false)) + .ConfigureAwait(continueOnCapturedContext: false); + + // 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)); + } + + [TestMethod] + public async Task ReadAsync_Transport_CancelledResolver_ThrowsOperationCanceledException() + { + // Arrange + var transport = ConnectorTriggerPayloadTransportTests.CreateTransport( + ConnectorTriggerPayloadTransportTests.MetadataPayload); + using var cancellationSource = new CancellationTokenSource(); + await cancellationSource.CancelAsync().ConfigureAwait(continueOnCapturedContext: false); + var resolver = new StubTriggerConfigResolver(cancellationSource.Token); + + // Act & Assert + await Assert.ThrowsAsync( + async () => await ConnectorTriggerPayload + .ReadAsync( + transport, + ConnectorTriggerPayloadTransportTests.ExpectedIdentity, + resolver, + cancellationToken: cancellationSource.Token) + .ConfigureAwait(continueOnCapturedContext: false)) + .ConfigureAwait(continueOnCapturedContext: false); + } + + [TestMethod] + public async Task ReadAsync_Stream_ExistingOverload_StillWorks() + { + // Arrange + using var stream = new MemoryStream( + Encoding.UTF8.GetBytes(ConnectorTriggerPayloadTransportTests.MetadataPayload)); + + // Act + var payload = await ConnectorTriggerPayload + .ReadAsync(stream) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + Assert.IsNotNull(payload); + Assert.AreEqual("report.docx", payload.Body?.Value?[0].Name); + } + + private static ConnectorTriggerTransport CreateTransportWithoutHeader(string headerName) + { + 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, + }; + } + + 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, + resolver) + .ConfigureAwait(continueOnCapturedContext: false)) + .ConfigureAwait(continueOnCapturedContext: false); + + // Assert + StringAssert.Contains(exception.Message, headerName); + Assert.IsFalse(exception.PresentResourceIdentityHeaderNames.Contains(headerName)); + } + + private sealed class StubTriggerConfigResolver : IConnectorNamespaceTriggerConfigResolver + { + private readonly ConnectorNamespaceTriggerConfig? _triggerConfig; + private readonly Exception? _exception; + private readonly CancellationToken _cancelledToken; + private readonly bool _throwCancellation; + + public StubTriggerConfigResolver(ConnectorNamespaceTriggerConfig triggerConfig) + { + this._triggerConfig = triggerConfig; + } + + 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!); + } + } + } +}