From ba79eab9fa06cb570cb1656d601af9b2a4278515 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:50:04 +0100 Subject: [PATCH 1/3] fix(aspnetcore): honor WebApplicationFactoryClientOptions in CreateClient TestWebApplicationFactory.CreateClient() and TracedWebApplicationFactory.CreateClient() shadowed the base implementation but only passed TUnit's propagation handlers, so ClientOptions was ignored: no CookieContainerHandler (breaking cookie auth), no RedirectHandler, and a custom BaseAddress was dropped. The base CreateClient(WebApplicationFactoryClientOptions) overload had the opposite problem: it honored the options but skipped TUnit's propagation handlers. Both factories now build the option handlers (RedirectHandler, CookieContainerHandler) after the propagation handlers and apply BaseAddress, and expose a CreateClient(WebApplicationFactoryClientOptions) overload that does the same. Fixes #6921 Co-Authored-By: Claude Opus 5.5 --- .../Http/TUnitHttpClientFilter.cs | 31 +++++ .../TestWebApplicationFactory.cs | 20 +++- .../TracedWebApplicationFactory.cs | 19 ++- .../TUnit.AspNetCore.Tests.WebApp/Program.cs | 18 +++ .../ClientOptionsTests.cs | 111 ++++++++++++++++++ 5 files changed, 191 insertions(+), 8 deletions(-) create mode 100644 tests/TUnit.AspNetCore.Tests/ClientOptionsTests.cs diff --git a/src/TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs b/src/TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs index b98c6ee75f0..2865d51ec82 100644 --- a/src/TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs +++ b/src/TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.Mvc.Testing.Handlers; using Microsoft.Extensions.Http; namespace TUnit.AspNetCore.Http; @@ -40,4 +42,33 @@ internal static DelegatingHandler[] PrependPropagationHandlers(DelegatingHandler Array.Copy(handlers, 0, all, 2, handlers.Length); return all; } + + /// + /// Returns the TUnit propagation handlers followed by the handlers that + /// requests: a + /// when is set, then a + /// when + /// is set. Mirrors the internal WebApplicationFactoryClientOptions.CreateHandlers used by + /// WebApplicationFactory.CreateClient(WebApplicationFactoryClientOptions). + /// + internal static DelegatingHandler[] CreateClientOptionsHandlers(WebApplicationFactoryClientOptions options) + { + var count = 2 + (options.AllowAutoRedirect ? 1 : 0) + (options.HandleCookies ? 1 : 0); + var all = new DelegatingHandler[count]; + var index = 0; + all[index++] = new ActivityPropagationHandler(); + all[index++] = new TUnitTestIdHandler(); + + if (options.AllowAutoRedirect) + { + all[index++] = new RedirectHandler(options.MaxAutomaticRedirections); + } + + if (options.HandleCookies) + { + all[index] = new CookieContainerHandler(); + } + + return all; + } } diff --git a/src/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs b/src/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs index aa20d3ae609..c19936751a0 100644 --- a/src/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs +++ b/src/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs @@ -233,13 +233,23 @@ private static ServiceDescriptor WrapHostedServiceDescriptor(ServiceDescriptor d } /// - /// Creates an with automatic Activity tracing and test context propagation. - /// Equivalent to calling with no additional handlers. + /// Creates an configured by , + /// with automatic Activity tracing and test context propagation. /// - public new HttpClient CreateClient() + public new HttpClient CreateClient() => CreateClient(ClientOptions); + + /// + /// Creates an configured by , with automatic + /// Activity tracing and test context propagation. Honors + /// , + /// , + /// and + /// . + /// + public new HttpClient CreateClient(WebApplicationFactoryClientOptions options) { - var client = CreateDefaultClient(); - ConfigureClient(client); + var client = base.CreateDefaultClient(TUnitHttpClientFilter.CreateClientOptionsHandlers(options)); + client.BaseAddress = options.BaseAddress; return client; } diff --git a/src/TUnit.AspNetCore.Core/TracedWebApplicationFactory.cs b/src/TUnit.AspNetCore.Core/TracedWebApplicationFactory.cs index 6d549a3b393..15a3f4f5729 100644 --- a/src/TUnit.AspNetCore.Core/TracedWebApplicationFactory.cs +++ b/src/TUnit.AspNetCore.Core/TracedWebApplicationFactory.cs @@ -39,10 +39,23 @@ public TracedWebApplicationFactory(WebApplicationFactory inner) public IServiceProvider Services => _inner.Services; /// - /// Creates an with activity tracing and test context propagation. + /// Creates an configured by the inner factory's + /// , with activity tracing and + /// test context propagation. /// - public HttpClient CreateClient() => - _inner.CreateDefaultClient(TUnitHttpClientFilter.PrependPropagationHandlers([])); + public HttpClient CreateClient() => CreateClient(_inner.ClientOptions); + + /// + /// Creates an configured by , with activity + /// tracing and test context propagation. Honors cookie handling, auto-redirect and base address + /// settings the same way as . + /// + public HttpClient CreateClient(WebApplicationFactoryClientOptions options) + { + var client = _inner.CreateDefaultClient(TUnitHttpClientFilter.CreateClientOptionsHandlers(options)); + client.BaseAddress = options.BaseAddress; + return client; + } /// /// Creates an with the specified delegating handlers, plus diff --git a/tests/TUnit.AspNetCore.Tests.WebApp/Program.cs b/tests/TUnit.AspNetCore.Tests.WebApp/Program.cs index 3e2f4e2fa7f..01d355b2170 100644 --- a/tests/TUnit.AspNetCore.Tests.WebApp/Program.cs +++ b/tests/TUnit.AspNetCore.Tests.WebApp/Program.cs @@ -18,6 +18,24 @@ app.MapGet("/ping", () => "pong"); +// Cookie round-trip endpoints (thomhurst/TUnit#6921): the client must store the cookie +// set by /cookie/set and send it back on /cookie/get when ClientOptions.HandleCookies is on. +app.MapGet("/cookie/set/{value}", (string value, HttpContext context) => +{ + context.Response.Cookies.Append("tunit-cookie", value); + return Results.Ok(); +}); + +app.MapGet("/cookie/get", (HttpContext context) => + Results.Text(context.Request.Cookies.TryGetValue("tunit-cookie", out var value) ? value : "")); + +app.MapGet("/redirect", () => Results.Redirect("/ping")); + +// Echoes the request headers the test client sent, so tests can assert that TUnit's +// propagation headers are still emitted by option-configured clients. +app.MapGet("/echo-headers", (HttpContext context) => + Results.Text(string.Join("\n", context.Request.Headers.Select(h => $"{h.Key}: {h.Value}")))); + // Outbound call through IHttpClientFactory. The downstream pipeline's primary // handler echoes request headers back in the response body so tests can assert // which headers the SUT-side HttpClient actually emitted. diff --git a/tests/TUnit.AspNetCore.Tests/ClientOptionsTests.cs b/tests/TUnit.AspNetCore.Tests/ClientOptionsTests.cs new file mode 100644 index 00000000000..b4cb670367e --- /dev/null +++ b/tests/TUnit.AspNetCore.Tests/ClientOptionsTests.cs @@ -0,0 +1,111 @@ +using System.Net; +using Microsoft.AspNetCore.Mvc.Testing; +using TUnit.AspNetCore; +using TUnit.Core; + +namespace TUnit.AspNetCore.Tests; + +/// +/// Regression coverage for thomhurst/TUnit#6921: clients created by +/// must honor +/// (cookies, redirects, base address) +/// while still carrying TUnit's propagation headers. +/// +public class ClientOptionsTests +{ + [ClassDataSource(Shared = [SharedType.PerTestSession])] + public TestWebAppFactory Factory { get; set; } = null!; + + [Test] + public async Task CreateClient_HandlesCookies_ByDefault() + { + using var client = Factory.CreateClient(); + + await AssertCookieRoundTrip(client, expected: "abc"); + } + + [Test] + public async Task CreateClient_FollowsRedirects_ByDefault() + { + using var client = Factory.CreateClient(); + + var response = await client.GetAsync("/redirect"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + await Assert.That(await response.Content.ReadAsStringAsync()).IsEqualTo("pong"); + } + + [Test] + public async Task CreateClient_WithOptions_CanDisableCookiesAndRedirects() + { + using var client = Factory.CreateClient(new WebApplicationFactoryClientOptions + { + HandleCookies = false, + AllowAutoRedirect = false, + }); + + await AssertCookieRoundTrip(client, expected: ""); + + var response = await client.GetAsync("/redirect"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Redirect); + } + + [Test] + public async Task CreateClient_WithOptions_UsesBaseAddress() + { + var baseAddress = new Uri("http://tunit.test/"); + + using var client = Factory.CreateClient(new WebApplicationFactoryClientOptions { BaseAddress = baseAddress }); + + await Assert.That(client.BaseAddress).IsEqualTo(baseAddress); + } + + [Test] + public async Task CreateClient_WithOptions_KeepsPropagationHeaders() + { + using var client = Factory.CreateClient(new WebApplicationFactoryClientOptions()); + + var echoed = await client.GetStringAsync("/echo-headers"); + + await Assert.That(echoed).Contains(TUnitTestIdHandler.HeaderName + ": " + TestContext.Current!.Id); + } + + internal static async Task AssertCookieRoundTrip(HttpClient client, string expected) + { + var set = await client.GetAsync("/cookie/set/abc"); + set.EnsureSuccessStatusCode(); + + var value = await client.GetStringAsync("/cookie/get"); + + await Assert.That(value).IsEqualTo(expected); + } +} + +public class WebApplicationTestClientOptionsTests : WebApplicationTest +{ + [Test] + public async Task CreateClient_HandlesCookies_ByDefault() + { + using var client = Factory.CreateClient(); + + await ClientOptionsTests.AssertCookieRoundTrip(client, expected: "abc"); + } + + [Test] + public async Task CreateClient_WithOptions_CanDisableCookies() + { + using var client = Factory.CreateClient(new WebApplicationFactoryClientOptions { HandleCookies = false }); + + await ClientOptionsTests.AssertCookieRoundTrip(client, expected: ""); + } + + [Test] + public async Task CreateClient_WithOptions_KeepsPropagationHeaders() + { + using var client = Factory.CreateClient(new WebApplicationFactoryClientOptions()); + + var echoed = await client.GetStringAsync("/echo-headers"); + + await Assert.That(echoed).Contains(TUnitTestIdHandler.HeaderName + ": " + TestContext.Current!.Id); + } +} From 7483db43c6861e59d524a59295a70177d37900c2 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:08:04 +0100 Subject: [PATCH 2/3] fix(aspnetcore): run propagation handlers inside redirect handler; share option client helper - Order option handlers as RedirectHandler, CookieContainerHandler, then the TUnit propagation handlers, so each redirect hop gets freshly injected headers. - Route both CreateClient(options) overloads through TUnitHttpClientFilter.CreateClient. - Cover redirects, redirected-request headers and BaseAddress on the traced factory, and a ConfigureClient override on TestWebApplicationFactory. --- .../Http/TUnitHttpClientFilter.cs | 38 ++++++-- .../TestWebApplicationFactory.cs | 8 +- .../TracedWebApplicationFactory.cs | 8 +- .../TUnit.AspNetCore.Tests.WebApp/Program.cs | 1 + .../ClientOptionsTests.cs | 90 ++++++++++++++++++- 5 files changed, 123 insertions(+), 22 deletions(-) diff --git a/src/TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs b/src/TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs index 2865d51ec82..61ff8e88ac7 100644 --- a/src/TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs +++ b/src/TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs @@ -44,20 +44,39 @@ internal static DelegatingHandler[] PrependPropagationHandlers(DelegatingHandler } /// - /// Returns the TUnit propagation handlers followed by the handlers that - /// requests: a - /// when is set, then a + /// Creates a client the way WebApplicationFactory.CreateClient(WebApplicationFactoryClientOptions) + /// does, with the TUnit propagation handlers added. must be the + /// factory's base CreateDefaultClient(DelegatingHandler[]), which also runs the factory's + /// ConfigureClient override. + /// + internal static HttpClient CreateClient( + Func createDefaultClient, + WebApplicationFactoryClientOptions options) + { + var client = createDefaultClient(CreateClientOptionsHandlers(options)); + // Read BaseAddress after the client is created: starting a Kestrel-backed factory + // replaces ClientOptions.BaseAddress with the server's real address. + client.BaseAddress = options.BaseAddress; + return client; + } + + /// + /// Returns the handlers that requests, followed by the + /// TUnit propagation handlers: a when + /// is set, then a /// when - /// is set. Mirrors the internal WebApplicationFactoryClientOptions.CreateHandlers used by - /// WebApplicationFactory.CreateClient(WebApplicationFactoryClientOptions). + /// is set. Mirrors the internal WebApplicationFactoryClientOptions.CreateHandlers in + /// Microsoft.AspNetCore.Mvc.Testing 8.0 to 10.0; update this if a later version adds a handler. /// + /// + /// The propagation handlers sit inside so that every redirect hop + /// gets freshly injected headers, not only the first request. + /// internal static DelegatingHandler[] CreateClientOptionsHandlers(WebApplicationFactoryClientOptions options) { var count = 2 + (options.AllowAutoRedirect ? 1 : 0) + (options.HandleCookies ? 1 : 0); var all = new DelegatingHandler[count]; var index = 0; - all[index++] = new ActivityPropagationHandler(); - all[index++] = new TUnitTestIdHandler(); if (options.AllowAutoRedirect) { @@ -66,9 +85,12 @@ internal static DelegatingHandler[] CreateClientOptionsHandlers(WebApplicationFa if (options.HandleCookies) { - all[index] = new CookieContainerHandler(); + all[index++] = new CookieContainerHandler(); } + all[index++] = new ActivityPropagationHandler(); + all[index] = new TUnitTestIdHandler(); + return all; } } diff --git a/src/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs b/src/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs index c19936751a0..01d8aaf0a44 100644 --- a/src/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs +++ b/src/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs @@ -246,11 +246,7 @@ private static ServiceDescriptor WrapHostedServiceDescriptor(ServiceDescriptor d /// and /// . /// - public new HttpClient CreateClient(WebApplicationFactoryClientOptions options) - { - var client = base.CreateDefaultClient(TUnitHttpClientFilter.CreateClientOptionsHandlers(options)); - client.BaseAddress = options.BaseAddress; - return client; - } + public new HttpClient CreateClient(WebApplicationFactoryClientOptions options) => + TUnitHttpClientFilter.CreateClient(base.CreateDefaultClient, options); } diff --git a/src/TUnit.AspNetCore.Core/TracedWebApplicationFactory.cs b/src/TUnit.AspNetCore.Core/TracedWebApplicationFactory.cs index 15a3f4f5729..782d23c13d9 100644 --- a/src/TUnit.AspNetCore.Core/TracedWebApplicationFactory.cs +++ b/src/TUnit.AspNetCore.Core/TracedWebApplicationFactory.cs @@ -50,12 +50,8 @@ public TracedWebApplicationFactory(WebApplicationFactory inner) /// tracing and test context propagation. Honors cookie handling, auto-redirect and base address /// settings the same way as . /// - public HttpClient CreateClient(WebApplicationFactoryClientOptions options) - { - var client = _inner.CreateDefaultClient(TUnitHttpClientFilter.CreateClientOptionsHandlers(options)); - client.BaseAddress = options.BaseAddress; - return client; - } + public HttpClient CreateClient(WebApplicationFactoryClientOptions options) => + TUnitHttpClientFilter.CreateClient(_inner.CreateDefaultClient, options); /// /// Creates an with the specified delegating handlers, plus diff --git a/tests/TUnit.AspNetCore.Tests.WebApp/Program.cs b/tests/TUnit.AspNetCore.Tests.WebApp/Program.cs index 01d355b2170..bdde333fe13 100644 --- a/tests/TUnit.AspNetCore.Tests.WebApp/Program.cs +++ b/tests/TUnit.AspNetCore.Tests.WebApp/Program.cs @@ -30,6 +30,7 @@ Results.Text(context.Request.Cookies.TryGetValue("tunit-cookie", out var value) ? value : "")); app.MapGet("/redirect", () => Results.Redirect("/ping")); +app.MapGet("/redirect-to-echo-headers", () => Results.Redirect("/echo-headers")); // Echoes the request headers the test client sent, so tests can assert that TUnit's // propagation headers are still emitted by option-configured clients. diff --git a/tests/TUnit.AspNetCore.Tests/ClientOptionsTests.cs b/tests/TUnit.AspNetCore.Tests/ClientOptionsTests.cs index b4cb670367e..08c5048b802 100644 --- a/tests/TUnit.AspNetCore.Tests/ClientOptionsTests.cs +++ b/tests/TUnit.AspNetCore.Tests/ClientOptionsTests.cs @@ -70,6 +70,25 @@ public async Task CreateClient_WithOptions_KeepsPropagationHeaders() await Assert.That(echoed).Contains(TUnitTestIdHandler.HeaderName + ": " + TestContext.Current!.Id); } + [Test] + public async Task CreateClient_Redirect_KeepsPropagationHeadersOnRedirectedRequest() + { + using var client = Factory.CreateClient(); + + await AssertRedirectedRequestHasTestIdHeader(client); + } + + internal static async Task AssertRedirectedRequestHasTestIdHeader(HttpClient client) + { + var response = await client.GetAsync("/redirect-to-echo-headers"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var lines = (await response.Content.ReadAsStringAsync()).Split('\n'); + + // Exactly one value: the header is injected on the redirected request, not duplicated. + await Assert.That(lines).Contains(TUnitTestIdHandler.HeaderName + ": " + TestContext.Current!.Id); + } + internal static async Task AssertCookieRoundTrip(HttpClient client, string expected) { var set = await client.GetAsync("/cookie/set/abc"); @@ -92,11 +111,47 @@ public async Task CreateClient_HandlesCookies_ByDefault() } [Test] - public async Task CreateClient_WithOptions_CanDisableCookies() + public async Task CreateClient_FollowsRedirects_ByDefault() + { + using var client = Factory.CreateClient(); + + var response = await client.GetAsync("/redirect"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + await Assert.That(await response.Content.ReadAsStringAsync()).IsEqualTo("pong"); + } + + [Test] + public async Task CreateClient_Redirect_KeepsPropagationHeadersOnRedirectedRequest() { - using var client = Factory.CreateClient(new WebApplicationFactoryClientOptions { HandleCookies = false }); + using var client = Factory.CreateClient(); + + await ClientOptionsTests.AssertRedirectedRequestHasTestIdHeader(client); + } + + [Test] + public async Task CreateClient_WithOptions_CanDisableCookiesAndRedirects() + { + using var client = Factory.CreateClient(new WebApplicationFactoryClientOptions + { + HandleCookies = false, + AllowAutoRedirect = false, + }); await ClientOptionsTests.AssertCookieRoundTrip(client, expected: ""); + + var response = await client.GetAsync("/redirect"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Redirect); + } + + [Test] + public async Task CreateClient_WithOptions_UsesBaseAddress() + { + var baseAddress = new Uri("http://tunit.test/"); + + using var client = Factory.CreateClient(new WebApplicationFactoryClientOptions { BaseAddress = baseAddress }); + + await Assert.That(client.BaseAddress).IsEqualTo(baseAddress); } [Test] @@ -109,3 +164,34 @@ public async Task CreateClient_WithOptions_KeepsPropagationHeaders() await Assert.That(echoed).Contains(TUnitTestIdHandler.HeaderName + ": " + TestContext.Current!.Id); } } + +/// +/// A ConfigureClient override must still apply to clients created through the +/// option-aware CreateClient overloads. +/// +public class ConfigureClientOverrideTests +{ + [ClassDataSource(Shared = [SharedType.PerTestSession])] + public ConfigureClientWebAppFactory Factory { get; set; } = null!; + + [Test] + public async Task CreateClient_RunsConfigureClientOverride() + { + using var client = Factory.CreateClient(); + + var echoed = await client.GetStringAsync("/echo-headers"); + + await Assert.That(echoed).Contains(ConfigureClientWebAppFactory.HeaderName + ": yes"); + } +} + +public class ConfigureClientWebAppFactory : TestWebAppFactory +{ + public const string HeaderName = "X-Configured-By-Override"; + + protected override void ConfigureClient(HttpClient client) + { + base.ConfigureClient(client); + client.DefaultRequestHeaders.Add(HeaderName, "yes"); + } +} From a4043ff6e12d4b38e2cebb3ed709a0f592b6d5ac Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:43:44 +0100 Subject: [PATCH 3/3] test: clarify why the redirect header assertion proves a single value --- tests/TUnit.AspNetCore.Tests/ClientOptionsTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/TUnit.AspNetCore.Tests/ClientOptionsTests.cs b/tests/TUnit.AspNetCore.Tests/ClientOptionsTests.cs index 08c5048b802..96e4bdea194 100644 --- a/tests/TUnit.AspNetCore.Tests/ClientOptionsTests.cs +++ b/tests/TUnit.AspNetCore.Tests/ClientOptionsTests.cs @@ -85,7 +85,8 @@ internal static async Task AssertRedirectedRequestHasTestIdHeader(HttpClient cli var lines = (await response.Content.ReadAsStringAsync()).Split('\n'); - // Exactly one value: the header is injected on the redirected request, not duplicated. + // Exact line match proves a single value: /echo-headers joins repeated values with + // commas, so a header duplicated across redirect hops would not match this line. await Assert.That(lines).Contains(TUnitTestIdHandler.HeaderName + ": " + TestContext.Current!.Id); }