diff --git a/src/Web/Features/BlogPosts/Edit/Edit.razor b/src/Web/Features/BlogPosts/Edit/Edit.razor index 22cc29f8..42d64150 100644 --- a/src/Web/Features/BlogPosts/Edit/Edit.razor +++ b/src/Web/Features/BlogPosts/Edit/Edit.razor @@ -1,6 +1,7 @@ @page "/blog/edit/{Id:guid}" @inject ISender Sender @inject NavigationManager Navigation +@inject AuthenticationStateProvider AuthStateProvider @rendermode InteractiveServer @attribute [Authorize(Roles = "Author,Admin")] @@ -64,11 +65,32 @@ else if (_model is not null) { var result = await Sender.Send(new GetBlogPostByIdQuery(Id)); if (result.Success) - _model = result.Value is not null - ? new PostFormModel { Title = result.Value.Title, Content = result.Value.Content } - : null; + { + if (result.Value is not null) + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + var userSub = user.FindFirst("sub")?.Value ?? string.Empty; + var isAdmin = user.IsInRole("Admin"); + var isAuthor = result.Value.AuthorId == userSub; + + if (!isAdmin && !isAuthor) + { + Navigation.NavigateTo("/blog"); + return; + } + + _model = new PostFormModel { Title = result.Value.Title, Content = result.Value.Content }; + } + else + { + _model = null; + } + } else + { _error = result.Error; + } } private async Task HandleSubmit() diff --git a/tests/Web.Tests.Bunit/Features/EditAclTests.cs b/tests/Web.Tests.Bunit/Features/EditAclTests.cs new file mode 100644 index 00000000..8f557522 --- /dev/null +++ b/tests/Web.Tests.Bunit/Features/EditAclTests.cs @@ -0,0 +1,132 @@ +//======================================================= +//Copyright (c) 2026. All rights reserved. +//File Name : EditAclTests.cs +//Company : mpaulosky +//Author : Matthew Paulosky +//Solution Name : MyBlog +//Project Name : Web.Tests.Bunit +//======================================================= + +using MediatR; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; + +using MyBlog.Domain.Abstractions; +using MyBlog.Web.Features.BlogPosts.Edit; + +using Web.Testing; + +namespace Web.Features; + +public class EditAclTests : BunitContext +{ + private readonly TestAuthenticationStateProvider _authProvider = new(); + + public EditAclTests() + { + Services.AddAuthorizationCore(); + Services.AddSingleton(); + Services.AddSingleton(_authProvider); + } + + [Fact] + public void EditRedirectsToBlogWhenAuthorIsNotPostOwner() + { + // Arrange + var sender = Substitute.For(); + var postId = Guid.NewGuid(); + const string OwnerSub = "auth0|owner-user"; + const string NonOwnerSub = "auth0|other-user"; + var post = new BlogPostDto(postId, "Test Post", "Content", OwnerSub, "Owner", string.Empty, [], DateTime.UtcNow, null, false); + + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok(post))); + + Services.AddSingleton(sender); + + var navigation = Services.GetRequiredService(); + + // Act + RenderWithUser( + CreatePrincipalWithSub(NonOwnerSub, ["Author"]), + parameters => parameters.Add(p => p.Id, postId)); + + // Assert + navigation.Uri.Should().EndWith("/blog"); + } + + [Fact] + public void EditAllowsAccessWhenAuthorIsPostOwner() + { + // Arrange + var sender = Substitute.For(); + var postId = Guid.NewGuid(); + const string OwnerSub = "auth0|owner-user"; + var post = new BlogPostDto(postId, "Test Post", "Content", OwnerSub, "Owner", string.Empty, [], DateTime.UtcNow, null, false); + + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok(post))); + + Services.AddSingleton(sender); + + var navigation = Services.GetRequiredService(); + + // Act + var cut = RenderWithUser( + CreatePrincipalWithSub(OwnerSub, ["Author"]), + parameters => parameters.Add(p => p.Id, postId)); + + // Assert + navigation.Uri.Should().NotEndWith("/blog"); + cut.Markup.Should().Contain("Edit Post"); + } + + [Fact] + public void EditAllowsAdminToEditAnyPost() + { + // Arrange + var sender = Substitute.For(); + var postId = Guid.NewGuid(); + const string OwnerSub = "auth0|some-author"; + const string AdminSub = "auth0|admin-user"; + var post = new BlogPostDto(postId, "Test Post", "Content", OwnerSub, "SomeAuthor", string.Empty, [], DateTime.UtcNow, null, false); + + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok(post))); + + Services.AddSingleton(sender); + + var navigation = Services.GetRequiredService(); + + // Act + var cut = RenderWithUser( + CreatePrincipalWithSub(AdminSub, ["Admin"]), + parameters => parameters.Add(p => p.Id, postId)); + + // Assert + navigation.Uri.Should().NotEndWith("/blog"); + cut.Markup.Should().Contain("Edit Post"); + } + + private IRenderedComponent RenderWithUser( + ClaimsPrincipal principal, + Action>? configure = null) + where TComponent : IComponent + { + _authProvider.SetUser(principal); + return Render(parameters => + { + parameters.AddCascadingValue(Task.FromResult(new AuthenticationState(principal))); + configure?.Invoke(parameters); + }); + } + + private static ClaimsPrincipal CreatePrincipalWithSub(string sub, string[] roles) + { + var claims = new List { new("sub", sub) }; + claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); + return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth", null, ClaimTypes.Role)); + } +}