Fix pagination for users restricted by start nodes (#18907)

* Fix pagination for users restricted by start nodes

* Default implementation to avoid breakage

* Review comments

* Fix failing test

* Add media start node tests
This commit is contained in:
Kenn Jacobsen
2025-04-02 09:34:52 +02:00
committed by GitHub
parent 11c19847cf
commit 9d30d5b11c
10 changed files with 1241 additions and 9 deletions

View File

@@ -42,11 +42,21 @@ public abstract class UserStartNodeTreeControllerBase<TItem> : EntityTreeControl
protected override IEntitySlim[] GetPagedChildEntities(Guid parentKey, int skip, int take, out long totalItems)
{
IEntitySlim[] children = base.GetPagedChildEntities(parentKey, skip, take, out totalItems);
return UserHasRootAccess() || IgnoreUserStartNodes()
? children
// Keeping the correct totalItems amount from GetPagedChildEntities
: CalculateAccessMap(() => _userStartNodeEntitiesService.ChildUserAccessEntities(children, UserStartNodePaths), out _);
if (UserHasRootAccess() || IgnoreUserStartNodes())
{
return base.GetPagedChildEntities(parentKey, skip, take, out totalItems);
}
IEnumerable<UserAccessEntity> userAccessEntities = _userStartNodeEntitiesService.ChildUserAccessEntities(
ItemObjectType,
UserStartNodePaths,
parentKey,
skip,
take,
ItemOrdering,
out totalItems);
return CalculateAccessMap(() => userAccessEntities, out _);
}
protected override TItem[] MapTreeItemViewModels(Guid? parentKey, IEntitySlim[] entities)

View File

@@ -16,9 +16,41 @@ public interface IUserStartNodeEntitiesService
/// <remarks>
/// The returned entities may include entities that outside of the user start node scope, but are needed to
/// for browsing to the actual user start nodes. These entities will be marked as "no access" entities.
///
/// This method does not support pagination, because it must load all entities explicitly in order to calculate
/// the correct result, given that user start nodes can be descendants of root nodes. Consumers need to apply
/// pagination to the result if applicable.
/// </remarks>
IEnumerable<UserAccessEntity> RootUserAccessEntities(UmbracoObjectTypes umbracoObjectType, int[] userStartNodeIds);
/// <summary>
/// Calculates the applicable child entities for a given object type for users without root access.
/// </summary>
/// <param name="umbracoObjectType">The object type.</param>
/// <param name="userStartNodePaths">The calculated start node paths for the user.</param>
/// <param name="parentKey">The key of the parent.</param>
/// <param name="skip">The number of applicable children to skip.</param>
/// <param name="take">The number of applicable children to take.</param>
/// <param name="ordering">The ordering to apply when fetching and paginating the children.</param>
/// <param name="totalItems">The total number of applicable children available.</param>
/// <returns>A list of child entities applicable for the user.</returns>
/// <remarks>
/// The returned entities may include entities that outside of the user start node scope, but are needed to
/// for browsing to the actual user start nodes. These entities will be marked as "no access" entities.
/// </remarks>
IEnumerable<UserAccessEntity> ChildUserAccessEntities(
UmbracoObjectTypes umbracoObjectType,
string[] userStartNodePaths,
Guid parentKey,
int skip,
int take,
Ordering ordering,
out long totalItems)
{
totalItems = 0;
return [];
}
/// <summary>
/// Calculates the applicable child entities from a list of candidate child entities for users without root access.
/// </summary>

View File

@@ -1,8 +1,12 @@
using Umbraco.Cms.Core;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Api.Management.Models.Entities;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Persistence.Querying;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Services.Entities;
@@ -10,8 +14,24 @@ namespace Umbraco.Cms.Api.Management.Services.Entities;
public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService
{
private readonly IEntityService _entityService;
private readonly ICoreScopeProvider _scopeProvider;
private readonly IIdKeyMap _idKeyMap;
public UserStartNodeEntitiesService(IEntityService entityService) => _entityService = entityService;
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in V17.")]
public UserStartNodeEntitiesService(IEntityService entityService)
: this(
entityService,
StaticServiceProvider.Instance.GetRequiredService<ICoreScopeProvider>(),
StaticServiceProvider.Instance.GetRequiredService<IIdKeyMap>())
{
}
public UserStartNodeEntitiesService(IEntityService entityService, ICoreScopeProvider scopeProvider, IIdKeyMap idKeyMap)
{
_entityService = entityService;
_scopeProvider = scopeProvider;
_idKeyMap = idKeyMap;
}
/// <inheritdoc />
public IEnumerable<UserAccessEntity> RootUserAccessEntities(UmbracoObjectTypes umbracoObjectType, int[] userStartNodeIds)
@@ -43,6 +63,54 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService
.ToArray();
}
public IEnumerable<UserAccessEntity> ChildUserAccessEntities(UmbracoObjectTypes umbracoObjectType, string[] userStartNodePaths, Guid parentKey, int skip, int take, Ordering ordering, out long totalItems)
{
Attempt<int> parentIdAttempt = _idKeyMap.GetIdForKey(parentKey, umbracoObjectType);
if (parentIdAttempt.Success is false)
{
totalItems = 0;
return [];
}
var parentId = parentIdAttempt.Result;
IEntitySlim? parent = _entityService.Get(parentId);
if (parent is null)
{
totalItems = 0;
return [];
}
IEntitySlim[] children;
if (userStartNodePaths.Any(path => $"{parent.Path},".StartsWith($"{path},")))
{
// the requested parent is one of the user start nodes (or a descendant of one), all children are by definition allowed
children = _entityService.GetPagedChildren(parentKey, umbracoObjectType, skip, take, out totalItems, ordering: ordering).ToArray();
return ChildUserAccessEntities(children, userStartNodePaths);
}
// if one or more of the user start nodes are descendants of the requested parent, find the "next child IDs" in those user start node paths
// - e.g. given the user start node path "-1,2,3,4,5", if the requested parent ID is 3, the "next child ID" is 4.
var userStartNodePathIds = userStartNodePaths.Select(path => path.Split(Constants.CharArrays.Comma).Select(int.Parse).ToArray()).ToArray();
var allowedChildIds = userStartNodePathIds
.Where(ids => ids.Contains(parentId))
// given the previous checks, the parent ID can never be the last in the user start node path, so this is safe
.Select(ids => ids[ids.IndexOf(parentId) + 1])
.Distinct()
.ToArray();
totalItems = allowedChildIds.Length;
if (allowedChildIds.Length == 0)
{
// the requested parent is outside the scope of any user start nodes
return [];
}
// even though we know the IDs of the allowed child entities to fetch, we still use a Query to yield correctly sorted children
IQuery<IUmbracoEntity> query = _scopeProvider.CreateQuery<IUmbracoEntity>().Where(x => allowedChildIds.Contains(x.Id));
children = _entityService.GetPagedChildren(parentKey, umbracoObjectType, skip, take, out totalItems, query, ordering).ToArray();
return ChildUserAccessEntities(children, userStartNodePaths);
}
/// <inheritdoc />
public IEnumerable<UserAccessEntity> ChildUserAccessEntities(IEnumerable<IEntitySlim> candidateChildren, string[] userStartNodePaths)
// child entities for users without root access should include:

View File

@@ -0,0 +1,85 @@
using NUnit.Framework;
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services;
public partial class UserStartNodeEntitiesServiceMediaTests
{
[Test]
public async Task RootUserAccessEntities_FirstAndLastRoot_YieldsBoth_AsAllowed()
{
var contentStartNodeIds = await CreateUserAndGetStartNodeIds(_mediaByName["1"].Id, _mediaByName["5"].Id);
var roots = UserStartNodeEntitiesService
.RootUserAccessEntities(
UmbracoObjectTypes.Media,
contentStartNodeIds)
.ToArray();
// expected total is 2, because only two items at root ("1" amd "10") are allowed
Assert.AreEqual(2, roots.Length);
Assert.Multiple(() =>
{
// first and last content items are the ones allowed
Assert.AreEqual(_mediaByName["1"].Key, roots[0].Entity.Key);
Assert.AreEqual(_mediaByName["5"].Key, roots[1].Entity.Key);
// explicitly verify the entity sort order, both so we know sorting works,
// and so we know it's actually the first and last item at root
Assert.AreEqual(0, roots[0].Entity.SortOrder);
Assert.AreEqual(4, roots[1].Entity.SortOrder);
// both are allowed (they are the actual start nodes)
Assert.IsTrue(roots[0].HasAccess);
Assert.IsTrue(roots[1].HasAccess);
});
}
[Test]
public async Task RootUserAccessEntities_ChildrenAsStartNode_YieldsChildRoots_AsNotAllowed()
{
var contentStartNodeIds = await CreateUserAndGetStartNodeIds(_mediaByName["1-3"].Id, _mediaByName["3-3"].Id, _mediaByName["5-3"].Id);
var roots = UserStartNodeEntitiesService
.RootUserAccessEntities(
UmbracoObjectTypes.Media,
contentStartNodeIds)
.ToArray();
Assert.AreEqual(3, roots.Length);
Assert.Multiple(() =>
{
// the three start nodes are the children of the "1", "3" and "5" roots, respectively, so these are expected as roots
Assert.AreEqual(_mediaByName["1"].Key, roots[0].Entity.Key);
Assert.AreEqual(_mediaByName["3"].Key, roots[1].Entity.Key);
Assert.AreEqual(_mediaByName["5"].Key, roots[2].Entity.Key);
// all are disallowed - only the children (the actual start nodes) are allowed
Assert.IsTrue(roots.All(r => r.HasAccess is false));
});
}
[Test]
public async Task RootUserAccessEntities_GrandchildrenAsStartNode_YieldsGrandchildRoots_AsNotAllowed()
{
var contentStartNodeIds = await CreateUserAndGetStartNodeIds(_mediaByName["1-2-3"].Id, _mediaByName["2-3-4"].Id, _mediaByName["3-4-5"].Id);
var roots = UserStartNodeEntitiesService
.RootUserAccessEntities(
UmbracoObjectTypes.Media,
contentStartNodeIds)
.ToArray();
Assert.AreEqual(3, roots.Length);
Assert.Multiple(() =>
{
// the three start nodes are the grandchildren of the "1", "2" and "3" roots, respectively, so these are expected as roots
Assert.AreEqual(_mediaByName["1"].Key, roots[0].Entity.Key);
Assert.AreEqual(_mediaByName["2"].Key, roots[1].Entity.Key);
Assert.AreEqual(_mediaByName["3"].Key, roots[2].Entity.Key);
// all are disallowed - only the grandchildren (the actual start nodes) are allowed
Assert.IsTrue(roots.All(r => r.HasAccess is false));
});
}
}

View File

@@ -0,0 +1,336 @@
using NUnit.Framework;
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services;
public partial class UserStartNodeEntitiesServiceMediaTests
{
[Test]
public async Task ChildUserAccessEntities_FirstAndLastChildOfRoot_YieldsBothInFirstPage_AsAllowed()
{
var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1-1"].Id, _mediaByName["1-10"].Id);
var children = UserStartNodeEntitiesService
.ChildUserAccessEntities(
UmbracoObjectTypes.Media,
mediaStartNodePaths,
_mediaByName["1"].Key,
0,
3,
BySortOrder,
out var totalItems)
.ToArray();
// expected total is 2, because only two items under "1" are allowed (note the page size is 3 for good measure)
Assert.AreEqual(2, totalItems);
Assert.AreEqual(2, children.Length);
Assert.Multiple(() =>
{
// first and last media items are the ones allowed
Assert.AreEqual(_mediaByName["1-1"].Key, children[0].Entity.Key);
Assert.AreEqual(_mediaByName["1-10"].Key, children[1].Entity.Key);
// explicitly verify the entity sort order, both so we know sorting works,
// and so we know it's actually the first and last item below "1"
Assert.AreEqual(0, children[0].Entity.SortOrder);
Assert.AreEqual(9, children[1].Entity.SortOrder);
// both are allowed (they are the actual start nodes)
Assert.IsTrue(children[0].HasAccess);
Assert.IsTrue(children[1].HasAccess);
});
}
[Test]
public async Task ChildUserAccessEntities_InAndOutOfScope_YieldsOnlyChildrenInScope()
{
var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1-5"].Id, _mediaByName["2-10"].Id);
Assert.AreEqual(2, mediaStartNodePaths.Length);
var children = UserStartNodeEntitiesService
.ChildUserAccessEntities(
UmbracoObjectTypes.Media,
mediaStartNodePaths,
_mediaByName["2"].Key,
0,
10,
BySortOrder,
out var totalItems)
.ToArray();
Assert.AreEqual(1, totalItems);
Assert.AreEqual(1, children.Length);
Assert.Multiple(() =>
{
// only the "2-10" media item is returned, as "1-5" is out of scope
Assert.AreEqual(_mediaByName["2-10"].Key, children[0].Entity.Key);
Assert.IsTrue(children[0].HasAccess);
});
}
[Test]
public async Task ChildUserAccessEntities_OutOfScope_YieldsNothing()
{
var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1-5"].Id, _mediaByName["2-10"].Id);
Assert.AreEqual(2, mediaStartNodePaths.Length);
var children = UserStartNodeEntitiesService
.ChildUserAccessEntities(
UmbracoObjectTypes.Media,
mediaStartNodePaths,
_mediaByName["3"].Key,
0,
10,
BySortOrder,
out var totalItems)
.ToArray();
Assert.AreEqual(0, totalItems);
Assert.AreEqual(0, children.Length);
}
[Test]
public async Task ChildUserAccessEntities_SpanningMultipleResultPages_CanPaginate()
{
var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(
_mediaByName["1-1"].Id,
_mediaByName["1-3"].Id,
_mediaByName["1-5"].Id,
_mediaByName["1-7"].Id,
_mediaByName["1-9"].Id);
var children = UserStartNodeEntitiesService
.ChildUserAccessEntities(
UmbracoObjectTypes.Media,
mediaStartNodePaths,
_mediaByName["1"].Key,
0,
2,
BySortOrder,
out var totalItems)
.ToArray();
Assert.AreEqual(5, totalItems);
// page size is 2
Assert.AreEqual(2, children.Length);
Assert.Multiple(() =>
{
Assert.AreEqual(_mediaByName["1-1"].Key, children[0].Entity.Key);
Assert.AreEqual(_mediaByName["1-3"].Key, children[1].Entity.Key);
Assert.IsTrue(children[0].HasAccess);
Assert.IsTrue(children[1].HasAccess);
});
// next result page
children = UserStartNodeEntitiesService
.ChildUserAccessEntities(
UmbracoObjectTypes.Media,
mediaStartNodePaths,
_mediaByName["1"].Key,
2,
2,
BySortOrder,
out totalItems)
.ToArray();
Assert.AreEqual(5, totalItems);
// page size is still 2
Assert.AreEqual(2, children.Length);
Assert.Multiple(() =>
{
Assert.AreEqual(_mediaByName["1-5"].Key, children[0].Entity.Key);
Assert.AreEqual(_mediaByName["1-7"].Key, children[1].Entity.Key);
Assert.IsTrue(children[0].HasAccess);
Assert.IsTrue(children[1].HasAccess);
});
// next result page
children = UserStartNodeEntitiesService
.ChildUserAccessEntities(
UmbracoObjectTypes.Media,
mediaStartNodePaths,
_mediaByName["1"].Key,
4,
2,
BySortOrder,
out totalItems)
.ToArray();
Assert.AreEqual(5, totalItems);
// page size is still 2, but this is the last result page
Assert.AreEqual(1, children.Length);
Assert.Multiple(() =>
{
Assert.AreEqual(_mediaByName["1-9"].Key, children[0].Entity.Key);
Assert.IsTrue(children[0].HasAccess);
});
}
[Test]
public async Task ChildUserAccessEntities_ChildrenAsStartNode_YieldsAllGrandchildren_AsAllowed()
{
var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["3-3"].Id);
var children = UserStartNodeEntitiesService
.ChildUserAccessEntities(
UmbracoObjectTypes.Media,
mediaStartNodePaths,
_mediaByName["3-3"].Key,
0,
100,
BySortOrder,
out var totalItems)
.ToArray();
Assert.AreEqual(5, totalItems);
Assert.AreEqual(5, children.Length);
Assert.Multiple(() =>
{
// all children of "3-3" should be allowed because "3-3" is a start node
foreach (var childNumber in Enumerable.Range(1, 5))
{
var child = children[childNumber - 1];
Assert.AreEqual(_mediaByName[$"3-3-{childNumber}"].Key, child.Entity.Key);
Assert.IsTrue(child.HasAccess);
}
});
}
[Test]
public async Task ChildUserAccessEntities_GrandchildrenAsStartNode_YieldsGrandchildren_AsAllowed()
{
var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["3-3-3"].Id, _mediaByName["3-3-4"].Id);
var children = UserStartNodeEntitiesService
.ChildUserAccessEntities(
UmbracoObjectTypes.Media,
mediaStartNodePaths,
_mediaByName["3-3"].Key,
0,
3,
BySortOrder,
out var totalItems)
.ToArray();
Assert.AreEqual(2, totalItems);
Assert.AreEqual(2, children.Length);
Assert.Multiple(() =>
{
// the two items are the children of "3-3" - that is, the actual start nodes
Assert.AreEqual(_mediaByName["3-3-3"].Key, children[0].Entity.Key);
Assert.AreEqual(_mediaByName["3-3-4"].Key, children[1].Entity.Key);
// both are allowed (they are the actual start nodes)
Assert.IsTrue(children[0].HasAccess);
Assert.IsTrue(children[1].HasAccess);
});
}
[Test]
public async Task ChildUserAccessEntities_GrandchildrenAsStartNode_YieldsChildren_AsNotAllowed()
{
var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["3-3-3"].Id, _mediaByName["3-5-3"].Id);
var children = UserStartNodeEntitiesService
.ChildUserAccessEntities(
UmbracoObjectTypes.Media,
mediaStartNodePaths,
_mediaByName["3"].Key,
0,
3,
BySortOrder,
out var totalItems)
.ToArray();
Assert.AreEqual(2, totalItems);
Assert.AreEqual(2, children.Length);
Assert.Multiple(() =>
{
// the two items are the children of "3" - that is, the parents of the actual start nodes
Assert.AreEqual(_mediaByName["3-3"].Key, children[0].Entity.Key);
Assert.AreEqual(_mediaByName["3-5"].Key, children[1].Entity.Key);
// both are disallowed - only the two children (the actual start nodes) are allowed
Assert.IsFalse(children[0].HasAccess);
Assert.IsFalse(children[1].HasAccess);
});
}
[Test]
public async Task ChildUserAccessEntities_ChildAndGrandchildAsStartNode_AllowsOnlyGrandchild()
{
// NOTE: this test proves that start node paths are *not* additive when structural inheritance is in play.
// if one has a start node that is a descendant to another start node, the descendant start node "wins"
// and the ancestor start node is ignored. this differs somewhat from the norm; we normally consider
// permissions additive (which in this case would mean ignoring the descendant rather than the ancestor).
var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["3-3"].Id, _mediaByName["3-3-1"].Id, _mediaByName["3-3-5"].Id);
Assert.AreEqual(2, mediaStartNodePaths.Length);
var children = UserStartNodeEntitiesService
.ChildUserAccessEntities(
UmbracoObjectTypes.Media,
mediaStartNodePaths,
_mediaByName["3"].Key,
0,
10,
BySortOrder,
out var totalItems)
.ToArray();
Assert.AreEqual(1, totalItems);
Assert.AreEqual(1, children.Length);
Assert.Multiple(() =>
{
Assert.AreEqual(_mediaByName["3-3"].Key, children[0].Entity.Key);
Assert.IsFalse(children[0].HasAccess);
});
children = UserStartNodeEntitiesService
.ChildUserAccessEntities(
UmbracoObjectTypes.Media,
mediaStartNodePaths,
_mediaByName["3-3"].Key,
0,
10,
BySortOrder,
out totalItems)
.ToArray();
Assert.AreEqual(2, totalItems);
Assert.AreEqual(2, children.Length);
Assert.Multiple(() =>
{
// the two items are the children of "3-3" - that is, the actual start nodes
Assert.AreEqual(_mediaByName["3-3-1"].Key, children[0].Entity.Key);
Assert.AreEqual(_mediaByName["3-3-5"].Key, children[1].Entity.Key);
Assert.IsTrue(children[0].HasAccess);
Assert.IsTrue(children[1].HasAccess);
});
}
[Test]
public async Task ChildUserAccessEntities_ReverseStartNodeOrder_DoesNotAffectResultOrder()
{
var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["3-3"].Id, _mediaByName["3-2"].Id, _mediaByName["3-1"].Id);
var children = UserStartNodeEntitiesService
.ChildUserAccessEntities(
UmbracoObjectTypes.Media,
mediaStartNodePaths,
_mediaByName["3"].Key,
0,
10,
BySortOrder,
out var totalItems)
.ToArray();
Assert.AreEqual(3, totalItems);
Assert.AreEqual(3, children.Length);
Assert.Multiple(() =>
{
Assert.AreEqual(_mediaByName["3-1"].Key, children[0].Entity.Key);
Assert.AreEqual(_mediaByName["3-2"].Key, children[1].Entity.Key);
Assert.AreEqual(_mediaByName["3-3"].Key, children[2].Entity.Key);
});
}
}

