Added user start node restrictions to sibling endpoints (#19839)

* Added user start node restrictions to sibling endpoints.

* Further integration tests.

* Tidy up.

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Revert previous update.

* Applied previous update correctly.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Andy Butland
2025-08-05 09:53:39 +02:00
committed by GitHub
parent f5ff2bbf59
commit 20254f0bbc
17 changed files with 492 additions and 51 deletions

View File

@@ -35,6 +35,9 @@ public class SiblingsDocumentTreeController : DocumentTreeControllerBase
[HttpGet("siblings")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(IEnumerable<DocumentTreeItemResponseModel>), StatusCodes.Status200OK)]
public Task<ActionResult<IEnumerable<DocumentTreeItemResponseModel>>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after)
=> GetSiblings(target, before, after);
public Task<ActionResult<IEnumerable<DocumentTreeItemResponseModel>>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after, Guid? dataTypeId = null)
{
IgnoreUserStartNodesForDataType(dataTypeId);
return GetSiblings(target, before, after);
}
}

View File

@@ -24,6 +24,9 @@ public class SiblingsMediaTreeController : MediaTreeControllerBase
[HttpGet("siblings")]
[ProducesResponseType(typeof(IEnumerable<MediaTreeItemResponseModel>), StatusCodes.Status200OK)]
public Task<ActionResult<IEnumerable<MediaTreeItemResponseModel>>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after)
=> GetSiblings(target, before, after);
public Task<ActionResult<IEnumerable<MediaTreeItemResponseModel>>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after, Guid? dataTypeId = null)
{
IgnoreUserStartNodesForDataType(dataTypeId);
return GetSiblings(target, before, after);
}
}

View File

