mirror of
https://github.com/DuendeSoftware/products
synced 2026-05-24 09:28:24 +00:00
Merge pull request #2331 from DuendeSoftware/dh/client-enumeration-store
Add GetAllClientsAsync to IClientStore for client enumeration
This commit is contained in:
commit
e65c97126d
8 changed files with 774 additions and 331 deletions
|
|
@ -40,19 +40,19 @@
|
|||
"identity-server\\clients\\src\\MvcJarUriJwt\\MvcJarUriJwt.csproj",
|
||||
"identity-server\\clients\\src\\Web\\Web.csproj",
|
||||
"identity-server\\clients\\src\\WindowsConsoleSystemBrowser\\WindowsConsoleSystemBrowser.csproj",
|
||||
"identity-server\\hosts\\Shared\\Host.Shared.csproj",
|
||||
"identity-server\\hosts\\UI\\AspNetIdentity\\UI.AspNetIdentity.csproj",
|
||||
"identity-server\\hosts\\UI\\EntityFramework\\UI.EntityFramework.csproj",
|
||||
"identity-server\\hosts\\UI\\Main\\UI.Main.csproj",
|
||||
"identity-server\\hosts\\net10\\AspNetIdentity10\\Host.AspNetIdentity10.csproj",
|
||||
"identity-server\\hosts\\net10\\EntityFramework10\\Host.EntityFramework10.csproj",
|
||||
"identity-server\\hosts\\net10\\Main10\\Host.Main10.csproj",
|
||||
"identity-server\\hosts\\net8\\AspNetIdentity8\\Host.AspNetIdentity8.csproj",
|
||||
"identity-server\\hosts\\net8\\EntityFramework8\\Host.EntityFramework8.csproj",
|
||||
"identity-server\\hosts\\net8\\Main8\\Host.Main8.csproj",
|
||||
"identity-server\\hosts\\net9\\AspNetIdentity9\\Host.AspNetIdentity9.csproj",
|
||||
"identity-server\\hosts\\net9\\EntityFramework9\\Host.EntityFramework9.csproj",
|
||||
"identity-server\\hosts\\net9\\Main9\\Host.Main9.csproj",
|
||||
"identity-server\\hosts\\net10\\AspNetIdentity10\\Host.AspNetIdentity10.csproj",
|
||||
"identity-server\\hosts\\net10\\EntityFramework10\\Host.EntityFramework10.csproj",
|
||||
"identity-server\\hosts\\net10\\Main10\\Host.Main10.csproj",
|
||||
"identity-server\\hosts\\Shared\\Host.Shared.csproj",
|
||||
"identity-server\\hosts\\UI\\Main\\UI.Main.csproj",
|
||||
"identity-server\\hosts\\UI\\EntityFramework\\UI.EntityFramework.csproj",
|
||||
"identity-server\\hosts\\UI\\AspNetIdentity\\UI.AspNetIdentity.csproj",
|
||||
"identity-server\\migrations\\AspNetIdentityDb\\AspNetIdentityDb.csproj",
|
||||
"identity-server\\migrations\\IdentityServerDb\\IdentityServerDb.csproj",
|
||||
"identity-server\\src\\AspNetIdentity\\Duende.IdentityServer.AspNetIdentity.csproj",
|
||||
|
|
@ -68,8 +68,8 @@
|
|||
"identity-server\\templates\\src\\IdentityServerInMem\\IdentityServerInMem.csproj",
|
||||
"identity-server\\templates\\src\\IdentityServer\\IdentityServerTemplate.csproj",
|
||||
"identity-server\\templates\\src\\WebApp\\TemplateWebApp.csproj",
|
||||
"identity-server\\test\\IdentityServer.IntegrationTests\\IdentityServer.IntegrationTests.csproj",
|
||||
"identity-server\\test\\IdentityServer.EndToEndTests\\IdentityServer.EndToEndTests.csproj",
|
||||
"identity-server\\test\\IdentityServer.IntegrationTests\\IdentityServer.IntegrationTests.csproj",
|
||||
"identity-server\\test\\IdentityServer.UnitTests\\IdentityServer.UnitTests.csproj",
|
||||
"shared\\ShouldlyExtensions\\ShouldlyExtensions.csproj",
|
||||
"shared\\Xunit.Playwright\\Duende.Xunit.Playwright.csproj"
|
||||
|
|
|
|||
|
|
@ -1,88 +1,118 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
|
||||
using Duende.IdentityServer.EntityFramework.Interfaces;
|
||||
using Duende.IdentityServer.EntityFramework.Mappers;
|
||||
using Duende.IdentityServer.Services;
|
||||
using Duende.IdentityServer.Stores;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Duende.IdentityServer.EntityFramework.Stores;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of IClientStore thats uses EF.
|
||||
/// </summary>
|
||||
/// <seealso cref="IClientStore" />
|
||||
public class ClientStore : IClientStore
|
||||
{
|
||||
/// <summary>
|
||||
/// The DbContext.
|
||||
/// </summary>
|
||||
protected readonly IConfigurationDbContext Context;
|
||||
|
||||
/// <summary>
|
||||
/// The CancellationToken provider.
|
||||
/// </summary>
|
||||
protected readonly ICancellationTokenProvider CancellationTokenProvider;
|
||||
|
||||
/// <summary>
|
||||
/// The logger.
|
||||
/// </summary>
|
||||
protected readonly ILogger<ClientStore> Logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ClientStore"/> class.
|
||||
/// </summary>
|
||||
/// <param name="context">The context.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="cancellationTokenProvider"></param>
|
||||
/// <exception cref="ArgumentNullException">context</exception>
|
||||
public ClientStore(IConfigurationDbContext context, ILogger<ClientStore> logger, ICancellationTokenProvider cancellationTokenProvider)
|
||||
{
|
||||
Context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
Logger = logger;
|
||||
CancellationTokenProvider = cancellationTokenProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds a client by id
|
||||
/// </summary>
|
||||
/// <param name="clientId">The client id</param>
|
||||
/// <returns>
|
||||
/// The client
|
||||
/// </returns>
|
||||
public virtual async Task<Duende.IdentityServer.Models.Client> FindClientByIdAsync(string clientId)
|
||||
{
|
||||
using var activity = Tracing.StoreActivitySource.StartActivity("ClientStore.FindClientById");
|
||||
activity?.SetTag(Tracing.Properties.ClientId, clientId);
|
||||
|
||||
var query = Context.Clients
|
||||
.Where(x => x.ClientId == clientId)
|
||||
.Include(x => x.AllowedCorsOrigins)
|
||||
.Include(x => x.AllowedGrantTypes)
|
||||
.Include(x => x.AllowedScopes)
|
||||
.Include(x => x.Claims)
|
||||
.Include(x => x.ClientSecrets)
|
||||
.Include(x => x.IdentityProviderRestrictions)
|
||||
.Include(x => x.PostLogoutRedirectUris)
|
||||
.Include(x => x.Properties)
|
||||
.Include(x => x.RedirectUris)
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery();
|
||||
|
||||
var client = (await query.ToArrayAsync(CancellationTokenProvider.CancellationToken)).
|
||||
SingleOrDefault(x => x.ClientId == clientId);
|
||||
if (client == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var model = client.ToModel();
|
||||
|
||||
Logger.LogDebug("{clientId} found in database: {clientIdFound}", clientId, model != null);
|
||||
|
||||
return model;
|
||||
}
|
||||
}
|
||||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
|
||||
using Duende.IdentityServer.EntityFramework.Interfaces;
|
||||
using Duende.IdentityServer.EntityFramework.Mappers;
|
||||
using Duende.IdentityServer.Services;
|
||||
using Duende.IdentityServer.Stores;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Duende.IdentityServer.EntityFramework.Stores;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of IClientStore thats uses EF.
|
||||
/// </summary>
|
||||
/// <seealso cref="IClientStore" />
|
||||
public class ClientStore : IClientStore
|
||||
{
|
||||
/// <summary>
|
||||
/// The DbContext.
|
||||
/// </summary>
|
||||
protected readonly IConfigurationDbContext Context;
|
||||
|
||||
/// <summary>
|
||||
/// The CancellationToken provider.
|
||||
/// </summary>
|
||||
protected readonly ICancellationTokenProvider CancellationTokenProvider;
|
||||
|
||||
/// <summary>
|
||||
/// The logger.
|
||||
/// </summary>
|
||||
protected readonly ILogger<ClientStore> Logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ClientStore"/> class.
|
||||
/// </summary>
|
||||
/// <param name="context">The context.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="cancellationTokenProvider"></param>
|
||||
/// <exception cref="ArgumentNullException">context</exception>
|
||||
public ClientStore(IConfigurationDbContext context, ILogger<ClientStore> logger, ICancellationTokenProvider cancellationTokenProvider)
|
||||
{
|
||||
Context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
Logger = logger;
|
||||
CancellationTokenProvider = cancellationTokenProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds a client by id
|
||||
/// </summary>
|
||||
/// <param name="clientId">The client id</param>
|
||||
/// <returns>
|
||||
/// The client
|
||||
/// </returns>
|
||||
public virtual async Task<Duende.IdentityServer.Models.Client> FindClientByIdAsync(string clientId)
|
||||
{
|
||||
using var activity = Tracing.StoreActivitySource.StartActivity("ClientStore.FindClientById");
|
||||
activity?.SetTag(Tracing.Properties.ClientId, clientId);
|
||||
|
||||
var query = Context.Clients
|
||||
.Where(x => x.ClientId == clientId)
|
||||
.Include(x => x.AllowedCorsOrigins)
|
||||
.Include(x => x.AllowedGrantTypes)
|
||||
.Include(x => x.AllowedScopes)
|
||||
.Include(x => x.Claims)
|
||||
.Include(x => x.ClientSecrets)
|
||||
.Include(x => x.IdentityProviderRestrictions)
|
||||
.Include(x => x.PostLogoutRedirectUris)
|
||||
.Include(x => x.Properties)
|
||||
.Include(x => x.RedirectUris)
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery();
|
||||
|
||||
var client = (await query.ToArrayAsync(CancellationTokenProvider.CancellationToken)).
|
||||
SingleOrDefault(x => x.ClientId == clientId);
|
||||
if (client == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var model = client.ToModel();
|
||||
|
||||
Logger.LogDebug("{clientId} found in database: {clientIdFound}", clientId, model != null);
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
#if NET10_0_OR_GREATER
|
||||
/// <inheritdoc/>
|
||||
public virtual async IAsyncEnumerable<Duende.IdentityServer.Models.Client> GetAllClientsAsync()
|
||||
{
|
||||
using var activity = Tracing.StoreActivitySource.StartActivity("ClientStore.GetAllClients");
|
||||
|
||||
var query = Context.Clients
|
||||
.Include(x => x.AllowedCorsOrigins)
|
||||
.Include(x => x.AllowedGrantTypes)
|
||||
.Include(x => x.AllowedScopes)
|
||||
.Include(x => x.Claims)
|
||||
.Include(x => x.ClientSecrets)
|
||||
.Include(x => x.IdentityProviderRestrictions)
|
||||
.Include(x => x.PostLogoutRedirectUris)
|
||||
.Include(x => x.Properties)
|
||||
.Include(x => x.RedirectUris)
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery();
|
||||
|
||||
var clientCount = 0;
|
||||
await foreach (var client in query.AsAsyncEnumerable().WithCancellation(CancellationTokenProvider.CancellationToken))
|
||||
{
|
||||
clientCount++;
|
||||
yield return client.ToModel();
|
||||
}
|
||||
|
||||
Logger.LogDebug("Retrieved {clientCount} clients for enumeration", clientCount);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
|
||||
using Duende.IdentityServer.Extensions;
|
||||
using Duende.IdentityServer.Models;
|
||||
|
||||
|
|
@ -46,4 +45,18 @@ public class InMemoryClientStore : IClientStore
|
|||
|
||||
return Task.FromResult(query.SingleOrDefault());
|
||||
}
|
||||
|
||||
#if NET10_0_OR_GREATER
|
||||
/// <inheritdoc/>
|
||||
public async IAsyncEnumerable<Client> GetAllClientsAsync()
|
||||
{
|
||||
using var activity = Tracing.StoreActivitySource.StartActivity("InMemoryClientStore.GetAllClients");
|
||||
|
||||
foreach (var client in _clients)
|
||||
{
|
||||
yield return client;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -77,4 +77,31 @@ public class ValidatingClientStore<T> : IClientStore
|
|||
|
||||
return null;
|
||||
}
|
||||
|
||||
#if NET10_0_OR_GREATER
|
||||
/// <inheritdoc/>
|
||||
public async IAsyncEnumerable<Client> GetAllClientsAsync()
|
||||
{
|
||||
using var activity = Tracing.StoreActivitySource.StartActivity("ValidatingClientStore.GetAllClients");
|
||||
await foreach (var client in _inner.GetAllClientsAsync())
|
||||
{
|
||||
_logger.LogTrace("Calling into client configuration validator: {validatorType}", _validatorType);
|
||||
var context = new ClientConfigurationValidationContext(client);
|
||||
await _validator.ValidateAsync(context);
|
||||
if (context.IsValid)
|
||||
{
|
||||
_logger.LogDebug("client configuration validation for client {clientId} succeeded.", client.ClientId);
|
||||
Telemetry.Metrics.ClientValidation(client.ClientId);
|
||||
yield return client;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("Invalid client configuration for client {clientId}: {errorMessage}", client.ClientId, context.ErrorMessage);
|
||||
Telemetry.Metrics.ClientValidationFailure(client.ClientId, context.ErrorMessage);
|
||||
await _events.RaiseAsync(new InvalidClientConfigurationEvent(client, context.ErrorMessage));
|
||||
// Skip invalid clients - do not yield
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,32 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
|
||||
#nullable enable
|
||||
|
||||
using Duende.IdentityServer.Models;
|
||||
|
||||
namespace Duende.IdentityServer.Stores;
|
||||
|
||||
/// <summary>
|
||||
/// Retrieval of client configuration
|
||||
/// </summary>
|
||||
public interface IClientStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds a client by id
|
||||
/// </summary>
|
||||
/// <param name="clientId">The client id</param>
|
||||
/// <returns>The client</returns>
|
||||
Task<Client?> FindClientByIdAsync(string clientId);
|
||||
}
|
||||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
|
||||
#nullable enable
|
||||
|
||||
using Duende.IdentityServer.Models;
|
||||
|
||||
namespace Duende.IdentityServer.Stores;
|
||||
|
||||
/// <summary>
|
||||
/// Retrieval of client configuration
|
||||
/// </summary>
|
||||
public interface IClientStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds a client by id
|
||||
/// </summary>
|
||||
/// <param name="clientId">The client id</param>
|
||||
/// <returns>The client</returns>
|
||||
Task<Client?> FindClientByIdAsync(string clientId);
|
||||
|
||||
#if NET10_0_OR_GREATER
|
||||
/// <summary>
|
||||
/// Returns all clients for enumeration purposes (e.g., conformance assessment).
|
||||
/// This method has a default implementation that throws <see cref="NotSupportedException"/>.
|
||||
/// </summary>
|
||||
/// <returns>An async enumerable of all clients.</returns>
|
||||
IAsyncEnumerable<Client> GetAllClientsAsync()
|
||||
=> throw new NotSupportedException("Client enumeration is not supported by this store implementation.");
|
||||
#endif
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,174 +1,282 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
|
||||
using Duende.IdentityServer.EntityFramework.DbContexts;
|
||||
using Duende.IdentityServer.EntityFramework.Mappers;
|
||||
using Duende.IdentityServer.EntityFramework.Options;
|
||||
using Duende.IdentityServer.EntityFramework.Stores;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace Duende.IdentityServer.IntegrationTests.EntityFramework.Storage.Stores;
|
||||
|
||||
public class ClientStoreTests : IntegrationTest<ClientStoreTests, ConfigurationDbContext, ConfigurationStoreOptions>
|
||||
{
|
||||
public ClientStoreTests(DatabaseProviderFixture<ConfigurationDbContext> fixture) : base(fixture)
|
||||
{
|
||||
foreach (var options in TestDatabaseProviders)
|
||||
{
|
||||
using var context = new ConfigurationDbContext(options);
|
||||
context.Database.EnsureCreated();
|
||||
}
|
||||
}
|
||||
|
||||
[Theory, MemberData(nameof(TestDatabaseProviders))]
|
||||
public async Task FindClientByIdAsync_WhenClientDoesNotExist_ExpectNull(DbContextOptions<ConfigurationDbContext> options)
|
||||
{
|
||||
await using var context = new ConfigurationDbContext(options);
|
||||
var store = new ClientStore(context, new NullLogger<ClientStore>(), new NoneCancellationTokenProvider());
|
||||
var client = await store.FindClientByIdAsync(Guid.NewGuid().ToString());
|
||||
client.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory, MemberData(nameof(TestDatabaseProviders))]
|
||||
public async Task FindClientByIdAsync_WhenClientExists_ExpectClientReturned(DbContextOptions<ConfigurationDbContext> options)
|
||||
{
|
||||
var testClient = new Client
|
||||
{
|
||||
ClientId = "test_client",
|
||||
ClientName = "Test Client"
|
||||
};
|
||||
|
||||
await using (var context = new ConfigurationDbContext(options))
|
||||
{
|
||||
context.Clients.Add(testClient.ToEntity());
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
Client client;
|
||||
await using (var context = new ConfigurationDbContext(options))
|
||||
{
|
||||
var store = new ClientStore(context, new NullLogger<ClientStore>(), new NoneCancellationTokenProvider());
|
||||
client = await store.FindClientByIdAsync(testClient.ClientId);
|
||||
}
|
||||
|
||||
client.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Theory, MemberData(nameof(TestDatabaseProviders))]
|
||||
public async Task FindClientByIdAsync_WhenClientExistsWithCollections_ExpectClientReturnedCollections(DbContextOptions<ConfigurationDbContext> options)
|
||||
{
|
||||
var testClient = new Client
|
||||
{
|
||||
ClientId = "properties_test_client",
|
||||
ClientName = "Properties Test Client",
|
||||
AllowedCorsOrigins = { "https://localhost" },
|
||||
AllowedGrantTypes = GrantTypes.HybridAndClientCredentials,
|
||||
AllowedScopes = { "openid", "profile", "api1" },
|
||||
Claims = { new ClientClaim("test", "value") },
|
||||
ClientSecrets = { new Secret("secret".Sha256()) },
|
||||
IdentityProviderRestrictions = { "AD" },
|
||||
PostLogoutRedirectUris = { "https://locahost/signout-callback" },
|
||||
Properties = { { "foo1", "bar1" }, { "foo2", "bar2" }, },
|
||||
RedirectUris = { "https://locahost/signin" }
|
||||
};
|
||||
|
||||
await using (var context = new ConfigurationDbContext(options))
|
||||
{
|
||||
context.Clients.Add(testClient.ToEntity());
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
Client client;
|
||||
await using (var context = new ConfigurationDbContext(options))
|
||||
{
|
||||
var store = new ClientStore(context, new NullLogger<ClientStore>(), new NoneCancellationTokenProvider());
|
||||
client = await store.FindClientByIdAsync(testClient.ClientId);
|
||||
}
|
||||
|
||||
client.ShouldSatisfyAllConditions(c =>
|
||||
{
|
||||
c.ClientId.ShouldBe(testClient.ClientId);
|
||||
c.ClientName.ShouldBe(testClient.ClientName);
|
||||
c.AllowedCorsOrigins.ShouldBe(testClient.AllowedCorsOrigins);
|
||||
c.AllowedGrantTypes.ShouldBe(testClient.AllowedGrantTypes, true);
|
||||
c.AllowedScopes.ShouldBe(testClient.AllowedScopes, true);
|
||||
c.Claims.ShouldBe(testClient.Claims);
|
||||
c.ClientSecrets.ShouldBe(testClient.ClientSecrets, true);
|
||||
c.IdentityProviderRestrictions.ShouldBe(testClient.IdentityProviderRestrictions);
|
||||
c.PostLogoutRedirectUris.ShouldBe(testClient.PostLogoutRedirectUris);
|
||||
c.Properties.ShouldBe(testClient.Properties);
|
||||
c.RedirectUris.ShouldBe(testClient.RedirectUris);
|
||||
});
|
||||
}
|
||||
|
||||
[Theory, MemberData(nameof(TestDatabaseProviders))]
|
||||
public async Task FindClientByIdAsync_WhenClientsExistWithManyCollections_ExpectClientReturnedInUnderFiveSeconds(DbContextOptions<ConfigurationDbContext> options)
|
||||
{
|
||||
var testClient = new Client
|
||||
{
|
||||
ClientId = "test_client_with_uris",
|
||||
ClientName = "Test client with URIs",
|
||||
AllowedScopes = { "openid", "profile", "api1" },
|
||||
AllowedGrantTypes = GrantTypes.CodeAndClientCredentials
|
||||
};
|
||||
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
testClient.RedirectUris.Add($"https://localhost/{i}");
|
||||
testClient.PostLogoutRedirectUris.Add($"https://localhost/{i}");
|
||||
testClient.AllowedCorsOrigins.Add($"https://localhost:{i}");
|
||||
}
|
||||
|
||||
await using (var context = new ConfigurationDbContext(options))
|
||||
{
|
||||
context.Clients.Add(testClient.ToEntity());
|
||||
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
context.Clients.Add(new Client
|
||||
{
|
||||
ClientId = testClient.ClientId + i,
|
||||
ClientName = testClient.ClientName,
|
||||
AllowedScopes = testClient.AllowedScopes,
|
||||
AllowedGrantTypes = testClient.AllowedGrantTypes,
|
||||
RedirectUris = testClient.RedirectUris,
|
||||
PostLogoutRedirectUris = testClient.PostLogoutRedirectUris,
|
||||
AllowedCorsOrigins = testClient.AllowedCorsOrigins,
|
||||
}.ToEntity());
|
||||
}
|
||||
|
||||
context.SaveChanges();
|
||||
}
|
||||
|
||||
await using (var context = new ConfigurationDbContext(options))
|
||||
{
|
||||
var store = new ClientStore(context, new NullLogger<ClientStore>(), new NoneCancellationTokenProvider());
|
||||
|
||||
const int timeout = 5000;
|
||||
var task = Task.Run(() => store.FindClientByIdAsync(testClient.ClientId));
|
||||
|
||||
if (await Task.WhenAny(task, Task.Delay(timeout)) == task)
|
||||
{
|
||||
#pragma warning disable xUnit1031 // Do not use blocking task operations in test method, suppressed because the task must have completed to enter this block
|
||||
var client = task.Result;
|
||||
#pragma warning restore xUnit1031 // Do not use blocking task operations in test method
|
||||
client.ShouldSatisfyAllConditions(c =>
|
||||
{
|
||||
c.ClientId.ShouldBe(testClient.ClientId);
|
||||
c.ClientName.ShouldBe(testClient.ClientName);
|
||||
c.AllowedScopes.ShouldBe(testClient.AllowedScopes, true);
|
||||
c.AllowedGrantTypes.ShouldBe(testClient.AllowedGrantTypes);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new TestTimeoutException(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
|
||||
using Duende.IdentityServer.EntityFramework.DbContexts;
|
||||
using Duende.IdentityServer.EntityFramework.Mappers;
|
||||
using Duende.IdentityServer.EntityFramework.Options;
|
||||
using Duende.IdentityServer.EntityFramework.Stores;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace Duende.IdentityServer.IntegrationTests.EntityFramework.Storage.Stores;
|
||||
|
||||
public class ClientStoreTests : IntegrationTest<ClientStoreTests, ConfigurationDbContext, ConfigurationStoreOptions>
|
||||
{
|
||||
public ClientStoreTests(DatabaseProviderFixture<ConfigurationDbContext> fixture) : base(fixture)
|
||||
{
|
||||
foreach (var options in TestDatabaseProviders)
|
||||
{
|
||||
using var context = new ConfigurationDbContext(options);
|
||||
context.Database.EnsureCreated();
|
||||
}
|
||||
}
|
||||
|
||||
[Theory, MemberData(nameof(TestDatabaseProviders))]
|
||||
public async Task FindClientByIdAsync_WhenClientDoesNotExist_ExpectNull(DbContextOptions<ConfigurationDbContext> options)
|
||||
{
|
||||
await using var context = new ConfigurationDbContext(options);
|
||||
var store = new ClientStore(context, new NullLogger<ClientStore>(), new NoneCancellationTokenProvider());
|
||||
var client = await store.FindClientByIdAsync(Guid.NewGuid().ToString());
|
||||
client.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory, MemberData(nameof(TestDatabaseProviders))]
|
||||
public async Task FindClientByIdAsync_WhenClientExists_ExpectClientReturned(DbContextOptions<ConfigurationDbContext> options)
|
||||
{
|
||||
var testClient = new Client
|
||||
{
|
||||
ClientId = "test_client",
|
||||
ClientName = "Test Client"
|
||||
};
|
||||
|
||||
await using (var context = new ConfigurationDbContext(options))
|
||||
{
|
||||
context.Clients.Add(testClient.ToEntity());
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
Client client;
|
||||
await using (var context = new ConfigurationDbContext(options))
|
||||
{
|
||||
var store = new ClientStore(context, new NullLogger<ClientStore>(), new NoneCancellationTokenProvider());
|
||||
client = await store.FindClientByIdAsync(testClient.ClientId);
|
||||
}
|
||||
|
||||
client.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Theory, MemberData(nameof(TestDatabaseProviders))]
|
||||
public async Task FindClientByIdAsync_WhenClientExistsWithCollections_ExpectClientReturnedCollections(DbContextOptions<ConfigurationDbContext> options)
|
||||
{
|
||||
var testClient = new Client
|
||||
{
|
||||
ClientId = "properties_test_client",
|
||||
ClientName = "Properties Test Client",
|
||||
AllowedCorsOrigins = { "https://localhost" },
|
||||
AllowedGrantTypes = GrantTypes.HybridAndClientCredentials,
|
||||
AllowedScopes = { "openid", "profile", "api1" },
|
||||
Claims = { new ClientClaim("test", "value") },
|
||||
ClientSecrets = { new Secret("secret".Sha256()) },
|
||||
IdentityProviderRestrictions = { "AD" },
|
||||
PostLogoutRedirectUris = { "https://locahost/signout-callback" },
|
||||
Properties = { { "foo1", "bar1" }, { "foo2", "bar2" }, },
|
||||
RedirectUris = { "https://locahost/signin" }
|
||||
};
|
||||
|
||||
await using (var context = new ConfigurationDbContext(options))
|
||||
{
|
||||
context.Clients.Add(testClient.ToEntity());
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
Client client;
|
||||
await using (var context = new ConfigurationDbContext(options))
|
||||
{
|
||||
var store = new ClientStore(context, new NullLogger<ClientStore>(), new NoneCancellationTokenProvider());
|
||||
client = await store.FindClientByIdAsync(testClient.ClientId);
|
||||
}
|
||||
|
||||
client.ShouldSatisfyAllConditions(c =>
|
||||
{
|
||||
c.ClientId.ShouldBe(testClient.ClientId);
|
||||
c.ClientName.ShouldBe(testClient.ClientName);
|
||||
c.AllowedCorsOrigins.ShouldBe(testClient.AllowedCorsOrigins);
|
||||
c.AllowedGrantTypes.ShouldBe(testClient.AllowedGrantTypes, true);
|
||||
c.AllowedScopes.ShouldBe(testClient.AllowedScopes, true);
|
||||
c.Claims.ShouldBe(testClient.Claims);
|
||||
c.ClientSecrets.ShouldBe(testClient.ClientSecrets, true);
|
||||
c.IdentityProviderRestrictions.ShouldBe(testClient.IdentityProviderRestrictions);
|
||||
c.PostLogoutRedirectUris.ShouldBe(testClient.PostLogoutRedirectUris);
|
||||
c.Properties.ShouldBe(testClient.Properties);
|
||||
c.RedirectUris.ShouldBe(testClient.RedirectUris);
|
||||
});
|
||||
}
|
||||
|
||||
[Theory, MemberData(nameof(TestDatabaseProviders))]
|
||||
public async Task FindClientByIdAsync_WhenClientsExistWithManyCollections_ExpectClientReturnedInUnderFiveSeconds(DbContextOptions<ConfigurationDbContext> options)
|
||||
{
|
||||
var testClient = new Client
|
||||
{
|
||||
ClientId = "test_client_with_uris",
|
||||
ClientName = "Test client with URIs",
|
||||
AllowedScopes = { "openid", "profile", "api1" },
|
||||
AllowedGrantTypes = GrantTypes.CodeAndClientCredentials
|
||||
};
|
||||
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
testClient.RedirectUris.Add($"https://localhost/{i}");
|
||||
testClient.PostLogoutRedirectUris.Add($"https://localhost/{i}");
|
||||
testClient.AllowedCorsOrigins.Add($"https://localhost:{i}");
|
||||
}
|
||||
|
||||
await using (var context = new ConfigurationDbContext(options))
|
||||
{
|
||||
context.Clients.Add(testClient.ToEntity());
|
||||
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
context.Clients.Add(new Client
|
||||
{
|
||||
ClientId = testClient.ClientId + i,
|
||||
ClientName = testClient.ClientName,
|
||||
AllowedScopes = testClient.AllowedScopes,
|
||||
AllowedGrantTypes = testClient.AllowedGrantTypes,
|
||||
RedirectUris = testClient.RedirectUris,
|
||||
PostLogoutRedirectUris = testClient.PostLogoutRedirectUris,
|
||||
AllowedCorsOrigins = testClient.AllowedCorsOrigins,
|
||||
}.ToEntity());
|
||||
}
|
||||
|
||||
context.SaveChanges();
|
||||
}
|
||||
|
||||
await using (var context = new ConfigurationDbContext(options))
|
||||
{
|
||||
var store = new ClientStore(context, new NullLogger<ClientStore>(), new NoneCancellationTokenProvider());
|
||||
|
||||
const int timeout = 5000;
|
||||
var task = Task.Run(() => store.FindClientByIdAsync(testClient.ClientId));
|
||||
|
||||
if (await Task.WhenAny(task, Task.Delay(timeout)) == task)
|
||||
{
|
||||
#pragma warning disable xUnit1031 // Do not use blocking task operations in test method, suppressed because the task must have completed to enter this block
|
||||
var client = task.Result;
|
||||
#pragma warning restore xUnit1031 // Do not use blocking task operations in test method
|
||||
client.ShouldSatisfyAllConditions(c =>
|
||||
{
|
||||
c.ClientId.ShouldBe(testClient.ClientId);
|
||||
c.ClientName.ShouldBe(testClient.ClientName);
|
||||
c.AllowedScopes.ShouldBe(testClient.AllowedScopes, true);
|
||||
c.AllowedGrantTypes.ShouldBe(testClient.AllowedGrantTypes);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new TestTimeoutException(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if NET10_0_OR_GREATER
|
||||
[Theory, MemberData(nameof(TestDatabaseProviders))]
|
||||
public async Task GetAllClientsAsync_WhenNoClientsExist_ExpectEmptyCollection(DbContextOptions<ConfigurationDbContext> options)
|
||||
{
|
||||
await using var context = new ConfigurationDbContext(options);
|
||||
var store = new ClientStore(context, new NullLogger<ClientStore>(), new NoneCancellationTokenProvider());
|
||||
|
||||
var clients = new List<Client>();
|
||||
await foreach (var client in store.GetAllClientsAsync())
|
||||
{
|
||||
clients.Add(client);
|
||||
}
|
||||
|
||||
clients.ShouldNotBeNull();
|
||||
clients.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Theory, MemberData(nameof(TestDatabaseProviders))]
|
||||
public async Task GetAllClientsAsync_WhenClientsExist_ExpectAllClientsReturned(DbContextOptions<ConfigurationDbContext> options)
|
||||
{
|
||||
var testClients = new List<Client>
|
||||
{
|
||||
new Client { ClientId = "enum_client1", ClientName = "Enum Client 1" },
|
||||
new Client { ClientId = "enum_client2", ClientName = "Enum Client 2" },
|
||||
new Client { ClientId = "enum_client3", ClientName = "Enum Client 3" }
|
||||
};
|
||||
|
||||
await using (var context = new ConfigurationDbContext(options))
|
||||
{
|
||||
foreach (var client in testClients)
|
||||
{
|
||||
context.Clients.Add(client.ToEntity());
|
||||
}
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await using (var context = new ConfigurationDbContext(options))
|
||||
{
|
||||
var store = new ClientStore(context, new NullLogger<ClientStore>(), new NoneCancellationTokenProvider());
|
||||
|
||||
var clients = new List<Client>();
|
||||
await foreach (var client in store.GetAllClientsAsync())
|
||||
{
|
||||
clients.Add(client);
|
||||
}
|
||||
|
||||
clients.ShouldNotBeNull();
|
||||
clients.Count.ShouldBeGreaterThanOrEqualTo(3);
|
||||
clients.ShouldContain(c => c.ClientId == "enum_client1");
|
||||
clients.ShouldContain(c => c.ClientId == "enum_client2");
|
||||
clients.ShouldContain(c => c.ClientId == "enum_client3");
|
||||
}
|
||||
}
|
||||
|
||||
[Theory, MemberData(nameof(TestDatabaseProviders))]
|
||||
public async Task GetAllClientsAsync_WhenClientsExistWithCollections_ExpectCollectionsIncluded(DbContextOptions<ConfigurationDbContext> options)
|
||||
{
|
||||
var testClient = new Client
|
||||
{
|
||||
ClientId = "enum_collections_client",
|
||||
ClientName = "Enum Collections Client",
|
||||
AllowedCorsOrigins = { "https://localhost" },
|
||||
AllowedGrantTypes = GrantTypes.HybridAndClientCredentials,
|
||||
AllowedScopes = { "openid", "profile", "api1" },
|
||||
Claims = { new ClientClaim("test", "value") },
|
||||
ClientSecrets = { new Secret("secret".Sha256()) },
|
||||
IdentityProviderRestrictions = { "AD" },
|
||||
PostLogoutRedirectUris = { "https://localhost/signout-callback" },
|
||||
Properties = { { "foo1", "bar1" } },
|
||||
RedirectUris = { "https://localhost/signin" }
|
||||
};
|
||||
|
||||
await using (var context = new ConfigurationDbContext(options))
|
||||
{
|
||||
context.Clients.Add(testClient.ToEntity());
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await using (var context = new ConfigurationDbContext(options))
|
||||
{
|
||||
var store = new ClientStore(context, new NullLogger<ClientStore>(), new NoneCancellationTokenProvider());
|
||||
|
||||
var clients = new List<Client>();
|
||||
await foreach (var c in store.GetAllClientsAsync())
|
||||
{
|
||||
clients.Add(c);
|
||||
}
|
||||
var client = clients.FirstOrDefault(c => c.ClientId == testClient.ClientId);
|
||||
|
||||
client.ShouldSatisfyAllConditions(c =>
|
||||
{
|
||||
c.ShouldNotBeNull();
|
||||
c.ClientId.ShouldBe(testClient.ClientId);
|
||||
c.ClientName.ShouldBe(testClient.ClientName);
|
||||
c.AllowedCorsOrigins.ShouldBe(testClient.AllowedCorsOrigins);
|
||||
c.AllowedGrantTypes.ShouldBe(testClient.AllowedGrantTypes, true);
|
||||
c.AllowedScopes.ShouldBe(testClient.AllowedScopes, true);
|
||||
c.Claims.ShouldBe(testClient.Claims);
|
||||
c.ClientSecrets.ShouldBe(testClient.ClientSecrets, true);
|
||||
c.IdentityProviderRestrictions.ShouldBe(testClient.IdentityProviderRestrictions);
|
||||
c.PostLogoutRedirectUris.ShouldBe(testClient.PostLogoutRedirectUris);
|
||||
c.Properties.ShouldBe(testClient.Properties);
|
||||
c.RedirectUris.ShouldBe(testClient.RedirectUris);
|
||||
});
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,38 +1,82 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Stores;
|
||||
|
||||
namespace UnitTests.Stores;
|
||||
|
||||
public class InMemoryClientStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void InMemoryClient_should_throw_if_contain_duplicate_client_ids()
|
||||
{
|
||||
var clients = new List<Client>
|
||||
{
|
||||
new Client { ClientId = "1"},
|
||||
new Client { ClientId = "1"},
|
||||
new Client { ClientId = "3"}
|
||||
};
|
||||
|
||||
Action act = () => new InMemoryClientStore(clients);
|
||||
act.ShouldThrow<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InMemoryClient_should_not_throw_if_does_not_contain_duplicate_client_ids()
|
||||
{
|
||||
var clients = new List<Client>
|
||||
{
|
||||
new Client { ClientId = "1"},
|
||||
new Client { ClientId = "2"},
|
||||
new Client { ClientId = "3"}
|
||||
};
|
||||
|
||||
new InMemoryClientStore(clients);
|
||||
}
|
||||
}
|
||||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Stores;
|
||||
|
||||
namespace UnitTests.Stores;
|
||||
|
||||
public class InMemoryClientStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void InMemoryClient_should_throw_if_contain_duplicate_client_ids()
|
||||
{
|
||||
var clients = new List<Client>
|
||||
{
|
||||
new Client { ClientId = "1"},
|
||||
new Client { ClientId = "1"},
|
||||
new Client { ClientId = "3"}
|
||||
};
|
||||
|
||||
Action act = () => new InMemoryClientStore(clients);
|
||||
act.ShouldThrow<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InMemoryClient_should_not_throw_if_does_not_contain_duplicate_client_ids()
|
||||
{
|
||||
var clients = new List<Client>
|
||||
{
|
||||
new Client { ClientId = "1"},
|
||||
new Client { ClientId = "2"},
|
||||
new Client { ClientId = "3"}
|
||||
};
|
||||
|
||||
new InMemoryClientStore(clients);
|
||||
}
|
||||
|
||||
#if NET10_0_OR_GREATER
|
||||
[Fact]
|
||||
public async Task GetAllClientsAsync_should_return_all_clients()
|
||||
{
|
||||
var clients = new List<Client>
|
||||
{
|
||||
new Client { ClientId = "client1", ClientName = "Client One" },
|
||||
new Client { ClientId = "client2", ClientName = "Client Two" },
|
||||
new Client { ClientId = "client3", ClientName = "Client Three" }
|
||||
};
|
||||
|
||||
var store = new InMemoryClientStore(clients);
|
||||
|
||||
var result = new List<Client>();
|
||||
await foreach (var client in store.GetAllClientsAsync())
|
||||
{
|
||||
result.Add(client);
|
||||
}
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Count.ShouldBe(3);
|
||||
result.ShouldContain(c => c.ClientId == "client1");
|
||||
result.ShouldContain(c => c.ClientId == "client2");
|
||||
result.ShouldContain(c => c.ClientId == "client3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllClientsAsync_should_return_empty_when_no_clients()
|
||||
{
|
||||
var clients = new List<Client>();
|
||||
|
||||
var store = new InMemoryClientStore(clients);
|
||||
|
||||
var result = new List<Client>();
|
||||
await foreach (var client in store.GetAllClientsAsync())
|
||||
{
|
||||
result.Add(client);
|
||||
}
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,211 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
#nullable enable
|
||||
|
||||
using Duende.IdentityServer.Events;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Services;
|
||||
using Duende.IdentityServer.Stores;
|
||||
using Duende.IdentityServer.Validation;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using UnitTests.Common;
|
||||
|
||||
namespace UnitTests.Stores;
|
||||
|
||||
public class ValidatingClientStoreTests
|
||||
{
|
||||
private readonly TestEventService _events = new();
|
||||
private readonly NullLogger<ValidatingClientStore<StubClientStore>> _logger = new();
|
||||
|
||||
#if NET10_0_OR_GREATER
|
||||
[Fact]
|
||||
public async Task GetAllClientsAsync_WhenAllClientsAreValid_ShouldReturnAllClients()
|
||||
{
|
||||
var clients = new List<Client>
|
||||
{
|
||||
new() { ClientId = "client1" },
|
||||
new() { ClientId = "client2" },
|
||||
new() { ClientId = "client3" }
|
||||
};
|
||||
var innerStore = StubClientStore.WithClients(clients);
|
||||
var validator = new StubClientConfigurationValidator(isValid: true);
|
||||
var store = new ValidatingClientStore<StubClientStore>(innerStore, validator, _events, _logger);
|
||||
|
||||
var result = new List<Client>();
|
||||
await foreach (var client in store.GetAllClientsAsync())
|
||||
{
|
||||
result.Add(client);
|
||||
}
|
||||
|
||||
result.Count.ShouldBe(3);
|
||||
result.ShouldContain(c => c.ClientId == "client1");
|
||||
result.ShouldContain(c => c.ClientId == "client2");
|
||||
result.ShouldContain(c => c.ClientId == "client3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllClientsAsync_WhenSomeClientsAreInvalid_ShouldReturnOnlyValidClients()
|
||||
{
|
||||
var clients = new List<Client>
|
||||
{
|
||||
new() { ClientId = "valid1" },
|
||||
new() { ClientId = "invalid1" },
|
||||
new() { ClientId = "valid2" }
|
||||
};
|
||||
var innerStore = StubClientStore.WithClients(clients);
|
||||
// Validator that marks clients with "invalid" in the name as invalid
|
||||
var validator = new StubClientConfigurationValidator(
|
||||
validationFunc: client => !client.ClientId.Contains("invalid"),
|
||||
errorMessage: "Client is invalid"
|
||||
);
|
||||
var store = new ValidatingClientStore<StubClientStore>(innerStore, validator, _events, _logger);
|
||||
|
||||
var result = new List<Client>();
|
||||
await foreach (var client in store.GetAllClientsAsync())
|
||||
{
|
||||
result.Add(client);
|
||||
}
|
||||
|
||||
result.Count.ShouldBe(2);
|
||||
result.ShouldContain(c => c.ClientId == "valid1");
|
||||
result.ShouldContain(c => c.ClientId == "valid2");
|
||||
result.ShouldNotContain(c => c.ClientId == "invalid1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllClientsAsync_WhenClientIsInvalid_ShouldRaiseEvent()
|
||||
{
|
||||
var clients = new List<Client>
|
||||
{
|
||||
new() { ClientId = "invalid_client" }
|
||||
};
|
||||
var innerStore = StubClientStore.WithClients(clients);
|
||||
var validator = new StubClientConfigurationValidator(isValid: false, errorMessage: "Invalid configuration");
|
||||
var store = new ValidatingClientStore<StubClientStore>(innerStore, validator, _events, _logger);
|
||||
|
||||
var result = new List<Client>();
|
||||
await foreach (var client in store.GetAllClientsAsync())
|
||||
{
|
||||
result.Add(client);
|
||||
}
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
_events.AssertEventWasRaised<InvalidClientConfigurationEvent>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllClientsAsync_WhenNoClients_ShouldReturnEmpty()
|
||||
{
|
||||
var innerStore = StubClientStore.Empty();
|
||||
var validator = new StubClientConfigurationValidator(isValid: true);
|
||||
var store = new ValidatingClientStore<StubClientStore>(innerStore, validator, _events, _logger);
|
||||
|
||||
var result = new List<Client>();
|
||||
await foreach (var client in store.GetAllClientsAsync())
|
||||
{
|
||||
result.Add(client);
|
||||
}
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllClientsAsync_WhenAllClientsAreInvalid_ShouldReturnEmpty()
|
||||
{
|
||||
var clients = new List<Client>
|
||||
{
|
||||
new() { ClientId = "invalid1" },
|
||||
new() { ClientId = "invalid2" }
|
||||
};
|
||||
var innerStore = StubClientStore.WithClients(clients);
|
||||
var validator = new StubClientConfigurationValidator(isValid: false, errorMessage: "All invalid");
|
||||
// Use a stub event service that allows multiple events of the same type
|
||||
var eventService = new StubEventService();
|
||||
var store = new ValidatingClientStore<StubClientStore>(innerStore, validator, eventService, _logger);
|
||||
|
||||
var result = new List<Client>();
|
||||
await foreach (var client in store.GetAllClientsAsync())
|
||||
{
|
||||
result.Add(client);
|
||||
}
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
eventService.RaisedEventCount.ShouldBe(2);
|
||||
}
|
||||
#endif
|
||||
|
||||
private class StubClientStore : IClientStore
|
||||
{
|
||||
private readonly Client? _client;
|
||||
private readonly IEnumerable<Client> _clients;
|
||||
|
||||
private StubClientStore(Client? client, IEnumerable<Client> clients)
|
||||
{
|
||||
_client = client;
|
||||
_clients = clients;
|
||||
}
|
||||
|
||||
public static StubClientStore Empty() => new(null, []);
|
||||
|
||||
public static StubClientStore WithClient(Client client) => new(client, [client]);
|
||||
|
||||
public static StubClientStore WithClients(IEnumerable<Client> clients) => new(clients.FirstOrDefault(), clients);
|
||||
|
||||
public Task<Client?> FindClientByIdAsync(string clientId) => Task.FromResult(_client);
|
||||
|
||||
#if NET10_0_OR_GREATER
|
||||
public async IAsyncEnumerable<Client> GetAllClientsAsync()
|
||||
{
|
||||
foreach (var client in _clients)
|
||||
{
|
||||
yield return client;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private class StubClientConfigurationValidator : IClientConfigurationValidator
|
||||
{
|
||||
private readonly bool _isValid;
|
||||
private readonly string? _errorMessage;
|
||||
private readonly Func<Client, bool>? _validationFunc;
|
||||
|
||||
public StubClientConfigurationValidator(bool isValid, string? errorMessage = null)
|
||||
{
|
||||
_isValid = isValid;
|
||||
_errorMessage = errorMessage;
|
||||
}
|
||||
|
||||
public StubClientConfigurationValidator(Func<Client, bool> validationFunc, string? errorMessage = null)
|
||||
{
|
||||
_validationFunc = validationFunc;
|
||||
_errorMessage = errorMessage;
|
||||
}
|
||||
|
||||
public Task ValidateAsync(ClientConfigurationValidationContext context)
|
||||
{
|
||||
var isValid = _validationFunc != null ? _validationFunc(context.Client) : _isValid;
|
||||
|
||||
if (!isValid)
|
||||
{
|
||||
context.SetError(_errorMessage ?? "Validation failed");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private class StubEventService : IEventService
|
||||
{
|
||||
public int RaisedEventCount { get; private set; }
|
||||
|
||||
public bool CanRaiseEventType(EventTypes evtType) => true;
|
||||
|
||||
public Task RaiseAsync(Event evt)
|
||||
{
|
||||
RaisedEventCount++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue