Skip to content

Commit

Permalink
Add unit test coverage of Blazor projects.
Browse files Browse the repository at this point in the history
  • Loading branch information
josephdecock committed Aug 28, 2024
1 parent d37bd36 commit 66b3997
Show file tree
Hide file tree
Showing 22 changed files with 1,025 additions and 121 deletions.
33 changes: 31 additions & 2 deletions Duende.Bff.sln
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@


Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.9.34414.90
Expand Down Expand Up @@ -43,11 +43,14 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Duende.Bff.Blazor", "src\Du
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Duende.Bff.Blazor.Client", "src\Duende.Bff.Blazor.Client\Duende.Bff.Blazor.Client.csproj", "{DDB9C401-6B1F-4727-A4CB-932034FBF94E}"
EndProject
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Blazor.Wasm.Bff", "samples\Blazor.Wasm\Blazor.Wasm.Bff\Blazor.Wasm.Bff.csproj", "{BC21ADB7-F2CA-44F0-B6ED-0405E1EFFFA3}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Blazor.Wasm.Client", "samples\Blazor.Wasm\Blazor.Wasm.Client\Blazor.Wasm.Client.csproj", "{4E69FCF6-AE76-4F6D-98B8-969E9D244AE4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duende.Bff.Blazor.Client.UnitTests", "test\Duende.Bff.Blazor.Client.UnitTests\Duende.Bff.Blazor.Client.UnitTests.csproj", "{001840D4-8B83-4A8C-AF2C-5429D4F9A370}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duende.Bff.Blazor.UnitTests", "test\Duende.Bff.Blazor.UnitTests\Duende.Bff.Blazor.UnitTests.csproj", "{2A04808A-A06C-4F10-87B9-2D12E065F729}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -250,6 +253,30 @@ Global
{DDB9C401-6B1F-4727-A4CB-932034FBF94E}.Release|x64.Build.0 = Release|Any CPU
{DDB9C401-6B1F-4727-A4CB-932034FBF94E}.Release|x86.ActiveCfg = Release|Any CPU
{DDB9C401-6B1F-4727-A4CB-932034FBF94E}.Release|x86.Build.0 = Release|Any CPU
{001840D4-8B83-4A8C-AF2C-5429D4F9A370}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{001840D4-8B83-4A8C-AF2C-5429D4F9A370}.Debug|Any CPU.Build.0 = Debug|Any CPU
{001840D4-8B83-4A8C-AF2C-5429D4F9A370}.Debug|x64.ActiveCfg = Debug|Any CPU
{001840D4-8B83-4A8C-AF2C-5429D4F9A370}.Debug|x64.Build.0 = Debug|Any CPU
{001840D4-8B83-4A8C-AF2C-5429D4F9A370}.Debug|x86.ActiveCfg = Debug|Any CPU
{001840D4-8B83-4A8C-AF2C-5429D4F9A370}.Debug|x86.Build.0 = Debug|Any CPU
{001840D4-8B83-4A8C-AF2C-5429D4F9A370}.Release|Any CPU.ActiveCfg = Release|Any CPU
{001840D4-8B83-4A8C-AF2C-5429D4F9A370}.Release|Any CPU.Build.0 = Release|Any CPU
{001840D4-8B83-4A8C-AF2C-5429D4F9A370}.Release|x64.ActiveCfg = Release|Any CPU
{001840D4-8B83-4A8C-AF2C-5429D4F9A370}.Release|x64.Build.0 = Release|Any CPU
{001840D4-8B83-4A8C-AF2C-5429D4F9A370}.Release|x86.ActiveCfg = Release|Any CPU
{001840D4-8B83-4A8C-AF2C-5429D4F9A370}.Release|x86.Build.0 = Release|Any CPU
{2A04808A-A06C-4F10-87B9-2D12E065F729}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2A04808A-A06C-4F10-87B9-2D12E065F729}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2A04808A-A06C-4F10-87B9-2D12E065F729}.Debug|x64.ActiveCfg = Debug|Any CPU
{2A04808A-A06C-4F10-87B9-2D12E065F729}.Debug|x64.Build.0 = Debug|Any CPU
{2A04808A-A06C-4F10-87B9-2D12E065F729}.Debug|x86.ActiveCfg = Debug|Any CPU
{2A04808A-A06C-4F10-87B9-2D12E065F729}.Debug|x86.Build.0 = Debug|Any CPU
{2A04808A-A06C-4F10-87B9-2D12E065F729}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2A04808A-A06C-4F10-87B9-2D12E065F729}.Release|Any CPU.Build.0 = Release|Any CPU
{2A04808A-A06C-4F10-87B9-2D12E065F729}.Release|x64.ActiveCfg = Release|Any CPU
{2A04808A-A06C-4F10-87B9-2D12E065F729}.Release|x64.Build.0 = Release|Any CPU
{2A04808A-A06C-4F10-87B9-2D12E065F729}.Release|x86.ActiveCfg = Release|Any CPU
{2A04808A-A06C-4F10-87B9-2D12E065F729}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -271,6 +298,8 @@ Global
{CBB98134-92F5-487D-8CA3-84C19FF46775} = {E14F66D1-EA3E-40C6-835A-91A4382D4646}
{E02DF032-C17F-4D0C-9CAA-3BD3CC9E4F84} = {3C549079-A502-4B40-B051-5278915AE91B}
{DDB9C401-6B1F-4727-A4CB-932034FBF94E} = {3C549079-A502-4B40-B051-5278915AE91B}
{001840D4-8B83-4A8C-AF2C-5429D4F9A370} = {B2A776DB-385B-4AD4-96A5-61746FD909C3}
{2A04808A-A06C-4F10-87B9-2D12E065F729} = {B2A776DB-385B-4AD4-96A5-61746FD909C3}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {3DAD5980-4688-4794-9CF0-6F3CB67194E7}
Expand Down
25 changes: 16 additions & 9 deletions src/Duende.Bff.Blazor.Client/BffBlazorOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,38 @@
namespace Duende.Bff.Blazor.Client;

