diff --git a/src/TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs b/src/TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs index b98c6ee75f0..61ff8e88ac7 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,55 @@ internal static DelegatingHandler[] PrependPropagationHandlers(DelegatingHandler Array.Copy(handlers, 0, all, 2, handlers.Length); return all; } + + /// + /// 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 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; + + if (options.AllowAutoRedirect) + { + all[index++] = new RedirectHandler(options.MaxAutomaticRedirections); + } + + if (options.HandleCookies) + { + 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 aa20d3ae609..01d8aaf0a44 100644 --- a/src/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs +++ b/src/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs @@ -233,14 +233,20 @@ 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() - { - var client = CreateDefaultClient(); - ConfigureClient(client); - return client; - } + 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) => + TUnitHttpClientFilter.CreateClient(base.CreateDefaultClient, options); } diff --git a/src/TUnit.AspNetCore.Core/TracedWebApplicationFactory.cs b/src/TUnit.AspNetCore.Core/TracedWebApplicationFactory.cs index 6d549a3b393..782d23c13d9 100644 --- a/src/TUnit.AspNetCore.Core/TracedWebApplicationFactory.cs +++ b/src/TUnit.AspNetCore.Core/TracedWebApplicationFactory.cs @@ -39,10 +39,19 @@ 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) => + 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 3e2f4e2fa7f..bdde333fe13 100644 --- a/tests/TUnit.AspNetCore.Tests.WebApp/Program.cs +++ b/tests/TUnit.AspNetCore.Tests.WebApp/Program.cs @@ -18,6 +18,25 @@ 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")); +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. +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..96e4bdea194 --- /dev/null +++ b/tests/TUnit.AspNetCore.Tests/ClientOptionsTests.cs @@ -0,0 +1,198 @@ +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); + } + + [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'); + + // 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); + } + + 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_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(); + + 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] + 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); + } +} + +/// +/// 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"); + } +}