Skip to content

Commit b867a54

Browse files
Merge pull request #1412 from TransactionProcessing/copilot/refactor-grant-service
Refactor GrantService to MediatR commands/queries in BusinessLogic
2 parents 6050213 + ea183fe commit b867a54

14 files changed

Lines changed: 279 additions & 96 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
using MediatR;
2+
using OpenIddict.Abstractions;
3+
using SecurityService.BusinessLogic.Requests;
4+
using SecurityService.Models;
5+
using SimpleResults;
6+
using static OpenIddict.Abstractions.OpenIddictConstants;
7+
8+
namespace SecurityService.BusinessLogic.RequestHandlers;
9+
10+
public sealed class GrantRequestHandler :
11+
IRequestHandler<SecurityServiceQueries.GetUserGrantsQuery, Result<List<GrantDetails>>>,
12+
IRequestHandler<SecurityServiceCommands.RevokeGrantCommand, Result>
13+
{
14+
private readonly IOpenIddictAuthorizationManager _authorizationManager;
15+
private readonly IOpenIddictApplicationManager _applicationManager;
16+
17+
public GrantRequestHandler(IOpenIddictAuthorizationManager authorizationManager, IOpenIddictApplicationManager applicationManager)
18+
{
19+
this._authorizationManager = authorizationManager;
20+
this._applicationManager = applicationManager;
21+
}
22+
23+
public async Task<Result<List<GrantDetails>>> Handle(SecurityServiceQueries.GetUserGrantsQuery query, CancellationToken cancellationToken)
24+
{
25+
var authorizations = await this._authorizationManager.FindAsync(query.UserId, client: null, status: Statuses.Valid, type: null, scopes: null, cancellationToken).ToListAsync(cancellationToken);
26+
var grants = new List<GrantDetails>();
27+
28+
foreach (var authorization in authorizations)
29+
{
30+
var grant = await this.BuildGrantDetailsAsync(authorization, cancellationToken);
31+
if (grant is not null)
32+
{
33+
grants.Add(grant);
34+
}
35+
}
36+
37+
var sorted = grants
38+
.OrderByDescending(grant => grant.CreatedAt)
39+
.ThenBy(grant => grant.DisplayName, StringComparer.OrdinalIgnoreCase)
40+
.ToList();
41+
42+
return Result.Success(sorted);
43+
}
44+
45+
private async Task<GrantDetails?> BuildGrantDetailsAsync(object authorization, CancellationToken cancellationToken)
46+
{
47+
var authorizationId = await this._authorizationManager.GetIdAsync(authorization, cancellationToken);
48+
if (string.IsNullOrWhiteSpace(authorizationId))
49+
{
50+
return null;
51+
}
52+
53+
var applicationId = await this._authorizationManager.GetApplicationIdAsync(authorization, cancellationToken);
54+
var (clientId, displayName) = await this.GetApplicationDisplayAsync(applicationId, cancellationToken);
55+
56+
return new GrantDetails(
57+
authorizationId,
58+
clientId,
59+
displayName,
60+
await this._authorizationManager.GetScopesAsync(authorization, cancellationToken),
61+
await this._authorizationManager.GetCreationDateAsync(authorization, cancellationToken));
62+
}
63+
64+
private async Task<(string clientId, string displayName)> GetApplicationDisplayAsync(string? applicationId, CancellationToken cancellationToken)
65+
{
66+
if (string.IsNullOrWhiteSpace(applicationId))
67+
{
68+
return (string.Empty, string.Empty);
69+
}
70+
71+
var application = await this._applicationManager.FindByIdAsync(applicationId, cancellationToken);
72+
if (application is null)
73+
{
74+
return (string.Empty, string.Empty);
75+
}
76+
77+
var clientId = await this._applicationManager.GetClientIdAsync(application, cancellationToken) ?? string.Empty;
78+
var displayName = await this._applicationManager.GetDisplayNameAsync(application, cancellationToken) ?? clientId;
79+
return (clientId, string.IsNullOrWhiteSpace(displayName) ? clientId : displayName);
80+
}
81+
82+
public async Task<Result> Handle(SecurityServiceCommands.RevokeGrantCommand command, CancellationToken cancellationToken)
83+
{
84+
var authorization = await this._authorizationManager.FindByIdAsync(command.AuthorizationId, cancellationToken);
85+
if (authorization is null)
86+
{
87+
return Result.NotFound($"No authorization found with id '{command.AuthorizationId}'.");
88+
}
89+
90+
var subject = await this._authorizationManager.GetSubjectAsync(authorization, cancellationToken);
91+
if (string.Equals(subject, command.UserId, StringComparison.Ordinal) == false)
92+
{
93+
return Result.NotFound($"No authorization found with id '{command.AuthorizationId}'.");
94+
}
95+
96+
return await this._authorizationManager.TryRevokeAsync(authorization, cancellationToken)
97+
? Result.Success()
98+
: Result.Failure("The authorization could not be revoked.");
99+
}
100+
}

