diff --git a/pr2-diff.txt b/pr2-diff.txt deleted file mode 100644 index b7893347..00000000 --- a/pr2-diff.txt +++ /dev/null @@ -1,1698 +0,0 @@ -diff --git a/src/Web/Components/Layout/MainLayout.razor b/src/Web/Components/Layout/MainLayout.razor -index 78624f3..e963ef4 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 cb3e952..ded685b 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 9001e0b..369e958 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 2935210..0fed4a9 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 0000000..296b53d ---- /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 58dcbb9..1137e53 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 0000000..0070a66 ---- /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 0000000..8787c8d ---- /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 0000000..c65ef3c ---- /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 0000000..2905c31 ---- /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 0000000..d2e11dd ---- /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 0000000..46ba5bd ---- /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 0000000..bc08a54 ---- /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 0000000..ef9e725 ---- /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 961e494..a929434 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 -+