View File

@@ -0,0 +1,134 @@
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using Umbraco.Cms.Api.Management.Services.Entities;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Tests.Common.Builders;
using Umbraco.Cms.Tests.Common.Builders.Extensions;
using Umbraco.Cms.Tests.Common.Testing;
using Umbraco.Cms.Tests.Integration.Testing;
namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services;
[TestFixture]
[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerFixture)]
public partial class UserStartNodeEntitiesServiceMediaTests : UmbracoIntegrationTest
{
private Dictionary<string, IMedia> _mediaByName = new ();
private IUserGroup _userGroup;
private IMediaService MediaService => GetRequiredService<IMediaService>();
private IMediaTypeService MediaTypeService => GetRequiredService<IMediaTypeService>();
private IUserGroupService UserGroupService => GetRequiredService<IUserGroupService>();
private IUserService UserService => GetRequiredService<IUserService>();
private IEntityService EntityService => GetRequiredService<IEntityService>();
private IUserStartNodeEntitiesService UserStartNodeEntitiesService => GetRequiredService<IUserStartNodeEntitiesService>();
protected readonly Ordering BySortOrder = Ordering.By("sortOrder");
protected override void ConfigureTestServices(IServiceCollection services)
{
base.ConfigureTestServices(services);
services.AddTransient<IUserStartNodeEntitiesService, UserStartNodeEntitiesService>();
}
[SetUp]
public async Task SetUpTestAsync()
{
if (_mediaByName.Any())
{
return;
}
var mediaType = new MediaTypeBuilder()
.WithAlias("theMediaType")
.Build();
mediaType.AllowedAsRoot = true;
await MediaTypeService.CreateAsync(mediaType, Constants.Security.SuperUserKey);
mediaType.AllowedContentTypes = [new() { Alias = mediaType.Alias, Key = mediaType.Key }];
await MediaTypeService.UpdateAsync(mediaType, Constants.Security.SuperUserKey);
foreach (var rootNumber in Enumerable.Range(1, 5))
{
var root = new MediaBuilder()
.WithMediaType(mediaType)
.WithName($"{rootNumber}")
.Build();
MediaService.Save(root);
_mediaByName[root.Name!] = root;
foreach (var childNumber in Enumerable.Range(1, 10))
{
var child = new MediaBuilder()
.WithMediaType(mediaType)
.WithName($"{rootNumber}-{childNumber}")
.Build();
child.SetParent(root);
MediaService.Save(child);
_mediaByName[child.Name!] = child;
foreach (var grandChildNumber in Enumerable.Range(1, 5))
{
var grandchild = new MediaBuilder()
.WithMediaType(mediaType)
.WithName($"{rootNumber}-{childNumber}-{grandChildNumber}")
.Build();
grandchild.SetParent(child);
MediaService.Save(grandchild);
_mediaByName[grandchild.Name!] = grandchild;
}
}
}
_userGroup = new UserGroupBuilder()
.WithAlias("theGroup")
.WithAllowedSections(["media"])
.Build();
_userGroup.StartMediaId = null;
await UserGroupService.CreateAsync(_userGroup, Constants.Security.SuperUserKey);
}
private async Task<string[]> CreateUserAndGetStartNodePaths(params int[] startNodeIds)
{
var user = await CreateUser(startNodeIds);
var mediaStartNodePaths = user.GetMediaStartNodePaths(EntityService, AppCaches.NoCache);
Assert.IsNotNull(mediaStartNodePaths);
return mediaStartNodePaths;
}
private async Task<int[]> CreateUserAndGetStartNodeIds(params int[] startNodeIds)
{
var user = await CreateUser(startNodeIds);
var mediaStartNodeIds = user.CalculateMediaStartNodeIds(EntityService, AppCaches.NoCache);
Assert.IsNotNull(mediaStartNodeIds);
return mediaStartNodeIds;
}
private async Task<User> CreateUser(int[] startNodeIds)
{
var user = new UserBuilder()
.WithName(Guid.NewGuid().ToString("N"))
.WithStartMediaIds(startNodeIds)
.Build();
UserService.Save(user);
var attempt = await UserGroupService.AddUsersToUserGroupAsync(
new UsersToUserGroupManipulationModel(_userGroup.Key, [user.Key]),
Constants.Security.SuperUserKey);
Assert.IsTrue(attempt.Success);
return user;
}
}

