db620d33df
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
344 lines
13 KiB
C#
344 lines
13 KiB
C#
// Copyright (c) Microsoft. All rights reserved.
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Net.Http;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Azure.Core;
|
|
using Moq;
|
|
|
|
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
|
|
|
public class FoundryToolboxBearerTokenHandlerTests
|
|
{
|
|
private const string FakeToken = "test-bearer-token";
|
|
|
|
private static Mock<TokenCredential> CreateMockCredential()
|
|
{
|
|
var mock = new Mock<TokenCredential>();
|
|
mock.Setup(c => c.GetTokenAsync(It.IsAny<TokenRequestContext>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new AccessToken(FakeToken, DateTimeOffset.UtcNow.AddHours(1)));
|
|
return mock;
|
|
}
|
|
|
|
private static (FoundryToolboxBearerTokenHandler Handler, CountingHandler Inner) CreateHandlerPair(
|
|
Mock<TokenCredential>? credential = null,
|
|
string? featuresHeader = null,
|
|
HttpStatusCode statusCode = HttpStatusCode.OK)
|
|
{
|
|
credential ??= CreateMockCredential();
|
|
var inner = new CountingHandler(statusCode);
|
|
var handler = new FoundryToolboxBearerTokenHandler(credential.Object, featuresHeader)
|
|
{
|
|
InnerHandler = inner
|
|
};
|
|
return (handler, inner);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SendAsync_ForwardsCallIdWhenSetAsync()
|
|
{
|
|
// Arrange
|
|
var (handler, _) = CreateHandlerPair();
|
|
using var invoker = new HttpMessageInvoker(handler);
|
|
HostedCallContext.CallId = "call-abc";
|
|
|
|
try
|
|
{
|
|
// Act
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
|
using var response = await invoker.SendAsync(request, CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.True(request.Headers.TryGetValues("x-agent-foundry-call-id", out var values));
|
|
Assert.Equal("call-abc", values!.Single());
|
|
}
|
|
finally
|
|
{
|
|
HostedCallContext.CallId = null;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SendAsync_OmitsCallIdWhenAbsentAsync()
|
|
{
|
|
// Arrange
|
|
var (handler, _) = CreateHandlerPair();
|
|
using var invoker = new HttpMessageInvoker(handler);
|
|
HostedCallContext.CallId = null;
|
|
|
|
// Act
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
|
using var response = await invoker.SendAsync(request, CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.False(request.Headers.Contains("x-agent-foundry-call-id"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SendAsync_InjectsBearerTokenAsync()
|
|
{
|
|
var (handler, _) = CreateHandlerPair();
|
|
using var invoker = new HttpMessageInvoker(handler);
|
|
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
|
using var response = await invoker.SendAsync(request, CancellationToken.None);
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
Assert.Equal("Bearer", request.Headers.Authorization?.Scheme);
|
|
Assert.Equal(FakeToken, request.Headers.Authorization?.Parameter);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SendAsync_UsesAiAzureComScopeAsync()
|
|
{
|
|
// Arrange
|
|
var capturedContexts = new List<TokenRequestContext>();
|
|
var credential = new Mock<TokenCredential>();
|
|
credential
|
|
.Setup(c => c.GetTokenAsync(It.IsAny<TokenRequestContext>(), It.IsAny<CancellationToken>()))
|
|
.Callback<TokenRequestContext, CancellationToken>((ctx, _) => capturedContexts.Add(ctx))
|
|
.ReturnsAsync(new AccessToken(FakeToken, DateTimeOffset.MaxValue));
|
|
var (handler, _) = CreateHandlerPair(credential);
|
|
using var invoker = new HttpMessageInvoker(handler);
|
|
|
|
// Act
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
|
await invoker.SendAsync(request, CancellationToken.None);
|
|
|
|
// Assert: spec §4 mandates the https://ai.azure.com audience.
|
|
Assert.Single(capturedContexts);
|
|
Assert.Contains("https://ai.azure.com/.default", capturedContexts[0].Scopes);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SendAsync_AlwaysInjectsMandatoryFoundryFeaturesHeaderAsync()
|
|
{
|
|
// Arrange
|
|
var (handler, _) = CreateHandlerPair(featuresHeader: null);
|
|
using var invoker = new HttpMessageInvoker(handler);
|
|
|
|
// Act
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
|
using var response = await invoker.SendAsync(request, CancellationToken.None);
|
|
|
|
// Assert: spec §2 requires Foundry-Features: Toolboxes=V1Preview on every request.
|
|
Assert.True(request.Headers.TryGetValues("Foundry-Features", out var values));
|
|
Assert.Equal("Toolboxes=V1Preview", values.Single());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SendAsync_MergesMandatoryAndOverrideFeaturesAsync()
|
|
{
|
|
var (handler, _) = CreateHandlerPair(featuresHeader: "feature1,feature2");
|
|
using var invoker = new HttpMessageInvoker(handler);
|
|
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
|
await invoker.SendAsync(request, CancellationToken.None);
|
|
|
|
Assert.True(request.Headers.TryGetValues("Foundry-Features", out var values));
|
|
var header = values.Single();
|
|
Assert.Contains("Toolboxes=V1Preview", header, StringComparison.Ordinal);
|
|
Assert.Contains("feature1", header, StringComparison.Ordinal);
|
|
Assert.Contains("feature2", header, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SendAsync_DoesNotDuplicateMandatoryFlagAsync()
|
|
{
|
|
// Override already contains the mandatory flag — must not be duplicated in the merged value.
|
|
var (handler, _) = CreateHandlerPair(featuresHeader: "Toolboxes=V1Preview");
|
|
using var invoker = new HttpMessageInvoker(handler);
|
|
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
|
await invoker.SendAsync(request, CancellationToken.None);
|
|
|
|
Assert.True(request.Headers.TryGetValues("Foundry-Features", out var values));
|
|
var header = values.Single();
|
|
var count = 0;
|
|
var idx = 0;
|
|
while ((idx = header.IndexOf("Toolboxes=V1Preview", idx, StringComparison.OrdinalIgnoreCase)) >= 0)
|
|
{
|
|
count++;
|
|
idx += "Toolboxes=V1Preview".Length;
|
|
}
|
|
Assert.Equal(1, count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SendAsync_PropagatesTraceContextFromActivityAsync()
|
|
{
|
|
// Arrange: activate an Activity so Activity.Current is populated.
|
|
using var listener = new ActivityListener
|
|
{
|
|
ShouldListenTo = _ => true,
|
|
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData,
|
|
};
|
|
ActivitySource.AddActivityListener(listener);
|
|
using var source = new ActivitySource("test-source");
|
|
using var activity = source.StartActivity("test-op")!;
|
|
Assert.NotNull(activity);
|
|
activity.TraceStateString = "vendor=value";
|
|
activity.AddBaggage("user", "alice");
|
|
|
|
var (handler, _) = CreateHandlerPair();
|
|
using var invoker = new HttpMessageInvoker(handler);
|
|
|
|
// Act
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
|
await invoker.SendAsync(request, CancellationToken.None);
|
|
|
|
// Assert: spec §6.3 requires traceparent/tracestate/baggage propagation.
|
|
Assert.True(request.Headers.TryGetValues("traceparent", out var tpValues));
|
|
Assert.Contains(activity.TraceId.ToString(), tpValues.Single(), StringComparison.Ordinal);
|
|
|
|
Assert.True(request.Headers.TryGetValues("tracestate", out var tsValues));
|
|
Assert.Equal("vendor=value", tsValues.Single());
|
|
|
|
Assert.True(request.Headers.TryGetValues("baggage", out var bgValues));
|
|
Assert.Contains("user=alice", bgValues.Single(), StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SendAsync_DoesNotOverrideExistingTraceparentAsync()
|
|
{
|
|
// Caller pre-set traceparent on the message; must not be duplicated or replaced.
|
|
using var listener = new ActivityListener
|
|
{
|
|
ShouldListenTo = _ => true,
|
|
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData,
|
|
};
|
|
ActivitySource.AddActivityListener(listener);
|
|
using var source = new ActivitySource("test-source");
|
|
using var activity = source.StartActivity("test-op")!;
|
|
Assert.NotNull(activity);
|
|
|
|
var (handler, _) = CreateHandlerPair();
|
|
using var invoker = new HttpMessageInvoker(handler);
|
|
|
|
const string PresetTraceparent = "00-00000000000000000000000000000001-0000000000000001-01";
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
|
request.Headers.TryAddWithoutValidation("traceparent", PresetTraceparent);
|
|
|
|
// Act
|
|
await invoker.SendAsync(request, CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.True(request.Headers.TryGetValues("traceparent", out var values));
|
|
var list = values.ToList();
|
|
Assert.Single(list);
|
|
Assert.Equal(PresetTraceparent, list[0]);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(HttpStatusCode.OK)]
|
|
[InlineData(HttpStatusCode.Created)]
|
|
[InlineData(HttpStatusCode.BadRequest)]
|
|
[InlineData(HttpStatusCode.NotFound)]
|
|
public async Task SendAsync_NonRetryableStatusCode_ReturnsImmediatelyAsync(HttpStatusCode statusCode)
|
|
{
|
|
var (handler, inner) = CreateHandlerPair(statusCode: statusCode);
|
|
using var invoker = new HttpMessageInvoker(handler);
|
|
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
|
using var response = await invoker.SendAsync(request, CancellationToken.None);
|
|
|
|
Assert.Equal(statusCode, response.StatusCode);
|
|
Assert.Equal(1, inner.CallCount);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(HttpStatusCode.TooManyRequests)]
|
|
[InlineData(HttpStatusCode.InternalServerError)]
|
|
[InlineData(HttpStatusCode.BadGateway)]
|
|
[InlineData(HttpStatusCode.ServiceUnavailable)]
|
|
public async Task SendAsync_RetryableStatusCode_RetriesMaxTimesAsync(HttpStatusCode statusCode)
|
|
{
|
|
var (handler, inner) = CreateHandlerPair(statusCode: statusCode);
|
|
using var invoker = new HttpMessageInvoker(handler);
|
|
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
|
using var response = await invoker.SendAsync(request, CancellationToken.None);
|
|
|
|
// MaxRetries is 3, so exactly 3 total attempts (not 4).
|
|
Assert.Equal(3, inner.CallCount);
|
|
Assert.Equal(statusCode, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SendAsync_RetryableStatusCode_SucceedsOnSecondAttemptAsync()
|
|
{
|
|
// First call returns 503, second returns 200.
|
|
var inner = new SequenceHandler(
|
|
HttpStatusCode.ServiceUnavailable,
|
|
HttpStatusCode.OK);
|
|
|
|
var handler = new FoundryToolboxBearerTokenHandler(CreateMockCredential().Object, null)
|
|
{
|
|
InnerHandler = inner
|
|
};
|
|
using var invoker = new HttpMessageInvoker(handler);
|
|
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
|
using var response = await invoker.SendAsync(request, CancellationToken.None);
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
Assert.Equal(2, inner.CallCount);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A test handler that always returns the configured status code and counts how many times it was called.
|
|
/// </summary>
|
|
private sealed class CountingHandler : HttpMessageHandler
|
|
{
|
|
private readonly HttpStatusCode _statusCode;
|
|
private int _callCount;
|
|
|
|
public int CallCount => this._callCount;
|
|
|
|
public CountingHandler(HttpStatusCode statusCode)
|
|
{
|
|
this._statusCode = statusCode;
|
|
}
|
|
|
|
protected override Task<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
Interlocked.Increment(ref this._callCount);
|
|
return Task.FromResult(new HttpResponseMessage(this._statusCode));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A test handler that returns status codes from a sequence, cycling through them.
|
|
/// </summary>
|
|
private sealed class SequenceHandler : HttpMessageHandler
|
|
{
|
|
private readonly HttpStatusCode[] _statusCodes;
|
|
private int _callCount;
|
|
|
|
public int CallCount => this._callCount;
|
|
|
|
public SequenceHandler(params HttpStatusCode[] statusCodes)
|
|
{
|
|
this._statusCodes = statusCodes;
|
|
}
|
|
|
|
protected override Task<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var index = Interlocked.Increment(ref this._callCount) - 1;
|
|
var statusCode = index < this._statusCodes.Length
|
|
? this._statusCodes[index]
|
|
: this._statusCodes[^1];
|
|
return Task.FromResult(new HttpResponseMessage(statusCode));
|
|
}
|
|
}
|
|
}
|