diff --git a/src/Umbraco.Cms.Api.Management/Controllers/UserGroup/Filter/FilterUserGroupFilterController.cs b/src/Umbraco.Cms.Api.Management/Controllers/UserGroup/Filter/FilterUserGroupFilterController.cs new file mode 100644 index 0000000000..b78d0f13ce --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/UserGroup/Filter/FilterUserGroupFilterController.cs @@ -0,0 +1,64 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.UserGroup; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +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.Api.Management.Controllers.User.Filter; + +[ApiVersion("1.0")] +public class FilterUserGroupFilterController : UserGroupFilterControllerBase +{ + private readonly IUserGroupService _userGroupService; + private readonly IUserGroupPresentationFactory _userGroupPresentationFactory; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public FilterUserGroupFilterController( + IUserGroupService userGroupService, + IUserGroupPresentationFactory userGroupPresentationFactory, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + { + _userGroupService = userGroupService; + _userGroupPresentationFactory = userGroupPresentationFactory; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [HttpGet] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Filter( + CancellationToken cancellationToken, + int skip = 0, + int take = 100, + string filter = "") + { + Attempt, UserGroupOperationStatus> filterAttempt = await _userGroupService.FilterAsync( + CurrentUserKey(_backOfficeSecurityAccessor), + filter, + skip, + take); + + if (filterAttempt.Success is false) + { + return UserGroupOperationStatusResult(filterAttempt.Status); + } + + IEnumerable viewModels = await _userGroupPresentationFactory.CreateMultipleAsync(filterAttempt.Result.Items); + var responseModel = new PagedViewModel + { + Total = filterAttempt.Result.Total, + Items = viewModels, + }; + + return Ok(responseModel); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/UserGroup/Filter/UserGroupFilterControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/UserGroup/Filter/UserGroupFilterControllerBase.cs new file mode 100644 index 0000000000..e075a77257 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/UserGroup/Filter/UserGroupFilterControllerBase.cs @@ -0,0 +1,10 @@ +using Umbraco.Cms.Api.Management.Controllers.UserGroup; +using Umbraco.Cms.Api.Management.Routing; +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Api.Management.Controllers.User.Filter; + +[VersionedApiBackOfficeRoute($"{Constants.Web.RoutePath.Filter}/user-group")] +public abstract class UserGroupFilterControllerBase : UserGroupControllerBase +{ +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/UserGroup/GetAllUserGroupController.cs b/src/Umbraco.Cms.Api.Management/Controllers/UserGroup/GetAllUserGroupController.cs index f8f66bd83c..948d61f64d 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/UserGroup/GetAllUserGroupController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/UserGroup/GetAllUserGroupController.cs @@ -32,11 +32,6 @@ public class GetAllUserGroupController : UserGroupControllerBase int skip = 0, int take = 100) { - // FIXME: In the old controller this endpoint had a switch "onlyCurrentUserGroup" - // If this was enabled we'd only return the groups the current user was in - // and even if it was set to false we'd still remove the admin group. - // We still need to have this functionality, however, it does not belong here. - // Instead we should implement this functionality on the CurrentUserController PagedModel userGroups = await _userGroupService.GetAllAsync(skip, take); var viewModels = (await _userPresentationFactory.CreateMultipleAsync(userGroups.Items)).ToList(); diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 7ccec278c2..8a6f299e5a 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -27634,6 +27634,97 @@ ] } }, + "/umbraco/management/api/v1/filter/user-group": { + "get": { + "tags": [ + "User Group" + ], + "operationId": "GetFilterUserGroup", + "parameters": [ + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + }, + { + "name": "filter", + "in": "query", + "schema": { + "type": "string", + "default": "" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/PagedUserGroupResponseModel" + } + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user do not have access to this resource" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, "/umbraco/management/api/v1/item/user-group": { "get": { "tags": [ diff --git a/src/Umbraco.Core/Services/IUserGroupService.cs b/src/Umbraco.Core/Services/IUserGroupService.cs index c597b9ead3..fb66726b6c 100644 --- a/src/Umbraco.Core/Services/IUserGroupService.cs +++ b/src/Umbraco.Core/Services/IUserGroupService.cs @@ -62,6 +62,19 @@ public interface IUserGroupService Task> GetAsync(IEnumerable keys); + /// + /// Performs filtering for user groups + /// + /// The key of the performing (current) user. + /// The filter to apply. + /// The amount of user groups to skip. + /// The amount of user groups to take. + /// All matching user groups as an enumerable list of . + /// + /// If the performing user is not an administrator, this method only returns groups that the performing user is a member of. + /// + Task, UserGroupOperationStatus>> FilterAsync(Guid userKey, string? filter, int skip, int take); + /// /// Persists a new user group. /// diff --git a/src/Umbraco.Core/Services/UserGroupService.cs b/src/Umbraco.Core/Services/UserGroupService.cs index 78c049afe5..c87ba2dd4b 100644 --- a/src/Umbraco.Core/Services/UserGroupService.cs +++ b/src/Umbraco.Core/Services/UserGroupService.cs @@ -134,6 +134,35 @@ internal sealed class UserGroupService : RepositoryService, IUserGroupService return Task.FromResult>(result); } + /// + public async Task, UserGroupOperationStatus>> FilterAsync(Guid userKey, string? filter, int skip, int take) + { + IUser? requestingUser = await _userService.GetAsync(userKey); + if (requestingUser is null) + { + return Attempt.FailWithStatus(UserGroupOperationStatus.MissingUser, new PagedModel()); + } + + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + var groups = _userGroupRepository + .GetMany() + .Where(group => filter.IsNullOrWhiteSpace() || group.Name?.InvariantContains(filter) is true) + .OrderBy(group => group.Name) + .ToList(); + + if (requestingUser.IsAdmin() is false) + { + var requestingUserGroups = requestingUser.Groups.Select(group => group.Alias).ToArray(); + groups.RemoveAll(group => + group.Alias is Constants.Security.AdminGroupAlias + || requestingUserGroups.Contains(group.Alias) is false); + } + + return Attempt.SucceedWithStatus( + UserGroupOperationStatus.Success, + new PagedModel { Items = groups.Skip(skip).Take(take), Total = groups.Count }); + } + /// public async Task> DeleteAsync(ISet keys) { diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/UserGroupServiceTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/UserGroupServiceTests.cs new file mode 100644 index 0000000000..2384c72eb2 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/UserGroupServiceTests.cs @@ -0,0 +1,126 @@ +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Strings; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Services; + +[TestFixture] +public class UserGroupServiceTests +{ + [TestCase("one", "two", "three")] + [TestCase("two", "three")] + [TestCase("three")] + [TestCase] + public async Task Filter_Returns_Only_User_Groups_For_Non_Admin(params string[] userGroupAliases) + { + var userKey = Guid.NewGuid(); + var userGroupService = SetupUserGroupService(userKey, userGroupAliases); + + var result = await userGroupService.FilterAsync(userKey, null, 0, 10); + Assert.Multiple(() => + { + Assert.IsTrue(result.Success); + Assert.AreEqual(userGroupAliases.Length, result.Result.Items.Count()); + foreach (var userGroupAlias in userGroupAliases) + { + Assert.IsNotNull(result.Result.Items.SingleOrDefault(g => g.Alias == userGroupAlias)); + } + }); + } + + [TestCase("four", "five", "six")] + [TestCase("four")] + [TestCase] + public async Task Filter_Does_Not_Return_Non_Existing_Groups(params string[] userGroupAliases) + { + var userKey = Guid.NewGuid(); + var userGroupService = SetupUserGroupService(userKey, userGroupAliases); + + var result = await userGroupService.FilterAsync(userKey, null, 0, 10); + Assert.Multiple(() => + { + Assert.IsTrue(result.Success); + Assert.IsEmpty(result.Result.Items); + }); + } + + [Test] + public async Task Filter_Returns_All_Groups_For_Admin() + { + var userKey = Guid.NewGuid(); + var userGroupService = SetupUserGroupService(userKey, new [] { Constants.Security.AdminGroupAlias }); + + var result = await userGroupService.FilterAsync(userKey, null, 0, 10); + Assert.Multiple(() => + { + Assert.IsTrue(result.Success); + Assert.AreEqual(4, result.Result.Items.Count()); + Assert.IsNotNull(result.Result.Items.SingleOrDefault(g => g.Alias == Constants.Security.AdminGroupAlias)); + Assert.IsNotNull(result.Result.Items.SingleOrDefault(g => g.Alias == "one")); + Assert.IsNotNull(result.Result.Items.SingleOrDefault(g => g.Alias == "two")); + Assert.IsNotNull(result.Result.Items.SingleOrDefault(g => g.Alias == "three")); + }); + } + + [Test] + public async Task Filter_Can_Filter_By_Group_Name() + { + var userKey = Guid.NewGuid(); + var userGroupService = SetupUserGroupService(userKey, new [] { Constants.Security.AdminGroupAlias }); + + var result = await userGroupService.FilterAsync(userKey, "e", 0, 10); + Assert.Multiple(() => + { + Assert.IsTrue(result.Success); + Assert.AreEqual(2, result.Result.Items.Count()); + Assert.IsNotNull(result.Result.Items.SingleOrDefault(g => g.Alias == "one")); + Assert.IsNotNull(result.Result.Items.SingleOrDefault(g => g.Alias == "three")); + }); + } + + private IEnumerable CreateGroups(params string[] aliases) + => aliases.Select(alias => + { + var group = new Mock(); + group.SetupGet(g => g.Alias).Returns(alias); + return group.Object; + }).ToArray(); + + private IUserGroupService SetupUserGroupService(Guid userKey, string[] userGroupAliases) + { + var user = new Mock(); + user.SetupGet(u => u.Key).Returns(userKey); + user.Setup(u => u.Groups).Returns(CreateGroups(userGroupAliases)); + + var userService = new Mock(); + userService.Setup(s => s.GetAsync(userKey)).Returns(Task.FromResult(user.Object)); + + var userGroupRepository = new Mock(); + userGroupRepository + .Setup(r => r.GetMany()) + .Returns(new[] + { + new UserGroup(Mock.Of(), 0, Constants.Security.AdminGroupAlias, "Administrators", null), + new UserGroup(Mock.Of(), 0, "one", "Group One", null), + new UserGroup(Mock.Of(), 0, "two", "Group Two", null), + new UserGroup(Mock.Of(), 0, "three", "Group Three", null), + }); + + return new UserGroupService( + Mock.Of(), + Mock.Of(), + Mock.Of(), + userGroupRepository.Object, + Mock.Of(), + Mock.Of(), + userService.Object, + Mock.Of>()); + } +}