diff --git a/src/Duende.Bff.Blazor.Client/BffClientAuthenticationStateProvider.cs b/src/Duende.Bff.Blazor.Client/BffClientAuthenticationStateProvider.cs index a17cacb7..9870aa9a 100644 --- a/src/Duende.Bff.Blazor.Client/BffClientAuthenticationStateProvider.cs +++ b/src/Duende.Bff.Blazor.Client/BffClientAuthenticationStateProvider.cs @@ -44,7 +44,7 @@ public override async Task GetAuthenticationStateAsync() _logger.LogInformation("starting background check.."); ITimer? timer = null; - timer = _timeProvider.CreateTimer(async _ => + async void TimerCallback(object? _) { var currentUser = await _getUserService.GetUserAsync(false); // Always notify that auth state has changed, because the user @@ -65,7 +65,9 @@ public override async Task GetAuthenticationStateAsync() await timer.DisposeAsync(); } } - }, + } + + timer = _timeProvider.CreateTimer(TimerCallback, null, TimeSpan.FromMilliseconds(_options.StateProviderPollingDelay), TimeSpan.FromMilliseconds(_options.StateProviderPollingInterval)); diff --git a/test/Duende.Bff.Blazor.Client.UnitTests/BffClientAuthenticationStateProviderTests.cs b/test/Duende.Bff.Blazor.Client.UnitTests/BffClientAuthenticationStateProviderTests.cs new file mode 100644 index 00000000..145ba573 --- /dev/null +++ b/test/Duende.Bff.Blazor.Client.UnitTests/BffClientAuthenticationStateProviderTests.cs @@ -0,0 +1,146 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Security.Claims; +using Duende.Bff.Blazor.Client.Internals; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Shouldly; + +namespace Duende.Bff.Blazor.Client.UnitTests; + +public class BffClientAuthenticationStateProviderTests +{ + [Fact] + public async Task when_UserService_gives_anonymous_user_GetAuthState_returns_anonymous() + { + var userService = Substitute.For(); + userService.GetUserAsync().Returns(new ClaimsPrincipal(new ClaimsIdentity())); + var sut = new BffClientAuthenticationStateProvider( + userService, + new FakeTimeProvider(), + TestMocks.MockOptions(), + Substitute.For>()); + + var authState = await sut.GetAuthenticationStateAsync(); + authState.User.Identity.IsAuthenticated.ShouldBeFalse(); + } + + [Fact] + public async Task when_UserService_returns_persisted_user_GetAuthState_returns_that_user() + { + var expectedName = "test-user"; + var userService = Substitute.For(); + userService.GetUserAsync().Returns(new ClaimsPrincipal(new ClaimsIdentity( + new []{ new Claim("name", expectedName) }, + "pwd", "name", "role"))); + var sut = new BffClientAuthenticationStateProvider( + userService, + new FakeTimeProvider(), + TestMocks.MockOptions(), + Substitute.For>()); + + var authState = await sut.GetAuthenticationStateAsync(); + authState.User.Identity.IsAuthenticated.ShouldBeTrue(); + authState.User.Identity.Name.ShouldBe(expectedName); + userService.Received(1).GetUserAsync(); + } + + [Fact] + public async Task after_configured_delay_UserService_is_called_again_and_state_notification_is_called() + { + var expectedName = "test-user"; + var userService = Substitute.For(); + var time = new FakeTimeProvider(); + userService.GetUserAsync().Returns(new ClaimsPrincipal(new ClaimsIdentity( + new []{ new Claim("name", expectedName) }, + "pwd", "name", "role"))); + var sut = new BffClientAuthenticationStateProvider( + userService, + time, + TestMocks.MockOptions(new BffBlazorOptions + { + StateProviderPollingDelay = 2000, + StateProviderPollingInterval = 10000 + + }), + Substitute.For>()); + + var authState = await sut.GetAuthenticationStateAsync(); + + // Initially, we have called the user service once to initialize + userService.Received(1).GetUserAsync(); + + // Advance time within the polling delay, and note that we still haven't made additional calls + time.Advance(TimeSpan.FromSeconds(1)); // t = 1 + userService.Received(1).GetUserAsync(); + + // Advance time past the polling delay, and note that we make an additional call + time.Advance(TimeSpan.FromSeconds(2)); // t = 3 + userService.Received(1).GetUserAsync(true); + userService.Received(1).GetUserAsync(false); + + // Advance time within the polling interval, but more than the polling delay + // We don't expect additional calls yet + time.Advance(TimeSpan.FromSeconds(3)); // t = 6 + userService.Received(1).GetUserAsync(true); + userService.Received(1).GetUserAsync(false); + + // Advance time past the polling interval, and note that we make an additional call + time.Advance(TimeSpan.FromSeconds(10)); // t = 16 + userService.Received(1).GetUserAsync(true); + userService.Received(2).GetUserAsync(false); + } + + [Fact] + public async Task timer_stops_when_user_logs_out() + { + var expectedName = "test-user"; + var userService = Substitute.For(); + var time = new FakeTimeProvider(); + + var anonymousUser = new ClaimsPrincipal(new ClaimsIdentity()); + anonymousUser.Identity.IsAuthenticated.ShouldBeFalse(); + + var cachedUser = new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim("name", expectedName), + new Claim("source", "cache") + ], "pwd", "name", "role")); + + var fetchedUser = new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim("name", expectedName), + new Claim("source", "fetch") + ], "pwd", "name", "role")); + + userService.GetUserAsync(true).Returns(cachedUser); + userService.GetUserAsync(false).Returns(fetchedUser, anonymousUser); + var sut = new BffClientAuthenticationStateProvider( + userService, + time, + TestMocks.MockOptions(new BffBlazorOptions + { + StateProviderPollingDelay = 2000, + StateProviderPollingInterval = 10000 + + }), + Substitute.For>()); + + var authState = await sut.GetAuthenticationStateAsync(); + time.Advance(TimeSpan.FromSeconds(5)); + userService.Received(1).GetUserAsync(true); + userService.Received(1).GetUserAsync(false); + + time.Advance(TimeSpan.FromSeconds(10)); + userService.Received(1).GetUserAsync(true); + userService.Received(2).GetUserAsync(false); + + + time.Advance(TimeSpan.FromSeconds(50)); + userService.Received(1).GetUserAsync(true); + userService.Received(2).GetUserAsync(false); + + } +} \ No newline at end of file diff --git a/test/Duende.Bff.Blazor.Client.UnitTests/GetUserServiceTests.cs b/test/Duende.Bff.Blazor.Client.UnitTests/GetUserServiceTests.cs index dda67052..93b13339 100644 --- a/test/Duende.Bff.Blazor.Client.UnitTests/GetUserServiceTests.cs +++ b/test/Duende.Bff.Blazor.Client.UnitTests/GetUserServiceTests.cs @@ -6,39 +6,16 @@ using System.Text.Json; using Duende.Bff.Blazor.Client.Internals; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using Microsoft.Extensions.Time.Testing; using NSubstitute; using Shouldly; namespace Duende.Bff.Blazor.Client.UnitTests; - - public class GetUserServiceTests { record ClaimRecord(string type, object value); - private static IHttpClientFactory MockHttpClientFactory(string response, HttpStatusCode status) - { - var httpClient = new HttpClient(new MockHttpMessageHandler(response, status)) - { - // Just have to set something that looks reasonably like a URL so that the HttpClient's internal validation - // doesn't blow up - BaseAddress = new Uri("https://example.com") - }; - var factory = Substitute.For(); - factory.CreateClient(BffClientAuthenticationStateProvider.HttpClientName).Returns(httpClient); - return factory; - } - - private static IOptions MockOptions(BffBlazorOptions? opt = null) - { - var result = Substitute.For>(); - result.Value.Returns(opt ?? new BffBlazorOptions()); - return result; - } - [Fact] public async Task FetchUser_maps_claims_into_ClaimsPrincipal() { @@ -49,12 +26,12 @@ public async Task FetchUser_maps_claims_into_ClaimsPrincipal() new("foo", "bar") }; var json = JsonSerializer.Serialize(claims); - var factory = MockHttpClientFactory(json, HttpStatusCode.OK); + var factory = TestMocks.MockHttpClientFactory(json, HttpStatusCode.OK); var sut = new GetUserService( factory, Substitute.For(), new FakeTimeProvider(), - MockOptions(), + TestMocks.MockOptions(), Substitute.For>()); var result = await sut.FetchUser(); @@ -70,12 +47,12 @@ public async Task FetchUser_maps_claims_into_ClaimsPrincipal() [Fact] public async Task FetchUser_returns_anonymous_when_http_request_fails() { - var factory = MockHttpClientFactory("Internal Server Error", HttpStatusCode.InternalServerError); + var factory = TestMocks.MockHttpClientFactory("Internal Server Error", HttpStatusCode.InternalServerError); var sut = new GetUserService( factory, Substitute.For(), new FakeTimeProvider(), - MockOptions(), + TestMocks.MockOptions(), Substitute.For>()); var errorResult = await sut.FetchUser(); errorResult.Identity?.IsAuthenticated.ShouldBeFalse(); @@ -100,7 +77,7 @@ public async Task GetUser_returns_persisted_user_if_refresh_not_required() Substitute.For(), persistentUserService, timeProvider, - MockOptions(), + TestMocks.MockOptions(), Substitute.For>()); timeProvider.SetUtcNow(startTime); @@ -137,10 +114,10 @@ public async Task GetUser_fetches_user_if_no_persisted_user() }; var json = JsonSerializer.Serialize(claims); var sut = new GetUserService( - MockHttpClientFactory(json, HttpStatusCode.OK), + TestMocks.MockHttpClientFactory(json, HttpStatusCode.OK), Substitute.For(), timeProvider, - MockOptions(), + TestMocks.MockOptions(), Substitute.For>()); timeProvider.SetUtcNow(startTime); diff --git a/test/Duende.Bff.Blazor.Client.UnitTests/TestMocks.cs b/test/Duende.Bff.Blazor.Client.UnitTests/TestMocks.cs new file mode 100644 index 00000000..4a02170e --- /dev/null +++ b/test/Duende.Bff.Blazor.Client.UnitTests/TestMocks.cs @@ -0,0 +1,31 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Net; +using Microsoft.Extensions.Options; +using NSubstitute; + +namespace Duende.Bff.Blazor.Client.UnitTests; + +public static class TestMocks +{ + public static IHttpClientFactory MockHttpClientFactory(string response, HttpStatusCode status) + { + var httpClient = new HttpClient(new MockHttpMessageHandler(response, status)) + { + // Just have to set something that looks reasonably like a URL so that the HttpClient's internal validation + // doesn't blow up + BaseAddress = new Uri("https://example.com") + }; + var factory = Substitute.For(); + factory.CreateClient(BffClientAuthenticationStateProvider.HttpClientName).Returns(httpClient); + return factory; + } + + public static IOptions MockOptions(BffBlazorOptions? opt = null) + { + var result = Substitute.For>(); + result.Value.Returns(opt ?? new BffBlazorOptions()); + return result; + } +} \ No newline at end of file