View File

@@ -0,0 +1,336 @@
using NUnit.Framework;
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services;
public partial class UserStartNodeEntitiesServiceTests
{
[Test]
public async Task ChildUserAccessEntities_FirstAndLastChildOfRoot_YieldsBothInFirstPage_AsAllowed()
{
var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1-1"].Id, _contentByName["1-10"].Id);
var children = UserStartNodeEntitiesService
.ChildUserAccessEntities(
UmbracoObjectTypes.Document,
contentStartNodePaths,
_contentByName["1"].Key,
0,
3,
BySortOrder,
out var totalItems)
.ToArray();
// expected total is 2, because only two items under "1" are allowed (note the page size is 3 for good measure)
Assert.AreEqual(2, totalItems);
Assert.AreEqual(2, children.Length);
Assert.Multiple(() =>
{
// first and last content items are the ones allowed
Assert.AreEqual(_contentByName["1-1"].Key, children[0].Entity.Key);
Assert.AreEqual(_contentByName["1-10"].Key, children[1].Entity.Key);
// explicitly verify the entity sort order, both so we know sorting works,
// and so we know it's actually the first and last item below "1"
Assert.AreEqual(0, children[0].Entity.SortOrder);
Assert.AreEqual(9, children[1].Entity.SortOrder);
// both are allowed (they are the actual start nodes)
Assert.IsTrue(children[0].HasAccess);
Assert.IsTrue(children[1].HasAccess);
});
}
[Test]
public async Task ChildUserAccessEntities_InAndOutOfScope_YieldsOnlyChildrenInScope()
{
var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1-5"].Id, _contentByName["2-10"].Id);
Assert.AreEqual(2, contentStartNodePaths.Length);
var children = UserStartNodeEntitiesService
.ChildUserAccessEntities(
UmbracoObjectTypes.Document,
contentStartNodePaths,
_contentByName["2"].Key,
0,
10,
BySortOrder,
out var totalItems)
.ToArray();
Assert.AreEqual(1, totalItems);
Assert.AreEqual(1, children.Length);
Assert.Multiple(() =>
{
// only the "2-10" content item is returned, as "1-5" is out of scope
Assert.AreEqual(_contentByName["2-10"].Key, children[0].Entity.Key);
Assert.IsTrue(children[0].HasAccess);
});
}
[Test]
public async Task ChildUserAccessEntities_OutOfScope_YieldsNothing()
{
var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1-5"].Id, _contentByName["2-10"].Id);
Assert.AreEqual(2, contentStartNodePaths.Length);
var children = UserStartNodeEntitiesService
.ChildUserAccessEntities(
UmbracoObjectTypes.Document,
contentStartNodePaths,
_contentByName["3"].Key,
0,
10,
BySortOrder,
out var totalItems)
.ToArray();
Assert.AreEqual(0, totalItems);
Assert.AreEqual(0, children.Length);
}
[Test]
public async Task ChildUserAccessEntities_SpanningMultipleResultPages_CanPaginate()
{
var contentStartNodePaths = await CreateUserAndGetStartNodePaths(
_contentByName["1-1"].Id,
_contentByName["1-3"].Id,
_contentByName["1-5"].Id,
_contentByName["1-7"].Id,
_contentByName["1-9"].Id);
var children = UserStartNodeEntitiesService
.ChildUserAccessEntities(
UmbracoObjectTypes.Document,
contentStartNodePaths,
_contentByName["1"].Key,
0,
2,
BySortOrder,
out var totalItems)
.ToArray();
Assert.AreEqual(5, totalItems);
// page size is 2
Assert.AreEqual(2, children.Length);
Assert.Multiple(() =>
{
Assert.AreEqual(_contentByName["1-1"].Key, children[0].Entity.Key);
Assert.AreEqual(_contentByName["1-3"].Key, children[1].Entity.Key);
Assert.IsTrue(children[0].HasAccess);
Assert.IsTrue(children[1].HasAccess);
});
// next result page
children = UserStartNodeEntitiesService
.ChildUserAccessEntities(
UmbracoObjectTypes.Document,
contentStartNodePaths,
_contentByName["1"].Key,
2,
2,
BySortOrder,
out totalItems)
.ToArray();
Assert.AreEqual(5, totalItems);
// page size is still 2
Assert.AreEqual(2, children.Length);
Assert.Multiple(() =>
{
Assert.AreEqual(_contentByName["1-5"].Key, children[0].Entity.Key);
Assert.AreEqual(_contentByName["1-7"].Key, children[1].Entity.Key);
Assert.IsTrue(children[0].HasAccess);
Assert.IsTrue(children[1].HasAccess);
});
// next result page
children = UserStartNodeEntitiesService
.ChildUserAccessEntities(
UmbracoObjectTypes.Document,
contentStartNodePaths,
_contentByName["1"].Key,
4,
2,
BySortOrder,
out totalItems)
.ToArray();
Assert.AreEqual(5, totalItems);
// page size is still 2, but this is the last result page
Assert.AreEqual(1, children.Length);
Assert.Multiple(() =>
{
Assert.AreEqual(_contentByName["1-9"].Key, children[0].Entity.Key);
Assert.IsTrue(children[0].HasAccess);
});
}
[Test]
public async Task ChildUserAccessEntities_ChildrenAsStartNode_YieldsAllGrandchildren_AsAllowed()
{
var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["3-3"].Id);
var children = UserStartNodeEntitiesService
.ChildUserAccessEntities(
UmbracoObjectTypes.Document,
contentStartNodePaths,
_contentByName["3-3"].Key,
0,
100,
BySortOrder,
out var totalItems)
.ToArray();
Assert.AreEqual(5, totalItems);
Assert.AreEqual(5, children.Length);
Assert.Multiple(() =>
{
// all children of "3-3" should be allowed because "3-3" is a start node
foreach (var childNumber in Enumerable.Range(1, 5))
{
var child = children[childNumber - 1];
Assert.AreEqual(_contentByName[$"3-3-{childNumber}"].Key, child.Entity.Key);
Assert.IsTrue(child.HasAccess);
}
});
}
[Test]
public async Task ChildUserAccessEntities_GrandchildrenAsStartNode_YieldsGrandchildren_AsAllowed()
{
var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["3-3-3"].Id, _contentByName["3-3-4"].Id);
var children = UserStartNodeEntitiesService
.ChildUserAccessEntities(
UmbracoObjectTypes.Document,
contentStartNodePaths,
_contentByName["3-3"].Key,
0,
3,
BySortOrder,
out var totalItems)
.ToArray();
Assert.AreEqual(2, totalItems);
Assert.AreEqual(2, children.Length);
Assert.Multiple(() =>
{
// the two items are the children of "3-3" - that is, the actual start nodes
Assert.AreEqual(_contentByName["3-3-3"].Key, children[0].Entity.Key);
Assert.AreEqual(_contentByName["3-3-4"].Key, children[1].Entity.Key);
// both are allowed (they are the actual start nodes)
Assert.IsTrue(children[0].HasAccess);
Assert.IsTrue(children[1].HasAccess);
});
}
[Test]
public async Task ChildUserAccessEntities_GrandchildrenAsStartNode_YieldsChildren_AsNotAllowed()
{
var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["3-3-3"].Id, _contentByName["3-5-3"].Id);
var children = UserStartNodeEntitiesService
.ChildUserAccessEntities(
UmbracoObjectTypes.Document,
contentStartNodePaths,
_contentByName["3"].Key,
0,
3,
BySortOrder,
out var totalItems)
.ToArray();
Assert.AreEqual(2, totalItems);
Assert.AreEqual(2, children.Length);
Assert.Multiple(() =>
{
// the two items are the children of "3" - that is, the parents of the actual start nodes
Assert.AreEqual(_contentByName["3-3"].Key, children[0].Entity.Key);
Assert.AreEqual(_contentByName["3-5"].Key, children[1].Entity.Key);
// both are disallowed - only the two children (the actual start nodes) are allowed
Assert.IsFalse(children[0].HasAccess);
Assert.IsFalse(children[1].HasAccess);
});
}
[Test]
public async Task ChildUserAccessEntities_ChildAndGrandchildAsStartNode_AllowsOnlyGrandchild()
{
// NOTE: this test proves that start node paths are *not* additive when structural inheritance is in play.
// if one has a start node that is a descendant to another start node, the descendant start node "wins"
// and the ancestor start node is ignored. this differs somewhat from the norm; we normally consider
// permissions additive (which in this case would mean ignoring the descendant rather than the ancestor).
var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["3-3"].Id, _contentByName["3-3-1"].Id, _contentByName["3-3-5"].Id);
Assert.AreEqual(2, contentStartNodePaths.Length);
var children = UserStartNodeEntitiesService
.ChildUserAccessEntities(
UmbracoObjectTypes.Document,
contentStartNodePaths,
_contentByName["3"].Key,
0,
10,
BySortOrder,
out var totalItems)
.ToArray();
Assert.AreEqual(1, totalItems);
Assert.AreEqual(1, children.Length);
Assert.Multiple(() =>
{
Assert.AreEqual(_contentByName["3-3"].Key, children[0].Entity.Key);
Assert.IsFalse(children[0].HasAccess);
});
children = UserStartNodeEntitiesService
.ChildUserAccessEntities(
UmbracoObjectTypes.Document,
contentStartNodePaths,
_contentByName["3-3"].Key,
0,
10,
BySortOrder,
out totalItems)
.ToArray();
Assert.AreEqual(2, totalItems);
Assert.AreEqual(2, children.Length);
Assert.Multiple(() =>
{
// the two items are the children of "3-3" - that is, the actual start nodes
Assert.AreEqual(_contentByName["3-3-1"].Key, children[0].Entity.Key);
Assert.AreEqual(_contentByName["3-3-5"].Key, children[1].Entity.Key);
Assert.IsTrue(children[0].HasAccess);
Assert.IsTrue(children[1].HasAccess);
});
}
[Test]
public async Task ChildUserAccessEntities_ReverseStartNodeOrder_DoesNotAffectResultOrder()
{
var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["3-3"].Id, _contentByName["3-2"].Id, _contentByName["3-1"].Id);
var children = UserStartNodeEntitiesService
.ChildUserAccessEntities(
UmbracoObjectTypes.Document,
contentStartNodePaths,
_contentByName["3"].Key,
0,
10,
BySortOrder,
out var totalItems)
.ToArray();
Assert.AreEqual(3, totalItems);
Assert.AreEqual(3, children.Length);
Assert.Multiple(() =>
{
Assert.AreEqual(_contentByName["3-1"].Key, children[0].Entity.Key);
Assert.AreEqual(_contentByName["3-2"].Key, children[1].Entity.Key);
Assert.AreEqual(_contentByName["3-3"].Key, children[2].Entity.Key);
});
}
}