/// <summary>
/// Options for Blazor BFF
/// Options for Blazor BFF
/// </summary>
public class BffBlazorOptions
{
/// <summary>
/// The base path to use for remote APIs.
/// The base path to use for remote APIs.
/// </summary>
public string RemoteApiPath { get; set; } = "remote-apis/";

/// <summary>
/// The base address to use for remote APIs. If unset (the default), the
/// blazor hosting environment's base address is used.
/// The base address to use for remote APIs. If unset (the default), the
/// blazor hosting environment's base address is used.
/// </summary>
public string? RemoteApiBaseAddress { get; set; } = null;

/// <summary>
/// The delay, in milliseconds, before the AuthenticationStateProvider
/// will start polling the /bff/user endpoint. Defaults to 1000 ms.
/// 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.
/// </summary>
public string? StateProviderBaseAddress { get; set; } = null;

/// <summary>
/// The delay, in milliseconds, before the AuthenticationStateProvider will
/// start polling the /bff/user endpoint. Defaults to 1000 ms.
/// </summary>
public int StateProviderPollingDelay { get; set; } = 1000;

/// <summary>
/// The delay, in milliseconds, between polling requests by the
/// AuthenticationStateProvider to the /bff/user endpoint. Defaults to
/// 5000 ms.
/// The delay, in milliseconds, between polling requests by the
/// AuthenticationStateProvider to the /bff/user endpoint. Defaults to 5000
/// ms.
/// </summary>
public int StateProviderPollingInterval { get; set; } = 5000;
}
120 changes: 25 additions & 95 deletions src/Duende.Bff.Blazor.Client/BffClientAuthenticationStateProvider.cs
Original file line number Diff line number Diff line change
@@ -1,68 +1,58 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.

