Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Azure.Core;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents;
using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.Mcp;
using Microsoft.Extensions.AI;
using Shared.IntegrationTests;

namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests;

Expand Down Expand Up @@ -48,9 +53,9 @@ public Task ValidateInvokeMcpToolWithApprovalAsync(string workflowFileName, stri
#region InvokeHttpRequest Tests

[RetryTheory(3, 5000)]
[InlineData("HttpRequest.yaml", "visibility: public")]
public Task ValidateHttpRequestAsync(string workflowFileName, string? expectedResultContains) =>
this.RunHttpRequestTestAsync(workflowFileName, expectedResultContains);
[InlineData("HttpRequest.yaml")]
public Task ValidateHttpRequestAsync(string workflowFileName) =>
this.RunHttpRequestTestAsync(workflowFileName);

#endregion

Expand Down Expand Up @@ -261,16 +266,65 @@ private List<AIContent> ProcessMcpToolRequests(

#region InvokeHttpRequest Test Helpers

/// <summary>
/// The Azure ARM scope used to acquire bearer tokens for the HttpRequestAction
/// integration test. Matches the URL configured in <c>HttpRequest.yaml</c>.
/// </summary>
private const string ArmScope = "https://management.azure.com/.default";

/// <summary>
/// The expected ARM endpoint. Only requests whose absolute URL exactly matches
/// this scheme and host receive the authenticated <see cref="HttpClient"/>; all
/// other URLs (including subdomain look-alikes such as
/// <c>https://management.azure.com.evil.com</c>) fall through to the handler
/// default and never see the bearer token.
/// </summary>
private static readonly Uri s_armEndpoint = new("https://management.azure.com/");

/// <summary>
/// Runs an HttpRequestAction workflow test with the specified configuration.
/// </summary>
/// <remarks>
/// The workflow under test calls an authenticated Azure ARM endpoint. We acquire a
/// single bearer token via the same Azure CLI credential used elsewhere in the
/// integration test suite, attach it to a cached <see cref="HttpClient"/>, and route
/// matching requests through that client via <see cref="DefaultHttpRequestHandler"/>'s
/// <c>httpClientProvider</c> callback. The test owns the <see cref="HttpClient"/>'s
/// lifetime and disposes it explicitly — <see cref="DefaultHttpRequestHandler"/> does
/// not dispose provider-returned clients.
/// </remarks>
private async Task RunHttpRequestTestAsync(
string workflowFileName,
string? expectedResultContains = null)
string workflowFileName)
{
// Arrange
string workflowPath = GetWorkflowPath(workflowFileName);
await using DefaultHttpRequestHandler httpRequestHandler = new();

AccessToken accessToken =
await TestAzureCliCredentials
.CreateAzureCliCredential()
.GetTokenAsync(new TokenRequestContext([ArmScope]), CancellationToken.None)
.ConfigureAwait(false);

using HttpClient authenticatedClient = new();
authenticatedClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", accessToken.Token);

await using DefaultHttpRequestHandler httpRequestHandler =
new(httpClientProvider: (request, _) =>
{
if (Uri.TryCreate(request.Url, UriKind.Absolute, out Uri? requestUri) &&
string.Equals(requestUri.Scheme, s_armEndpoint.Scheme, StringComparison.OrdinalIgnoreCase) &&
string.Equals(requestUri.Host, s_armEndpoint.Host, StringComparison.OrdinalIgnoreCase))
{
#pragma warning disable CA2025 // authenticatedClient outlives the handler (LIFO using disposal) and the workflow awaits all dispatches.
Comment thread
peibekwe marked this conversation as resolved.
return Task.FromResult<HttpClient?>(authenticatedClient);
#pragma warning restore CA2025
}

// Fall back to the handler's internal client for any non-ARM URLs.
return Task.FromResult<HttpClient?>(null);
});

DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync(
externalConversation: false,
httpRequestHandler: httpRequestHandler);
Expand All @@ -284,11 +338,16 @@ private async Task RunHttpRequestTestAsync(
// Assert - Verify executor and action events
AssertWorkflowEventsEmitted(workflowEvents);

// Assert - Verify expected result if specified
if (expectedResultContains is not null)
{
AssertResultContains(workflowEvents, expectedResultContains);
}
MessageActivityEvent? messageEvent = workflowEvents.Events
.OfType<MessageActivityEvent>()
.LastOrDefault();

Assert.NotNull(messageEvent);
Assert.NotNull(messageEvent.Message);
Comment thread
peibekwe marked this conversation as resolved.
Assert.True(
Guid.TryParse(messageEvent.Message, out Guid retrievedTenantId),
$"Expected the SendMessage payload to be a tenant GUID, but got: '{messageEvent.Message}'");
Assert.NotEqual(Guid.Empty, retrievedTenantId);
}

#endregion
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
#
# This workflow tests invoking HttpRequestAction end-to-end.
# Uses the public GitHub API (unauthenticated) to fetch repo metadata.
# Uses the Azure ARM tenants endpoint, which is authenticated, fully static, and
# reachable with the credentials the integration test pipeline already provides
# (via az login). The bearer token is supplied by the test through a custom
# HttpClient passed to DefaultHttpRequestHandler; the YAML deliberately does not
# carry an Authorization header.
#
kind: Workflow
trigger:
Expand All @@ -9,24 +13,19 @@ trigger:
id: workflow_http_request_test
actions:

# Set the repo owner used to form the request URL.
- kind: SetVariable
id: set_repo_owner
variable: Local.RepoOwner
value: dotnet

# Invoke the GitHub repo API.
# Invoke the Azure ARM tenants list API.
- kind: HttpRequestAction
id: fetch_repo_info
id: fetch_tenants
conversationId: =System.ConversationId
method: GET
url: =Concatenate("https://api.github.com/repos/", Local.RepoOwner, "/runtime")
url: https://management.azure.com/tenants?api-version=2022-09-01
headers:
Accept: application/vnd.github+json
User-Agent: agent-framework-integration-test
response: Local.RepoInfo
Accept: application/json
response: Local.TenantsResponse

# Surface the Repo visibility field from the parsed JSON response.
# Surface the first tenant id from the parsed JSON response. Every
# authenticated principal belongs to at least one tenant, so this path
# always resolves on a successful call.
- kind: SendMessage
id: show_visibility
message: "visibility: {Local.RepoInfo.visibility}"
id: show_first_tenant
message: "{First(Local.TenantsResponse.value).tenantId}"
Loading