// 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 CreateMockCredential() { var mock = new Mock(); mock.Setup(c => c.GetTokenAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new AccessToken(FakeToken, DateTimeOffset.UtcNow.AddHours(1))); return mock; } private static (FoundryToolboxBearerTokenHandler Handler, CountingHandler Inner) CreateHandlerPair( Mock? 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(); var credential = new Mock(); credential .Setup(c => c.GetTokenAsync(It.IsAny(), It.IsAny())) .Callback((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 _) => 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 _) => 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); } /// /// A test handler that always returns the configured status code and counts how many times it was called. /// 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 SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { Interlocked.Increment(ref this._callCount); return Task.FromResult(new HttpResponseMessage(this._statusCode)); } } /// /// A test handler that returns status codes from a sequence, cycling through them. /// 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 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)); } } }