SecurityService.BusinessLogic/Requests/SecurityServiceCommands.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,5 @@ public record ProcessPasswordResetConfirmationCommand(String Username,
6969
String ClientId) : IRequest<Result<String>>;
7070

7171
public sealed record LoginCommand(string Username, string Password, bool RememberLogin) : IRequest<Result>;
72+
public sealed record RevokeGrantCommand(string UserId, string AuthorizationId) : IRequest<Result>;
7273
}

SecurityService.BusinessLogic/Requests/SecurityServiceQueries.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,5 @@ public sealed record GetRolesQuery() : IRequest<Result<List<RoleDetails>>>;
2323
public sealed record GetUserQuery(string UserId) : IRequest<Result<UserDetails>>;
2424
public sealed record GetUsersQuery(string? UserName) : IRequest<Result<List<UserDetails>>>;
2525
public sealed record GetExternalProvidersQuery() : IRequest<Result<List<ExternalProviderDetails>>>;
26+
public sealed record GetUserGrantsQuery(string UserId) : IRequest<Result<List<GrantDetails>>>;
2627
}

SecurityService.UnitTests/Handlers/DeveloperHandlerTests.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
using Microsoft.AspNetCore.Http.HttpResults;
33
using SecurityService.BusinessLogic;
44
using SecurityService.Handlers;
5-
using SecurityService.Services;
65
using Shouldly;
76

87
namespace SecurityService.UnitTests.Handlers;

SecurityService.UnitTests/Infrastructure/IdentityMocks.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
using Microsoft.Extensions.DependencyInjection;
1010
using Moq;
1111
using SecurityService.Database;
12-
using SecurityService.Services;
1312

