Elements: Add permission-based filtering to element tree endpoints (#21729)

* Add permission-based filtering to element tree endpoints

The element tree endpoints now filter results based on the current
user's browse permissions via a new IElementPermissionFilterService,
mirroring the existing document tree behavior.

Also extracts shared filtering logic from DocumentPermissionFilterService
into a PermissionFilterServiceBase to avoid duplication.

* Add unit tests for ElementPermissionFilterService

* Replace document-specific inheritdoc with neutral XML docs in PermissionFilterServiceBase

* Fix GetPermissionsAsync to use the provided objectTypes parameter instead of hardcoded Document type

---------

Co-authored-by: Kenn Jacobsen <kja@umbraco.dk>
This commit is contained in:
Laura Neto
2026-02-25 15:13:42 +00:00
committed by GitHub
co-authored by Kenn Jacobsen
parent 765a3b2968
commit cf9c2908b4
12 changed files with 494 additions and 75 deletions
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.Services.Entities;
using Umbraco.Cms.Api.Management.Services.Flags;
using Umbraco.Cms.Api.Management.Services.PermissionFilter;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Security;
@@ -21,8 +22,9 @@ public class AncestorsElementTreeController : ElementTreeControllerBase
IDataTypeService dataTypeService,
AppCaches appCaches,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
IElementPresentationFactory elementPresentationFactory)
: base(entityService, flagProviders, userStartNodeEntitiesService, dataTypeService, appCaches, backOfficeSecurityAccessor, elementPresentationFactory)
IElementPresentationFactory elementPresentationFactory,
IElementPermissionFilterService elementPermissionFilterService)
: base(entityService, flagProviders, userStartNodeEntitiesService, dataTypeService, appCaches, backOfficeSecurityAccessor, elementPresentationFactory, elementPermissionFilterService)
{
}
@@ -5,6 +5,7 @@ using Umbraco.Cms.Api.Common.ViewModels.Pagination;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.Services.Entities;
using Umbraco.Cms.Api.Management.Services.Flags;
using Umbraco.Cms.Api.Management.Services.PermissionFilter;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Security;
@@ -22,8 +23,9 @@ public class ChildrenElementTreeController : ElementTreeControllerBase
IDataTypeService dataTypeService,
AppCaches appCaches,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
IElementPresentationFactory elementPresentationFactory)
: base(entityService, flagProviders, userStartNodeEntitiesService, dataTypeService, appCaches, backOfficeSecurityAccessor, elementPresentationFactory)
IElementPresentationFactory elementPresentationFactory,
IElementPermissionFilterService elementPermissionFilterService)
: base(entityService, flagProviders, userStartNodeEntitiesService, dataTypeService, appCaches, backOfficeSecurityAccessor, elementPresentationFactory, elementPermissionFilterService)
{
}
@@ -5,6 +5,7 @@ using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.Routing;
using Umbraco.Cms.Api.Management.Services.Entities;
using Umbraco.Cms.Api.Management.Services.Flags;
using Umbraco.Cms.Api.Management.Services.PermissionFilter;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
@@ -24,6 +25,7 @@ public class ElementTreeControllerBase : UserStartNodeFolderTreeControllerBase<E
private readonly AppCaches _appCaches;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
private readonly IElementPresentationFactory _elementPresentationFactory;
private readonly IElementPermissionFilterService _elementPermissionFilterService;
public ElementTreeControllerBase(
IEntityService entityService,
@@ -32,12 +34,14 @@ public class ElementTreeControllerBase : UserStartNodeFolderTreeControllerBase<E
IDataTypeService dataTypeService,
AppCaches appCaches,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
IElementPresentationFactory elementPresentationFactory)
IElementPresentationFactory elementPresentationFactory,
IElementPermissionFilterService elementPermissionFilterService)
: base(entityService, flagProviders, userStartNodeEntitiesService, dataTypeService)
{
_appCaches = appCaches;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
_elementPresentationFactory = elementPresentationFactory;
_elementPermissionFilterService = elementPermissionFilterService;
}
protected override UmbracoObjectTypes ItemObjectType => UmbracoObjectTypes.Element;
@@ -79,4 +83,18 @@ public class ElementTreeControllerBase : UserStartNodeFolderTreeControllerBase<E
viewModel.NoAccess = true;
return viewModel;
}
/// <inheritdoc/>
protected override Task<(IEntitySlim[] Entities, long TotalItems)> FilterTreeEntities(
IEntitySlim[] entities,
long totalItems)
=> _elementPermissionFilterService.FilterAsync(entities, totalItems);
/// <inheritdoc/>
protected override Task<(IEntitySlim[] Entities, long TotalBefore, long TotalAfter)> FilterTreeEntities(
Guid targetKey,
IEntitySlim[] entities,
long totalBefore,
long totalAfter)
=> _elementPermissionFilterService.FilterAsync(targetKey, entities, totalBefore, totalAfter);
}
@@ -5,6 +5,7 @@ using Umbraco.Cms.Api.Common.ViewModels.Pagination;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.Services.Entities;
using Umbraco.Cms.Api.Management.Services.Flags;
using Umbraco.Cms.Api.Management.Services.PermissionFilter;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Security;
@@ -22,8 +23,9 @@ public class RootElementTreeController : ElementTreeControllerBase
IDataTypeService dataTypeService,
AppCaches appCaches,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
IElementPresentationFactory elementPresentationFactory)
: base(entityService, flagProviders, userStartNodeEntitiesService, dataTypeService, appCaches, backOfficeSecurityAccessor, elementPresentationFactory)
IElementPresentationFactory elementPresentationFactory,
IElementPermissionFilterService elementPermissionFilterService)
: base(entityService, flagProviders, userStartNodeEntitiesService, dataTypeService, appCaches, backOfficeSecurityAccessor, elementPresentationFactory, elementPermissionFilterService)
{
}
@@ -5,6 +5,7 @@ using Umbraco.Cms.Api.Common.ViewModels.Pagination;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.Services.Entities;
using Umbraco.Cms.Api.Management.Services.Flags;
using Umbraco.Cms.Api.Management.Services.PermissionFilter;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Security;
@@ -22,8 +23,9 @@ public class SiblingsElementTreeController : ElementTreeControllerBase
IDataTypeService dataTypeService,
AppCaches appCaches,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
IElementPresentationFactory elementPresentationFactory)
: base(entityService, flagProviders, userStartNodeEntitiesService, dataTypeService, appCaches, backOfficeSecurityAccessor, elementPresentationFactory)
IElementPresentationFactory elementPresentationFactory,
IElementPermissionFilterService elementPermissionFilterService)
: base(entityService, flagProviders, userStartNodeEntitiesService, dataTypeService, appCaches, backOfficeSecurityAccessor, elementPresentationFactory, elementPermissionFilterService)
{
}
@@ -1,6 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.Mapping.Element;
using Umbraco.Cms.Api.Management.Services.PermissionFilter;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Mapping;
@@ -13,6 +14,7 @@ internal static class ElementBuilderExtensions
builder.Services.AddTransient<IElementPresentationFactory, ElementPresentationFactory>();
builder.Services.AddTransient<IElementEditingPresentationFactory, ElementEditingPresentationFactory>();
builder.Services.AddTransient<IElementVersionPresentationFactory, ElementVersionPresentationFactory>();
builder.Services.AddScoped<IElementPermissionFilterService, ElementPermissionFilterService>();
builder.WithCollectionBuilder<MapDefinitionCollectionBuilder>()
.Add<ElementMapDefinition>()
@@ -1,7 +1,6 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Actions;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
@@ -11,9 +10,8 @@ namespace Umbraco.Cms.Api.Management.Services.PermissionFilter;
/// <summary>
/// Provides functionality to filter document entities based on the current user's permissions.
/// </summary>
internal sealed class DocumentPermissionFilterService : IDocumentPermissionFilterService
internal sealed class DocumentPermissionFilterService : PermissionFilterServiceBase, IDocumentPermissionFilterService
{
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
private readonly IUserService _userService;
/// <summary>
@@ -24,68 +22,15 @@ internal sealed class DocumentPermissionFilterService : IDocumentPermissionFilte
public DocumentPermissionFilterService(
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
IUserService userService)
{
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
_userService = userService;
}
: base(backOfficeSecurityAccessor)
=> _userService = userService;
/// <inheritdoc />
public async Task<(IEntitySlim[] Entities, long TotalItems)> FilterAsync(IEntitySlim[] entities, long totalItems)
{
Dictionary<Guid, NodePermissions>? permissionsByNodeKey = await GetDocumentPermissionsByNodeKeyAsync(entities);
if (permissionsByNodeKey is null)
{
return (entities, totalItems);
}
/// <inheritdoc/>
protected override string BrowseActionLetter => ActionBrowse.ActionLetter;
IEntitySlim[] filteredEntities = FilterEntitiesWithBrowsePermission(entities, permissionsByNodeKey);
var removedCount = entities.Length - filteredEntities.Length;
return (filteredEntities, totalItems - removedCount);
}
/// <inheritdoc />
public async Task<(IEntitySlim[] Entities, long TotalBefore, long TotalAfter)> FilterAsync(Guid targetKey, IEntitySlim[] entities, long totalBefore, long totalAfter)
{
Dictionary<Guid, NodePermissions>? permissionsByNodeKey = await GetDocumentPermissionsByNodeKeyAsync(entities);
if (permissionsByNodeKey is null)
{
return (entities, totalBefore, totalAfter);
}
// Find the index of the target entity to determine before/after boundaries
var targetIndex = Array.FindIndex(entities, e => e.Key == targetKey);
// Count removed entities before and after the target separately
var removedBefore = entities.Take(targetIndex).Count(e => HasBrowsePermission(e, permissionsByNodeKey) is false);
var removedAfter = entities.Skip(targetIndex + 1).Count(e => HasBrowsePermission(e, permissionsByNodeKey) is false);
IEntitySlim[] filteredEntities = FilterEntitiesWithBrowsePermission(entities, permissionsByNodeKey);
return (filteredEntities, totalBefore - removedBefore, totalAfter - removedAfter);
}
private async Task<Dictionary<Guid, NodePermissions>?> GetDocumentPermissionsByNodeKeyAsync(IEntitySlim[] entities)
{
Guid userKey = CurrentUserKey();
var entityKeys = entities.Select(e => e.Key).ToHashSet();
Attempt<IEnumerable<NodePermissions>, UserOperationStatus> permissionsAttempt =
await _userService.GetDocumentPermissionsAsync(userKey, entityKeys);
return permissionsAttempt.Success
? permissionsAttempt.Result.ToDictionary(p => p.NodeKey)
: null;
}
private Guid CurrentUserKey()
=> _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Key
?? throw new InvalidOperationException("No backoffice user found");
private static IEntitySlim[] FilterEntitiesWithBrowsePermission(IEntitySlim[] entities, Dictionary<Guid, NodePermissions> permissionsByNodeKey)
=> entities.Where(e => HasBrowsePermission(e, permissionsByNodeKey)).ToArray();
private static bool HasBrowsePermission(IEntitySlim entity, Dictionary<Guid, NodePermissions> permissionsByNodeKey)
=> permissionsByNodeKey.TryGetValue(entity.Key, out NodePermissions? nodePermissions) is false
|| nodePermissions.Permissions.Contains(ActionBrowse.ActionLetter);
/// <inheritdoc/>
protected override Task<Attempt<IEnumerable<NodePermissions>, UserOperationStatus>> GetPermissionsAsync(
Guid userKey,
HashSet<Guid> entityKeys)
=> _userService.GetDocumentPermissionsAsync(userKey, entityKeys);
}
@@ -0,0 +1,36 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Actions;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Api.Management.Services.PermissionFilter;
/// <summary>
/// Provides functionality to filter element entities based on the current user's permissions.
/// </summary>
internal sealed class ElementPermissionFilterService : PermissionFilterServiceBase, IElementPermissionFilterService
{
private readonly IUserService _userService;
/// <summary>
/// Initializes a new instance of the <see cref="ElementPermissionFilterService"/> class.
/// </summary>
/// <param name="backOfficeSecurityAccessor">Provides access to the current backoffice user's security context.</param>
/// <param name="userService">Service used to retrieve user and element permissions.</param>
public ElementPermissionFilterService(
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
IUserService userService)
: base(backOfficeSecurityAccessor)
=> _userService = userService;
/// <inheritdoc/>
protected override string BrowseActionLetter => ActionElementBrowse.ActionLetter;
/// <inheritdoc/>
protected override Task<Attempt<IEnumerable<NodePermissions>, UserOperationStatus>> GetPermissionsAsync(
Guid userKey,
HashSet<Guid> entityKeys)
=> _userService.GetElementPermissionsAsync(userKey, entityKeys);
}
@@ -0,0 +1,31 @@
using Umbraco.Cms.Core.Models.Entities;
namespace Umbraco.Cms.Api.Management.Services.PermissionFilter;
/// <summary>
/// Service for filtering element entities based on user browse permissions.
/// </summary>
public interface IElementPermissionFilterService
{
/// <summary>
/// Filters element entities based on the current user's browse permissions.
/// </summary>
/// <param name="entities">The entities to filter.</param>
/// <param name="totalItems">The total number of items before filtering.</param>
/// <returns>A tuple containing the filtered entities and the adjusted total items count.</returns>
Task<(IEntitySlim[] Entities, long TotalItems)> FilterAsync(IEntitySlim[] entities, long totalItems);
/// <summary>
/// Filters sibling element entities based on the current user's browse permissions.
/// </summary>
/// <param name="targetKey">The key of the target entity around which siblings are being retrieved.</param>
/// <param name="entities">The entities to filter.</param>
/// <param name="totalBefore">The total number of siblings before the target entity.</param>
/// <param name="totalAfter">The total number of siblings after the target entity.</param>
/// <returns>A tuple containing the filtered entities and the adjusted before/after counts.</returns>
Task<(IEntitySlim[] Entities, long TotalBefore, long TotalAfter)> FilterAsync(
Guid targetKey,
IEntitySlim[] entities,
long totalBefore,
long totalAfter);
}
@@ -0,0 +1,113 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Api.Management.Services.PermissionFilter;
/// <summary>
/// Base class for filtering entities based on the current user's browse permissions.
/// </summary>
internal abstract class PermissionFilterServiceBase
{
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="PermissionFilterServiceBase"/> class.
/// </summary>
/// <param name="backOfficeSecurityAccessor">Provides access to the current backoffice user's security context.</param>
protected PermissionFilterServiceBase(IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
=> _backOfficeSecurityAccessor = backOfficeSecurityAccessor;
/// <summary>
/// Gets the browse action letter used to check permissions.
/// </summary>
protected abstract string BrowseActionLetter { get; }
/// <summary>
/// Filters entities based on the current user's browse permissions.
/// </summary>
/// <param name="entities">The entities to filter.</param>
/// <param name="totalItems">The total number of items before filtering.</param>
/// <returns>A tuple containing the filtered entities and the adjusted total items count.</returns>
public async Task<(IEntitySlim[] Entities, long TotalItems)> FilterAsync(IEntitySlim[] entities, long totalItems)
{
Dictionary<Guid, NodePermissions>? permissionsByNodeKey = await GetPermissionsByNodeKeyAsync(entities);
if (permissionsByNodeKey is null)
{
return (entities, totalItems);
}
IEntitySlim[] filteredEntities = FilterEntitiesWithBrowsePermission(entities, permissionsByNodeKey);
var removedCount = entities.Length - filteredEntities.Length;
return (filteredEntities, totalItems - removedCount);
}
/// <summary>
/// Filters sibling entities based on the current user's browse permissions.
/// </summary>
/// <param name="targetKey">The key of the target entity around which siblings are being retrieved.</param>
/// <param name="entities">The entities to filter.</param>
/// <param name="totalBefore">The total number of siblings before the target entity.</param>
/// <param name="totalAfter">The total number of siblings after the target entity.</param>
/// <returns>A tuple containing the filtered entities and the adjusted before/after counts.</returns>
public async Task<(IEntitySlim[] Entities, long TotalBefore, long TotalAfter)> FilterAsync(
Guid targetKey,
IEntitySlim[] entities,
long totalBefore,
long totalAfter)
{
Dictionary<Guid, NodePermissions>? permissionsByNodeKey = await GetPermissionsByNodeKeyAsync(entities);
if (permissionsByNodeKey is null)
{
return (entities, totalBefore, totalAfter);
}
// Find the index of the target entity to determine before/after boundaries
var targetIndex = Array.FindIndex(entities, e => e.Key == targetKey);
// Count removed entities before and after the target separately
var removedBefore = entities.Take(targetIndex).Count(e => HasBrowsePermission(e, permissionsByNodeKey) is false);
var removedAfter = entities.Skip(targetIndex + 1).Count(e => HasBrowsePermission(e, permissionsByNodeKey) is false);
IEntitySlim[] filteredEntities = FilterEntitiesWithBrowsePermission(entities, permissionsByNodeKey);
return (filteredEntities, totalBefore - removedBefore, totalAfter - removedAfter);
}
/// <summary>
/// Retrieves node permissions for the given user and entity keys.
/// </summary>
/// <param name="userKey">The key of the user to retrieve permissions for.</param>
/// <param name="entityKeys">The keys of the entities to retrieve permissions for.</param>
/// <returns>An attempt containing the node permissions or a failure status.</returns>
protected abstract Task<Attempt<IEnumerable<NodePermissions>, UserOperationStatus>> GetPermissionsAsync(
Guid userKey,
HashSet<Guid> entityKeys);
private async Task<Dictionary<Guid, NodePermissions>?> GetPermissionsByNodeKeyAsync(IEntitySlim[] entities)
{
Guid userKey = CurrentUserKey();
var entityKeys = entities.Select(e => e.Key).ToHashSet();
Attempt<IEnumerable<NodePermissions>, UserOperationStatus> permissionsAttempt =
await GetPermissionsAsync(userKey, entityKeys);
return permissionsAttempt.Success
? permissionsAttempt.Result.ToDictionary(p => p.NodeKey)
: null;
}
private Guid CurrentUserKey()
=> _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Key
?? throw new InvalidOperationException("No backoffice user found");
private IEntitySlim[] FilterEntitiesWithBrowsePermission(IEntitySlim[] entities, Dictionary<Guid, NodePermissions> permissionsByNodeKey)
=> entities.Where(e => HasBrowsePermission(e, permissionsByNodeKey)).ToArray();
private bool HasBrowsePermission(IEntitySlim entity, Dictionary<Guid, NodePermissions> permissionsByNodeKey)
=> permissionsByNodeKey.TryGetValue(entity.Key, out NodePermissions? nodePermissions) is false
|| nodePermissions.Permissions.Contains(BrowseActionLetter);
}
+1 -1
View File
@@ -2053,7 +2053,7 @@ internal partial class UserService : RepositoryService, IUserService
}
Attempt<IEnumerable<NodePermissions>, UserOperationStatus> permissions =
await GetPermissionsAsync(userKey, idAttempt.Result, [UmbracoObjectTypes.Document]);
await GetPermissionsAsync(userKey, idAttempt.Result, objectTypes);
scope.Complete();
return permissions;
@@ -0,0 +1,266 @@
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Api.Management.Services.PermissionFilter;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Actions;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Management.Services.PermissionFilter;
[TestFixture]
public class ElementPermissionFilterServiceTests
{
private readonly Mock<IBackOfficeSecurityAccessor> _backOfficeSecurityAccessor = new(MockBehavior.Strict);
private readonly Mock<IUserService> _userService = new(MockBehavior.Strict);
private ElementPermissionFilterService ElementPermissionFilterService
=> new(_backOfficeSecurityAccessor.Object, _userService.Object);
[SetUp]
public void SetUp() => SetupCurrentUser();
[Test]
public async Task FilterAsync_ReturnsAllEntities_WhenAllHaveBrowsePermission()
{
// Arrange
var entities = CreateEntities(3);
SetupGetElementPermissionsAsync(
entities.Select(e => CreateNodePermissions(e.Key, ActionElementBrowse.ActionLetter)));
// Act
var (filteredEntities, totalItems) = await ElementPermissionFilterService
.FilterAsync(entities, 100);
// Assert
Assert.AreEqual(3, filteredEntities.Length);
Assert.AreEqual(100, totalItems);
}
[Test]
public async Task FilterAsync_FiltersEntities_WhenSomeAreDeniedBrowsePermission()
{
// Arrange
var entities = CreateEntities(3);
SetupGetElementPermissionsAsync(
[
CreateNodePermissions(entities[0].Key, ActionElementBrowse.ActionLetter),
CreateNodePermissions(entities[1].Key), // No browse permission
CreateNodePermissions(entities[2].Key, ActionElementBrowse.ActionLetter),
]);
// Act
var (filteredEntities, totalItems) = await ElementPermissionFilterService
.FilterAsync(entities, 100);
// Assert
Assert.AreEqual(2, filteredEntities.Length);
Assert.AreEqual(99, totalItems);
Assert.IsTrue(filteredEntities.Any(e => e.Key == entities[0].Key));
Assert.IsFalse(filteredEntities.Any(e => e.Key == entities[1].Key));
Assert.IsTrue(filteredEntities.Any(e => e.Key == entities[2].Key));
}
[Test]
public async Task FilterAsync_IncludesEntities_WhenNoPermissionEntryExists()
{
// Arrange
var entities = CreateEntities(3);
SetupGetElementPermissionsAsync(
[
CreateNodePermissions(entities[0].Key, ActionElementBrowse.ActionLetter),
// entities[1] has no permission entry - should be included
CreateNodePermissions(entities[2].Key, ActionElementBrowse.ActionLetter),
]);
// Act
var (filteredEntities, totalItems) = await ElementPermissionFilterService
.FilterAsync(entities, 100);
// Assert
Assert.AreEqual(3, filteredEntities.Length);
Assert.AreEqual(100, totalItems);
}
[Test]
public async Task FilterAsync_FiltersAllEntities_WhenNoneHaveBrowsePermission()
{
// Arrange
var entities = CreateEntities(3);
SetupGetElementPermissionsAsync(
entities.Select(e => CreateNodePermissions(e.Key))); // No browse permission
// Act
var (filteredEntities, totalItems) = await ElementPermissionFilterService
.FilterAsync(entities, 100);
// Assert
Assert.AreEqual(0, filteredEntities.Length);
Assert.AreEqual(97, totalItems);
}
[Test]
public async Task FilterAsync_Siblings_ReturnsAllEntities_WhenAllHaveBrowsePermission()
{
// Arrange
var entities = CreateEntities(5);
var targetKey = entities[2].Key;
SetupGetElementPermissionsAsync(
entities.Select(e => CreateNodePermissions(e.Key, ActionElementBrowse.ActionLetter)));
// Act
var (filteredEntities, totalBefore, totalAfter) = await ElementPermissionFilterService
.FilterAsync(targetKey, entities, 10, 20);
// Assert
Assert.AreEqual(5, filteredEntities.Length);
Assert.AreEqual(10, totalBefore);
Assert.AreEqual(20, totalAfter);
}
[Test]
public async Task FilterAsync_Siblings_DecrementsTotalBefore_WhenEntityBeforeTargetIsFiltered()
{
// Arrange
var entities = CreateEntities(5);
var targetKey = entities[2].Key; // Index 2 is target, indices 0,1 are before
SetupGetElementPermissionsAsync(
[
CreateNodePermissions(entities[0].Key), // No browse - BEFORE target
CreateNodePermissions(entities[1].Key, ActionElementBrowse.ActionLetter),
CreateNodePermissions(entities[2].Key, ActionElementBrowse.ActionLetter), // Target
CreateNodePermissions(entities[3].Key, ActionElementBrowse.ActionLetter),
CreateNodePermissions(entities[4].Key, ActionElementBrowse.ActionLetter),
]);
// Act
var (filteredEntities, totalBefore, totalAfter) = await ElementPermissionFilterService
.FilterAsync(targetKey, entities, 10, 20);
// Assert
Assert.AreEqual(4, filteredEntities.Length);
Assert.AreEqual(9, totalBefore); // Decremented by 1
Assert.AreEqual(20, totalAfter); // Unchanged
}
[Test]
public async Task FilterAsync_Siblings_DecrementsTotalAfter_WhenEntityAfterTargetIsFiltered()
{
// Arrange
var entities = CreateEntities(5);
var targetKey = entities[2].Key; // Index 2 is target, indices 3,4 are after
SetupGetElementPermissionsAsync(
[
CreateNodePermissions(entities[0].Key, ActionElementBrowse.ActionLetter),
CreateNodePermissions(entities[1].Key, ActionElementBrowse.ActionLetter),
CreateNodePermissions(entities[2].Key, ActionElementBrowse.ActionLetter), // Target
CreateNodePermissions(entities[3].Key), // No browse - AFTER target
CreateNodePermissions(entities[4].Key, ActionElementBrowse.ActionLetter),
]);
// Act
var (filteredEntities, totalBefore, totalAfter) = await ElementPermissionFilterService
.FilterAsync(targetKey, entities, 10, 20);
// Assert
Assert.AreEqual(4, filteredEntities.Length);
Assert.AreEqual(10, totalBefore); // Unchanged
Assert.AreEqual(19, totalAfter); // Decremented by 1
}
[Test]
public async Task FilterAsync_Siblings_DecrementsBothTotals_WhenEntitiesBeforeAndAfterAreFiltered()
{
// Arrange
var entities = CreateEntities(5);
var targetKey = entities[2].Key;
SetupGetElementPermissionsAsync(
[
CreateNodePermissions(entities[0].Key), // No browse - BEFORE
CreateNodePermissions(entities[1].Key), // No browse - BEFORE
CreateNodePermissions(entities[2].Key, ActionElementBrowse.ActionLetter), // Target
CreateNodePermissions(entities[3].Key), // No browse - AFTER
CreateNodePermissions(entities[4].Key), // No browse - AFTER
]);
// Act
var (filteredEntities, totalBefore, totalAfter) = await ElementPermissionFilterService
.FilterAsync(targetKey, entities, 10, 20);
// Assert
Assert.AreEqual(1, filteredEntities.Length); // Only target remains
Assert.AreEqual(8, totalBefore); // Decremented by 2
Assert.AreEqual(18, totalAfter); // Decremented by 2
}
[Test]
public async Task FilterAsync_Siblings_DoesNotAffectTotals_WhenTargetEntityIsFiltered()
{
// Arrange
var entities = CreateEntities(5);
var targetKey = entities[2].Key;
SetupGetElementPermissionsAsync(
[
CreateNodePermissions(entities[0].Key, ActionElementBrowse.ActionLetter),
CreateNodePermissions(entities[1].Key, ActionElementBrowse.ActionLetter),
CreateNodePermissions(entities[2].Key), // No browse - TARGET itself
CreateNodePermissions(entities[3].Key, ActionElementBrowse.ActionLetter),
CreateNodePermissions(entities[4].Key, ActionElementBrowse.ActionLetter),
]);
// Act
var (filteredEntities, totalBefore, totalAfter) = await ElementPermissionFilterService
.FilterAsync(targetKey, entities, 10, 20);
// Assert
Assert.AreEqual(4, filteredEntities.Length);
Assert.AreEqual(10, totalBefore); // Unchanged - target is not before or after
Assert.AreEqual(20, totalAfter); // Unchanged - target is not before or after
}
private static IEntitySlim[] CreateEntities(int count)
{
var entities = new IEntitySlim[count];
for (var i = 0; i < count; i++)
{
var mock = new Mock<IEntitySlim>();
mock.Setup(e => e.Key).Returns(Guid.NewGuid());
mock.Setup(e => e.Id).Returns(i + 1);
entities[i] = mock.Object;
}
return entities;
}
private void SetupCurrentUser()
{
var userMock = new Mock<IUser>();
userMock.Setup(u => u.Key).Returns(Guid.NewGuid());
var backOfficeSecurityMock = new Mock<IBackOfficeSecurity>();
backOfficeSecurityMock.Setup(b => b.CurrentUser).Returns(userMock.Object);
_backOfficeSecurityAccessor
.Setup(a => a.BackOfficeSecurity)
.Returns(backOfficeSecurityMock.Object);
}
private void SetupGetElementPermissionsAsync(IEnumerable<NodePermissions> permissions)
{
var attempt = Attempt.SucceedWithStatus(UserOperationStatus.Success, permissions);
_userService
.Setup(s => s.GetElementPermissionsAsync(It.IsAny<Guid>(), It.IsAny<ISet<Guid>>()))
.ReturnsAsync(attempt);
}
private static NodePermissions CreateNodePermissions(Guid nodeKey, params string[] permissions)
=> new()
{
NodeKey = nodeKey,
Permissions = permissions.ToHashSet(),
};
}