From 55fbde1b0d628f86eb76cb11526e49f2e0a46d65 Mon Sep 17 00:00:00 2001 From: mpaulosky <60372079+mpaulosky@users.noreply.github.com> Date: Sat, 18 Apr 2026 10:38:21 -0700 Subject: [PATCH] test: add component coverage gate and auth UI tests --- src/Web/Components/Layout/MainLayout.razor | 29 +- src/Web/Components/Layout/NavMenu.razor | 258 ++++++++--- src/Web/Components/Pages/Home.razor | 4 +- .../Features/UserManagement/ManageRoles.razor | 82 ++-- src/Web/Features/UserManagement/Profile.razor | 198 ++++++++ src/Web/Program.cs | 25 +- src/Web/Properties/AssemblyInfo.cs | 4 + src/Web/Security/RoleClaimsHelper.cs | 94 ++++ .../Components/Layout/NavMenuTests.cs | 100 ++++ .../Unit.Tests/Components/RazorSmokeTests.cs | 433 ++++++++++++++++++ .../Features/UserManagement/ProfileTests.cs | 98 ++++ tests/Unit.Tests/ResultTests.cs | 71 +++ .../Security/RoleClaimsHelperTests.cs | 79 ++++ .../Testing/TestAuthorizationService.cs | 40 ++ tests/Unit.Tests/Unit.Tests.csproj | 13 +- 15 files changed, 1408 insertions(+), 120 deletions(-) create mode 100644 src/Web/Features/UserManagement/Profile.razor create mode 100644 src/Web/Properties/AssemblyInfo.cs create mode 100644 src/Web/Security/RoleClaimsHelper.cs create mode 100644 tests/Unit.Tests/Components/Layout/NavMenuTests.cs create mode 100644 tests/Unit.Tests/Components/RazorSmokeTests.cs create mode 100644 tests/Unit.Tests/Features/UserManagement/ProfileTests.cs create mode 100644 tests/Unit.Tests/ResultTests.cs create mode 100644 tests/Unit.Tests/Security/RoleClaimsHelperTests.cs create mode 100644 tests/Unit.Tests/Testing/TestAuthorizationService.cs diff --git a/src/Web/Components/Layout/MainLayout.razor b/src/Web/Components/Layout/MainLayout.razor index 78624f3d..e963ef4b 100644 --- a/src/Web/Components/Layout/MainLayout.razor +++ b/src/Web/Components/Layout/MainLayout.razor @@ -1,23 +1,22 @@ @inherits LayoutComponentBase -
- -
-
- About -
+
+ -
- @Body -
-
+
+ @Body +
+ +
- An unhandled error has occurred. - Reload - 🗙 + An unhandled error has occurred. + Reload + 🗙
diff --git a/src/Web/Components/Layout/NavMenu.razor b/src/Web/Components/Layout/NavMenu.razor index cb3e952e..ded685bd 100644 --- a/src/Web/Components/Layout/NavMenu.razor +++ b/src/Web/Components/Layout/NavMenu.razor @@ -1,62 +1,200 @@ @using Microsoft.AspNetCore.Components.Authorization +@inject IJSRuntime Js +@inject NavigationManager Nav +@implements IDisposable +@rendermode InteractiveServer - - - - - + + +@code { + private string _currentColor = "blue"; + private string _currentBrightness = "light"; + + protected override void OnInitialized() + { + Nav.LocationChanged += OnLocationChanged; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await SyncThemeFromJs(); + } + } + + private async void OnLocationChanged(object? sender, LocationChangedEventArgs e) + { + try + { + await InvokeAsync(SyncThemeFromJs); + } + catch + { + } + } + + private async Task SyncThemeFromJs() + { + try + { + _currentColor = await Js.InvokeAsync("themeManager.getColor"); + _currentBrightness = await Js.InvokeAsync("themeManager.getBrightness"); + StateHasChanged(); + } + catch + { + } + } + + private async Task OnThemeChanged(ChangeEventArgs e) + { + _currentColor = e.Value?.ToString() ?? "blue"; + await Js.InvokeVoidAsync("themeManager.setColor", _currentColor); + } + + private async Task ToggleDark() + { + var newBrightness = _currentBrightness == "light" ? "dark" : "light"; + await Js.InvokeVoidAsync("themeManager.setBrightness", newBrightness); + _currentBrightness = await Js.InvokeAsync("themeManager.getBrightness"); + StateHasChanged(); + } + + private static string GetProfileLabel(System.Security.Claims.ClaimsPrincipal user) + { + var name = user.Identity?.Name; + return string.IsNullOrWhiteSpace(name) ? "Profile" : name; + } + + public void Dispose() + { + Nav.LocationChanged -= OnLocationChanged; + } + +} diff --git a/src/Web/Components/Pages/Home.razor b/src/Web/Components/Pages/Home.razor index 9001e0bd..369e9588 100644 --- a/src/Web/Components/Pages/Home.razor +++ b/src/Web/Components/Pages/Home.razor @@ -2,6 +2,6 @@ Home -

Hello, world!

+

Hello, users!

-Welcome to your new app. +

Welcome to your new app.