1413
namespace SecurityService.UnitTests.Infrastructure;
1514

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
using MediatR;
2+
using Microsoft.AspNetCore.Http;
3+
using Microsoft.AspNetCore.Identity;
4+
using Microsoft.AspNetCore.Mvc;
5+
using Microsoft.AspNetCore.Mvc.RazorPages;
6+
using Moq;
7+
using SecurityService.BusinessLogic.Requests;
8+
using SecurityService.Database;
9+
using SecurityService.Models;
10+
using SecurityService.UnitTests.Infrastructure;
11+
using Shouldly;
12+
using SimpleResults;
13+
14+
namespace SecurityService.UnitTests.Pages;
15+
16+
public class GrantsPageModelTests
17+
{
18+
[Fact]
19+
public async Task OnGetAsync_WhenUserNotFound_RedirectsToLogin()
20+
{
21+
var userManager = IdentityMocks.CreateUserManager();
22+
userManager.Setup(m => m.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>()))
23+
.ReturnsAsync((ApplicationUser?)null);
24+
25+
var mediator = new Mock<IMediator>();
26+
var model = CreateModel(userManager, mediator, new DefaultHttpContext());
27+
28+
var result = await model.OnGetAsync(CancellationToken.None);
29+
30+
result.ShouldBeOfType<RedirectResult>();
31+
}
32+
33+
[Fact]
34+
public async Task OnGetAsync_WhenUserFound_QueriesGrantsAndReturnsPage()
35+
{
36+
var user = new ApplicationUser { Id = "user-1" };
37+
var userManager = IdentityMocks.CreateUserManager();
38+
userManager.Setup(m => m.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>()))
39+
.ReturnsAsync(user);
40+
41+
var grants = new List<GrantDetails>
42+
{
43+
new GrantDetails("auth-1", "client-1", "Client One", new[] { "openid" }, DateTimeOffset.UtcNow)
44+
};
45+
46+
var mediator = new Mock<IMediator>();
47+
mediator.Setup(m => m.Send(It.Is<SecurityServiceQueries.GetUserGrantsQuery>(q => q.UserId == "user-1"), It.IsAny<CancellationToken>()))
48+
.ReturnsAsync(Result.Success(grants));
49+
50+
var model = CreateModel(userManager, mediator, new DefaultHttpContext());
51+
52+
var result = await model.OnGetAsync(CancellationToken.None);
53+
54+
result.ShouldBeOfType<PageResult>();
55+
model.Grants.ShouldHaveSingleItem();
56+
mediator.Verify(m => m.Send(It.Is<SecurityServiceQueries.GetUserGrantsQuery>(q => q.UserId == "user-1"), It.IsAny<CancellationToken>()), Times.Once);
57+
}
58+
59+
[Fact]
60+
public async Task OnPostRevokeAsync_WhenUserNotFound_RedirectsToLogin()
61+
{
62+
var userManager = IdentityMocks.CreateUserManager();
63+
userManager.Setup(m => m.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>()))
64+
.ReturnsAsync((ApplicationUser?)null);
65+
66+
var mediator = new Mock<IMediator>(MockBehavior.Strict);
67+
var model = CreateModel(userManager, mediator, new DefaultHttpContext());
68+
69+
var result = await model.OnPostRevokeAsync("auth-1", CancellationToken.None);
70+
71+
result.ShouldBeOfType<RedirectResult>();
72+
mediator.Verify(m => m.Send(It.IsAny<SecurityServiceCommands.RevokeGrantCommand>(), It.IsAny<CancellationToken>()), Times.Never);
73+
}
74+
75+
[Fact]
76+
public async Task OnPostRevokeAsync_WhenRevokeSucceeds_RedirectsToPage()
77+
{
78+
var user = new ApplicationUser { Id = "user-1" };
79+
var userManager = IdentityMocks.CreateUserManager();
80+
userManager.Setup(m => m.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>()))
81+
.ReturnsAsync(user);
82+
83+
var mediator = new Mock<IMediator>();
84+
mediator.Setup(m => m.Send(It.IsAny<SecurityServiceCommands.RevokeGrantCommand>(), It.IsAny<CancellationToken>()))
85+
.ReturnsAsync(Result.Success());
86+
87+
var model = CreateModel(userManager, mediator, new DefaultHttpContext());
88+
89+
var result = await model.OnPostRevokeAsync("auth-1", CancellationToken.None);
90+
91+
result.ShouldBeOfType<RedirectToPageResult>();
92+
}
93+
94+
[Fact]
95+
public async Task OnPostRevokeAsync_WhenRevokeFails_ReturnsPageWithStatusMessage()
96+
{
97+
var user = new ApplicationUser { Id = "user-1" };
98+
var userManager = IdentityMocks.CreateUserManager();
99+
userManager.Setup(m => m.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>()))
100+
.ReturnsAsync(user);
101+
102+
var mediator = new Mock<IMediator>();
103+
mediator.Setup(m => m.Send(It.IsAny<SecurityServiceCommands.RevokeGrantCommand>(), It.IsAny<CancellationToken>()))
104+
.ReturnsAsync(Result.Failure("The authorization could not be revoked."));
105+
mediator.Setup(m => m.Send(It.IsAny<SecurityServiceQueries.GetUserGrantsQuery>(), It.IsAny<CancellationToken>()))
106+
.ReturnsAsync(Result.Success(new List<GrantDetails>()));
107+
108+
var model = CreateModel(userManager, mediator, new DefaultHttpContext());
109+
110+
var result = await model.OnPostRevokeAsync("auth-1", CancellationToken.None);
111+
112+
result.ShouldBeOfType<PageResult>();
113+
model.StatusMessage.ShouldBe("The authorization could not be revoked.");
114+
}
115+
116+
private static SecurityService.Pages.Account.Grants.IndexModel CreateModel(
117+
Mock<UserManager<ApplicationUser>> userManager,
118+
Mock<IMediator> mediator,
119+
HttpContext httpContext)
120+
{
121+
return new SecurityService.Pages.Account.Grants.IndexModel(userManager.Object, mediator.Object)
122+
{
123+
PageContext = new PageContext
124+
{
125+
HttpContext = httpContext
126+
}
127+
};
128+
}
129+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using MediatR;
2+
using Microsoft.Extensions.DependencyInjection;
3+
using SecurityService.BusinessLogic.Requests;
4+
using SecurityService.UnitTests.Infrastructure;
5+
using Shouldly;
6+
using SimpleResults;
7+
8+
namespace SecurityService.UnitTests.RequestHandlers;
9+
10+
public class GrantRequestHandlerTests
11+
{
12+
[Fact]
13+
public async Task GetUserGrants_WhenNoAuthorizations_ReturnsEmptyList()
14+
{
15+
using var provider = TestServiceProviderFactory.Create(nameof(this.GetUserGrants_WhenNoAuthorizations_ReturnsEmptyList));
16+
var mediator = provider.GetRequiredService<IMediator>();
17+
18+
var result = await mediator.Send(new SecurityServiceQueries.GetUserGrantsQuery("user-1"));
19+
20+
result.IsSuccess.ShouldBeTrue();
21+
result.Data.ShouldNotBeNull();
22+
result.Data.ShouldBeEmpty();
23+
}
24+
25+
[Fact]
26+
public async Task RevokeGrant_WhenAuthorizationNotFound_ReturnsNotFound()
27+
{
28+
using var provider = TestServiceProviderFactory.Create(nameof(this.RevokeGrant_WhenAuthorizationNotFound_ReturnsNotFound));
29+
var mediator = provider.GetRequiredService<IMediator>();
30+
31+
var result = await mediator.Send(new SecurityServiceCommands.RevokeGrantCommand("user-1", "nonexistent-authorization-id"));
32+
33+
result.IsFailed.ShouldBeTrue();
34+
result.Status.ShouldBe(ResultStatus.NotFound);
35+
}
36+
}