View File

@@ -0,0 +1,85 @@
using NUnit.Framework;
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services;
public partial class UserStartNodeEntitiesServiceTests
{
[Test]
public async Task RootUserAccessEntities_FirstAndLastRoot_YieldsBoth_AsAllowed()
{
var contentStartNodeIds = await CreateUserAndGetStartNodeIds(_contentByName["1"].Id, _contentByName["5"].Id);
var roots = UserStartNodeEntitiesService
.RootUserAccessEntities(
UmbracoObjectTypes.Document,
contentStartNodeIds)
.ToArray();
// expected total is 2, because only two items at root ("1" amd "10") are allowed
Assert.AreEqual(2, roots.Length);
Assert.Multiple(() =>
{
// first and last content items are the ones allowed
Assert.AreEqual(_contentByName["1"].Key, roots[0].Entity.Key);
Assert.AreEqual(_contentByName["5"].Key, roots[1].Entity.Key);
// explicitly verify the entity sort order, both so we know sorting works,
// and so we know it's actually the first and last item at root
Assert.AreEqual(0, roots[0].Entity.SortOrder);
Assert.AreEqual(4, roots[1].Entity.SortOrder);
// both are allowed (they are the actual start nodes)
Assert.IsTrue(roots[0].HasAccess);
Assert.IsTrue(roots[1].HasAccess);
});
}
[Test]
public async Task RootUserAccessEntities_ChildrenAsStartNode_YieldsChildRoots_AsNotAllowed()
{
var contentStartNodeIds = await CreateUserAndGetStartNodeIds(_contentByName["1-3"].Id, _contentByName["3-3"].Id, _contentByName["5-3"].Id);
var roots = UserStartNodeEntitiesService
.RootUserAccessEntities(
UmbracoObjectTypes.Document,
contentStartNodeIds)
.ToArray();
Assert.AreEqual(3, roots.Length);
Assert.Multiple(() =>
{
// the three start nodes are the children of the "1", "3" and "5" roots, respectively, so these are expected as roots
Assert.AreEqual(_contentByName["1"].Key, roots[0].Entity.Key);
Assert.AreEqual(_contentByName["3"].Key, roots[1].Entity.Key);
Assert.AreEqual(_contentByName["5"].Key, roots[2].Entity.Key);
// all are disallowed - only the children (the actual start nodes) are allowed
Assert.IsTrue(roots.All(r => r.HasAccess is false));
});
}
[Test]
public async Task RootUserAccessEntities_GrandchildrenAsStartNode_YieldsGrandchildRoots_AsNotAllowed()
{
var contentStartNodeIds = await CreateUserAndGetStartNodeIds(_contentByName["1-2-3"].Id, _contentByName["2-3-4"].Id, _contentByName["3-4-5"].Id);
var roots = UserStartNodeEntitiesService
.RootUserAccessEntities(
UmbracoObjectTypes.Document,
contentStartNodeIds)
.ToArray();
Assert.AreEqual(3, roots.Length);
Assert.Multiple(() =>
{
// the three start nodes are the grandchildren of the "1", "2" and "3" roots, respectively, so these are expected as roots
Assert.AreEqual(_contentByName["1"].Key, roots[0].Entity.Key);
Assert.AreEqual(_contentByName["2"].Key, roots[1].Entity.Key);
Assert.AreEqual(_contentByName["3"].Key, roots[2].Entity.Key);
// all are disallowed - only the grandchildren (the actual start nodes) are allowed
Assert.IsTrue(roots.All(r => r.HasAccess is false));
});
}
}

