Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions src/Web/Features/BlogPosts/Edit/Edit.razor
Original file line number Diff line number Diff line change
@@ -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")]

Expand Down Expand Up @@ -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;

Comment on lines +73 to +76
if (!isAdmin && !isAuthor)
{
Navigation.NavigateTo("/blog");
return;
}
Comment on lines +77 to +81

_model = new PostFormModel { Title = result.Value.Title, Content = result.Value.Content };
}
else
{
_model = null;
}
}
else
{
_error = result.Error;
}
}

private async Task HandleSubmit()
Expand Down
132 changes: 132 additions & 0 deletions tests/Web.Tests.Bunit/Features/EditAclTests.cs
Original file line number Diff line number Diff line change
@@ -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<IAuthorizationService, TestAuthorizationService>();
Services.AddSingleton<AuthenticationStateProvider>(_authProvider);
}

[Fact]
public void EditRedirectsToBlogWhenAuthorIsNotPostOwner()
{
// Arrange
var sender = Substitute.For<ISender>();
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<GetBlogPostByIdQuery>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(Result.Ok<BlogPostDto?>(post)));

Services.AddSingleton(sender);

var navigation = Services.GetRequiredService<NavigationManager>();

// Act
RenderWithUser<Edit>(
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<ISender>();
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<GetBlogPostByIdQuery>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(Result.Ok<BlogPostDto?>(post)));

Services.AddSingleton(sender);

var navigation = Services.GetRequiredService<NavigationManager>();

// Act
var cut = RenderWithUser<Edit>(
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<ISender>();
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<GetBlogPostByIdQuery>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(Result.Ok<BlogPostDto?>(post)));

Services.AddSingleton(sender);

var navigation = Services.GetRequiredService<NavigationManager>();

// Act
var cut = RenderWithUser<Edit>(
CreatePrincipalWithSub(AdminSub, ["Admin"]),
parameters => parameters.Add(p => p.Id, postId));

// Assert
navigation.Uri.Should().NotEndWith("/blog");
cut.Markup.Should().Contain("Edit Post");
}

private IRenderedComponent<TComponent> RenderWithUser<TComponent>(
ClaimsPrincipal principal,
Action<ComponentParameterCollectionBuilder<TComponent>>? configure = null)
where TComponent : IComponent
{
_authProvider.SetUser(principal);
return Render<TComponent>(parameters =>
{
parameters.AddCascadingValue(Task.FromResult(new AuthenticationState(principal)));
configure?.Invoke(parameters);
});
}

private static ClaimsPrincipal CreatePrincipalWithSub(string sub, string[] roles)
{
var claims = new List<Claim> { new("sub", sub) };
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth", null, ClaimTypes.Role));
}
}
Loading