using System.Net.Http.Json;
using System.Security.Claims;
using Duende.Bff.Blazor.Client.Internals;
using Microsoft.Extensions.Logging;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Options;

namespace Duende.Bff.Blazor.Client;

public class BffClientAuthenticationStateProvider : AuthenticationStateProvider
{
private static readonly TimeSpan UserCacheRefreshInterval = TimeSpan.FromSeconds(60);

private readonly HttpClient _client;
private readonly ILogger<BffClientAuthenticationStateProvider> _logger;
public const string HttpClientName = "Duende.Bff.Blazor.Client:StateProvider";
private readonly IGetUserService _getUserService;
private readonly TimeProvider _timeProvider;
private readonly BffBlazorOptions _options;

private DateTimeOffset _userLastCheck = DateTimeOffset.MinValue;
private ClaimsPrincipal _cachedUser = new(new ClaimsIdentity());
private readonly ILogger<BffClientAuthenticationStateProvider> _logger;

/// <summary>
/// An <see cref="AuthenticationStateProvider"/> intended for use in
/// Blazor WASM. It polls the /bff/user endpoint to monitor session
/// state.
/// An <see cref="AuthenticationStateProvider"/> intended for use in Blazor
/// WASM. It polls the /bff/user endpoint to monitor session state.
/// </summary>
public BffClientAuthenticationStateProvider(
PersistentComponentState state,
IHttpClientFactory factory,
IGetUserService getUserService,
TimeProvider timeProvider,
IOptions<BffBlazorOptions> options,
ILogger<BffClientAuthenticationStateProvider> logger)
{
_client = factory.CreateClient("BffAuthenticationStateProvider");
_logger = logger;
_cachedUser = GetPersistedUser(state);
if (_cachedUser.Identity?.IsAuthenticated == true)
{
_userLastCheck = DateTimeOffset.Now;
}

_getUserService = getUserService;
_timeProvider = timeProvider;
_options = options.Value;
_logger = logger;
}

public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
var user = await GetUser();
_getUserService.InitializeCache();
var user = await _getUserService.GetUserAsync();
var state = new AuthenticationState(user);

// Periodically
if (user.Identity is { IsAuthenticated: true })
{
_logger.LogInformation("starting background check..");
Timer? timer = null;
ITimer? timer = null;

timer = new Timer(async _ =>
async void TimerCallback(object? _)
{
var currentUser = await GetUser(false);
var currentUser = await _getUserService.GetUserAsync(false);
// Always notify that auth state has changed, because the user
// management claims (usually) change over time.
//
// Future TODO - Someday we may want an extensibility point. If the
// user management claims have been customized, then auth state
// wouldn't always change. In that case, we'd want to only fire
// might not always change. In that case, we'd want to only fire
// if the user actually had changed.
NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(currentUser)));

Expand All @@ -75,73 +65,13 @@ public override async Task<AuthenticationState> GetAuthenticationStateAsync()
await timer.DisposeAsync();
}
}
}, null, _options.StateProviderPollingDelay, _options.StateProviderPollingInterval);
}

return state;
}

private async ValueTask<ClaimsPrincipal> GetUser(bool useCache = true)
{
var now = DateTimeOffset.Now;
if (useCache && now < _userLastCheck + UserCacheRefreshInterval)
{
_logger.LogDebug("Taking user from cache");
return _cachedUser;
}

_logger.LogDebug("Fetching user");
_cachedUser = await FetchUser();
_userLastCheck = now;

return _cachedUser;
}

// TODO - Consider using ClaimLite instead here
record ClaimRecord(string Type, object Value);

private async Task<ClaimsPrincipal> FetchUser()
{
try
{
_logger.LogInformation("Fetching user information.");
var response = await _client.GetAsync("bff/user?slide=false");
response.EnsureSuccessStatusCode();
var claims = await response.Content.ReadFromJsonAsync<List<ClaimRecord>>();

var identity = new ClaimsIdentity(
nameof(BffClientAuthenticationStateProvider),
"name",
"role");

if (claims != null)
{
foreach (var claim in claims)
{
identity.AddClaim(new Claim(claim.Type, claim.Value.ToString() ?? "no value"));
}
}

return new ClaimsPrincipal(identity);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Fetching user failed.");
}

return new ClaimsPrincipal(new ClaimsIdentity());
}