View File

@@ -0,0 +1,134 @@
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using Umbraco.Cms.Api.Management.Services.Entities;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Tests.Common.Builders;
using Umbraco.Cms.Tests.Common.Builders.Extensions;
using Umbraco.Cms.Tests.Common.Testing;
using Umbraco.Cms.Tests.Integration.Testing;
namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services;
[TestFixture]
[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerFixture)]
public partial class UserStartNodeEntitiesServiceTests : UmbracoIntegrationTest
{
private Dictionary<string, IContent> _contentByName = new ();
private IUserGroup _userGroup;
private IContentService ContentService => GetRequiredService<IContentService>();
private IContentTypeService ContentTypeService => GetRequiredService<IContentTypeService>();
private IUserGroupService UserGroupService => GetRequiredService<IUserGroupService>();
private IUserService UserService => GetRequiredService<IUserService>();
private IEntityService EntityService => GetRequiredService<IEntityService>();
private IUserStartNodeEntitiesService UserStartNodeEntitiesService => GetRequiredService<IUserStartNodeEntitiesService>();
protected readonly Ordering BySortOrder = Ordering.By("sortOrder");
protected override void ConfigureTestServices(IServiceCollection services)
{
base.ConfigureTestServices(services);
services.AddTransient<IUserStartNodeEntitiesService, UserStartNodeEntitiesService>();
}
[SetUp]
public async Task SetUpTestAsync()
{
if (_contentByName.Any())
{
return;
}
var contentType = new ContentTypeBuilder()
.WithAlias("theContentType")
.Build();
contentType.AllowedAsRoot = true;
await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey);
contentType.AllowedContentTypes = [new() { Alias = contentType.Alias, Key = contentType.Key }];
await ContentTypeService.UpdateAsync(contentType, Constants.Security.SuperUserKey);
foreach (var rootNumber in Enumerable.Range(1, 5))
{
var root = new ContentBuilder()
.WithContentType(contentType)
.WithName($"{rootNumber}")
.Build();
ContentService.Save(root);
_contentByName[root.Name!] = root;
foreach (var childNumber in Enumerable.Range(1, 10))
{
var child = new ContentBuilder()
.WithContentType(contentType)
.WithParent(root)
.WithName($"{rootNumber}-{childNumber}")
.Build();
ContentService.Save(child);
_contentByName[child.Name!] = child;
foreach (var grandChildNumber in Enumerable.Range(1, 5))
{
var grandchild = new ContentBuilder()
.WithContentType(contentType)
.WithParent(child)
.WithName($"{rootNumber}-{childNumber}-{grandChildNumber}")
.Build();
ContentService.Save(grandchild);
_contentByName[grandchild.Name!] = grandchild;
}
}
}
_userGroup = new UserGroupBuilder()
.WithAlias("theGroup")
.WithAllowedSections(["content"])
.Build();
_userGroup.StartContentId = null;
await UserGroupService.CreateAsync(_userGroup, Constants.Security.SuperUserKey);
}
private async Task<string[]> CreateUserAndGetStartNodePaths(params int[] startNodeIds)
{
var user = await CreateUser(startNodeIds);
var contentStartNodePaths = user.GetContentStartNodePaths(EntityService, AppCaches.NoCache);
Assert.IsNotNull(contentStartNodePaths);
return contentStartNodePaths;
}
private async Task<int[]> CreateUserAndGetStartNodeIds(params int[] startNodeIds)
{
var user = await CreateUser(startNodeIds);
var contentStartNodeIds = user.CalculateContentStartNodeIds(EntityService, AppCaches.NoCache);
Assert.IsNotNull(contentStartNodeIds);
return contentStartNodeIds;
}
private async Task<User> CreateUser(int[] startNodeIds)
{
var user = new UserBuilder()
.WithName(Guid.NewGuid().ToString("N"))
.WithStartContentIds(startNodeIds)
.Build();
UserService.Save(user);
var attempt = await UserGroupService.AddUsersToUserGroupAsync(
new UsersToUserGroupManipulationModel(_userGroup.Key, [user.Key]),
Constants.Security.SuperUserKey);
Assert.IsTrue(attempt.Success);
return user;
}
}

