From 4af5a339383e7a6b691528321d0cd2b4a57d8aa1 Mon Sep 17 00:00:00 2001 From: Joe DeCock Date: Mon, 26 Aug 2024 15:33:14 -0500 Subject: [PATCH] Add Blazor.Client unit tests --- .../BffBlazorOptions.cs | 7 ++ .../BffClientAuthenticationStateProvider.cs | 2 +- .../ServiceCollectionExtensions.cs | 41 ++++++- .../AntiforgeryHandlerTests.cs | 30 +++++ .../Duende.Bff.Blazor.Client.UnitTests.csproj | 29 +++++ .../ServiceCollectionExtensionsTests.cs | 106 ++++++++++++++++++ 6 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 test/Duende.Bff.Blazor.Client.UnitTests/AntiforgeryHandlerTests.cs create mode 100644 test/Duende.Bff.Blazor.Client.UnitTests/Duende.Bff.Blazor.Client.UnitTests.csproj create mode 100644 test/Duende.Bff.Blazor.Client.UnitTests/ServiceCollectionExtensionsTests.cs diff --git a/src/Duende.Bff.Blazor.Client/BffBlazorOptions.cs b/src/Duende.Bff.Blazor.Client/BffBlazorOptions.cs index 5e73bf3d..549ae63f 100644 --- a/src/Duende.Bff.Blazor.Client/BffBlazorOptions.cs +++ b/src/Duende.Bff.Blazor.Client/BffBlazorOptions.cs @@ -19,6 +19,13 @@ public class BffBlazorOptions /// public string? RemoteApiBaseAddress { get; set; } = null; + /// + /// The base address to use for the state provider's calls to the + /// /bff/user endpoint. If unset (the default), the blazor hosting + /// environment's base address is used. + /// + public string? StateProviderBaseAddress { get; set; } = null; + /// /// The delay, in milliseconds, before the AuthenticationStateProvider /// will start polling the /bff/user endpoint. Defaults to 1000 ms. diff --git a/src/Duende.Bff.Blazor.Client/BffClientAuthenticationStateProvider.cs b/src/Duende.Bff.Blazor.Client/BffClientAuthenticationStateProvider.cs index 60fa9bc8..cf22b28c 100644 --- a/src/Duende.Bff.Blazor.Client/BffClientAuthenticationStateProvider.cs +++ b/src/Duende.Bff.Blazor.Client/BffClientAuthenticationStateProvider.cs @@ -32,7 +32,7 @@ public BffClientAuthenticationStateProvider( IOptions options, ILogger logger) { - _client = factory.CreateClient("BffAuthenticationStateProvider"); + _client = factory.CreateClient(nameof(BffClientAuthenticationStateProvider)); _logger = logger; _cachedUser = GetPersistedUser(state); if (_cachedUser.Identity?.IsAuthenticated == true) diff --git a/src/Duende.Bff.Blazor.Client/ServiceCollectionExtensions.cs b/src/Duende.Bff.Blazor.Client/ServiceCollectionExtensions.cs index 17ab858f..ae1970ea 100644 --- a/src/Duende.Bff.Blazor.Client/ServiceCollectionExtensions.cs +++ b/src/Duende.Bff.Blazor.Client/ServiceCollectionExtensions.cs @@ -21,16 +21,31 @@ public static IServiceCollection AddBffBlazorClient(this IServiceCollection serv services .AddAuthorizationCore() .AddScoped() + // TODO - Should this have a different lifetime? .AddTransient() - .AddHttpClient("BffAuthenticationStateProvider", (sp, client) => + .AddHttpClient(nameof(BffClientAuthenticationStateProvider), (sp, client) => { - var baseAddress = GetBaseAddress(sp); + var baseAddress = GetStateProviderBaseAddress(sp); client.BaseAddress = new Uri(baseAddress); }).AddHttpMessageHandler(); return services; } + private static string GetStateProviderBaseAddress(IServiceProvider sp) + { + var opt = sp.GetRequiredService>(); + if (opt.Value.StateProviderBaseAddress != null) + { + return opt.Value.StateProviderBaseAddress; + } + else + { + var hostEnv = sp.GetRequiredService(); + return hostEnv.BaseAddress; + } + } + private static string GetBaseAddress(IServiceProvider sp) { var opt = sp.GetRequiredService>(); @@ -96,6 +111,16 @@ private static void SetBaseAddress(IServiceProvider sp, HttpClient client) client.BaseAddress = new Uri(new Uri(baseAddress), remoteApiPath); } + /// + /// Adds a named for use when invoking remote APIs + /// proxied through Duende.Bff and configures the client with a callback. + /// + /// The name of that to + /// configure. A common use case is to use the same named client in multiple + /// render contexts that are automatically switched between via interactive + /// render modes. + /// A configuration callback used to set up + /// the . public static IHttpClientBuilder AddRemoteApiHttpClient(this IServiceCollection services, string clientName, Action configureClient) { @@ -103,6 +128,17 @@ public static IHttpClientBuilder AddRemoteApiHttpClient(this IServiceCollection .AddHttpMessageHandler(); } + /// + /// Adds a named for use when invoking remote APIs + /// proxied through Duende.Bff and configures the client with a callback + /// that has access to the underlying service provider. + /// + /// The name of that to + /// configure. A common use case is to use the same named client in multiple + /// render contexts that are automatically switched between via interactive + /// render modes. + /// A configuration callback used to set up + /// the . public static IHttpClientBuilder AddRemoteApiHttpClient(this IServiceCollection services, string clientName, Action? configureClient = null) { @@ -110,6 +146,7 @@ public static IHttpClientBuilder AddRemoteApiHttpClient(this IServiceCollection .AddHttpMessageHandler(); } + // TODO - Manually test this API. public static IHttpClientBuilder AddRemoteApiHttpClient(this IServiceCollection services, Action configureClient) where T : class diff --git a/test/Duende.Bff.Blazor.Client.UnitTests/AntiforgeryHandlerTests.cs b/test/Duende.Bff.Blazor.Client.UnitTests/AntiforgeryHandlerTests.cs new file mode 100644 index 00000000..5e4ed834 --- /dev/null +++ b/test/Duende.Bff.Blazor.Client.UnitTests/AntiforgeryHandlerTests.cs @@ -0,0 +1,30 @@ +using NSubstitute; +using Shouldly; + +namespace Duende.Bff.Blazor.Client.UnitTests; + +public class AntiforgeryHandlerTests +{ + [Fact] + public async Task Adds_expected_header() + { + var sut = new TestAntiforgeryHandler() + { + InnerHandler = Substitute.For() + }; + + var request = new HttpRequestMessage(); + + await sut.SendAsync(request, CancellationToken.None); + + request.Headers.ShouldContain(h => h.Key == "X-CSRF" && h.Value.Contains("1")); + } +} + +public class TestAntiforgeryHandler : AntiforgeryHandler +{ + public new Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return base.SendAsync(request, cancellationToken); + } +} \ No newline at end of file diff --git a/test/Duende.Bff.Blazor.Client.UnitTests/Duende.Bff.Blazor.Client.UnitTests.csproj b/test/Duende.Bff.Blazor.Client.UnitTests/Duende.Bff.Blazor.Client.UnitTests.csproj new file mode 100644 index 00000000..3c9c9ae9 --- /dev/null +++ b/test/Duende.Bff.Blazor.Client.UnitTests/Duende.Bff.Blazor.Client.UnitTests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/test/Duende.Bff.Blazor.Client.UnitTests/ServiceCollectionExtensionsTests.cs b/test/Duende.Bff.Blazor.Client.UnitTests/ServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..81945850 --- /dev/null +++ b/test/Duende.Bff.Blazor.Client.UnitTests/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,106 @@ +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Shouldly; + +namespace Duende.Bff.Blazor.Client.UnitTests; + +public class ServiceCollectionExtensionsTests +{ + [Theory] + [InlineData("https://example.com/", "https://example.com/")] + [InlineData("https://example.com", "https://example.com/")] + public void When_base_address_option_is_set_AddBffBlazorClient_configures_HttpClient_base_address(string configuredRemoteAddress, string expectedBaseAddress) + { + var sut = new ServiceCollection(); + sut.AddBffBlazorClient(); + sut.Configure(opt => + { + opt.StateProviderBaseAddress = configuredRemoteAddress; + }); + + + var sp = sut.BuildServiceProvider(); + var httpClientFactory = sp.GetService(); + var httpClient = httpClientFactory?.CreateClient(nameof(BffClientAuthenticationStateProvider)); + httpClient.ShouldNotBeNull(); + httpClient.BaseAddress.ShouldNotBeNull(); + httpClient.BaseAddress.AbsoluteUri.ShouldBe(expectedBaseAddress); + } + + [Fact] + public void When_base_address_option_is_default_AddBffBlazorClient_configures_HttpClient_base_address_from_host_env() + { + var expectedBaseAddress = "https://example.com/"; + + var sut = new ServiceCollection(); + sut.AddBffBlazorClient(); + var env = Substitute.For(); + env.BaseAddress.Returns(expectedBaseAddress); + sut.AddSingleton(env); + + var sp = sut.BuildServiceProvider(); + var httpClientFactory = sp.GetService(); + var httpClient = httpClientFactory?.CreateClient(nameof(BffClientAuthenticationStateProvider)); + httpClient.ShouldNotBeNull(); + httpClient.BaseAddress.ShouldNotBeNull(); + httpClient.BaseAddress.AbsoluteUri.ShouldBe(expectedBaseAddress); + } + + [Theory] + [InlineData("https://example.com/", "remote-apis", "https://example.com/remote-apis/")] + [InlineData("https://example.com/", null, "https://example.com/remote-apis/")] + [InlineData("https://example.com", null, "https://example.com/remote-apis/")] + [InlineData("https://example.com", "custom/route/to/apis", "https://example.com/custom/route/to/apis/")] + [InlineData("https://example.com/with/base/path", "custom/route/to/apis", "https://example.com/with/base/path/custom/route/to/apis/")] + [InlineData("https://example.com/with/base/path/", "custom/route/to/apis", "https://example.com/with/base/path/custom/route/to/apis/")] + [InlineData("https://example.com/with/base/path", "/custom/route/to/apis", "https://example.com/with/base/path/custom/route/to/apis/")] + [InlineData("https://example.com/with/base/path/", "/custom/route/to/apis", "https://example.com/with/base/path/custom/route/to/apis/")] + [InlineData("https://example.com/with/base/path", null, "https://example.com/with/base/path/remote-apis/")] + public void AddRemoteApiHttpClient_configures_HttpClient_base_address(string configuredRemoteAddress, string? configuredRemotePath, string expectedBaseAddress) + { + var sut = new ServiceCollection(); + sut.AddBffBlazorClient(); + sut.AddRemoteApiHttpClient("clientName"); + sut.Configure(opt => + { + if (configuredRemoteAddress != null) + { + opt.RemoteApiBaseAddress = configuredRemoteAddress; + } + if (configuredRemotePath != null) + { + opt.RemoteApiPath = configuredRemotePath; + } + }); + + + var sp = sut.BuildServiceProvider(); + var httpClientFactory = sp.GetService(); + var httpClient = httpClientFactory?.CreateClient("clientName"); + httpClient.ShouldNotBeNull(); + httpClient.BaseAddress.ShouldNotBeNull(); + httpClient.BaseAddress.AbsoluteUri.ShouldBe(expectedBaseAddress); + } + + [Fact] + public void When_base_address_option_is_default_AddRemoteApiHttpClient_configures_HttpClient_base_address_from_host_env() + { + var hostBaseAddress = "https://example.com/"; + var expectedBaseAddress = "https://example.com/remote-apis/"; + + var sut = new ServiceCollection(); + sut.AddBffBlazorClient(); + sut.AddRemoteApiHttpClient("clientName"); + var env = Substitute.For(); + env.BaseAddress.Returns(hostBaseAddress); + sut.AddSingleton(env); + + var sp = sut.BuildServiceProvider(); + var httpClientFactory = sp.GetService(); + var httpClient = httpClientFactory?.CreateClient("clientName"); + httpClient.ShouldNotBeNull(); + httpClient.BaseAddress.ShouldNotBeNull(); + httpClient.BaseAddress.AbsoluteUri.ShouldBe(expectedBaseAddress); + } +} \ No newline at end of file