private ClaimsPrincipal GetPersistedUser(PersistentComponentState state)
{
if (!state.TryTakeFromJson<ClaimsPrincipalLite>(nameof(ClaimsPrincipalLite), out var lite) || lite is null)
{
_logger.LogDebug("Failed to load persisted user.");
return new ClaimsPrincipal(new ClaimsIdentity());
timer = _timeProvider.CreateTimer(TimerCallback,
null,
TimeSpan.FromMilliseconds(_options.StateProviderPollingDelay),
TimeSpan.FromMilliseconds(_options.StateProviderPollingInterval));
}

_logger.LogDebug("Persisted user loaded.");

return lite.ToClaimsPrincipal();
return state;
}
}
}
5 changes: 5 additions & 0 deletions src/Duende.Bff.Blazor.Client/Duende.Bff.Blazor.Client.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,9 @@
<ProjectReference Include="../Duende.Bff.Shared/Duende.Bff.Shared.csproj" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Duende.Bff.Blazor.Client.UnitTests" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>

</Project>
93 changes: 93 additions & 0 deletions src/Duende.Bff.Blazor.Client/Internals/GetUserService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.

using System.Net.Http.Json;
using System.Security.Claims;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Duende.Bff.Blazor.Client.Internals;

internal class GetUserService : IGetUserService
{
private readonly HttpClient _client;
private readonly IPersistentUserService _persistentUserService;
private readonly TimeProvider _timeProvider;
private readonly BffBlazorOptions _options;
private readonly ILogger<GetUserService> _logger;

private DateTimeOffset _userLastCheck = DateTimeOffset.MinValue;
private ClaimsPrincipal _cachedUser = new(new ClaimsIdentity());

public GetUserService(
IHttpClientFactory clientFactory,
IPersistentUserService persistentUserService,
TimeProvider timeProvider,
IOptions<BffBlazorOptions> options,
ILogger<GetUserService> logger)
{
_client = clientFactory.CreateClient(BffClientAuthenticationStateProvider.HttpClientName);
_persistentUserService = persistentUserService;
_timeProvider = timeProvider;
_options = options.Value;
_logger = logger;
}

public void InitializeCache()
{
_cachedUser = _persistentUserService.GetPersistedUser();
if (_cachedUser.Identity?.IsAuthenticated == true)
{
_userLastCheck = _timeProvider.GetUtcNow();
}
}

public async ValueTask<ClaimsPrincipal> GetUserAsync(bool useCache = true)
{
var now = _timeProvider.GetUtcNow();
if (useCache && now < _userLastCheck.AddMilliseconds(_options.StateProviderPollingDelay))
{
_logger.LogDebug("Taking user from cache");
return _cachedUser;
}

_logger.LogDebug("Fetching user");
_cachedUser = await FetchUser();
_userLastCheck = now;

return _cachedUser;
}

// TODO - Consider using ClaimLite instead here
record ClaimRecord(string Type, object Value);

internal async Task<ClaimsPrincipal> FetchUser()
{
try
{
_logger.LogInformation("Fetching user information.");
var claims = await _client.GetFromJsonAsync<List<ClaimRecord>>("bff/user?slide=false");

var identity = new ClaimsIdentity(
nameof(BffClientAuthenticationStateProvider),
"name",
"role");

if (claims != null)
{
foreach (var claim in claims)
{
identity.AddClaim(new Claim(claim.Type, claim.Value.ToString() ?? "no value"));
}
}

return new ClaimsPrincipal(identity);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Fetching user failed.");
}

return new ClaimsPrincipal(new ClaimsIdentity());
}
}
Loading

0 comments on commit 66b3997

Please sign in to comment.