diff --git a/src/Web/Features/UserManagement/ManageRoles.razor b/src/Web/Features/UserManagement/ManageRoles.razor index 29352105..0fed4a99 100644 --- a/src/Web/Features/UserManagement/ManageRoles.razor +++ b/src/Web/Features/UserManagement/ManageRoles.razor @@ -8,63 +8,65 @@ Manage User Roles -

Manage User Roles

+

Manage User Roles

@if (_loading) { -

Loading users...

+

Loading users...

} else { @if (_error is not null) { - } @code { diff --git a/src/Web/Features/UserManagement/Profile.razor b/src/Web/Features/UserManagement/Profile.razor new file mode 100644 index 00000000..296b53db --- /dev/null +++ b/src/Web/Features/UserManagement/Profile.razor @@ -0,0 +1,198 @@ +@page "/profile" +@using System.Security.Claims +@using Microsoft.AspNetCore.Authorization +@using MyBlog.Web.Security +@attribute [Authorize] + +User Profile + +

User Profile

+ +@if (_user is null) +{ +

Loading profile...

+} +else +{ +
+
+
+ @if (!string.IsNullOrWhiteSpace(_pictureUrl)) + { + @($ + } + else + { +
+ @_initials +
+ } + +
+

@_displayName

+

@_emailAddress

+ @if (!string.IsNullOrWhiteSpace(_userId)) + { +

User ID: @_userId

+ } +
+
+ +
+
+

Identity

+
+
+
Authenticated
+
@(_user.Identity?.IsAuthenticated == true ? "Yes" : "No")
+
+
+
Authentication Type
+
@(_user.Identity?.AuthenticationType ?? "Unknown")
+
+
+
+ +
+

Roles

+ @if (_roles.Count > 0) + { +
+ @foreach (var role in _roles) + { + @role + } +
+ } + else + { +

No roles found in the current claims.

+ } +
+
+
+ +
+
+

Claims

+

Claims currently present on your authenticated user principal.

+
+ + @if (_claims.Count == 0) + { +
+

No claims were found.

+
+ } + else + { +
+ + + + + + + + + @foreach (var claim in _claims) + { + + + + + } + +
Claim TypeValue
@claim.Type@claim.Value
+
+ } +
+
+} + +@code { + [CascadingParameter] + private Task? AuthenticationStateTask { get; set; } + + private ClaimsPrincipal? _user; + private string _displayName = "Unknown User"; + private string _emailAddress = "No email claim found"; + private string _userId = string.Empty; + private string _pictureUrl = string.Empty; + private string _initials = "?"; + private IReadOnlyList _roles = []; + private IReadOnlyList _claims = []; + + protected override async Task OnInitializedAsync() + { + if (AuthenticationStateTask is null) + { + return; + } + + var authenticationState = await AuthenticationStateTask; + _user = authenticationState.User; + + _displayName = GetFirstClaimValue(_user, + ClaimTypes.Name, + "name", + "preferred_username", + "nickname", + ClaimTypes.GivenName) ?? _user.Identity?.Name ?? "Unknown User"; + + _emailAddress = GetFirstClaimValue(_user, + ClaimTypes.Email, + "email") ?? "No email claim found"; + + _userId = GetFirstClaimValue(_user, + ClaimTypes.NameIdentifier, + "sub") ?? string.Empty; + + _pictureUrl = GetFirstClaimValue(_user, + "picture", + "avatar_url", + "image") ?? string.Empty; + + _initials = GetInitials(_displayName, _emailAddress); + _roles = RoleClaimsHelper.GetRoles(_user); + _claims = _user.Claims.OrderBy(c => c.Type).ThenBy(c => c.Value).ToList(); + } + + private static string? GetFirstClaimValue(ClaimsPrincipal user, params string[] claimTypes) + { + foreach (var claimType in claimTypes) + { + var value = user.Claims.FirstOrDefault(c => c.Type.Equals(claimType, StringComparison.OrdinalIgnoreCase))?.Value; + + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + + return null; + } + + + private static string GetInitials(string displayName, string emailAddress) + { + var source = !string.IsNullOrWhiteSpace(displayName) && !displayName.Equals("Unknown User", StringComparison.OrdinalIgnoreCase) + ? displayName + : emailAddress; + + if (string.IsNullOrWhiteSpace(source)) + { + return "?"; + } + + var parts = source + .Split([' ', '@', '.', '-', '_'], StringSplitOptions.RemoveEmptyEntries) + .Take(2) + .Select(part => char.ToUpperInvariant(part[0])); + + var initials = new string(parts.ToArray()); + return string.IsNullOrWhiteSpace(initials) ? "?" : initials; + } +} diff --git a/src/Web/Program.cs b/src/Web/Program.cs index 58dcbb96..1137e53a 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -1,9 +1,12 @@ using Auth0.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.EntityFrameworkCore; using MyBlog.Domain.Interfaces; using MyBlog.Web.Components; using MyBlog.Web.Data; +using MyBlog.Web.Security; +using System.Security.Claims; var builder = WebApplication.CreateBuilder(args); @@ -14,6 +17,7 @@ .AddInteractiveServerComponents(); // Auth0 authentication +var auth0RoleClaimTypes = RoleClaimsHelper.GetRoleClaimTypes(builder.Configuration); builder.Services.AddAuth0WebAppAuthentication(opts => { opts.Domain = builder.Configuration["Auth0:Domain"]!; @@ -22,6 +26,24 @@ opts.CallbackPath = "/signin-auth0"; }); +builder.Services.PostConfigure(Auth0Constants.AuthenticationScheme, options => +{ + options.TokenValidationParameters.RoleClaimType = ClaimTypes.Role; + + var existingOnTokenValidated = options.Events.OnTokenValidated; + options.Events.OnTokenValidated = async context => + { + await existingOnTokenValidated(context); + + if (context.Principal?.Identity is not ClaimsIdentity identity) + { + return; + } + + RoleClaimsHelper.AddRoleClaims(identity, auth0RoleClaimTypes); + }; +}); + builder.Services.AddCascadingAuthenticationState(); builder.Services.AddAuthorization(); @@ -78,7 +100,7 @@ await ctx.ChallengeAsync(Auth0Constants.AuthenticationScheme, props); }).AllowAnonymous(); -app.MapGet("/Account/Logout", async (HttpContext ctx) => +app.MapGet("/Account/Logout", async ctx => { var props = new LogoutAuthenticationPropertiesBuilder() .WithRedirectUri("/") @@ -92,4 +114,3 @@ app.MapDefaultEndpoints(); app.Run(); - diff --git a/src/Web/Properties/AssemblyInfo.cs b/src/Web/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..0070a66b --- /dev/null +++ b/src/Web/Properties/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("MyBlog.Unit.Tests")] +[assembly: InternalsVisibleTo("Unit.Tests")] diff --git a/src/Web/Security/RoleClaimsHelper.cs b/src/Web/Security/RoleClaimsHelper.cs new file mode 100644 index 00000000..8787c8de --- /dev/null +++ b/src/Web/Security/RoleClaimsHelper.cs @@ -0,0 +1,94 @@ +using System.Security.Claims; +using System.Text.Json; + +namespace MyBlog.Web.Security; + +public static class RoleClaimsHelper +{ + public static readonly string[] DefaultRoleClaimTypes = + [ + "https://myblog/roles", + "roles", + "role" + ]; + + public static IReadOnlyList GetRoleClaimTypes(IConfiguration configuration) + { + var configured = configuration.GetSection("Auth0:RoleClaimTypes").Get(); + + return configured is { Length: > 0 } + ? configured.Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray() + : DefaultRoleClaimTypes; + } + + public static IReadOnlyList ExpandRoleValues(string? claimValue) + { + if (string.IsNullOrWhiteSpace(claimValue)) + { + return []; + } + + var trimmed = claimValue.Trim(); + + if (trimmed.StartsWith("[", StringComparison.Ordinal)) + { + try + { + using var document = JsonDocument.Parse(trimmed); + + if (document.RootElement.ValueKind == JsonValueKind.Array) + { + return document.RootElement + .EnumerateArray() + .Select(element => element.GetString()) + .Where(role => !string.IsNullOrWhiteSpace(role)) + .Cast() + .ToArray(); + } + } + catch (JsonException) + { + } + } + + if (trimmed.Contains(',', StringComparison.Ordinal)) + { + return trimmed.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + } + + return [trimmed]; + } + + public static void AddRoleClaims(ClaimsIdentity identity, IEnumerable roleClaimTypes) + { + foreach (var roleClaimType in roleClaimTypes) + { + foreach (var claim in identity.FindAll(roleClaimType).ToList()) + { + foreach (var role in ExpandRoleValues(claim.Value)) + { + if (!identity.HasClaim(ClaimTypes.Role, role)) + { + identity.AddClaim(new Claim(ClaimTypes.Role, role)); + } + } + } + } + } + + public static IReadOnlyList GetRoles(ClaimsPrincipal user, IEnumerable? roleClaimTypes = null) + { + var types = (roleClaimTypes ?? DefaultRoleClaimTypes.Append(ClaimTypes.Role)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return user.Claims + .Where(claim => types.Contains(claim.Type, StringComparer.OrdinalIgnoreCase)) + .SelectMany(claim => ExpandRoleValues(claim.Value)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(role => role) + .ToList(); + } +} diff --git a/tests/Unit.Tests/Components/Layout/NavMenuTests.cs b/tests/Unit.Tests/Components/Layout/NavMenuTests.cs new file mode 100644 index 00000000..c65ef3c9 --- /dev/null +++ b/tests/Unit.Tests/Components/Layout/NavMenuTests.cs @@ -0,0 +1,100 @@ +using System.Security.Claims; +using Bunit; +using FluentAssertions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using MyBlog.Web.Components.Layout; +using MyBlog.Unit.Tests.Testing; + +namespace MyBlog.Unit.Tests.Components.Layout; + +public class NavMenuTests : BunitContext +{ + public NavMenuTests() + { + Services.AddAuthorizationCore(); + Services.AddSingleton(); + } + + [Fact] + public void UnauthenticatedUser_SeesLoginAndNoProtectedLinks() + { + var cut = RenderForUser(CreatePrincipal(authenticated: false)); + + cut.Markup.Should().Contain("Login"); + cut.Markup.Should().NotContain("Logout"); + cut.Markup.Should().NotContain("Manage Users"); + cut.Markup.Should().NotContain("New Post"); + } + + [Fact] + public void AuthenticatedAdmin_UsesDisplayNameAsProfileLabel_AndShowsAdminLinks() + { + var cut = RenderForUser(CreatePrincipal(name: "Admin User", roles: ["Admin"])); + + cut.Markup.Should().Contain("Admin User"); + cut.Markup.Should().Contain("Manage Users"); + cut.Markup.Should().Contain("New Post"); + cut.Markup.Should().Contain("Logout"); + cut.Markup.Should().NotContain("Logout (Admin User)"); + } + + [Fact] + public void AuthenticatedUser_WithoutName_FallsBackToProfileLabel() + { + var cut = RenderForUser(CreatePrincipal(roles: ["Author"])); + + cut.Markup.Should().Contain(">Profile<"); + cut.Markup.Should().Contain("New Post"); + cut.Markup.Should().NotContain("Manage Users"); + } + + [Fact] + public void NavMenu_LoadsThemeFromJs_AndAllowsThemeInteraction() + { + JSInterop.Mode = JSRuntimeMode.Loose; + JSInterop.Setup("themeManager.getColor").SetResult("green"); + JSInterop.Setup("themeManager.getBrightness").SetResult("dark"); + + var cut = RenderForUser(CreatePrincipal(name: "Theme User", roles: ["Admin"])); + + cut.WaitForAssertion(() => cut.Markup.Should().Contain("Theme User")); + cut.Find("select").Change("yellow"); + cut.FindAll("button").Last().Click(); + + JSInterop.Invocations.Should().Contain(invocation => invocation.Identifier == "themeManager.getColor"); + JSInterop.Invocations.Should().Contain(invocation => invocation.Identifier == "themeManager.getBrightness"); + JSInterop.Invocations.Should().Contain(invocation => invocation.Identifier == "themeManager.setColor"); + JSInterop.Invocations.Should().Contain(invocation => invocation.Identifier == "themeManager.setBrightness"); + } + + private IRenderedComponent RenderForUser(ClaimsPrincipal principal) + { + return Render(parameters => parameters + .AddCascadingValue(Task.FromResult(new AuthenticationState(principal)))); + } + + private static ClaimsPrincipal CreatePrincipal(bool authenticated = true, string? name = null, string[]? roles = null) + { + if (!authenticated) + { + return new ClaimsPrincipal(new ClaimsIdentity()); + } + + var claims = new List(); + + if (!string.IsNullOrWhiteSpace(name)) + { + claims.Add(new Claim(ClaimTypes.Name, name)); + } + + if (roles is not null) + { + claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); + } + + return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth", ClaimTypes.Name, ClaimTypes.Role)); + } +} diff --git a/tests/Unit.Tests/Components/RazorSmokeTests.cs b/tests/Unit.Tests/Components/RazorSmokeTests.cs new file mode 100644 index 00000000..2905c317 --- /dev/null +++ b/tests/Unit.Tests/Components/RazorSmokeTests.cs @@ -0,0 +1,433 @@ +using Bunit; +using Domain.Abstractions; +using FluentAssertions; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using MyBlog.Web.Components.Layout; +using MyBlog.Web.Components.Shared; +using MyBlog.Unit.Tests.Testing; +using MyBlog.Web.Components.Pages; +using MyBlog.Web.Data; +using MyBlog.Web.Features.BlogPosts.Create; +using MyBlog.Web.Features.BlogPosts.Delete; +using MyBlog.Web.Features.BlogPosts.Edit; +using MyBlog.Web.Features.BlogPosts.List; +using MyBlog.Web.Features.UserManagement; +using NSubstitute; +using System.Security.Claims; + +namespace MyBlog.Unit.Tests.Components; + +public class RazorSmokeTests : BunitContext +{ + public RazorSmokeTests() + { + Services.AddAuthorizationCore(); + Services.AddSingleton(); + } + + [Fact] + public void Home_RendersWelcomeMessage() + { + var cut = Render(); + + cut.Markup.Should().Contain("Hello, users!"); + cut.Markup.Should().Contain("Welcome to your new app."); + } + + [Fact] + public void Counter_Increments_WhenButtonClicked() + { + var cut = Render(); + + cut.Markup.Should().Contain("Current count: 0"); + cut.Find("button").Click(); + cut.Markup.Should().Contain("Current count: 1"); + } + + [Fact] + public void Weather_LoadsForecasts() + { + var cut = Render(); + + cut.WaitForAssertion(() => + { + cut.Markup.Should().Contain("Temp. (C)"); + cut.FindAll("tbody tr").Should().HaveCount(5); + }, TimeSpan.FromSeconds(2)); + } + + [Fact] + public void Error_UsesCascadingHttpContextTraceIdentifier() + { + var httpContext = new DefaultHttpContext + { + TraceIdentifier = "trace-123" + }; + + var cut = Render(parameters => parameters.AddCascadingValue(httpContext)); + + cut.Markup.Should().Contain("trace-123"); + } + + [Fact] + public void NotFound_RendersNotFoundMessage() + { + var cut = Render(); + + cut.Markup.Should().Contain("Not Found"); + cut.Markup.Should().Contain("does not exist"); + } + + [Fact] + public void ConfirmDeleteDialog_ShowsDialog_WhenVisible() + { + var cut = Render(parameters => parameters + .Add(p => p.IsVisible, true) + .Add(p => p.PostTitle, "My Post")); + + cut.Markup.Should().Contain("Confirm Delete"); + cut.Markup.Should().Contain("My Post"); + } + + [Fact] + public void RedirectToLogin_NavigatesToLoginWithReturnUrl() + { + var navigation = Services.GetRequiredService(); + + Render(); + + navigation.Uri.Should().Contain("/Account/Login?returnUrl="); + } + + [Fact] + public void MainLayout_RendersMainContentTargetAndFooter() + { + var cut = RenderWithUser(CreatePrincipal("Layout User", ["Author"]), parameters => parameters + .Add(layout => layout.Body, (RenderFragment)(builder => builder.AddContent(0, "Body content")))); + + cut.Markup.Should().Contain("id=\"main-content\""); + cut.Markup.Should().Contain("Body content"); + cut.Markup.Should().Contain("Training Project"); + } + + [Fact] + public void BlogIndex_RendersPostsForAuthorizedUser_AndCanOpenDeleteDialog() + { + var sender = Substitute.For(); + var posts = new[] + { + new BlogPostDto(Guid.NewGuid(), "First", "Content", "Alice", DateTime.UtcNow, null, false) + }; + + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok>(posts))); + + Services.AddSingleton(sender); + + var cut = RenderWithUser(CreatePrincipal("Alice", ["Author"])); + + cut.Markup.Should().Contain("First"); + cut.Markup.Should().Contain("Edit"); + cut.Find("button").Click(); + cut.Markup.Should().Contain("Confirm Delete"); + } + + [Fact] + public void BlogIndex_ShowsEmptyState_WhenNoPostsExist() + { + var sender = Substitute.For(); + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok>(Array.Empty()))); + + Services.AddSingleton(sender); + + var cut = RenderWithUser(CreatePrincipal("Alice", ["Author"])); + + cut.Markup.Should().Contain("No posts yet."); + } + + [Fact] + public void BlogIndex_ConfirmDelete_SendsDeleteCommandAndRefreshesList() + { + var sender = Substitute.For(); + var postId = Guid.NewGuid(); + var posts = new[] + { + new BlogPostDto(postId, "First", "Content", "Alice", DateTime.UtcNow, null, false) + }; + + sender.Send(Arg.Any(), Arg.Any()) + .Returns( + Task.FromResult(Result.Ok>(posts)), + Task.FromResult(Result.Ok>(Array.Empty()))); + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok())); + + Services.AddSingleton(sender); + + var cut = RenderWithUser(CreatePrincipal("Alice", ["Author"])); + + cut.Find("button").Click(); + cut.FindAll("button").Last(button => button.TextContent.Contains("Delete")).Click(); + + sender.Received(1).Send(Arg.Is(command => command.Id == postId), Arg.Any()); + cut.WaitForAssertion(() => cut.Markup.Should().Contain("No posts yet.")); + } + + [Fact] + public void BlogIndex_ShowsConcurrencyWarning_WhenDeleteFailsWithConcurrency() + { + var sender = Substitute.For(); + var postId = Guid.NewGuid(); + var posts = new[] + { + new BlogPostDto(postId, "First", "Content", "Alice", DateTime.UtcNow, null, false) + }; + + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok>(posts))); + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Fail("Concurrency error", ResultErrorCode.Concurrency))); + + Services.AddSingleton(sender); + + var cut = RenderWithUser(CreatePrincipal("Alice", ["Author"])); + + cut.Find("button").Click(); + cut.FindAll("button").Last(button => button.TextContent.Contains("Delete")).Click(); + + cut.WaitForAssertion(() => + { + cut.Markup.Should().Contain("Concurrency Conflict"); + cut.Markup.Should().Contain("Concurrency error"); + }); + } + + [Fact] + public void CreatePost_RendersForm() + { + Services.AddSingleton(Substitute.For()); + + var cut = RenderWithUser(CreatePrincipal("Alice", ["Author"])); + + cut.Markup.Should().Contain("Create Post"); + cut.FindAll("input").Count.Should().BeGreaterThanOrEqualTo(2); + cut.Find("textarea"); + } + + [Fact] + public void CreatePost_SubmitsAndNavigatesToBlog_WhenCommandSucceeds() + { + var sender = Substitute.For(); + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok(Guid.NewGuid()))); + Services.AddSingleton(sender); + + var navigation = Services.GetRequiredService(); + var cut = RenderWithUser(CreatePrincipal("Alice", ["Author"])); + + cut.FindAll("input")[0].Change("My title"); + cut.FindAll("input")[1].Change("Alice"); + cut.Find("textarea").Change("Hello world"); + cut.Find("form").Submit(); + + sender.Received(1).Send(Arg.Is(command => + command.Title == "My title" && + command.Author == "Alice" && + command.Content == "Hello world"), Arg.Any()); + navigation.Uri.Should().EndWith("/blog"); + } + + [Fact] + public void EditPost_LoadsExistingPost() + { + var sender = Substitute.For(); + var postId = Guid.NewGuid(); + var post = new BlogPostDto(postId, "Existing title", "Existing content", "Alice", DateTime.UtcNow, null, false); + + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok(post))); + + Services.AddSingleton(sender); + + var cut = RenderWithUser(CreatePrincipal("Alice", ["Author"]), parameters => parameters.Add(p => p.Id, postId)); + + cut.Markup.Should().Contain("Edit Post"); + cut.Markup.Should().Contain("Existing title"); + cut.Markup.Should().Contain("Existing content"); + } + + [Fact] + public void EditPost_ShowsConcurrencyMessage_WhenSaveFailsWithConcurrency() + { + var sender = Substitute.For(); + var postId = Guid.NewGuid(); + var post = new BlogPostDto(postId, "Existing title", "Existing content", "Alice", DateTime.UtcNow, null, false); + + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok(post))); + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Fail("Concurrency error", ResultErrorCode.Concurrency))); + + Services.AddSingleton(sender); + + var cut = RenderWithUser(CreatePrincipal("Alice", ["Author"]), parameters => parameters.Add(p => p.Id, postId)); + + cut.Find("form").Submit(); + + cut.WaitForAssertion(() => cut.Markup.Should().Contain("Concurrency Conflict")); + } + + [Fact] + public void EditPost_SubmitsAndNavigatesToBlog_WhenSaveSucceeds() + { + var sender = Substitute.For(); + var postId = Guid.NewGuid(); + var post = new BlogPostDto(postId, "Existing title", "Existing content", "Alice", DateTime.UtcNow, null, false); + + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok(post))); + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok())); + + Services.AddSingleton(sender); + + var navigation = Services.GetRequiredService(); + var cut = RenderWithUser(CreatePrincipal("Alice", ["Author"]), parameters => parameters.Add(p => p.Id, postId)); + + cut.Find("form").Submit(); + + sender.Received(1).Send(Arg.Is(command => command.Id == postId), Arg.Any()); + navigation.Uri.Should().EndWith("/blog"); + } + + [Fact] + public void ManageRoles_RendersUsersAndAvailableRoles() + { + var sender = Substitute.For(); + var users = new[] + { + new UserWithRolesDto("user-1", "admin@example.com", "Admin User", ["Admin"]) + }; + var roles = new[] + { + new RoleDto("role-admin", "Admin"), + new RoleDto("role-author", "Author") + }; + + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok>(users))); + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok>(roles))); + + Services.AddSingleton(sender); + + var cut = RenderWithUser(CreatePrincipal("Admin User", ["Admin"])); + + cut.Markup.Should().Contain("Manage User Roles"); + cut.Markup.Should().Contain("Available roles: Admin, Author"); + cut.Markup.Should().Contain("admin@example.com"); + } + + [Fact] + public void ManageRoles_AssignButton_SendsCommandAndRefreshesUsers() + { + var sender = Substitute.For(); + var users = new[] + { + new UserWithRolesDto("user-1", "admin@example.com", "Admin User", ["Admin"]) + }; + var refreshedUsers = new[] + { + new UserWithRolesDto("user-1", "admin@example.com", "Admin User", ["Admin", "Author"]) + }; + var roles = new[] + { + new RoleDto("role-admin", "Admin"), + new RoleDto("role-author", "Author") + }; + + sender.Send(Arg.Any(), Arg.Any()) + .Returns( + Task.FromResult(Result.Ok>(users)), + Task.FromResult(Result.Ok>(refreshedUsers))); + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok>(roles))); + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok())); + + Services.AddSingleton(sender); + + var cut = RenderWithUser(CreatePrincipal("Admin User", ["Admin"])); + + cut.FindAll("button").First(button => button.TextContent.Contains("+ Author")).Click(); + + sender.Received(1).Send(Arg.Is(command => command.UserId == "user-1" && command.RoleId == "role-author"), Arg.Any()); + cut.WaitForAssertion(() => cut.Markup.Should().Contain("Admin, Author")); + } + + [Fact] + public void ManageRoles_RemoveButton_SendsCommandAndRefreshesUsers() + { + var sender = Substitute.For(); + var users = new[] + { + new UserWithRolesDto("user-1", "admin@example.com", "Admin User", ["Admin", "Author"]) + }; + var refreshedUsers = new[] + { + new UserWithRolesDto("user-1", "admin@example.com", "Admin User", ["Admin"]) + }; + var roles = new[] + { + new RoleDto("role-admin", "Admin"), + new RoleDto("role-author", "Author") + }; + + sender.Send(Arg.Any(), Arg.Any()) + .Returns( + Task.FromResult(Result.Ok>(users)), + Task.FromResult(Result.Ok>(refreshedUsers))); + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok>(roles))); + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok())); + + Services.AddSingleton(sender); + + var cut = RenderWithUser(CreatePrincipal("Admin User", ["Admin"])); + + cut.FindAll("button").First(button => button.TextContent.Contains("- Author")).Click(); + + sender.Received(1).Send(Arg.Is(command => command.UserId == "user-1" && command.RoleId == "role-author"), Arg.Any()); + cut.WaitForAssertion(() => + { + cut.Markup.Should().Contain("+ Author"); + cut.Markup.Should().Contain("Admin"); + }); + } + + private IRenderedComponent RenderWithUser( + ClaimsPrincipal principal, + Action>? configure = null) + where TComponent : IComponent + { + return Render(parameters => + { + parameters.AddCascadingValue(Task.FromResult(new AuthenticationState(principal))); + configure?.Invoke(parameters); + }); + } + + private static ClaimsPrincipal CreatePrincipal(string name, string[] roles) + { + var claims = new List { new(ClaimTypes.Name, name) }; + claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); + return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth", ClaimTypes.Name, ClaimTypes.Role)); + } +} diff --git a/tests/Unit.Tests/Features/UserManagement/ProfileTests.cs b/tests/Unit.Tests/Features/UserManagement/ProfileTests.cs new file mode 100644 index 00000000..d2e11dda --- /dev/null +++ b/tests/Unit.Tests/Features/UserManagement/ProfileTests.cs @@ -0,0 +1,98 @@ +using System.Security.Claims; +using Bunit; +using FluentAssertions; +using Microsoft.AspNetCore.Components.Authorization; +using MyBlog.Web.Features.UserManagement; + +namespace MyBlog.Unit.Tests.Features.UserManagement; + +public class ProfileTests : BunitContext +{ + [Fact] + public void Profile_RendersIdentityDetailsRolesPictureAndClaims() + { + var principal = CreatePrincipal( + name: "Admin User", + email: "admin@example.com", + userId: "auth0|123", + pictureUrl: "https://example.com/avatar.png", + rolesJson: "[\"Admin\",\"Author\"]", + extraClaims: [new Claim("department", "Engineering")]); + + var cut = RenderForUser(principal); + + cut.Markup.Should().Contain("Admin User"); + cut.Markup.Should().Contain("admin@example.com"); + cut.Markup.Should().Contain("auth0|123"); + cut.Markup.Should().Contain("avatar.png"); + cut.Markup.Should().Contain("Admin"); + cut.Markup.Should().Contain("Author"); + cut.Markup.Should().Contain("department"); + cut.Markup.Should().Contain("Engineering"); + } + + [Fact] + public void Profile_UsesFallbackValues_WhenOptionalClaimsAreMissing() + { + var principal = CreatePrincipal( + name: null, + email: null, + userId: null, + pictureUrl: null, + rolesJson: null, + extraClaims: []); + + var cut = RenderForUser(principal); + + cut.Markup.Should().Contain("Unknown User"); + cut.Markup.Should().Contain("No email claim found"); + cut.Markup.Should().Contain("No roles found in the current claims."); + cut.Markup.Should().Contain(">NE<"); + } + + private IRenderedComponent RenderForUser(ClaimsPrincipal principal) + { + return Render(parameters => parameters + .AddCascadingValue(Task.FromResult(new AuthenticationState(principal)))); + } + + private static ClaimsPrincipal CreatePrincipal( + string? name, + string? email, + string? userId, + string? pictureUrl, + string? rolesJson, + IEnumerable extraClaims) + { + var claims = new List(); + + if (!string.IsNullOrWhiteSpace(name)) + { + claims.Add(new Claim(ClaimTypes.Name, name)); + } + + if (!string.IsNullOrWhiteSpace(email)) + { + claims.Add(new Claim(ClaimTypes.Email, email)); + } + + if (!string.IsNullOrWhiteSpace(userId)) + { + claims.Add(new Claim(ClaimTypes.NameIdentifier, userId)); + } + + if (!string.IsNullOrWhiteSpace(pictureUrl)) + { + claims.Add(new Claim("picture", pictureUrl)); + } + + if (!string.IsNullOrWhiteSpace(rolesJson)) + { + claims.Add(new Claim("https://myblog/roles", rolesJson)); + } + + claims.AddRange(extraClaims); + + return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth", ClaimTypes.Name, ClaimTypes.Role)); + } +} diff --git a/tests/Unit.Tests/ResultTests.cs b/tests/Unit.Tests/ResultTests.cs new file mode 100644 index 00000000..46ba5bd4 --- /dev/null +++ b/tests/Unit.Tests/ResultTests.cs @@ -0,0 +1,71 @@ +using Domain.Abstractions; +using FluentAssertions; + +namespace MyBlog.Unit.Tests; + +public class ResultTests +{ + [Fact] + public void Ok_CreatesSuccessfulNonGenericResult() + { + var result = Result.Ok(); + + result.Success.Should().BeTrue(); + result.Failure.Should().BeFalse(); + result.Error.Should().BeNull(); + result.ErrorCode.Should().Be(ResultErrorCode.None); + } + + [Fact] + public void Fail_CreatesFailedNonGenericResultWithCodeAndDetails() + { + var result = Result.Fail("boom", ResultErrorCode.Validation, new { Field = "Title" }); + + result.Success.Should().BeFalse(); + result.Failure.Should().BeTrue(); + result.Error.Should().Be("boom"); + result.ErrorCode.Should().Be(ResultErrorCode.Validation); + result.Details.Should().NotBeNull(); + } + + [Fact] + public void GenericOk_CarriesValue() + { + var result = Result.Ok("hello"); + + result.Success.Should().BeTrue(); + result.Value.Should().Be("hello"); + } + + [Fact] + public void GenericFail_CreatesFailedResultWithCode() + { + var result = Result.Fail("missing", ResultErrorCode.NotFound); + + result.Success.Should().BeFalse(); + result.Error.Should().Be("missing"); + result.ErrorCode.Should().Be(ResultErrorCode.NotFound); + result.Value.Should().BeNull(); + } + + [Fact] + public void FromValue_ReturnsFailedResultWhenValueIsNull() + { + string? value = null; + + var result = Result.FromValue(value); + + result.Success.Should().BeFalse(); + result.Error.Should().Be("Provided value is null."); + } + + [Fact] + public void ImplicitConversions_WorkForGenericResult() + { + Result result = "hello"; + string? value = result; + + value.Should().Be("hello"); + result.Value.Should().Be("hello"); + } +} diff --git a/tests/Unit.Tests/Security/RoleClaimsHelperTests.cs b/tests/Unit.Tests/Security/RoleClaimsHelperTests.cs new file mode 100644 index 00000000..bc08a54e --- /dev/null +++ b/tests/Unit.Tests/Security/RoleClaimsHelperTests.cs @@ -0,0 +1,79 @@ +using System.Security.Claims; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using MyBlog.Web.Security; + +namespace MyBlog.Unit.Tests.Security; + +public class RoleClaimsHelperTests +{ + [Fact] + public void GetRoleClaimTypes_UsesConfiguredDistinctValues_WhenPresent() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Auth0:RoleClaimTypes:0"] = "roles", + ["Auth0:RoleClaimTypes:1"] = "https://myblog/roles", + ["Auth0:RoleClaimTypes:2"] = "roles" + }) + .Build(); + + var result = RoleClaimsHelper.GetRoleClaimTypes(configuration); + + result.Should().BeEquivalentTo(["roles", "https://myblog/roles"]); + } + + [Fact] + public void GetRoleClaimTypes_ReturnsDefaults_WhenConfigurationIsMissing() + { + var configuration = new ConfigurationBuilder().Build(); + + var result = RoleClaimsHelper.GetRoleClaimTypes(configuration); + + result.Should().BeEquivalentTo(RoleClaimsHelper.DefaultRoleClaimTypes); + } + + [Theory] + [InlineData("Admin", new[] { "Admin" })] + [InlineData("Admin,Author", new[] { "Admin", "Author" })] + [InlineData(" [\"Admin\",\"Author\"] ", new[] { "Admin", "Author" })] + public void ExpandRoleValues_ParsesSupportedFormats(string input, string[] expected) + { + var result = RoleClaimsHelper.ExpandRoleValues(input); + + result.Should().BeEquivalentTo(expected); + } + + [Fact] + public void AddRoleClaims_AddsExpandedRoleClaimsWithoutDuplicates() + { + var identity = new ClaimsIdentity(new[] + { + new Claim("roles", "Admin,Author"), + new Claim(ClaimTypes.Role, "Admin") + }, "TestAuth", ClaimTypes.Name, ClaimTypes.Role); + + RoleClaimsHelper.AddRoleClaims(identity, ["roles"]); + + identity.FindAll(ClaimTypes.Role) + .Select(claim => claim.Value) + .Should() + .BeEquivalentTo(["Admin", "Author"]); + } + + [Fact] + public void GetRoles_CollectsDistinctRolesAcrossMultipleClaimTypes() + { + var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.Role, "Admin"), + new Claim("https://myblog/roles", "[\"Author\",\"Admin\"]"), + new Claim("roles", "Editor,Author") + }, "TestAuth", ClaimTypes.Name, ClaimTypes.Role)); + + var result = RoleClaimsHelper.GetRoles(principal); + + result.Should().Equal("Admin", "Author", "Editor"); + } +} diff --git a/tests/Unit.Tests/Testing/TestAuthorizationService.cs b/tests/Unit.Tests/Testing/TestAuthorizationService.cs new file mode 100644 index 00000000..ef9e7256 --- /dev/null +++ b/tests/Unit.Tests/Testing/TestAuthorizationService.cs @@ -0,0 +1,40 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Infrastructure; + +namespace MyBlog.Unit.Tests.Testing; + +internal sealed class TestAuthorizationService : IAuthorizationService +{ + public Task AuthorizeAsync(ClaimsPrincipal user, object? resource, IEnumerable requirements) + { + foreach (var requirement in requirements) + { + switch (requirement) + { + case DenyAnonymousAuthorizationRequirement: + if (user.Identity?.IsAuthenticated != true) + { + return Task.FromResult(AuthorizationResult.Failed()); + } + break; + + case RolesAuthorizationRequirement rolesRequirement: + if (!rolesRequirement.AllowedRoles.Any(user.IsInRole)) + { + return Task.FromResult(AuthorizationResult.Failed()); + } + break; + } + } + + return Task.FromResult(AuthorizationResult.Success()); + } + + public Task AuthorizeAsync(ClaimsPrincipal user, object? resource, string policyName) + { + return Task.FromResult(user.Identity?.IsAuthenticated == true + ? AuthorizationResult.Success() + : AuthorizationResult.Failed()); + } +} diff --git a/tests/Unit.Tests/Unit.Tests.csproj b/tests/Unit.Tests/Unit.Tests.csproj index 961e4945..a929434e 100644 --- a/tests/Unit.Tests/Unit.Tests.csproj +++ b/tests/Unit.Tests/Unit.Tests.csproj @@ -6,10 +6,21 @@ enable false MyBlog.Unit.Tests + true + cobertura + 89 + line + Total + **/tests/**/*,**/Program.cs,**/Extensions.cs,**/BlogDbContext.cs,**/MongoDbBlogPostRepository.cs,**/UserManagementHandler.cs,**/Microsoft.NET.Test.Sdk.Program.cs + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + @@ -26,4 +37,4 @@ - \ No newline at end of file +