Skip to content

Commit de23033

Browse files
authored
Add user group filter endpoint (#16087)
1 parent 39e51a4 commit de23033

7 files changed

Lines changed: 333 additions & 5 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
using Asp.Versioning;
2+
using Microsoft.AspNetCore.Http;
3+
using Microsoft.AspNetCore.Mvc;
4+
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
5+
using Umbraco.Cms.Api.Management.Factories;
6+
using Umbraco.Cms.Api.Management.ViewModels.UserGroup;
7+
using Umbraco.Cms.Core;
8+
using Umbraco.Cms.Core.Models;
9+
using Umbraco.Cms.Core.Models.Membership;
10+
using Umbraco.Cms.Core.Security;
11+
using Umbraco.Cms.Core.Services;
12+
using Umbraco.Cms.Core.Services.OperationStatus;
13+
14+
namespace Umbraco.Cms.Api.Management.Controllers.User.Filter;
15+
16+
[ApiVersion("1.0")]
17+
public class FilterUserGroupFilterController : UserGroupFilterControllerBase
18+
{
19+
private readonly IUserGroupService _userGroupService;
20+
private readonly IUserGroupPresentationFactory _userGroupPresentationFactory;
21+
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
22+
23+
public FilterUserGroupFilterController(
24+
IUserGroupService userGroupService,
25+
IUserGroupPresentationFactory userGroupPresentationFactory,
26+
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
27+
{
28+
_userGroupService = userGroupService;
29+
_userGroupPresentationFactory = userGroupPresentationFactory;
30+
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
31+
}
32+
33+
[HttpGet]
34+
[MapToApiVersion("1.0")]
35+
[ProducesResponseType(typeof(PagedViewModel<UserGroupResponseModel>), StatusCodes.Status200OK)]
36+
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
37+
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
38+
public async Task<IActionResult> Filter(
39+
CancellationToken cancellationToken,
40+
int skip = 0,
41+
int take = 100,
42+
string filter = "")
43+
{
44+
Attempt<PagedModel<IUserGroup>, UserGroupOperationStatus> filterAttempt = await _userGroupService.FilterAsync(
45+
CurrentUserKey(_backOfficeSecurityAccessor),
46+
filter,
47+
skip,
48+
take);
49+
50+
if (filterAttempt.Success is false)
51+
{
52+
return UserGroupOperationStatusResult(filterAttempt.Status);
53+
}
54+
55+
IEnumerable<UserGroupResponseModel> viewModels = await _userGroupPresentationFactory.CreateMultipleAsync(filterAttempt.Result.Items);
56+
var responseModel = new PagedViewModel<UserGroupResponseModel>
57+
{
58+
Total = filterAttempt.Result.Total,
59+
Items = viewModels,
60+
};
61+
62+
return Ok(responseModel);
63+
}
64+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using Umbraco.Cms.Api.Management.Controllers.UserGroup;
2+
using Umbraco.Cms.Api.Management.Routing;
3+
using Umbraco.Cms.Core;
4+
5+
namespace Umbraco.Cms.Api.Management.Controllers.User.Filter;
6+
7+
[VersionedApiBackOfficeRoute($"{Constants.Web.RoutePath.Filter}/user-group")]
8+
public abstract class UserGroupFilterControllerBase : UserGroupControllerBase
9+
{
10+
}

src/Umbraco.Cms.Api.Management/Controllers/UserGroup/GetAllUserGroupController.cs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,6 @@ public async Task<ActionResult<PagedViewModel<UserGroupResponseModel>>> GetAll(
3232
int skip = 0,
3333
int take = 100)
3434
{
35-
// FIXME: In the old controller this endpoint had a switch "onlyCurrentUserGroup"
36-
// If this was enabled we'd only return the groups the current user was in
37-
// and even if it was set to false we'd still remove the admin group.
38-
// We still need to have this functionality, however, it does not belong here.
39-
// Instead we should implement this functionality on the CurrentUserController
4035
PagedModel<IUserGroup> userGroups = await _userGroupService.GetAllAsync(skip, take);
4136

4237
var viewModels = (await _userPresentationFactory.CreateMultipleAsync(userGroups.Items)).ToList();

src/Umbraco.Cms.Api.Management/OpenApi.json

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27634,6 +27634,97 @@
2763427634
]
2763527635
}
2763627636
},
27637+
"/umbraco/management/api/v1/filter/user-group": {
27638+
"get": {
27639+
"tags": [
27640+
"User Group"
27641+
],
27642+
"operationId": "GetFilterUserGroup",
27643+
"parameters": [
27644+
{
27645+
"name": "skip",
27646+
"in": "query",
27647+
"schema": {
27648+
"type": "integer",
27649+
"format": "int32",
27650+
"default": 0
27651+
}
27652+
},
27653+
{
27654+
"name": "take",
27655+
"in": "query",
27656+
"schema": {
27657+
"type": "integer",
27658+
"format": "int32",
27659+
"default": 100
27660+
}
27661+
},
27662+
{
27663+
"name": "filter",
27664+
"in": "query",
27665+
"schema": {
27666+
"type": "string",
27667+
"default": ""
27668+
}
27669+
}
27670+
],
27671+
"responses": {
27672+
"200": {
27673+
"description": "Success",
27674+
"content": {
27675+
"application/json": {
27676+
"schema": {
27677+
"oneOf": [
27678+
{
27679+
"$ref": "#/components/schemas/PagedUserGroupResponseModel"
27680+
}
27681+
]
27682+
}
27683+
}
27684+
}
27685+
},
27686+
"400": {
27687+
"description": "Bad Request",
27688+
"content": {
27689+
"application/json": {
27690+
"schema": {
27691+
"oneOf": [
27692+
{
27693+
"$ref": "#/components/schemas/ProblemDetails"
27694+
}
27695+
]
27696+
}
27697+
}
27698+
}
27699+
},
27700+
"404": {
27701+
"description": "Not Found",
27702+
"content": {
27703+
"application/json": {
27704+
"schema": {
27705+
"oneOf": [
27706+
{
27707+
"$ref": "#/components/schemas/ProblemDetails"
27708+
}
27709+
]
27710+
}
27711+
}
27712+
}
27713+
},
27714+
"401": {
27715+
"description": "The resource is protected and requires an authentication token"
27716+
},
27717+
"403": {
27718+
"description": "The authenticated user do not have access to this resource"
27719+
}
27720+
},
27721+
"security": [
27722+
{
27723+
"Backoffice User": [ ]
27724+
}
27725+
]
27726+
}
27727+
},
2763727728
"/umbraco/management/api/v1/item/user-group": {
2763827729
"get": {
2763927730
"tags": [

src/Umbraco.Core/Services/IUserGroupService.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,19 @@ public interface IUserGroupService
6262

6363
Task<IEnumerable<IUserGroup>> GetAsync(IEnumerable<Guid> keys);
6464

65+
/// <summary>
66+
/// Performs filtering for user groups
67+
/// </summary>
68+
/// <param name="userKey">The key of the performing (current) user.</param>
69+
/// <param name="filter">The filter to apply.</param>
70+
/// <param name="skip">The amount of user groups to skip.</param>
71+
/// <param name="take">The amount of user groups to take.</param>
72+
/// <returns>All matching user groups as an enumerable list of <see cref="IUserGroup"/>.</returns>
73+
/// <remarks>
74+
/// If the performing user is not an administrator, this method only returns groups that the performing user is a member of.
75+
/// </remarks>
76+
Task<Attempt<PagedModel<IUserGroup>, UserGroupOperationStatus>> FilterAsync(Guid userKey, string? filter, int skip, int take);
77+
6578
/// <summary>
6679
/// Persists a new user group.
6780
/// </summary>

src/Umbraco.Core/Services/UserGroupService.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,35 @@ public Task<IEnumerable<IUserGroup>> GetAsync(IEnumerable<Guid> keys)
134134
return Task.FromResult<IEnumerable<IUserGroup>>(result);
135135
}
136136

137+
/// <inheritdoc/>
138+
public async Task<Attempt<PagedModel<IUserGroup>, UserGroupOperationStatus>> FilterAsync(Guid userKey, string? filter, int skip, int take)
139+
{
140+
IUser? requestingUser = await _userService.GetAsync(userKey);
141+
if (requestingUser is null)
142+
{
143+
return Attempt.FailWithStatus(UserGroupOperationStatus.MissingUser, new PagedModel<IUserGroup>());
144+
}
145+
146+
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
147+
var groups = _userGroupRepository
148+
.GetMany()
149+
.Where(group => filter.IsNullOrWhiteSpace() || group.Name?.InvariantContains(filter) is true)
150+
.OrderBy(group => group.Name)
151+
.ToList();
152+
153+
if (requestingUser.IsAdmin() is false)
154+
{
155+
var requestingUserGroups = requestingUser.Groups.Select(group => group.Alias).ToArray();
156+
groups.RemoveAll(group =>
157+
group.Alias is Constants.Security.AdminGroupAlias
158+
|| requestingUserGroups.Contains(group.Alias) is false);
159+
}
160+
161+
return Attempt.SucceedWithStatus(
162+
UserGroupOperationStatus.Success,
163+
new PagedModel<IUserGroup> { Items = groups.Skip(skip).Take(take), Total = groups.Count });
164+
}
165+
137166
/// <inheritdoc/>
138167
public async Task<Attempt<UserGroupOperationStatus>> DeleteAsync(ISet<Guid> keys)
139168
{
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
using Microsoft.Extensions.Logging;
2+
using Moq;
3+
using NUnit.Framework;
4+
using Umbraco.Cms.Core;
5+
using Umbraco.Cms.Core.Events;
6+
using Umbraco.Cms.Core.Models.Membership;
7+
using Umbraco.Cms.Core.Persistence.Repositories;
8+
using Umbraco.Cms.Core.Scoping;
9+
using Umbraco.Cms.Core.Services;
10+
using Umbraco.Cms.Core.Strings;
11+
12+
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Services;
13+
14+
[TestFixture]
15+
public class UserGroupServiceTests
16+
{
17+
[TestCase("one", "two", "three")]
18+
[TestCase("two", "three")]
19+
[TestCase("three")]
20+
[TestCase]
21+
public async Task Filter_Returns_Only_User_Groups_For_Non_Admin(params string[] userGroupAliases)
22+
{
23+
var userKey = Guid.NewGuid();
24+
var userGroupService = SetupUserGroupService(userKey, userGroupAliases);
25+
26+
var result = await userGroupService.FilterAsync(userKey, null, 0, 10);
27+
Assert.Multiple(() =>
28+
{
29+
Assert.IsTrue(result.Success);
30+
Assert.AreEqual(userGroupAliases.Length, result.Result.Items.Count());
31+
foreach (var userGroupAlias in userGroupAliases)
32+
{
33+
Assert.IsNotNull(result.Result.Items.SingleOrDefault(g => g.Alias == userGroupAlias));
34+
}
35+
});
36+
}
37+
38+
[TestCase("four", "five", "six")]
39+
[TestCase("four")]
40+
[TestCase]
41+
public async Task Filter_Does_Not_Return_Non_Existing_Groups(params string[] userGroupAliases)
42+
{
43+
var userKey = Guid.NewGuid();
44+
var userGroupService = SetupUserGroupService(userKey, userGroupAliases);
45+
46+
var result = await userGroupService.FilterAsync(userKey, null, 0, 10);
47+
Assert.Multiple(() =>
48+
{
49+
Assert.IsTrue(result.Success);
50+
Assert.IsEmpty(result.Result.Items);
51+
});
52+
}
53+
54+
[Test]
55+
public async Task Filter_Returns_All_Groups_For_Admin()
56+
{
57+
var userKey = Guid.NewGuid();
58+
var userGroupService = SetupUserGroupService(userKey, new [] { Constants.Security.AdminGroupAlias });
59+
60+
var result = await userGroupService.FilterAsync(userKey, null, 0, 10);
61+
Assert.Multiple(() =>
62+
{
63+
Assert.IsTrue(result.Success);
64+
Assert.AreEqual(4, result.Result.Items.Count());
65+
Assert.IsNotNull(result.Result.Items.SingleOrDefault(g => g.Alias == Constants.Security.AdminGroupAlias));
66+
Assert.IsNotNull(result.Result.Items.SingleOrDefault(g => g.Alias == "one"));
67+
Assert.IsNotNull(result.Result.Items.SingleOrDefault(g => g.Alias == "two"));
68+
Assert.IsNotNull(result.Result.Items.SingleOrDefault(g => g.Alias == "three"));
69+
});
70+
}
71+
72+
[Test]
73+
public async Task Filter_Can_Filter_By_Group_Name()
74+
{
75+
var userKey = Guid.NewGuid();
76+
var userGroupService = SetupUserGroupService(userKey, new [] { Constants.Security.AdminGroupAlias });
77+
78+
var result = await userGroupService.FilterAsync(userKey, "e", 0, 10);
79+
Assert.Multiple(() =>
80+
{
81+
Assert.IsTrue(result.Success);
82+
Assert.AreEqual(2, result.Result.Items.Count());
83+
Assert.IsNotNull(result.Result.Items.SingleOrDefault(g => g.Alias == "one"));
84+
Assert.IsNotNull(result.Result.Items.SingleOrDefault(g => g.Alias == "three"));
85+
});
86+
}
87+
88+
private IEnumerable<IReadOnlyUserGroup> CreateGroups(params string[] aliases)
89+
=> aliases.Select(alias =>
90+
{
91+
var group = new Mock<IReadOnlyUserGroup>();
92+
group.SetupGet(g => g.Alias).Returns(alias);
93+
return group.Object;
94+
}).ToArray();
95+
96+
private IUserGroupService SetupUserGroupService(Guid userKey, string[] userGroupAliases)
97+
{
98+
var user = new Mock<IUser>();
99+
user.SetupGet(u => u.Key).Returns(userKey);
100+
user.Setup(u => u.Groups).Returns(CreateGroups(userGroupAliases));
101+
102+
var userService = new Mock<IUserService>();
103+
userService.Setup(s => s.GetAsync(userKey)).Returns(Task.FromResult(user.Object));
104+
105+
var userGroupRepository = new Mock<IUserGroupRepository>();
106+
userGroupRepository
107+
.Setup(r => r.GetMany())
108+
.Returns(new[]
109+
{
110+
new UserGroup(Mock.Of<IShortStringHelper>(), 0, Constants.Security.AdminGroupAlias, "Administrators", null),
111+
new UserGroup(Mock.Of<IShortStringHelper>(), 0, "one", "Group One", null),
112+
new UserGroup(Mock.Of<IShortStringHelper>(), 0, "two", "Group Two", null),
113+
new UserGroup(Mock.Of<IShortStringHelper>(), 0, "three", "Group Three", null),
114+
});
115+
116+
return new UserGroupService(
117+
Mock.Of<ICoreScopeProvider>(),
118+
Mock.Of<ILoggerFactory>(),
119+
Mock.Of<IEventMessagesFactory>(),
120+
userGroupRepository.Object,
121+
Mock.Of<IUserGroupPermissionService>(),
122+
Mock.Of<IEntityService>(),
123+
userService.Object,
124+
Mock.Of<ILogger<UserGroupService>>());
125+
}
126+
}

0 commit comments

Comments
 (0)