diff --git a/SecurityService.BusinessLogic/RequestHandlers/ClientRequestHandler.cs b/SecurityService.BusinessLogic/RequestHandlers/ClientRequestHandler.cs index 58f61e0..2fecf29 100644 --- a/SecurityService.BusinessLogic/RequestHandlers/ClientRequestHandler.cs +++ b/SecurityService.BusinessLogic/RequestHandlers/ClientRequestHandler.cs @@ -12,129 +12,151 @@ using SecurityService.Database.Entities; using SecurityService.Models; using SecurityService.BusinessLogic.Validation; +using Shared.Results; using SimpleResults; using static OpenIddict.Abstractions.OpenIddictConstants; namespace SecurityService.BusinessLogic.RequestHandlers; -public sealed class ClientRequestHandler : - IRequestHandler, - IRequestHandler>, - IRequestHandler>> -{ +public sealed class ClientRequestHandler : IRequestHandler, + IRequestHandler>, + IRequestHandler>> { private readonly SecurityServiceDbContext DbContext; private readonly IOpenIddictApplicationManager ApplicationManager; private readonly OAuthOptions OAuthOptions; - public ClientRequestHandler( - SecurityServiceDbContext dbContext, - IOpenIddictApplicationManager applicationManager, - IOptions options) - { + public ClientRequestHandler(SecurityServiceDbContext dbContext, + IOpenIddictApplicationManager applicationManager, + IOptions options) { this.DbContext = dbContext; this.ApplicationManager = applicationManager; this.OAuthOptions = options.Value.OAuth; } - public async Task Handle(SecurityServiceCommands.CreateClientCommand command, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(command.ClientId)) - { + private Result<(List RedirectUris, List PostLogoutRedirectUris)> ValidateCommand( + SecurityServiceCommands.CreateClientCommand command) { + if (string.IsNullOrWhiteSpace(command.ClientId)) { return Result.Invalid("Client id is required."); } - if (command.AllowedGrantTypes.Count == 0) - { + if (command.AllowedGrantTypes.Count == 0) { return Result.Invalid("At least one grant type is required."); } - String[] invalidGrantTypes = command.AllowedGrantTypes.Where(grantType => OAuthGrantPolicy.IsGrantAllowed(grantType, command.ClientId, this.OAuthOptions) == false).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); - if (invalidGrantTypes.Length > 0) - { + String[] invalidGrantTypes = command.AllowedGrantTypes + .Where(grantType => + OAuthGrantPolicy.IsGrantAllowed(grantType, command.ClientId, + this.OAuthOptions) == false) + .Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + if (invalidGrantTypes.Length > 0) { return Result.Invalid($"Unsupported grant types: {string.Join(", ", invalidGrantTypes)}."); } List redirectUris = command.ClientRedirectUris - .Where(uri => string.IsNullOrWhiteSpace(uri) == false) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - Result> redirectUriValidation = ClientRedirectUriValidator.Validate(redirectUris, nameof(command.ClientRedirectUris)); - if (redirectUriValidation.IsFailed) - { + .Where(uri => string.IsNullOrWhiteSpace(uri) == false) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + Result> redirectUriValidation = + ClientRedirectUriValidator.Validate(redirectUris, nameof(command.ClientRedirectUris)); + if (redirectUriValidation.IsFailed) { return Result.Invalid(redirectUriValidation.Message); } List postLogoutRedirectUris = command.ClientPostLogoutRedirectUris - .Where(uri => string.IsNullOrWhiteSpace(uri) == false) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - Result> postLogoutRedirectUriValidation = ClientRedirectUriValidator.Validate(postLogoutRedirectUris, nameof(command.ClientPostLogoutRedirectUris)); - if (postLogoutRedirectUriValidation.IsFailed) - { + .Where(uri => string.IsNullOrWhiteSpace(uri) == false) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + Result> postLogoutRedirectUriValidation = + ClientRedirectUriValidator.Validate(postLogoutRedirectUris, nameof(command.ClientPostLogoutRedirectUris)); + if (postLogoutRedirectUriValidation.IsFailed) { return Result.Invalid(postLogoutRedirectUriValidation.Message); } - if (await this.DbContext.ClientDefinitions.AnyAsync(client => client.ClientId == command.ClientId, cancellationToken)) - { - return Result.Conflict($"A client with id '{command.ClientId}' already exists."); + return Result.Success<(List RedirectUri, List PostLogoutRedirectUri)>((redirectUriValidation.Data, + postLogoutRedirectUriValidation.Data)); + } + + public async Task Handle(SecurityServiceCommands.CreateClientCommand command, + CancellationToken cancellationToken) { + Result<(List RedirectUris, List PostLogoutRedirectUris)> validateResult = + this.ValidateCommand(command); + + if (validateResult.IsFailed) { + return ResultHelpers.CreateFailure(validateResult); } - OAuthGrantPolicyResult permissions = OAuthGrantPolicy.CreatePermissions(command.AllowedGrantTypes, command.ClientId, this.OAuthOptions); + if (await this.DbContext.ClientDefinitions.AnyAsync(client => client.ClientId == command.ClientId, + cancellationToken)) { + return Result.Conflict($"A client with id '{command.ClientId}' already exists."); + } - OpenIddictApplicationDescriptor descriptor = new OpenIddictApplicationDescriptor - { - ClientId = command.ClientId, - DisplayName = command.ClientName, - ConsentType = command.RequireConsent ? ConsentTypes.Explicit : ConsentTypes.Implicit, - ClientType = string.IsNullOrWhiteSpace(command.Secret) ? ClientTypes.Public : ClientTypes.Confidential, - ClientSecret = string.IsNullOrWhiteSpace(command.Secret) ? null : command.Secret - }; + OAuthGrantPolicyResult permissions = + OAuthGrantPolicy.CreatePermissions(command.AllowedGrantTypes, command.ClientId, this.OAuthOptions); + + OpenIddictApplicationDescriptor descriptor = new() { + ClientId = command.ClientId, + DisplayName = command.ClientName, + ConsentType = + command.RequireConsent + ? ConsentTypes.Explicit + : ConsentTypes.Implicit, + ClientType = + string.IsNullOrWhiteSpace(command.Secret) + ? ClientTypes.Public + : ClientTypes.Confidential, + ClientSecret = string.IsNullOrWhiteSpace(command.Secret) + ? null + : command.Secret + }; descriptor.Permissions.UnionWith(permissions.Permissions); descriptor.Requirements.UnionWith(permissions.Requirements); - if (command.AllowOfflineAccess) - { + if (command.AllowOfflineAccess) { descriptor.Permissions.Add(Permissions.GrantTypes.RefreshToken); } - foreach (Uri redirectUri in redirectUriValidation.Data!) - { + foreach (Uri redirectUri in validateResult.Data.RedirectUris) { descriptor.RedirectUris.Add(redirectUri); } - foreach (Uri postLogoutRedirectUri in postLogoutRedirectUriValidation.Data!) - { + foreach (Uri postLogoutRedirectUri in validateResult.Data.PostLogoutRedirectUris) { descriptor.PostLogoutRedirectUris.Add(postLogoutRedirectUri); } - ClientDefinition definition = new ClientDefinition - { - Id = Guid.NewGuid(), - ClientId = command.ClientId, - ClientName = command.ClientName, - Description = command.ClientDescription, - ClientUri = command.ClientUri, - SecretHash = string.IsNullOrWhiteSpace(command.Secret) ? null : Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(command.Secret))), - AllowedScopesJson = JsonListSerializer.Serialize(command.AllowedScopes), - AllowedGrantTypesJson = JsonListSerializer.Serialize(command.AllowedGrantTypes), - RedirectUrisJson = JsonListSerializer.Serialize(redirectUris), - PostLogoutRedirectUrisJson = JsonListSerializer.Serialize(postLogoutRedirectUris), - RequireConsent = command.RequireConsent, - AllowOfflineAccess = command.AllowOfflineAccess, - ClientType = descriptor.ClientType - }; - - try - { + ClientDefinition definition = new() { + Id = Guid.NewGuid(), + ClientId = command.ClientId, + ClientName = command.ClientName, + Description = command.ClientDescription, + ClientUri = command.ClientUri, + SecretHash = + string.IsNullOrWhiteSpace(command.Secret) + ? null + : Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(command.Secret))), + AllowedScopesJson = JsonListSerializer.Serialize(command.AllowedScopes), + AllowedGrantTypesJson = + JsonListSerializer.Serialize(command.AllowedGrantTypes), + RedirectUrisJson = + JsonListSerializer.Serialize(validateResult.Data.RedirectUris + .Select(uri => uri.OriginalString).ToList()), + PostLogoutRedirectUrisJson = + JsonListSerializer.Serialize(validateResult.Data + .PostLogoutRedirectUris.Select(uri => uri.OriginalString).ToList()), + RequireConsent = command.RequireConsent, + AllowOfflineAccess = command.AllowOfflineAccess, + ClientType = descriptor.ClientType + }; + + try { await this.PersistClientAsync(descriptor, definition, command.ClientId, cancellationToken); } - catch (DbUpdateException) - { + catch (DbUpdateException) { this.DbContext.ChangeTracker.Clear(); - if (await this.DbContext.ClientDefinitions.AnyAsync(client => client.ClientId == command.ClientId, cancellationToken) || - await this.ApplicationManager.FindByClientIdAsync(command.ClientId, cancellationToken) is not null) - { + if (await this.DbContext.ClientDefinitions.AnyAsync(client => client.ClientId == command.ClientId, + cancellationToken) || + await this.ApplicationManager.FindByClientIdAsync(command.ClientId, cancellationToken) is not null) { return Result.Conflict($"A client with id '{command.ClientId}' already exists."); } @@ -144,90 +166,81 @@ await this.ApplicationManager.FindByClientIdAsync(command.ClientId, cancellation return Result.Success(); } - private async Task PersistClientAsync( - OpenIddictApplicationDescriptor descriptor, - ClientDefinition definition, - string clientId, - CancellationToken cancellationToken) - { + private async Task PersistClientAsync(OpenIddictApplicationDescriptor descriptor, + ClientDefinition definition, + string clientId, + CancellationToken cancellationToken) { IExecutionStrategy executionStrategy = this.DbContext.Database.CreateExecutionStrategy(); - await executionStrategy.ExecuteAsync(async () => - { - if (this.DbContext.Database.IsRelational() == false) - { + await executionStrategy.ExecuteAsync(async () => { + if (this.DbContext.Database.IsRelational() == false) { await this.PersistClientWithoutTransactionAsync(descriptor, definition, clientId, cancellationToken); return; } - await using IDbContextTransaction transaction = await this.DbContext.Database.BeginTransactionAsync(cancellationToken); - try - { + await using IDbContextTransaction transaction = + await this.DbContext.Database.BeginTransactionAsync(cancellationToken); + try { object? application = await this.ApplicationManager.FindByClientIdAsync(clientId, cancellationToken); - bool definitionExists = await this.DbContext.ClientDefinitions.AnyAsync(client => client.ClientId == clientId, cancellationToken); - if (application is not null && definitionExists) - { + bool definitionExists = + await this.DbContext.ClientDefinitions.AnyAsync(client => client.ClientId == clientId, + cancellationToken); + if (application is not null && definitionExists) { return; } - if (application is null) - { + if (application is null) { await this.ApplicationManager.CreateAsync(descriptor, cancellationToken); } - if (definitionExists == false) - { + if (definitionExists == false) { await this.DbContext.ClientDefinitions.AddAsync(definition, cancellationToken); await this.DbContext.SaveChangesAsync(cancellationToken); } await transaction.CommitAsync(cancellationToken); } - catch - { + catch { await transaction.RollbackAsync(CancellationToken.None); throw; } }); } - private async Task PersistClientWithoutTransactionAsync( - OpenIddictApplicationDescriptor descriptor, - ClientDefinition definition, - string clientId, - CancellationToken cancellationToken) - { + private async Task PersistClientWithoutTransactionAsync(OpenIddictApplicationDescriptor descriptor, + ClientDefinition definition, + string clientId, + CancellationToken cancellationToken) { object? application = await this.ApplicationManager.FindByClientIdAsync(clientId, cancellationToken); - bool definitionExists = await this.DbContext.ClientDefinitions.AnyAsync(client => client.ClientId == clientId, cancellationToken); - if (application is not null && definitionExists) - { + bool definitionExists = + await this.DbContext.ClientDefinitions.AnyAsync(client => client.ClientId == clientId, cancellationToken); + if (application is not null && definitionExists) { return; } - if (application is null) - { + if (application is null) { await this.ApplicationManager.CreateAsync(descriptor, cancellationToken); } - if (definitionExists == false) - { + if (definitionExists == false) { await this.DbContext.ClientDefinitions.AddAsync(definition, cancellationToken); await this.DbContext.SaveChangesAsync(cancellationToken); } } - public async Task> Handle(SecurityServiceQueries.GetClientQuery query, CancellationToken cancellationToken) - { - ClientDefinition? definition = await this.DbContext.ClientDefinitions.SingleOrDefaultAsync(client => client.ClientId == query.ClientId, cancellationToken); + public async Task> Handle(SecurityServiceQueries.GetClientQuery query, + CancellationToken cancellationToken) { + ClientDefinition? definition = + await this.DbContext.ClientDefinitions.SingleOrDefaultAsync(client => client.ClientId == query.ClientId, + cancellationToken); return definition is null ? Result.NotFound($"No client found with id '{query.ClientId}'.") : Result.Success(Factory.ConvertFrom(definition)); } - public async Task>> Handle(SecurityServiceQueries.GetClientsQuery query, CancellationToken cancellationToken) - { - List definitions = await this.DbContext.ClientDefinitions.OrderBy(client => client.ClientId).ToListAsync(cancellationToken); + public async Task>> Handle(SecurityServiceQueries.GetClientsQuery query, + CancellationToken cancellationToken) { + List definitions = await this.DbContext.ClientDefinitions.OrderBy(client => client.ClientId) + .ToListAsync(cancellationToken); return Result.Success(definitions.Select(Factory.ConvertFrom).ToList()); } - - } diff --git a/SecurityService.UnitTests/Endpoints/ManagementEndpointsTests.cs b/SecurityService.UnitTests/Endpoints/ManagementEndpointsTests.cs index dd4d1b2..959358a 100644 --- a/SecurityService.UnitTests/Endpoints/ManagementEndpointsTests.cs +++ b/SecurityService.UnitTests/Endpoints/ManagementEndpointsTests.cs @@ -1,12 +1,12 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.Routing; +using Imposter.Abstractions; using MediatR; -using SecurityService.BusinessLogic.Requests; using SecurityService.Authorization; using SecurityService.Endpoints; +using SecurityService.UnitTests.Infrastructure; using Shouldly; namespace SecurityService.UnitTests.Endpoints; @@ -17,8 +17,7 @@ public sealed class ManagementEndpointsTests public async Task MapManagementEndpoints_AppliesManagementPolicyToEveryRoute() { var builder = WebApplication.CreateBuilder(); - builder.Services.AddMediatR(configuration => - configuration.RegisterServicesFromAssembly(typeof(SecurityServiceCommands).Assembly)); + builder.Services.AddSingleton(new IMediatorImposter().Instance()); using var app = builder.Build(); app.MapManagementEndpoints(); diff --git a/SecurityService.UnitTests/Infrastructure/ThrowOnClientDefinitionSaveInterceptor.cs b/SecurityService.UnitTests/Infrastructure/ThrowOnClientDefinitionSaveInterceptor.cs index 882d763..336e310 100644 --- a/SecurityService.UnitTests/Infrastructure/ThrowOnClientDefinitionSaveInterceptor.cs +++ b/SecurityService.UnitTests/Infrastructure/ThrowOnClientDefinitionSaveInterceptor.cs @@ -6,6 +6,15 @@ namespace SecurityService.UnitTests.Infrastructure; internal sealed class ThrowOnClientDefinitionSaveInterceptor : SaveChangesInterceptor { + private readonly Exception _exception; + private readonly bool _throwOnOpenIddictApplicationSave; + + public ThrowOnClientDefinitionSaveInterceptor(Exception? exception = null, bool throwOnOpenIddictApplicationSave = false) + { + this._exception = exception ?? new InvalidOperationException("Injected ClientDefinition persistence failure."); + this._throwOnOpenIddictApplicationSave = throwOnOpenIddictApplicationSave; + } + public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) { ThrowIfClientDefinitionIsBeingAdded(eventData.Context); @@ -21,11 +30,17 @@ public override ValueTask> SavingChangesAsync( return base.SavingChangesAsync(eventData, result, cancellationToken); } - private static void ThrowIfClientDefinitionIsBeingAdded(DbContext? context) + private void ThrowIfClientDefinitionIsBeingAdded(DbContext? context) { - if (context?.ChangeTracker.Entries().Any(entry => entry.State == EntityState.Added) == true) + bool clientDefinitionAdded = + context?.ChangeTracker.Entries().Any(entry => entry.State == EntityState.Added) == true; + bool openIddictApplicationAdded = this._throwOnOpenIddictApplicationSave && + context?.ChangeTracker.Entries().Any(entry => entry.State == EntityState.Added && + entry.Entity.GetType().Name.Contains("Application", StringComparison.Ordinal)) == true; + + if (clientDefinitionAdded || openIddictApplicationAdded) { - throw new InvalidOperationException("Injected ClientDefinition persistence failure."); + throw this._exception; } } } diff --git a/SecurityService.UnitTests/RequestHandlers/ClientRequestHandlerTests.cs b/SecurityService.UnitTests/RequestHandlers/ClientRequestHandlerTests.cs index 4c773c1..ff0e36d 100644 --- a/SecurityService.UnitTests/RequestHandlers/ClientRequestHandlerTests.cs +++ b/SecurityService.UnitTests/RequestHandlers/ClientRequestHandlerTests.cs @@ -1,7 +1,13 @@ +using System.Reflection; using MediatR; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using OpenIddict.Abstractions; +using SecurityService.BusinessLogic; +using SecurityService.BusinessLogic.RequestHandlers; using SecurityService.Database.DbContexts; +using SecurityService.Database.Entities; using SecurityService.BusinessLogic.Requests; using SecurityService.UnitTests.Infrastructure; using Shouldly; @@ -41,6 +47,36 @@ public async Task ClientLifecycle_CreateGetAndList_Works() listResult.Data.ShouldHaveSingleItem(); } + [Fact] + public async Task CreateClient_WithEscapedRedirectUris_PreservesOriginalUriStrings() + { + using var provider = TestServiceProviderFactory.Create( + nameof(this.CreateClient_WithEscapedRedirectUris_PreservesOriginalUriStrings)); + var mediator = provider.GetRequiredService(); + const string redirectUri = "https://client.example/callback?name=a%20b"; + const string postLogoutRedirectUri = "https://client.example/logout?name=a%20b"; + + var createResult = await mediator.Send(new SecurityServiceCommands.CreateClientCommand( + "escaped-uri-client", + "secret", + "Escaped URI Client", + null, + [], + [OpenIddictConstants.GrantTypes.ClientCredentials], + null, + [redirectUri], + [postLogoutRedirectUri], + false, + false)); + + createResult.IsSuccess.ShouldBeTrue(); + var getResult = await mediator.Send(new SecurityServiceQueries.GetClientQuery("escaped-uri-client")); + + getResult.IsSuccess.ShouldBeTrue(); + getResult.Data!.RedirectUris.ShouldContain(redirectUri); + getResult.Data.PostLogoutRedirectUris.ShouldContain(postLogoutRedirectUri); + } + [Fact] public async Task CreateClient_PersistsGrantPermissionsAndPkceRequirement() { @@ -128,6 +164,62 @@ public async Task CreateClient_WhenGrantTypeIsUnsupported_ReturnsInvalid() result.Message.ShouldContain("Unsupported grant types"); } + [Fact] + public async Task CreateClient_WhenClientIdIsBlank_ReturnsInvalidWithoutPersisting() + { + using var provider = TestServiceProviderFactory.Create(nameof(this.CreateClient_WhenClientIdIsBlank_ReturnsInvalidWithoutPersisting)); + var mediator = provider.GetRequiredService(); + var applicationManager = provider.GetRequiredService(); + var dbContext = provider.GetRequiredService(); + + var result = await mediator.Send(new SecurityServiceCommands.CreateClientCommand( + " ", + "secret", + "Blank Client Id", + null, + [], + [OpenIddictConstants.GrantTypes.ClientCredentials], + null, + [], + [], + false, + false)); + + result.IsFailed.ShouldBeTrue(); + result.Status.ShouldBe(ResultStatus.Invalid); + result.Message.ShouldContain("Client id is required"); + (await applicationManager.FindByClientIdAsync(" ")).ShouldBeNull(); + (await dbContext.ClientDefinitions.AnyAsync()).ShouldBeFalse(); + } + + [Fact] + public async Task CreateClient_WhenNoGrantTypesAreProvided_ReturnsInvalidWithoutPersisting() + { + using var provider = TestServiceProviderFactory.Create(nameof(this.CreateClient_WhenNoGrantTypesAreProvided_ReturnsInvalidWithoutPersisting)); + var mediator = provider.GetRequiredService(); + var applicationManager = provider.GetRequiredService(); + var dbContext = provider.GetRequiredService(); + + var result = await mediator.Send(new SecurityServiceCommands.CreateClientCommand( + "no-grants-client", + "secret", + "No Grants Client", + null, + [], + [], + null, + [], + [], + false, + false)); + + result.IsFailed.ShouldBeTrue(); + result.Status.ShouldBe(ResultStatus.Invalid); + result.Message.ShouldContain("At least one grant type is required"); + (await applicationManager.FindByClientIdAsync("no-grants-client")).ShouldBeNull(); + (await dbContext.ClientDefinitions.AnyAsync()).ShouldBeFalse(); + } + [Fact] public async Task CreateClient_WithLegacyGrantType_IsRejectedUnlessClientIsConfigured() { @@ -326,6 +418,124 @@ await Should.ThrowAsync(() => mediator.Send(new Secur (await verificationDbContext.ClientDefinitions.AnyAsync(client => client.ClientId == "atomic-client")).ShouldBeFalse(); } + [Fact] + public async Task CreateClient_WhenMetadataSaveThrowsDbUpdateExceptionAndApplicationExists_ReturnsConflict() + { + using var provider = TestServiceProviderFactory.Create( + nameof(this.CreateClient_WhenMetadataSaveThrowsDbUpdateExceptionAndApplicationExists_ReturnsConflict), + useSqlite: true, + saveChangesInterceptor: new ThrowOnClientDefinitionSaveInterceptor(new DbUpdateException("Injected duplicate."))); + var applicationManager = provider.GetRequiredService(); + await applicationManager.CreateAsync(new OpenIddictApplicationDescriptor + { + ClientId = "duplicate-after-save-failure", + ClientSecret = "secret", + ClientType = OpenIddictConstants.ClientTypes.Confidential, + ConsentType = OpenIddictConstants.ConsentTypes.Implicit + }); + var mediator = provider.GetRequiredService(); + + var result = await mediator.Send(new SecurityServiceCommands.CreateClientCommand( + "duplicate-after-save-failure", + "secret", + "Duplicate Client", + null, + [], + [OpenIddictConstants.GrantTypes.ClientCredentials], + null, + [], + [], + false, + false)); + + result.IsFailed.ShouldBeTrue(); + result.Status.ShouldBe(ResultStatus.Conflict); + } + + [Fact] + public async Task CreateClient_WhenOpenIddictApplicationSaveThrowsDbUpdateExceptionWithoutDuplicate_RethrowsAndRollsBack() + { + using var provider = TestServiceProviderFactory.Create( + nameof(this.CreateClient_WhenOpenIddictApplicationSaveThrowsDbUpdateExceptionWithoutDuplicate_RethrowsAndRollsBack), + useSqlite: true, + saveChangesInterceptor: new ThrowOnClientDefinitionSaveInterceptor( + new DbUpdateException("Injected database failure."), throwOnOpenIddictApplicationSave: true)); + var mediator = provider.GetRequiredService(); + + await Should.ThrowAsync(() => mediator.Send(new SecurityServiceCommands.CreateClientCommand( + "database-failure-client", + "secret", + "Database Failure Client", + null, + [], + [OpenIddictConstants.GrantTypes.ClientCredentials], + null, + [], + [], + false, + false))); + + using IServiceScope verificationScope = provider.CreateScope(); + var verificationApplicationManager = verificationScope.ServiceProvider.GetRequiredService(); + var verificationDbContext = verificationScope.ServiceProvider.GetRequiredService(); + (await verificationApplicationManager.FindByClientIdAsync("database-failure-client")).ShouldBeNull(); + (await verificationDbContext.ClientDefinitions.AnyAsync(client => client.ClientId == "database-failure-client")) + .ShouldBeFalse(); + } + + [Fact] + public async Task PersistClient_WhenApplicationAndDefinitionAlreadyExist_ReturnsWithoutDuplicatingThem() + { + using var provider = TestServiceProviderFactory.Create( + nameof(this.PersistClient_WhenApplicationAndDefinitionAlreadyExist_ReturnsWithoutDuplicatingThem), + useSqlite: true); + var applicationManager = provider.GetRequiredService(); + await applicationManager.CreateAsync(new OpenIddictApplicationDescriptor + { + ClientId = "concurrent-client", + ClientSecret = "secret", + ClientType = OpenIddictConstants.ClientTypes.Confidential, + ConsentType = OpenIddictConstants.ConsentTypes.Implicit + }); + var dbContext = provider.GetRequiredService(); + dbContext.ClientDefinitions.Add(new ClientDefinition + { + Id = Guid.NewGuid(), + ClientId = "concurrent-client", + ClientName = "Concurrent Client", + AllowedScopesJson = "[]", + AllowedGrantTypesJson = "[]", + RedirectUrisJson = "[]", + PostLogoutRedirectUrisJson = "[]", + ClientType = OpenIddictConstants.ClientTypes.Confidential + }); + await dbContext.SaveChangesAsync(); + var handler = new ClientRequestHandler( + dbContext, + applicationManager, + provider.GetRequiredService>()); + var definition = await dbContext.ClientDefinitions.SingleAsync(client => client.ClientId == "concurrent-client"); + var persistClient = typeof(ClientRequestHandler).GetMethod("PersistClientAsync", BindingFlags.Instance | BindingFlags.NonPublic)!; + + await (Task)persistClient.Invoke(handler, + [ + new OpenIddictApplicationDescriptor + { + ClientId = "concurrent-client", + ClientSecret = "secret", + ClientType = OpenIddictConstants.ClientTypes.Confidential, + ConsentType = OpenIddictConstants.ConsentTypes.Implicit + }, + definition, + "concurrent-client", + CancellationToken.None + ])!; + + (await applicationManager.FindByClientIdAsync("concurrent-client")).ShouldNotBeNull(); + (await dbContext.ClientDefinitions.CountAsync(client => client.ClientId == "concurrent-client")) + .ShouldBe(1); + } + [Fact] public async Task CreateClient_WhenOpenIddictApplicationAlreadyExists_CompletesMetadataRegistration() {