SecurityService/Handlers/DeveloperHandler.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
using MessagingService.Client;
22
using MessagingService.DataTransferObjects;
33
using SecurityService.BusinessLogic;
4-
using SecurityService.Services;
54

65
namespace SecurityService.Handlers;
76

SecurityService/Pages/Account/ForgotPassword/Index.cshtml.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
using SecurityService.BusinessLogic.Requests;
77
using SecurityService.Database;
88
using SecurityService.Pages.Account.ChangePassword;
9-
using SecurityService.Services;
109
using System.Collections.Specialized;
1110
using System.ComponentModel.DataAnnotations;
1211
using System.Text;

SecurityService/Pages/Account/Grants/Index.cshtml.cs

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,23 @@
1+
using MediatR;
12
using Microsoft.AspNetCore.Identity;
23
using Microsoft.AspNetCore.Mvc;
34
using Microsoft.AspNetCore.Mvc.RazorPages;
5+
using SecurityService.BusinessLogic.Oidc;
6+
using SecurityService.BusinessLogic.Requests;
47
using SecurityService.Database;
58
using SecurityService.Models;
6-
using SecurityService.BusinessLogic.Oidc;
7-
using SecurityService.Services;
89

910
namespace SecurityService.Pages.Account.Grants;
1011

1112
public sealed class IndexModel : PageModel
1213
{
1314
private readonly UserManager<ApplicationUser> _userManager;
14-
private readonly IGrantService _grantService;
15+
private readonly IMediator _mediator;
1516

16-
public IndexModel(UserManager<ApplicationUser> userManager, IGrantService grantService)
17+
public IndexModel(UserManager<ApplicationUser> userManager, IMediator mediator)
1718
{
1819
this._userManager = userManager;
19-
this._grantService = grantService;
20+
this._mediator = mediator;
2021
}
2122

2223
public IReadOnlyCollection<GrantDetails> Grants { get; private set; } = Array.Empty<GrantDetails>();
@@ -31,7 +32,8 @@ public async Task<IActionResult> OnGetAsync(CancellationToken cancellationToken)
3132
return this.Redirect($"/Account/Login?returnUrl={Uri.EscapeDataString(OidcHelpers.BuildCurrentRequestUrl(this.Request))}");
3233
}
3334

34-
this.Grants = await this._grantService.GetUserGrantsAsync(user.Id, cancellationToken);
35+
var result = await this._mediator.Send(new SecurityServiceQueries.GetUserGrantsQuery(user.Id), cancellationToken);
36+
this.Grants = result.Data ?? new List<GrantDetails>();
3537
return this.Page();
3638
}
3739

@@ -43,14 +45,16 @@ public async Task<IActionResult> OnPostRevokeAsync(string authorizationId, Cance
4345
return this.Redirect($"/Account/Login?returnUrl={Uri.EscapeDataString(OidcHelpers.BuildCurrentRequestUrl(this.Request))}");
4446
}
4547

46-
var result = await this._grantService.RevokeAsync(user.Id, authorizationId, cancellationToken);
48+
var result = await this._mediator.Send(new SecurityServiceCommands.RevokeGrantCommand(user.Id, authorizationId), cancellationToken);
4749
if (result.IsSuccess == false)
4850
{
4951
this.StatusMessage = result.Message ?? "The grant could not be revoked.";
50-
this.Grants = await this._grantService.GetUserGrantsAsync(user.Id, cancellationToken);
52+
var grantsResult = await this._mediator.Send(new SecurityServiceQueries.GetUserGrantsQuery(user.Id), cancellationToken);
53+
this.Grants = grantsResult.Data ?? new List<GrantDetails>();
5154
return this.Page();
5255
}
5356

5457
return this.RedirectToPage();
5558
}
5659
}
60+

0 commit comments

Comments
 (0)