View File

@@ -9,7 +9,7 @@
<EnablePackageValidation>$(BaseEnablePackageValidation)</EnablePackageValidation>
<NoWarn>$(NoWarn),NU5100</NoWarn>
</PropertyGroup>
<PropertyGroup>
<!--
TODO: Fix and remove overrides:
@@ -26,7 +26,7 @@
-->
<WarningsNotAsErrors>$(WarningsNotAsErrors),CS0108,SYSLIB0012,CS0618,SA1116,SA1117,CS0162,CS0169,SA1134,SA1405,CS4014,CS1998,CS0649,CS0168</WarningsNotAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Bogus" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
@@ -253,5 +253,17 @@
<Compile Update="Umbraco.Core\Services\PublishedUrlInfoProviderTests.cs">
<DependentUpon>PublishedUrlInfoProviderTestsBase.cs</DependentUpon>
</Compile>
<Compile Update="ManagementApi\Services\UserStartNodeEntitiesServiceTests.ChildUserAccessEntities.cs">
<DependentUpon>UserStartNodeEntitiesServiceTests.cs</DependentUpon>
</Compile>
<Compile Update="ManagementApi\Services\UserStartNodeEntitiesServiceTests.RootUserAccessEntities.cs">
<DependentUpon>UserStartNodeEntitiesServiceTests.cs</DependentUpon>
</Compile>
<Compile Update="ManagementApi\Services\UserStartNodeEntitiesServiceMediaTests.childUserAccessEntities.cs">
<DependentUpon>UserStartNodeEntitiesServiceMediaTests.cs</DependentUpon>
</Compile>
<Compile Update="ManagementApi\Services\UserStartNodeEntitiesServiceMediaTests.RootUserAccessEntities.cs">
<DependentUpon>UserStartNodeEntitiesServiceMediaTests.cs</DependentUpon>
</Compile>
</ItemGroup>
</Project>