@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
using Umbraco.Cms.Api.Management.ViewModels;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
@@ -46,7 +46,7 @@ public abstract class EntityTreeControllerBase<TItem> : ManagementApiControllerB
protected Task<ActionResult<IEnumerable<TItem>>> GetSiblings(Guid target, int before, int after)
{
IEntitySlim[] siblings = EntityService.GetSiblings(target, ItemObjectType, before, after, ItemOrdering).ToArray();
IEntitySlim[] siblings = GetSiblingEntities(target, before, after);
if (siblings.Length == 0)
{
return Task.FromResult<ActionResult<IEnumerable<TItem>>>(NotFound());
@@ -110,7 +110,8 @@ public abstract class EntityTreeControllerBase<TItem> : ManagementApiControllerB
.ToArray();
protected virtual IEntitySlim[] GetPagedChildEntities(Guid parentKey, int skip, int take, out long totalItems) =>
EntityService.GetPagedChildren(
EntityService
.GetPagedChildren(
parentKey,
ItemObjectType,
skip,
@@ -119,6 +120,16 @@ public abstract class EntityTreeControllerBase<TItem> : ManagementApiControllerB
ordering: ItemOrdering)
.ToArray();
protected virtual IEntitySlim[] GetSiblingEntities(Guid target, int before, int after) =>
EntityService
.GetSiblings(
target,
ItemObjectType,
before,
after,
ordering: ItemOrdering)
.ToArray();
protected virtual TItem[] MapTreeItemViewModels(Guid? parentKey, IEntitySlim[] entities)
=> entities.Select(entity => MapTreeItemViewModel(parentKey, entity)).ToArray();

View File

@@ -1,4 +1,4 @@
using Umbraco.Cms.Api.Management.Models.Entities;
using Umbraco.Cms.Api.Management.Models.Entities;
using Umbraco.Cms.Api.Management.Services.Entities;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
using Umbraco.Cms.Core;
@@ -59,6 +59,24 @@ public abstract class UserStartNodeTreeControllerBase<TItem> : EntityTreeControl
return CalculateAccessMap(() => userAccessEntities, out _);
}
protected override IEntitySlim[] GetSiblingEntities(Guid target, int before, int after)
{
if (UserHasRootAccess() || IgnoreUserStartNodes())
{
return base.GetSiblingEntities(target, before, after);
}
IEnumerable<UserAccessEntity> userAccessEntities = _userStartNodeEntitiesService.SiblingUserAccessEntities(
ItemObjectType,
UserStartNodePaths,
target,
before,
after,
ItemOrdering);
return CalculateAccessMap(() => userAccessEntities, out _);
}
protected override TItem[] MapTreeItemViewModels(Guid? parentKey, IEntitySlim[] entities)
{
if (UserHasRootAccess() || IgnoreUserStartNodes())

View File

@@ -1,4 +1,4 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Api.Management.Models.Entities;
@@ -64,6 +64,28 @@ public interface IUserStartNodeEntitiesService
/// </remarks>
IEnumerable<UserAccessEntity> ChildUserAccessEntities(IEnumerable<IEntitySlim> candidateChildren, string[] userStartNodePaths);
/// <summary>
/// Calculates the applicable sibling 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="targetKey">The key of the target.</param>
/// <param name="before">The number of applicable siblings to retrieve before the target.</param>
/// <param name="after">The number of applicable siblings to retrieve after the target.</param>
/// <param name="ordering">The ordering to apply when fetching and paginating the children.</param>
/// <returns>A list of sibling 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> SiblingUserAccessEntities(
UmbracoObjectTypes umbracoObjectType,
string[] userStartNodePaths,
Guid targetKey,
int before,
int after,
Ordering ordering) => [];
/// <summary>
/// Calculates the access level of a collection of entities for users without root access.
/// </summary>

View File

@@ -1,4 +1,4 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
@@ -36,17 +36,17 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService
/// <inheritdoc />
public IEnumerable<UserAccessEntity> RootUserAccessEntities(UmbracoObjectTypes umbracoObjectType, int[] userStartNodeIds)
{
// root entities for users without root access should include:
// Root entities for users without root access should include:
// - the start nodes that are actual root entities (level == 1)
// - the root level ancestors to the rest of the start nodes (required for browsing to the actual start nodes - will be marked as "no access")
IEntitySlim[] userStartEntities = userStartNodeIds.Any()
? _entityService.GetAll(umbracoObjectType, userStartNodeIds).ToArray()
: Array.Empty<IEntitySlim>();
// find the start nodes that are at root level (level == 1)
// Find the start nodes that are at root level (level == 1).
IEntitySlim[] allowedTopmostEntities = userStartEntities.Where(entity => entity.Level == 1).ToArray();
// find the root level ancestors of the rest of the start nodes, and add those as well
// Find the root level ancestors of the rest of the start nodes, and add those as well.
var nonAllowedTopmostEntityIds = userStartEntities.Except(allowedTopmostEntities)
.Select(entity => int.TryParse(entity.Path.Split(Constants.CharArrays.Comma).Skip(1).FirstOrDefault(), out var id) ? id : 0)
.Where(id => id > 0)
@@ -63,6 +63,7 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService
.ToArray();
}
/// <inheritdoc/>
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);
@@ -83,40 +84,46 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService
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
// 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();
int[] allowedChildIds = GetAllowedIds(userStartNodePaths, parentId);
totalItems = allowedChildIds.Length;
if (allowedChildIds.Length == 0)
{
// the requested parent is outside the scope of any user start nodes
// 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
// 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);
}
private static int[] GetAllowedIds(string[] userStartNodePaths, int parentId)
{
// 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
// that are the final entries in the path.
// 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();
return userStartNodePathIds
.Where(ids => ids.Contains(parentId))
.Select(ids => ids[ids.IndexOf(parentId) + 1]) // Given the previous checks, the parent ID can never be the last in the user start node path, so this is safe
.Distinct()
.ToArray();
}
/// <inheritdoc />
public IEnumerable<UserAccessEntity> ChildUserAccessEntities(IEnumerable<IEntitySlim> candidateChildren, string[] userStartNodePaths)
// child entities for users without root access should include:
// Child or sibling entities for users without root access should include:
// - children that are descendant-or-self of a user start node
// - children that are ancestors of a user start node (required for browsing to the actual start nodes - will be marked as "no access")
// all other candidate children should be discarded
// All other candidate children should be discarded.
=> candidateChildren.Select(child =>
{
// is descendant-or-self of a start node?
@@ -134,9 +141,55 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService
return null;
}).WhereNotNull().ToArray();
/// <inheritdoc />
public IEnumerable<UserAccessEntity> SiblingUserAccessEntities(UmbracoObjectTypes umbracoObjectType, string[] userStartNodePaths, Guid targetKey, int before, int after, Ordering ordering)
{
Attempt<int> targetIdAttempt = _idKeyMap.GetIdForKey(targetKey, umbracoObjectType);
if (targetIdAttempt.Success is false)
{
return [];
}
var targetId = targetIdAttempt.Result;
IEntitySlim? target = _entityService.Get(targetId);
if (target is null)
{
return [];
}
IEntitySlim[] siblings;
IEntitySlim? targetParent = _entityService.Get(target.ParentId);
if (targetParent is null) // Even if the parent is the root, we still expect to get a value here.
{
return [];
}
if (userStartNodePaths.Any(path => $"{targetParent?.Path},".StartsWith($"{path},")))
{
// The requested parent of the target is one of the user start nodes (or a descendant of one), all siblings are by definition allowed.
siblings = _entityService.GetSiblings(targetKey, umbracoObjectType, before, after, ordering: ordering).ToArray();
return ChildUserAccessEntities(siblings, userStartNodePaths);
}
int[] allowedSiblingIds = GetAllowedIds(userStartNodePaths, targetParent.Id);
if (allowedSiblingIds.Length == 0)
{
// The requested target is outside the scope of any user start nodes.
return [];
}
// Even though we know the IDs of the allowed sibling entities to fetch, we still use a Query to yield correctly sorted children.
IQuery<IUmbracoEntity> query = _scopeProvider.CreateQuery<IUmbracoEntity>().Where(x => allowedSiblingIds.Contains(x.Id));
siblings = _entityService.GetSiblings(targetKey, umbracoObjectType, before, after, query, ordering).ToArray();
return ChildUserAccessEntities(siblings, userStartNodePaths);
}
/// <inheritdoc />
public IEnumerable<UserAccessEntity> UserAccessEntities(IEnumerable<IEntitySlim> entities, string[] userStartNodePaths)
// entities for users without root access should include:
// Entities for users without root access should include:
// - entities that are descendant-or-self of a user start node as regular entities
// - all other entities as "no access" entities
=> entities.Select(entity => new UserAccessEntity(entity, IsDescendantOrSelf(entity, userStartNodePaths))).ToArray();

View File

@@ -26,9 +26,10 @@ public interface IEntityRepository : IRepository
/// <param name="targetKey">The key of the target entity whose siblings are to be retrieved.</param>
/// <param name="before">The number of siblings to retrieve before the target entity.</param>
/// <param name="after">The number of siblings to retrieve after the target entity.</param>
/// <param name="filter">An optional filter to apply to the result set.</param>
/// <param name="ordering">The ordering to apply to the siblings.</param>
/// <returns>Enumerable of sibling entities.</returns>
IEnumerable<IEntitySlim> GetSiblings(Guid objectType, Guid targetKey, int before, int after, Ordering ordering) => [];
IEnumerable<IEntitySlim> GetSiblings(Guid objectType, Guid targetKey, int before, int after, IQuery<IUmbracoEntity>? filter, Ordering ordering) => [];
/// <summary>
/// Gets entities for a query

View File

@@ -1,4 +1,4 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Services;
@@ -8,11 +8,6 @@ public static class DateTypeServiceExtensions
{
public static bool IsDataTypeIgnoringUserStartNodes(this IDataTypeService dataTypeService, Guid key)
{
if (DataTypeExtensions.IsBuildInDataType(key))
{
return false; // built in ones can never be ignoring start nodes
}
IDataType? dataType = dataTypeService.GetAsync(key).GetAwaiter().GetResult();
if (dataType != null && dataType.ConfigurationObject is IIgnoreUserStartNodesConfig ignoreStartNodesConfig)

View File

@@ -324,6 +324,7 @@ public class EntityService : RepositoryService, IEntityService
UmbracoObjectTypes objectType,
int before,
int after,
IQuery<IUmbracoEntity>? filter = null,
Ordering? ordering = null)
{
if (before < 0)
@@ -345,6 +346,7 @@ public class EntityService : RepositoryService, IEntityService
key,
before,
after,
filter,
ordering);
scope.Complete();

View File

@@ -177,6 +177,7 @@ public interface IEntityService
/// <param name="objectType">The object type key of the entities.</param>
/// <param name="before">The number of siblings to retrieve before the target entity. Needs to be greater or equal to 0.</param>
/// <param name="after">The number of siblings to retrieve after the target entity. Needs to be greater or equal to 0.</param>
/// <param name="filter">An optional filter to apply to the result set.</param>
/// <param name="ordering">The ordering to apply to the siblings.</param>
/// <returns>Enumerable of sibling entities.</returns>
IEnumerable<IEntitySlim> GetSiblings(
@@ -184,6 +185,7 @@ public interface IEntityService
UmbracoObjectTypes objectType,
int before,
int after,
IQuery<IUmbracoEntity>? filter = null,
Ordering? ordering = null) => [];
/// <summary>

View File

@@ -146,7 +146,13 @@ internal sealed class EntityRepository : RepositoryBase, IEntityRepositoryExtend
}
/// <inheritdoc/>
public IEnumerable<IEntitySlim> GetSiblings(Guid objectType, Guid targetKey, int before, int after, Ordering ordering)
public IEnumerable<IEntitySlim> GetSiblings(
Guid objectType,
Guid targetKey,
int before,
int after,
IQuery<IUmbracoEntity>? filter,
Ordering ordering)
{
// Ideally we don't want to have to do a second query for the parent ID, but the siblings query is already messy enough
// without us also having to do a nested query for the parent ID too.
@@ -167,6 +173,23 @@ internal sealed class EntityRepository : RepositoryBase, IEntityRepositoryExtend
.From<NodeDto>()
.Where<NodeDto>(x => x.ParentId == parentId && x.Trashed == false);
// Apply the filter if provided. Note that in doing this, we'll add more parameters to the query, so need to track
// how many so we can offset the parameter indexes for the "before" and "after" values added later.
int beforeAfterParameterIndexOffset = 0;
if (filter != null)
{
foreach (Tuple<string, object[]> filterClause in filter.GetWhereClauses())
{
rowNumberSql.Where(filterClause.Item1, filterClause.Item2);
// We need to offset by one for each non-array parameter in the filter clause.
// If a query is created using Contains or some other set based operation, we'll get both the array and the
// items in the array provided in the where clauses. It's only the latter that count for applying parameters
// to the SQL statement, and hence we should only offset by them.
beforeAfterParameterIndexOffset += filterClause.Item2.Count(x => !x.GetType().IsArray);
}
}
// Find the specific row number of the target node.
// We need this to determine the bounds of the row numbers to select.
Sql<ISqlContext> targetRowSql = Sql()
@@ -180,11 +203,12 @@ internal sealed class EntityRepository : RepositoryBase, IEntityRepositoryExtend
IEnumerable<object> afterArguments = targetRowSql.Arguments.Concat([after]);
// Select the UniqueId of nodes which row number is within the specified range of the target node's row number.
const int BeforeAfterParameterIndex = 3;
Sql<ISqlContext>? mainSql = Sql()
.Select("UniqueId")
.From().AppendSubQuery(rowNumberSql, "NumberedNodes")
.Where($"rn >= ({targetRowSql.SQL}) - @3", beforeArguments.ToArray())
.Where($"rn <= ({targetRowSql.SQL}) + @3", afterArguments.ToArray())
.Where($"rn >= ({targetRowSql.SQL}) - @{BeforeAfterParameterIndex + beforeAfterParameterIndexOffset}", beforeArguments.ToArray())
.Where($"rn <= ({targetRowSql.SQL}) + @{BeforeAfterParameterIndex + beforeAfterParameterIndexOffset}", afterArguments.ToArray())
.OrderBy("rn");
List<Guid>? keys = Database.Fetch<Guid>(mainSql);

View File

@@ -0,0 +1,131 @@
using NUnit.Framework;
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services;
public partial class UserStartNodeEntitiesServiceMediaTests
{
[Test]
public async Task SiblingUserAccessEntities_WithStartNodeOfTargetParent_YieldsAll_AsAllowed()
{
var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1"].Id);
var siblings = UserStartNodeEntitiesService
.SiblingUserAccessEntities(
UmbracoObjectTypes.Media,
mediaStartNodePaths,
_mediaByName["1-5"].Key,
2,
2,
BySortOrder)
.ToArray();
Assert.AreEqual(5, siblings.Length);
Assert.Multiple(() =>
{
for (int i = 0; i < 4; i++)
{
Assert.AreEqual(_mediaByName[$"1-{i + 3}"].Key, siblings[i].Entity.Key);
Assert.IsTrue(siblings[i].HasAccess);
}
});
}
[Test]
public async Task SiblingUserAccessEntities_WithStartNodeOfTargetParentAndTarget_YieldsOnlyTarget_AsAllowed()
{
// See notes on ChildUserAccessEntities_ChildAndGrandchildAsStartNode_AllowsOnlyGrandchild.
var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1"].Id, _mediaByName["1-5"].Id);
var siblings = UserStartNodeEntitiesService
.SiblingUserAccessEntities(
UmbracoObjectTypes.Media,
contentStartNodePaths,
_mediaByName["1-5"].Key,
2,
2,
BySortOrder)
.ToArray();
Assert.AreEqual(1, siblings.Length);
Assert.Multiple(() =>
{
Assert.AreEqual(_mediaByName[$"1-5"].Key, siblings[0].Entity.Key);
Assert.IsTrue(siblings[0].HasAccess);
});
}
[Test]
public async Task SiblingUserAccessEntities_WithStartNodeOfTarget_YieldsOnlyTarget_AsAllowed()
{
var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1-5"].Id);
var siblings = UserStartNodeEntitiesService
.SiblingUserAccessEntities(
UmbracoObjectTypes.Media,
contentStartNodePaths,
_mediaByName["1-5"].Key,
2,
2,
BySortOrder)
.ToArray();
Assert.AreEqual(1, siblings.Length);
Assert.Multiple(() =>
{
Assert.AreEqual(_mediaByName[$"1-5"].Key, siblings[0].Entity.Key);
Assert.IsTrue(siblings[0].HasAccess);
});
}
[Test]
public async Task SiblingUserAccessEntities_WithStartsNodesOfTargetAndSiblings_YieldsOnlyPermitted_AsAllowed()
{
var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1-3"].Id, _mediaByName["1-5"].Id, _mediaByName["1-7"].Id);
var siblings = UserStartNodeEntitiesService
.SiblingUserAccessEntities(
UmbracoObjectTypes.Media,
mediaStartNodePaths,
_mediaByName["1-5"].Key,
2,
2,
BySortOrder)
.ToArray();
Assert.AreEqual(3, siblings.Length);
Assert.Multiple(() =>
{
Assert.AreEqual(_mediaByName[$"1-3"].Key, siblings[0].Entity.Key);
Assert.IsTrue(siblings[0].HasAccess);
Assert.AreEqual(_mediaByName[$"1-5"].Key, siblings[1].Entity.Key);
Assert.IsTrue(siblings[1].HasAccess);
Assert.AreEqual(_mediaByName[$"1-7"].Key, siblings[2].Entity.Key);
Assert.IsTrue(siblings[2].HasAccess);
});
}
[Test]
public async Task SiblingUserAccessEntities_WithStartsNodesOfTargetGrandchild_YieldsTarget_AsNotAllowed()
{
var mediaStartNodePaths = await CreateUserAndGetStartNodePaths(_mediaByName["1-5-1"].Id);
var siblings = UserStartNodeEntitiesService
.SiblingUserAccessEntities(
UmbracoObjectTypes.Media,
mediaStartNodePaths,
_mediaByName["1-5"].Key,
2,
2,
BySortOrder)
.ToArray();
Assert.AreEqual(1, siblings.Length);
Assert.Multiple(() =>
{
Assert.AreEqual(_mediaByName[$"1-5"].Key, siblings[0].Entity.Key);
Assert.IsFalse(siblings[0].HasAccess);
});
}
}

View File

@@ -1,4 +1,4 @@
using NUnit.Framework;
using NUnit.Framework;
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services;

View File

@@ -0,0 +1,131 @@
using NUnit.Framework;
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services;
public partial class UserStartNodeEntitiesServiceTests
{
[Test]
public async Task SiblingUserAccessEntities_WithStartNodeOfTargetParent_YieldsAll_AsAllowed()
{
var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1"].Id);
var siblings = UserStartNodeEntitiesService
.SiblingUserAccessEntities(
UmbracoObjectTypes.Document,
contentStartNodePaths,
_contentByName["1-5"].Key,
2,
2,
BySortOrder)
.ToArray();
Assert.AreEqual(5, siblings.Length);
Assert.Multiple(() =>
{
for (int i = 0; i < 4; i++)
{
Assert.AreEqual(_contentByName[$"1-{i + 3}"].Key, siblings[i].Entity.Key);
Assert.IsTrue(siblings[i].HasAccess);
}
});
}
[Test]
public async Task SiblingUserAccessEntities_WithStartNodeOfTargetParentAndTarget_YieldsOnlyTarget_AsAllowed()
{
// See notes on ChildUserAccessEntities_ChildAndGrandchildAsStartNode_AllowsOnlyGrandchild.
var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1"].Id, _contentByName["1-5"].Id);
var siblings = UserStartNodeEntitiesService
.SiblingUserAccessEntities(
UmbracoObjectTypes.Document,
contentStartNodePaths,
_contentByName["1-5"].Key,
2,
2,
BySortOrder)
.ToArray();
Assert.AreEqual(1, siblings.Length);
Assert.Multiple(() =>
{
Assert.AreEqual(_contentByName[$"1-5"].Key, siblings[0].Entity.Key);
Assert.IsTrue(siblings[0].HasAccess);
});
}
[Test]
public async Task SiblingUserAccessEntities_WithStartNodeOfTarget_YieldsOnlyTarget_AsAllowed()
{
var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1-5"].Id);
var siblings = UserStartNodeEntitiesService
.SiblingUserAccessEntities(
UmbracoObjectTypes.Document,
contentStartNodePaths,
_contentByName["1-5"].Key,
2,
2,
BySortOrder)
.ToArray();
Assert.AreEqual(1, siblings.Length);
Assert.Multiple(() =>
{
Assert.AreEqual(_contentByName[$"1-5"].Key, siblings[0].Entity.Key);
Assert.IsTrue(siblings[0].HasAccess);
});
}
[Test]
public async Task SiblingUserAccessEntities_WithStartsNodesOfTargetAndSiblings_YieldsOnlyPermitted_AsAllowed()
{
var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1-3"].Id, _contentByName["1-5"].Id, _contentByName["1-7"].Id);
var siblings = UserStartNodeEntitiesService
.SiblingUserAccessEntities(
UmbracoObjectTypes.Document,
contentStartNodePaths,
_contentByName["1-5"].Key,
2,
2,
BySortOrder)
.ToArray();
Assert.AreEqual(3, siblings.Length);
Assert.Multiple(() =>
{
Assert.AreEqual(_contentByName[$"1-3"].Key, siblings[0].Entity.Key);
Assert.IsTrue(siblings[0].HasAccess);
Assert.AreEqual(_contentByName[$"1-5"].Key, siblings[1].Entity.Key);
Assert.IsTrue(siblings[1].HasAccess);
Assert.AreEqual(_contentByName[$"1-7"].Key, siblings[2].Entity.Key);
Assert.IsTrue(siblings[2].HasAccess);
});
}
[Test]
public async Task SiblingUserAccessEntities_WithStartNodesOfTargetChild_YieldsTarget_AsNotAllowed()
{
var contentStartNodePaths = await CreateUserAndGetStartNodePaths(_contentByName["1-5-1"].Id);
var siblings = UserStartNodeEntitiesService
.SiblingUserAccessEntities(
UmbracoObjectTypes.Document,
contentStartNodePaths,
_contentByName["1-5"].Key,
2,
2,
BySortOrder)
.ToArray();
Assert.AreEqual(1, siblings.Length);
Assert.Multiple(() =>
{
Assert.AreEqual(_contentByName[$"1-5"].Key, siblings[0].Entity.Key);
Assert.IsFalse(siblings[0].HasAccess);
});
}
}

View File

@@ -1,4 +1,4 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using Umbraco.Cms.Api.Management.Services.Entities;
using Umbraco.Cms.Core;
@@ -32,7 +32,7 @@ public partial class UserStartNodeEntitiesServiceTests : UmbracoIntegrationTest
private IUserStartNodeEntitiesService UserStartNodeEntitiesService => GetRequiredService<IUserStartNodeEntitiesService>();
protected readonly Ordering BySortOrder = Ordering.By("sortOrder");
protected static readonly Ordering BySortOrder = Ordering.By("sortOrder");
protected override void ConfigureTestServices(IServiceCollection services)
{

View File

@@ -5,6 +5,7 @@ using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Persistence.Querying;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.ContentTypeEditing;
using Umbraco.Cms.Infrastructure.Persistence;
@@ -936,9 +937,9 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest
{
var children = CreateSiblingsTestData();
var taget = children[1];
var target = children[1];
var result = EntityService.GetSiblings(taget.Key, UmbracoObjectTypes.Document, 1, 1).ToArray();
var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1).ToArray();
Assert.AreEqual(3, result.Length);
Assert.IsTrue(result[0].Key == children[0].Key);
Assert.IsTrue(result[1].Key == children[1].Key);
@@ -953,8 +954,8 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest
var trash = children[1];
ContentService.MoveToRecycleBin(trash);
var taget = children[2];
var result = EntityService.GetSiblings(taget.Key, UmbracoObjectTypes.Document, 1, 1).ToArray();
var target = children[2];
var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1).ToArray();
Assert.AreEqual(3, result.Length);
Assert.IsFalse(result.Any(x => x.Key == trash.Key));
Assert.IsTrue(result[0].Key == children[0].Key);
@@ -962,6 +963,44 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest
Assert.IsTrue(result[2].Key == children[3].Key);
}
[Test]
public void EntityService_Siblings_SkipsFilteredEntities_UsingFilterWithSet()
{
var children = CreateSiblingsTestData();
// Apply a filter that excludes the child at index 1. We'd expect to not get this, but
// get still get one previous sibling, i.e. the entity at index 0.
Guid[] keysToExclude = [children[1].Key];
IQuery<IUmbracoEntity> filter = ScopeProvider.CreateQuery<IUmbracoEntity>().Where(x => !keysToExclude.Contains(x.Key));
var target = children[2];
var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1, filter).ToArray();
Assert.AreEqual(3, result.Length);
Assert.IsFalse(result.Any(x => x.Key == keysToExclude[0]));
Assert.IsTrue(result[0].Key == children[0].Key);
Assert.IsTrue(result[1].Key == children[2].Key);
Assert.IsTrue(result[2].Key == children[3].Key);
}
[Test]
public void EntityService_Siblings_SkipsFilteredEntities_UsingFilterWithoutSet()
{
var children = CreateSiblingsTestData();
// Apply a filter that excludes the child at index 1. We'd expect to not get this, but
// get still get one previous sibling, i.e. the entity at index 0.
var keyToExclude = children[1].Key;
IQuery<IUmbracoEntity> filter = ScopeProvider.CreateQuery<IUmbracoEntity>().Where(x => x.Key != keyToExclude);
var target = children[2];
var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1, filter).ToArray();
Assert.AreEqual(3, result.Length);
Assert.IsFalse(result.Any(x => x.Key == keyToExclude));
Assert.IsTrue(result[0].Key == children[0].Key);
Assert.IsTrue(result[1].Key == children[2].Key);
Assert.IsTrue(result[2].Key == children[3].Key);
}
[Test]
public void EntityService_Siblings_RespectsOrdering()
{
@@ -970,8 +1009,8 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest
// Order the children by name to ensure the ordering works when differing from the default sort order, the name is a GUID.
children = children.OrderBy(x => x.Name).ToList();
var taget = children[1];
var result = EntityService.GetSiblings(taget.Key, UmbracoObjectTypes.Document, 1, 1, Ordering.By(nameof(NodeDto.Text))).ToArray();
var target = children[1];
var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 1, ordering: Ordering.By(nameof(NodeDto.Text))).ToArray();
Assert.AreEqual(3, result.Length);
Assert.IsTrue(result[0].Key == children[0].Key);
Assert.IsTrue(result[1].Key == children[1].Key);
@@ -983,8 +1022,8 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest
{
var children = CreateSiblingsTestData();
var taget = children[1];
var result = EntityService.GetSiblings(taget.Key, UmbracoObjectTypes.Document, 100, 1).ToArray();
var target = children[1];
var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 100, 1).ToArray();
Assert.AreEqual(3, result.Length);
Assert.IsTrue(result[0].Key == children[0].Key);
Assert.IsTrue(result[1].Key == children[1].Key);
@@ -996,8 +1035,8 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest
{
var children = CreateSiblingsTestData();
var taget = children[^2];
var result = EntityService.GetSiblings(taget.Key, UmbracoObjectTypes.Document, 1, 100).ToArray();
var target = children[^2];
var result = EntityService.GetSiblings(target.Key, UmbracoObjectTypes.Document, 1, 100).ToArray();
Assert.AreEqual(3, result.Length);
Assert.IsTrue(result[^1].Key == children[^1].Key);
Assert.IsTrue(result[^2].Key == children[^2].Key);

View File

@@ -286,12 +286,18 @@
<Compile Update="ManagementApi\Services\UserStartNodeEntitiesServiceTests.RootUserAccessEntities.cs">
<DependentUpon>UserStartNodeEntitiesServiceTests.cs</DependentUpon>
</Compile>
<Compile Update="ManagementApi\Services\UserStartNodeEntitiesServiceTests.SiblingUserAccessEntities.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>
<Compile Update="ManagementApi\Services\UserStartNodeEntitiesServiceMediaTests.SiblingUserAccessEntities.cs">
<DependentUpon>UserStartNodeEntitiesServiceMediaTests.cs</DependentUpon>
</Compile>
<Compile Update="Umbraco.Infrastructure\Services\ContentBlueprintEditingServiceTests.GetScaffold.cs">
<DependentUpon>ContentBlueprintEditingServiceTests.cs</DependentUpon>
</Compile>