From 7404f1438c56208edb49913ef116e1aecf61c453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Chalet?= Date: Wed, 20 May 2026 19:20:19 +0200 Subject: [PATCH] Add the ability to re-hash client secrets dynamically and make the PBKDF2 options configurable --- .../Program.cs | 2 + .../OpenIddictResources.resx | 22 +- .../Managers/OpenIddictApplicationManager.cs | 184 +- .../OpenIddictAuthorizationManager.cs | 9 +- .../Managers/OpenIddictTokenManager.cs | 6 +- src/OpenIddict.Core/OpenIddictCoreBuilder.cs | 72 + .../OpenIddictCoreConfiguration.cs | 41 +- .../OpenIddictCoreExtensions.cs | 4 +- src/OpenIddict.Core/OpenIddictCoreOptions.cs | 36 + .../OpenIddict.Client.IntegrationTests.csproj | 7 +- .../Caches/OpenIddictApplicationCacheTests.cs | 437 ++ .../OpenIddictAuthorizationCacheTests.cs | 330 ++ .../Caches/OpenIddictScopeCacheTests.cs | 417 ++ .../Caches/OpenIddictTokenCacheTests.cs | 503 +++ .../OpenIddictApplicationManagerTests.cs | 3561 +++++++++++++++++ .../OpenIddictAuthorizationManagerTests.cs | 1290 ++++++ .../Managers/OpenIddictScopeManagerTests.cs | 977 +++++ .../Managers/OpenIddictTokenManagerTests.cs | 1343 +++++++ .../OpenIddict.Core.Tests.csproj | 4 + .../OpenIddictCoreBuilderTests.cs | 255 +- .../OpenIddictCoreConfigurationTests.cs | 303 ++ .../OpenIddictCoreExtensionsTests.cs | 7 +- .../OpenIddictCoreOptionsTests.cs | 152 + .../OpenIddict.Server.IntegrationTests.csproj | 7 +- ...nIddict.Validation.IntegrationTests.csproj | 7 +- 25 files changed, 9834 insertions(+), 142 deletions(-) create mode 100644 test/OpenIddict.Core.Tests/Caches/OpenIddictApplicationCacheTests.cs create mode 100644 test/OpenIddict.Core.Tests/Caches/OpenIddictAuthorizationCacheTests.cs create mode 100644 test/OpenIddict.Core.Tests/Caches/OpenIddictScopeCacheTests.cs create mode 100644 test/OpenIddict.Core.Tests/Caches/OpenIddictTokenCacheTests.cs create mode 100644 test/OpenIddict.Core.Tests/Managers/OpenIddictApplicationManagerTests.cs create mode 100644 test/OpenIddict.Core.Tests/Managers/OpenIddictAuthorizationManagerTests.cs create mode 100644 test/OpenIddict.Core.Tests/Managers/OpenIddictScopeManagerTests.cs create mode 100644 test/OpenIddict.Core.Tests/Managers/OpenIddictTokenManagerTests.cs create mode 100644 test/OpenIddict.Core.Tests/OpenIddictCoreConfigurationTests.cs create mode 100644 test/OpenIddict.Core.Tests/OpenIddictCoreOptionsTests.cs diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Program.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Program.cs index 74a87152..1891abf6 100644 --- a/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Program.cs +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Client/Program.cs @@ -97,6 +97,8 @@ builder.Services.AddOpenIddict() RedirectUri = new Uri("callback/login/local", UriKind.Relative), PostLogoutRedirectUri = new Uri("callback/logout/local", UriKind.Relative), + // ClientSecret = "emCimpdc9SeOaZzN5jzm4_eek-STF6VenfVlKO1_qt0", + // // On supported platforms, this application can authenticate using 3 different client // authentication methods that all offer a higher security level than shared client secrets: // diff --git a/src/OpenIddict.Abstractions/OpenIddictResources.resx b/src/OpenIddict.Abstractions/OpenIddictResources.resx index bf0fb02e..eb44e11f 100644 --- a/src/OpenIddict.Abstractions/OpenIddictResources.resx +++ b/src/OpenIddict.Abstractions/OpenIddictResources.resx @@ -849,10 +849,10 @@ To register the validation services, use 'services.AddOpenIddict().AddValidation The secret cannot be null or empty. - The specified hash algorithm is not valid. + The specified hash algorithm ({0}) is not valid or is not supported by the platform. - The comparand cannot be null or empty. + The client secret payload stored in the database is malformed, uses an unsupported version or doesn't meet the minimum security requirements and may have been tampered with. One or more validation error(s) occurred while trying to create a new authorization: @@ -1869,6 +1869,15 @@ To use a custom policy relying on the system store, set 'OpenIddictServerOptions The '{0}' grant type is already assigned to a standard grant type and cannot be used for custom flows. + + The number of iterations for derivation of client secrets cannot be lower than {0} or higher than {1}. + + + The length of salts used for derivation of client secrets cannot be lower than {0} or higher than {1} bits. + + + The output length used for derivation of client secrets cannot be lower than {0} or higher than {1} bits. + The security token is missing. @@ -2549,6 +2558,9 @@ To use a custom policy relying on the system store, set 'OpenIddictServerOptions The X.509 client certificate shouldn't be null at this point. + + The length of the memory span ({0}) doesn't match the expected value ({1}). + An error occurred while validating the token '{Token}'. @@ -3332,6 +3344,12 @@ This may indicate that the hashed entry is corrupted or malformed. Certificate validation failed because the token binding certificate provided by an anonymous client was not valid: {Errors}. + + The client secret attached to the client application {ClientId} was automatically re-hashed to match the new settings. + + + The client secret attached to the client application {ClientId} couldn't be automatically re-hashed due to a concurency exception. + https://documentation.openiddict.com/errors/{0} diff --git a/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs b/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs index 151f62f3..1c6c3e0d 100644 --- a/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs +++ b/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs @@ -4,9 +4,11 @@ * the license and the contributors participating to this project. */ +using System.Buffers; using System.Buffers.Binary; using System.Collections.Immutable; using System.ComponentModel.DataAnnotations; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Runtime.CompilerServices; @@ -894,7 +896,7 @@ public class OpenIddictApplicationManager : IOpenIddictApplication ArgumentNullException.ThrowIfNull(application); ArgumentException.ThrowIfNullOrEmpty(type); - return string.Equals(await GetApplicationTypeAsync(application, cancellationToken), type, StringComparison.OrdinalIgnoreCase); + return string.Equals(await GetApplicationTypeAsync(application, cancellationToken), type, StringComparison.Ordinal); } /// @@ -910,7 +912,7 @@ public class OpenIddictApplicationManager : IOpenIddictApplication ArgumentNullException.ThrowIfNull(application); ArgumentException.ThrowIfNullOrEmpty(type); - return string.Equals(await GetClientTypeAsync(application, cancellationToken), type, StringComparison.OrdinalIgnoreCase); + return string.Equals(await GetClientTypeAsync(application, cancellationToken), type, StringComparison.Ordinal); } /// @@ -926,7 +928,7 @@ public class OpenIddictApplicationManager : IOpenIddictApplication ArgumentNullException.ThrowIfNull(application); ArgumentException.ThrowIfNullOrEmpty(type); - return string.Equals(await GetConsentTypeAsync(application, cancellationToken), type, StringComparison.OrdinalIgnoreCase); + return string.Equals(await GetConsentTypeAsync(application, cancellationToken), type, StringComparison.Ordinal); } /// @@ -1284,22 +1286,21 @@ public class OpenIddictApplicationManager : IOpenIddictApplication else { // Ensure the application type is supported by the manager. - if (!string.Equals(type, ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase) && - !string.Equals(type, ClientTypes.Public, StringComparison.OrdinalIgnoreCase)) + if (type is not (ClientTypes.Confidential or ClientTypes.Public)) { yield return new ValidationResult(SR.GetResourceString(SR.ID2112)); } // Ensure no client secret was specified if the client is a public application. var secret = await Store.GetClientSecretAsync(application, cancellationToken); - if (!string.IsNullOrEmpty(secret) && string.Equals(type, ClientTypes.Public, StringComparison.OrdinalIgnoreCase)) + if (!string.IsNullOrEmpty(secret) && type is ClientTypes.Public) { yield return new ValidationResult(SR.GetResourceString(SR.ID2114)); } // Ensure a client secret or a JSON Web Key suitable for signing // was specified if the client is a confidential application. - if (string.IsNullOrEmpty(secret) && string.Equals(type, ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase)) + if (string.IsNullOrEmpty(secret) && type is ClientTypes.Confidential) { var set = await Store.GetJsonWebKeySetAsync(application, cancellationToken); if (set?.Keys is null || !set.Keys.Any(static key => @@ -1395,13 +1396,35 @@ public class OpenIddictApplicationManager : IOpenIddictApplication return false; } - if (!await ValidateClientSecretAsync(secret, value, cancellationToken)) + var result = await ValidateClientSecretAsync(secret, value, cancellationToken); + if (!result.IsValid) { Logger.LogInformation(6161, SR.GetResourceString(SR.ID6161), await GetClientIdAsync(application, cancellationToken)); return false; } + // If the client secret was valid but a rehash is required, update the stored client secret with the new hash. + if (result.IsRehashRequired && !Options.CurrentValue.DisableAutomaticClientSecretRehashing) + { + try + { + await UpdateAsync(application, secret, cancellationToken); + } + + catch (Exception exception) when (!OpenIddictHelpers.IsFatal(exception)) + { + // If a non-fatal exception is thrown, ignore it: the client secret will be updated the next time it is validated again. + Logger.LogDebug(6295, exception, SR.GetResourceString(SR.ID6295), await GetClientIdAsync(application, cancellationToken)); + + return true; + } + + Logger.LogInformation(6294, SR.GetResourceString(SR.ID6294), await GetClientIdAsync(application, cancellationToken)); + + return true; + } + return true; } @@ -1729,30 +1752,37 @@ public class OpenIddictApplicationManager : IOpenIddictApplication { ArgumentException.ThrowIfNullOrEmpty(secret); - // Note: the PRF, iteration count, salt length and key length currently all match the default values - // used by CryptoHelper and ASP.NET Core Identity but this may change in the future, if necessary. - - var salt = RandomNumberGenerator.GetBytes(count: 128 / 8); - var hash = HashSecret(secret, salt, HashAlgorithmName.SHA256, iterations: 10_000, length: 256 / 8); - - return new(Convert.ToBase64String(hash)); - // Note: the following logic deliberately uses the same format as CryptoHelper (used in OpenIddict 1.x/2.x), // which was itself based on ASP.NET Core Identity's latest hashed password format. This guarantees that // secrets hashed using a recent OpenIddict version can still be read by older packages (and vice versa). - static byte[] HashSecret(string secret, byte[] salt, HashAlgorithmName algorithm, int iterations, int length) - { - var key = Rfc2898DeriveBytes.Pbkdf2(secret, salt, iterations, algorithm, length); - var payload = new byte[13 + salt.Length + key.Length]; + var options = Options.CurrentValue; + + var salt = RandomNumberGenerator.GetBytes(options.ClientSecretKeyDerivationSaltLength / 8); + var key = Rfc2898DeriveBytes.Pbkdf2(secret, salt, + options.ClientSecretKeyDerivationIterations, + options.ClientSecretKeyDerivationHashAlgorithm, + options.ClientSecretKeyDerivationOutputLength / 8); + + var length = 1 + sizeof(uint) * 3 + salt.Length + key.Length; + // To avoid unnecessary allocations on the heap, use a stack-allocated buffer when the total length is less + // than 256 bytes. Otherwise, rent a buffer from the shared array pool and return it to the pool after use. + byte[]? array = null; + Span payload = (length is <= 256 + ? stackalloc byte[256] + : (array = ArrayPool.Shared.Rent(minimumLength: length)))[..length]; + Debug.Assert(payload.Length == length, SR.FormatID4021(payload.Length, length)); + + try + { // Write the format marker. payload[0] = 0x01; // Write the hashing algorithm version. - BinaryPrimitives.WriteUInt32BigEndian(payload.AsSpan(1, sizeof(uint)), algorithm switch + BinaryPrimitives.WriteUInt32BigEndian(payload.Slice(1, sizeof(uint)), options.ClientSecretKeyDerivationHashAlgorithm switch { - var name when name == HashAlgorithmName.SHA1 => 0, + var name when name == HashAlgorithmName.SHA1 => 0, var name when name == HashAlgorithmName.SHA256 => 1, var name when name == HashAlgorithmName.SHA512 => 2, @@ -1760,18 +1790,27 @@ public class OpenIddictApplicationManager : IOpenIddictApplication }); // Write the iteration count of the algorithm. - BinaryPrimitives.WriteUInt32BigEndian(payload.AsSpan(5, sizeof(uint)), (uint) iterations); + BinaryPrimitives.WriteUInt32BigEndian(payload.Slice(5, sizeof(uint)), (uint) options.ClientSecretKeyDerivationIterations); // Write the size of the salt. - BinaryPrimitives.WriteUInt32BigEndian(payload.AsSpan(9, sizeof(uint)), (uint) salt.Length); + BinaryPrimitives.WriteUInt32BigEndian(payload.Slice(9, sizeof(uint)), (uint) salt.Length); // Write the salt. - salt.CopyTo(payload.AsSpan(13)); + salt.CopyTo(payload.Slice(13, salt.Length)); // Write the subkey. - key.CopyTo(payload.AsSpan(13 + salt.Length)); + key.CopyTo(payload.Slice(13 + salt.Length, key.Length)); - return payload; + return new(Convert.ToBase64String(payload, Base64FormattingOptions.None)); + } + + finally + { + // Return the rented buffer to the pool if one was used. + if (array is not null) + { + ArrayPool.Shared.Return(array, clearArray: true); + } } } @@ -1783,43 +1822,31 @@ public class OpenIddictApplicationManager : IOpenIddictApplication /// The value stored in the database, which is usually a hashed representation of the secret. /// The that can be used to abort the operation. /// - /// A that can be used to monitor the asynchronous operation, - /// whose result returns a boolean indicating whether the specified value was valid. + /// A that can be used to monitor the asynchronous operation, whose result returns + /// a tuple indicating whether the client secret was valid and whether the client secret should be re-hashed. /// - protected virtual ValueTask ValidateClientSecretAsync( + protected virtual ValueTask<(bool IsValid, bool IsRehashRequired)> ValidateClientSecretAsync( string secret, string comparand, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrEmpty(secret); ArgumentException.ThrowIfNullOrEmpty(comparand); - try - { - return new(VerifyHashedSecret(comparand, secret)); - } - - catch (Exception exception) when (!OpenIddictHelpers.IsFatal(exception)) - { - Logger.LogWarning(6163, exception, SR.GetResourceString(SR.ID6163)); - - return new(false); - } - // Note: the following logic deliberately uses the same format as CryptoHelper (used in OpenIddict 1.x/2.x), // which was itself based on ASP.NET Core Identity's latest hashed password format. This guarantees that // secrets hashed using a recent OpenIddict version can still be read by older packages (and vice versa). - static bool VerifyHashedSecret(string hash, string secret) + try { - var payload = new ReadOnlySpan(Convert.FromBase64String(hash)); - if (payload.Length is 0) + ReadOnlySpan payload = Convert.FromBase64String(comparand); + if (payload is []) { - return false; + throw new ArgumentException(SR.GetResourceString(SR.ID0218), nameof(comparand)); } // Verify the hashing format version. if (payload[0] is not 0x01) { - return false; + throw new ArgumentException(SR.GetResourceString(SR.ID0218), nameof(comparand)); } // Read the hashing algorithm version. @@ -1829,32 +1856,67 @@ public class OpenIddictApplicationManager : IOpenIddictApplication 1 => HashAlgorithmName.SHA256, 2 => HashAlgorithmName.SHA512, - _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0217)) + _ => throw new ArgumentException(SR.GetResourceString(SR.ID0218), nameof(comparand)) }; - // Read the iteration count of the algorithm. + // Read the iteration count of the algorithm and ensure it's more than + // 10 000 iterations, which is the value used in previous OpenIddict versions. var iterations = (int) BinaryPrimitives.ReadUInt32BigEndian(payload.Slice(5, sizeof(uint))); + if (iterations is not (>= 10_000 and <= 10_000_000)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0218), nameof(comparand)); + } - // Read the size of the salt and ensure it's more than 128 bits. - var saltLength = (int) BinaryPrimitives.ReadUInt32BigEndian(payload.Slice(9, sizeof(uint))); - if (saltLength is < 128 / 8) + // Read the size of the salt and ensure it's more than 128 bits, + // which is the value used in previous OpenIddict versions. + var length = (int) BinaryPrimitives.ReadUInt32BigEndian(payload.Slice(9, sizeof(uint))); + if (length is not (>= 128 / 8 and <= 1024 / 8)) { - return false; + throw new ArgumentException(SR.GetResourceString(SR.ID0218), nameof(comparand)); } // Read the salt. - var salt = payload.Slice(13, saltLength); + var salt = payload.Slice(13, length); - // Ensure the derived key length is more than 128 bits. - var keyLength = payload.Length - 13 - salt.Length; - if (keyLength is < 128 / 8) + // Ensure the derived key length is more than 128 bits, + // which is the value used in previous OpenIddict versions. + length = payload.Length - 13 - salt.Length; + if (length is not (>= 128 / 8 and <= 2048 / 8)) { - return false; + throw new ArgumentException(SR.GetResourceString(SR.ID0218), nameof(comparand)); } - return CryptographicOperations.FixedTimeEquals( - left : payload.Slice(13 + salt.Length, keyLength), - right: Rfc2898DeriveBytes.Pbkdf2(secret, salt.ToArray(), iterations, algorithm, keyLength)); + // Read the derived key. + var key = payload.Slice(13 + salt.Length, length); + + // Hash the specified client secret with the same salt, iteration count and algorithm as the + // stored value, and compare the results: if they don't match, the client secret is invalid. + if (!CryptographicOperations.FixedTimeEquals(key, Rfc2898DeriveBytes.Pbkdf2( + secret, salt, iterations, algorithm, key.Length))) + { + return new((IsValid: false, IsRehashRequired: false)); + } + + var options = Options.CurrentValue; + + // Note: if the client secret is valid but one of the key derivation options is not strictly identical (even + // when the application is now configured to use a lower security level), indicate that a rehash is required. + // + // This deliberately differs from ASP.NET Core Identity's logic, which only considers that a rehash is required + // when the iteration count configured in the password options is higher than the one extracted from the payload: + // doing that allows developers to select a lower security level (e.g fewer iterations or a less expensive hash + // algorithm) if the previous settings used in production proved to be too slow for their needs. + return new((IsValid: true, IsRehashRequired: algorithm != options.ClientSecretKeyDerivationHashAlgorithm || + iterations != options.ClientSecretKeyDerivationIterations || + salt.Length != options.ClientSecretKeyDerivationSaltLength / 8 || + key.Length != options.ClientSecretKeyDerivationOutputLength / 8)); + } + + catch (Exception exception) when (!OpenIddictHelpers.IsFatal(exception)) + { + Logger.LogWarning(6163, exception, SR.GetResourceString(SR.ID6163)); + + return new((IsValid: false, IsRehashRequired: false)); } } diff --git a/src/OpenIddict.Core/Managers/OpenIddictAuthorizationManager.cs b/src/OpenIddict.Core/Managers/OpenIddictAuthorizationManager.cs index 6b5c7c36..c8979b7b 100644 --- a/src/OpenIddict.Core/Managers/OpenIddictAuthorizationManager.cs +++ b/src/OpenIddict.Core/Managers/OpenIddictAuthorizationManager.cs @@ -630,7 +630,7 @@ public class OpenIddictAuthorizationManager : IOpenIddictAuthori ArgumentNullException.ThrowIfNull(authorization); ArgumentException.ThrowIfNullOrEmpty(status); - return string.Equals(await GetStatusAsync(authorization, cancellationToken), status, StringComparison.OrdinalIgnoreCase); + return string.Equals(await GetStatusAsync(authorization, cancellationToken), status, StringComparison.Ordinal); } /// @@ -646,7 +646,7 @@ public class OpenIddictAuthorizationManager : IOpenIddictAuthori ArgumentNullException.ThrowIfNull(authorization); ArgumentException.ThrowIfNullOrEmpty(type); - return string.Equals(await GetTypeAsync(authorization, cancellationToken), type, StringComparison.OrdinalIgnoreCase); + return string.Equals(await GetTypeAsync(authorization, cancellationToken), type, StringComparison.Ordinal); } /// @@ -811,7 +811,7 @@ public class OpenIddictAuthorizationManager : IOpenIddictAuthori ArgumentNullException.ThrowIfNull(authorization); var status = await Store.GetStatusAsync(authorization, cancellationToken); - if (string.Equals(status, Statuses.Revoked, StringComparison.OrdinalIgnoreCase)) + if (status is Statuses.Revoked) { return true; } @@ -935,8 +935,7 @@ public class OpenIddictAuthorizationManager : IOpenIddictAuthori yield return new ValidationResult(SR.GetResourceString(SR.ID2116)); } - else if (!string.Equals(type, AuthorizationTypes.AdHoc, StringComparison.OrdinalIgnoreCase) && - !string.Equals(type, AuthorizationTypes.Permanent, StringComparison.OrdinalIgnoreCase)) + else if (type is not (AuthorizationTypes.AdHoc or AuthorizationTypes.Permanent)) { yield return new ValidationResult(SR.GetResourceString(SR.ID2117)); } diff --git a/src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs b/src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs index d1741a9e..f334ed8c 100644 --- a/src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs +++ b/src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs @@ -708,7 +708,7 @@ public class OpenIddictTokenManager : IOpenIddictTokenManager where TTok ArgumentNullException.ThrowIfNull(token); ArgumentException.ThrowIfNullOrEmpty(status); - return string.Equals(await GetStatusAsync(token, cancellationToken), status, StringComparison.OrdinalIgnoreCase); + return string.Equals(await GetStatusAsync(token, cancellationToken), status, StringComparison.Ordinal); } /// @@ -723,7 +723,7 @@ public class OpenIddictTokenManager : IOpenIddictTokenManager where TTok ArgumentNullException.ThrowIfNull(token); ArgumentException.ThrowIfNullOrEmpty(type); - return string.Equals(await GetTypeAsync(token, cancellationToken), type, StringComparison.OrdinalIgnoreCase); + return string.Equals(await GetTypeAsync(token, cancellationToken), type, StringComparison.Ordinal); } /// @@ -745,7 +745,7 @@ public class OpenIddictTokenManager : IOpenIddictTokenManager where TTok for (var index = 0; index < types.Length; index++) { - if (string.Equals(type, types[index], StringComparison.OrdinalIgnoreCase)) + if (string.Equals(type, types[index], StringComparison.Ordinal)) { return true; } diff --git a/src/OpenIddict.Core/OpenIddictCoreBuilder.cs b/src/OpenIddict.Core/OpenIddictCoreBuilder.cs index a468b7e7..191727cc 100644 --- a/src/OpenIddict.Core/OpenIddictCoreBuilder.cs +++ b/src/OpenIddict.Core/OpenIddictCoreBuilder.cs @@ -6,6 +6,7 @@ using System.ComponentModel; using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography; using Microsoft.Extensions.DependencyInjection.Extensions; using OpenIddict.Core; @@ -372,6 +373,7 @@ public sealed class OpenIddictCoreBuilder /// Disabling this feature MAY result in security vulnerabilities in the other cases. /// /// The instance. + [EditorBrowsable(EditorBrowsableState.Advanced)] public OpenIddictCoreBuilder DisableAdditionalFiltering() => Configure(options => options.DisableAdditionalFiltering = true); @@ -381,9 +383,79 @@ public sealed class OpenIddictCoreBuilder /// of your application and result in multiple queries being sent by the stores. /// /// The instance. + [EditorBrowsable(EditorBrowsableState.Advanced)] public OpenIddictCoreBuilder DisableEntityCaching() => Configure(options => options.DisableEntityCaching = true); + /// + /// Disables the automatic client secret rehashing applied by the application + /// manager when a client secret is validated and needs to be rehashed. + /// + /// The instance. + [EditorBrowsable(EditorBrowsableState.Advanced)] + public OpenIddictCoreBuilder DisableAutomaticClientSecretRehashing() + => Configure(options => options.DisableAutomaticClientSecretRehashing = true); + + /// + /// Configures OpenIddict to use the specified hash algorithm to protect the client secrets. + /// + /// The hash algorithm to use. + /// The instance. + [EditorBrowsable(EditorBrowsableState.Advanced)] + public OpenIddictCoreBuilder SetClientSecretKeyDerivationHashAlgorithm(HashAlgorithmName algorithm) + { + if (algorithm != HashAlgorithmName.SHA1 && + algorithm != HashAlgorithmName.SHA256 && + algorithm != HashAlgorithmName.SHA512) + { + throw new ArgumentException(SR.FormatID0217(algorithm.Name), nameof(algorithm)); + } + + return Configure(options => options.ClientSecretKeyDerivationHashAlgorithm = algorithm); + } + + /// + /// Configures OpenIddict to use the specified number of iterations to protect the client secrets. + /// + /// The number of iterations to use. + /// The instance. + [EditorBrowsable(EditorBrowsableState.Advanced)] + public OpenIddictCoreBuilder SetClientSecretKeyDerivationIterations(int iterations) + { + ArgumentOutOfRangeException.ThrowIfLessThan(iterations, 10_000); + ArgumentOutOfRangeException.ThrowIfGreaterThan(iterations, 10_000_000); + + return Configure(options => options.ClientSecretKeyDerivationIterations = iterations); + } + + /// + /// Configures OpenIddict to use the specified output length (in bits) to protect the client secrets. + /// + /// The output length to use. + /// The instance. + [EditorBrowsable(EditorBrowsableState.Advanced)] + public OpenIddictCoreBuilder SetClientSecretKeyDerivationOutputLength(int length) + { + ArgumentOutOfRangeException.ThrowIfLessThan(length, 256); + ArgumentOutOfRangeException.ThrowIfGreaterThan(length, 2048); + + return Configure(options => options.ClientSecretKeyDerivationOutputLength = length); + } + + /// + /// Configures OpenIddict to use the specified salt length (in bits) to protect the client secrets. + /// + /// The salt length to use. + /// The instance. + [EditorBrowsable(EditorBrowsableState.Advanced)] + public OpenIddictCoreBuilder SetClientSecretKeyDerivationSaltLength(int length) + { + ArgumentOutOfRangeException.ThrowIfLessThan(length, 128); + ArgumentOutOfRangeException.ThrowIfGreaterThan(length, 1024); + + return Configure(options => options.ClientSecretKeyDerivationSaltLength = length); + } + /// /// Configures OpenIddict to use the specified entity as the default application entity. /// diff --git a/src/OpenIddict.Core/OpenIddictCoreConfiguration.cs b/src/OpenIddict.Core/OpenIddictCoreConfiguration.cs index c1e2d5a1..e83b79fd 100644 --- a/src/OpenIddict.Core/OpenIddictCoreConfiguration.cs +++ b/src/OpenIddict.Core/OpenIddictCoreConfiguration.cs @@ -4,6 +4,7 @@ * the license and the contributors participating to this project. */ +using System.Security.Cryptography; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -12,7 +13,7 @@ namespace OpenIddict.Core; /// /// Contains the methods required to ensure that the OpenIddict core configuration is valid. /// -public class OpenIddictCoreConfiguration : IPostConfigureOptions +public class OpenIddictCoreConfiguration : IPostConfigureOptions, IValidateOptions { private readonly IServiceProvider _provider; @@ -26,6 +27,44 @@ public class OpenIddictCoreConfiguration : IPostConfigureOptions public void PostConfigure(string? name, OpenIddictCoreOptions options) { + ArgumentNullException.ThrowIfNull(options); + options.TimeProvider ??= _provider.GetService() ?? TimeProvider.System; } + + /// + public ValidateOptionsResult Validate(string? name, OpenIddictCoreOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var builder = new ValidateOptionsResultBuilder(); + + // Ensure the options used to feed the PBKDF-based client secret protector respect + // the minimum security requirements used in previous versions of OpenIddict. + // + // Note: the values used here MUST be kept in sync with the values in the application manager. + if (options.ClientSecretKeyDerivationHashAlgorithm != HashAlgorithmName.SHA1 && + options.ClientSecretKeyDerivationHashAlgorithm != HashAlgorithmName.SHA256 && + options.ClientSecretKeyDerivationHashAlgorithm != HashAlgorithmName.SHA512) + { + builder.AddError(SR.FormatID0217(options.ClientSecretKeyDerivationHashAlgorithm.Name)); + } + + if (options.ClientSecretKeyDerivationIterations is not (>= 10_000 and <= 10_000_000)) + { + builder.AddError(SR.FormatID0518(10_000, 10_000_000)); + } + + if (options.ClientSecretKeyDerivationSaltLength is not (>= 128 and <= 1024)) + { + builder.AddError(SR.FormatID0519(128, 1024)); + } + + if (options.ClientSecretKeyDerivationOutputLength is not (>= 256 and <= 2048)) + { + builder.AddError(SR.FormatID0520(256, 2048)); + } + + return builder.Build(); + } } diff --git a/src/OpenIddict.Core/OpenIddictCoreExtensions.cs b/src/OpenIddict.Core/OpenIddictCoreExtensions.cs index 4a045114..a20e97ea 100644 --- a/src/OpenIddict.Core/OpenIddictCoreExtensions.cs +++ b/src/OpenIddict.Core/OpenIddictCoreExtensions.cs @@ -51,9 +51,11 @@ public static class OpenIddictCoreExtensions builder.Services.TryAddScoped(static provider => throw new InvalidOperationException(SR.GetResourceString(SR.ID0472))); - // Note: TryAddEnumerable() is used here to ensure the initializer is registered only once. + // Note: TryAddEnumerable() is used here to ensure the initializers are registered only once. builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton< IPostConfigureOptions, OpenIddictCoreConfiguration>()); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton< + IValidateOptions, OpenIddictCoreConfiguration>()); return new OpenIddictCoreBuilder(builder.Services); } diff --git a/src/OpenIddict.Core/OpenIddictCoreOptions.cs b/src/OpenIddict.Core/OpenIddictCoreOptions.cs index a3129da5..20a35b4b 100644 --- a/src/OpenIddict.Core/OpenIddictCoreOptions.cs +++ b/src/OpenIddict.Core/OpenIddictCoreOptions.cs @@ -4,6 +4,9 @@ * the license and the contributors participating to this project. */ +using System.ComponentModel; +using System.Security.Cryptography; + namespace OpenIddict.Core; /// @@ -11,6 +14,30 @@ namespace OpenIddict.Core; /// public sealed class OpenIddictCoreOptions { + /// + /// Gets or sets the hash algorithm used to protect the client secrets (by default, SHA512). + /// + [EditorBrowsable(EditorBrowsableState.Advanced)] + public HashAlgorithmName ClientSecretKeyDerivationHashAlgorithm { get; set; } = HashAlgorithmName.SHA512; + + /// + /// Gets or sets the number of iterations used to protect the client secrets (by default, 100 000). + /// + [EditorBrowsable(EditorBrowsableState.Advanced)] + public int ClientSecretKeyDerivationIterations { get; set; } = 100_000; + + /// + /// Gets or sets the length (in bits) of the PBKDF2 key used to protect the client secrets (by default, 512 bits). + /// + [EditorBrowsable(EditorBrowsableState.Advanced)] + public int ClientSecretKeyDerivationOutputLength { get; set; } = 512; + + /// + /// Gets or sets the length (in bits) of the salt used to protect the client secrets (by default, 256 bits). + /// + [EditorBrowsable(EditorBrowsableState.Advanced)] + public int ClientSecretKeyDerivationSaltLength { get; set; } = 256; + /// /// Gets or sets a boolean indicating whether additional filtering should be disabled, /// so that the OpenIddict managers don't execute a second check to ensure the results @@ -19,13 +46,21 @@ public sealed class OpenIddictCoreOptions /// are guaranteed to execute case-sensitive filtering at the database level. /// Disabling this feature MAY result in security vulnerabilities in the other cases. /// + [EditorBrowsable(EditorBrowsableState.Advanced)] public bool DisableAdditionalFiltering { get; set; } + /// + /// Gets or sets a boolean indicating whether automatic client secret rehashing should be disabled. + /// + [EditorBrowsable(EditorBrowsableState.Advanced)] + public bool DisableAutomaticClientSecretRehashing { get; set; } + /// /// Gets or sets a boolean indicating whether entity caching should be disabled. /// Disabling entity caching may have a noticeable impact on the performance /// of your application and result in multiple queries being sent by the stores. /// + [EditorBrowsable(EditorBrowsableState.Advanced)] public bool DisableEntityCaching { get; set; } /// @@ -34,6 +69,7 @@ public sealed class OpenIddictCoreOptions /// abnormally and doesn't cause a memory starvation or out-of-memory exceptions. /// This property is not used when is . /// + [EditorBrowsable(EditorBrowsableState.Advanced)] public int EntityCacheLimit { get; set; } = 250; /// diff --git a/test/OpenIddict.Client.IntegrationTests/OpenIddict.Client.IntegrationTests.csproj b/test/OpenIddict.Client.IntegrationTests/OpenIddict.Client.IntegrationTests.csproj index 902e4012..3021eed0 100644 --- a/test/OpenIddict.Client.IntegrationTests/OpenIddict.Client.IntegrationTests.csproj +++ b/test/OpenIddict.Client.IntegrationTests/OpenIddict.Client.IntegrationTests.csproj @@ -16,17 +16,12 @@ - - - - + diff --git a/test/OpenIddict.Core.Tests/Caches/OpenIddictApplicationCacheTests.cs b/test/OpenIddict.Core.Tests/Caches/OpenIddictApplicationCacheTests.cs new file mode 100644 index 00000000..d61c08c1 --- /dev/null +++ b/test/OpenIddict.Core.Tests/Caches/OpenIddictApplicationCacheTests.cs @@ -0,0 +1,437 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace OpenIddict.Core.Tests; + +public class OpenIddictApplicationCacheTests +{ + [Fact] + public void Constructor_ThrowsAnExceptionForNullOptions() + { + // Arrange + var options = (IOptionsMonitor) null!; + var store = Mock.Of>(); + + // Act and assert + var exception = Assert.Throws(() => new OpenIddictApplicationCache(options, store)); + + Assert.Equal("options", exception.ParamName); + } + + [Fact] + public void Constructor_ThrowsAnExceptionForNullStore() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = (IOpenIddictApplicationStore) null!; + + // Act and assert + var exception = Assert.Throws(() => new OpenIddictApplicationCache(options, store)); + + Assert.Equal("store", exception.ParamName); + } + + [Fact] + public async Task AddAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictApplicationCache(options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => cache.AddAsync(application: null!, CancellationToken.None).AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public void Dispose_CanBeCalledMultipleTimes() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictApplicationCache(options, store); + + // Act and assert + cache.Dispose(); + cache.Dispose(); + } + + [Fact] + public async Task FindByClientIdAsync_ThrowsAnExceptionForNullIdentifier() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictApplicationCache(options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => cache.FindByClientIdAsync(identifier: null!, CancellationToken.None).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task FindByClientIdAsync_ThrowsAnExceptionForEmptyIdentifier() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictApplicationCache(options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => cache.FindByClientIdAsync(identifier: string.Empty, CancellationToken.None).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task FindByClientIdAsync_ReturnsCachedApplicationOnCacheHit() + { + // Arrange + var application = new OpenIddictApplication(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(application, It.IsAny())) + .ReturnsAsync("application-id"); + store.Setup(store => store.GetClientIdAsync(application, It.IsAny())) + .ReturnsAsync("client-id"); + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + store.Setup(store => store.GetRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + var cache = new OpenIddictApplicationCache(options, store.Object); + + await cache.AddAsync(application, CancellationToken.None); + + // Act + var result = await cache.FindByClientIdAsync("client-id", CancellationToken.None); + + // Assert + Assert.Same(application, result); + store.Verify(store => store.FindByClientIdAsync("client-id", It.IsAny()), Times.Never()); + } + + [Fact] + public async Task FindByClientIdAsync_QueriesStoreOnCacheMiss() + { + // Arrange + var application = new OpenIddictApplication(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.FindByClientIdAsync("client-id", It.IsAny())) + .ReturnsAsync(application); + store.Setup(store => store.GetIdAsync(application, It.IsAny())) + .ReturnsAsync("application-id"); + store.Setup(store => store.GetClientIdAsync(application, It.IsAny())) + .ReturnsAsync("client-id"); + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + store.Setup(store => store.GetRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + var cache = new OpenIddictApplicationCache(options, store.Object); + + // Act + var result = await cache.FindByClientIdAsync("client-id", CancellationToken.None); + + // Assert + Assert.Same(application, result); + store.Verify(store => store.FindByClientIdAsync("client-id", It.IsAny()), Times.Once()); + } + + [Fact] + public async Task FindByIdAsync_ThrowsAnExceptionForNullIdentifier() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictApplicationCache(options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => cache.FindByIdAsync(identifier: null!, CancellationToken.None).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task FindByIdAsync_ThrowsAnExceptionForEmptyIdentifier() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictApplicationCache(options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => cache.FindByIdAsync(identifier: string.Empty, CancellationToken.None).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task FindByIdAsync_ReturnsCachedApplicationOnCacheHit() + { + // Arrange + var application = new OpenIddictApplication(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(application, It.IsAny())) + .ReturnsAsync("application-id"); + store.Setup(store => store.GetClientIdAsync(application, It.IsAny())) + .ReturnsAsync("client-id"); + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + store.Setup(store => store.GetRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + var cache = new OpenIddictApplicationCache(options, store.Object); + + await cache.AddAsync(application, CancellationToken.None); + + // Act + var result = await cache.FindByIdAsync("application-id", CancellationToken.None); + + // Assert + Assert.Same(application, result); + store.Verify(store => store.FindByIdAsync("application-id", It.IsAny()), Times.Never()); + } + + [Fact] + public async Task FindByIdAsync_QueriesStoreOnCacheMiss() + { + // Arrange + var application = new OpenIddictApplication(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.FindByIdAsync("application-id", It.IsAny())) + .ReturnsAsync(application); + store.Setup(store => store.GetIdAsync(application, It.IsAny())) + .ReturnsAsync("application-id"); + store.Setup(store => store.GetClientIdAsync(application, It.IsAny())) + .ReturnsAsync("client-id"); + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + store.Setup(store => store.GetRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + var cache = new OpenIddictApplicationCache(options, store.Object); + + // Act + var result = await cache.FindByIdAsync("application-id", CancellationToken.None); + + // Assert + Assert.Same(application, result); + store.Verify(store => store.FindByIdAsync("application-id", It.IsAny()), Times.Once()); + } + + [Fact] + public async Task FindByIdAsync_ReturnsNullWhenApplicationNotFound() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.FindByIdAsync("application-id", It.IsAny())) + .ReturnsAsync((OpenIddictApplication?) null); + + var cache = new OpenIddictApplicationCache(options, store.Object); + + // Act + var result = await cache.FindByIdAsync("application-id", CancellationToken.None); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task FindByPostLogoutRedirectUriAsync_ReturnsCachedApplicationsOnCacheHit() + { + // Arrange + var applications = new[] + { + new OpenIddictApplication(), + new OpenIddictApplication() + }; + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(applications[0], It.IsAny())) + .ReturnsAsync("application-id-1"); + store.Setup(store => store.GetClientIdAsync(applications[0], It.IsAny())) + .ReturnsAsync("client-id-1"); + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(applications[0], It.IsAny())) + .ReturnsAsync([]); + store.Setup(store => store.GetRedirectUrisAsync(applications[0], It.IsAny())) + .ReturnsAsync([]); + + store.Setup(store => store.GetIdAsync(applications[1], It.IsAny())) + .ReturnsAsync("application-id-2"); + store.Setup(store => store.GetClientIdAsync(applications[1], It.IsAny())) + .ReturnsAsync("client-id-2"); + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(applications[1], It.IsAny())) + .ReturnsAsync([]); + store.Setup(store => store.GetRedirectUrisAsync(applications[1], It.IsAny())) + .ReturnsAsync([]); + + store.Setup(store => store.FindByPostLogoutRedirectUriAsync("https://localhost/signout-callback", It.IsAny())) + .Returns(applications.ToAsyncEnumerable()); + + var cache = new OpenIddictApplicationCache(options, store.Object); + + await cache.FindByPostLogoutRedirectUriAsync("https://localhost/signout-callback", CancellationToken.None).ToListAsync(); + + // Act + var results = await cache.FindByPostLogoutRedirectUriAsync("https://localhost/signout-callback", CancellationToken.None).ToListAsync(); + + // Assert + Assert.Equal(2, results.Count); + Assert.Contains(applications[0], results); + Assert.Contains(applications[1], results); + store.Verify(store => store.FindByPostLogoutRedirectUriAsync("https://localhost/signout-callback", It.IsAny()), Times.Once()); + } + + [Fact] + public async Task FindByRedirectUriAsync_QueriesStoreOnCacheMiss() + { + // Arrange + var applications = new[] + { + new OpenIddictApplication(), + new OpenIddictApplication() + }; + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(applications[0], It.IsAny())) + .ReturnsAsync("application-id-1"); + store.Setup(store => store.GetClientIdAsync(applications[0], It.IsAny())) + .ReturnsAsync("client-id-1"); + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(applications[0], It.IsAny())) + .ReturnsAsync([]); + store.Setup(store => store.GetRedirectUrisAsync(applications[0], It.IsAny())) + .ReturnsAsync([]); + + store.Setup(store => store.GetIdAsync(applications[1], It.IsAny())) + .ReturnsAsync("application-id-2"); + store.Setup(store => store.GetClientIdAsync(applications[1], It.IsAny())) + .ReturnsAsync("client-id-2"); + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(applications[1], It.IsAny())) + .ReturnsAsync([]); + store.Setup(store => store.GetRedirectUrisAsync(applications[1], It.IsAny())) + .ReturnsAsync([]); + + store.Setup(store => store.FindByRedirectUriAsync("https://localhost/callback", It.IsAny())) + .Returns(applications.ToAsyncEnumerable()); + + var cache = new OpenIddictApplicationCache(options, store.Object); + + // Act + var results = await cache.FindByRedirectUriAsync("https://localhost/callback", CancellationToken.None).ToListAsync(); + + // Assert + Assert.Equal(2, results.Count); + Assert.Contains(applications[0], results); + Assert.Contains(applications[1], results); + store.Verify(store => store.FindByRedirectUriAsync("https://localhost/callback", It.IsAny()), Times.Once()); + } + + [Fact] + public async Task RemoveAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictApplicationCache(options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => cache.RemoveAsync(application: null!, CancellationToken.None).AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task RemoveAsync_InvalidatesCachedEntries() + { + // Arrange + var application = new OpenIddictApplication(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(application, It.IsAny())) + .ReturnsAsync("application-id"); + store.Setup(store => store.GetClientIdAsync(application, It.IsAny())) + .ReturnsAsync("client-id"); + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + store.Setup(store => store.GetRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + var cache = new OpenIddictApplicationCache(options, store.Object); + + await cache.AddAsync(application, CancellationToken.None); + + // Act + await cache.RemoveAsync(application, CancellationToken.None); + + var result = await cache.FindByIdAsync("application-id", CancellationToken.None); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task RemoveAsync_ThrowsForApplicationWithoutId() + { + // Arrange + var application = new OpenIddictApplication(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(application, It.IsAny())) + .ReturnsAsync((string?) null); + + var cache = new OpenIddictApplicationCache(options, store.Object); + + // Act and assert + await Assert.ThrowsAsync( + () => cache.RemoveAsync(application, CancellationToken.None).AsTask()); + } + + public sealed class OpenIddictApplication { } +} diff --git a/test/OpenIddict.Core.Tests/Caches/OpenIddictAuthorizationCacheTests.cs b/test/OpenIddict.Core.Tests/Caches/OpenIddictAuthorizationCacheTests.cs new file mode 100644 index 00000000..6fc7b5c8 --- /dev/null +++ b/test/OpenIddict.Core.Tests/Caches/OpenIddictAuthorizationCacheTests.cs @@ -0,0 +1,330 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace OpenIddict.Core.Tests; + +public class OpenIddictAuthorizationCacheTests +{ + [Fact] + public void Constructor_ThrowsAnExceptionForNullOptions() + { + // Arrange + var options = (IOptionsMonitor) null!; + var store = Mock.Of>(); + + // Act and assert + var exception = Assert.Throws(() => new OpenIddictAuthorizationCache(options, store)); + + Assert.Equal("options", exception.ParamName); + } + + [Fact] + public void Constructor_ThrowsAnExceptionForNullStore() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = (IOpenIddictAuthorizationStore) null!; + + // Act and assert + var exception = Assert.Throws(() => new OpenIddictAuthorizationCache(options, store)); + + Assert.Equal("store", exception.ParamName); + } + + [Fact] + public async Task AddAsync_ThrowsAnExceptionForNullAuthorization() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictAuthorizationCache(options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => cache.AddAsync(authorization: null!, CancellationToken.None).AsTask()); + + Assert.Equal("authorization", exception.ParamName); + } + + [Fact] + public void Dispose_CanBeCalledMultipleTimes() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictAuthorizationCache(options, store); + + // Act and assert + cache.Dispose(); + cache.Dispose(); + } + + [Fact] + public async Task FindByApplicationIdAsync_ReturnsCachedAuthorizationsOnCacheHit() + { + // Arrange + var authorizations = new[] + { + new OpenIddictAuthorization(), + new OpenIddictAuthorization() + }; + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(authorizations[0], It.IsAny())) + .ReturnsAsync("authorization-id-1"); + store.Setup(store => store.GetApplicationIdAsync(authorizations[0], It.IsAny())) + .ReturnsAsync("application-id"); + store.Setup(store => store.GetSubjectAsync(authorizations[0], It.IsAny())) + .ReturnsAsync("subject-1"); + + store.Setup(store => store.GetIdAsync(authorizations[1], It.IsAny())) + .ReturnsAsync("authorization-id-2"); + store.Setup(store => store.GetApplicationIdAsync(authorizations[1], It.IsAny())) + .ReturnsAsync("application-id"); + store.Setup(store => store.GetSubjectAsync(authorizations[1], It.IsAny())) + .ReturnsAsync("subject-2"); + + store.Setup(store => store.FindByApplicationIdAsync("application-id", It.IsAny())) + .Returns(authorizations.ToAsyncEnumerable()); + + var cache = new OpenIddictAuthorizationCache(options, store.Object); + await cache.FindByApplicationIdAsync("application-id", CancellationToken.None).ToListAsync(); + + // Act + var results = await cache.FindByApplicationIdAsync("application-id", CancellationToken.None).ToListAsync(); + + // Assert + Assert.Equal(2, results.Count); + Assert.Contains(authorizations[0], results); + Assert.Contains(authorizations[1], results); + store.Verify(store => store.FindByApplicationIdAsync("application-id", It.IsAny()), Times.Once()); + } + + [Fact] + public async Task FindByIdAsync_ThrowsAnExceptionForNullIdentifier() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictAuthorizationCache(options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => cache.FindByIdAsync(identifier: null!, CancellationToken.None).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task FindByIdAsync_ThrowsAnExceptionForEmptyIdentifier() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictAuthorizationCache(options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => cache.FindByIdAsync(identifier: string.Empty, CancellationToken.None).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task FindByIdAsync_ReturnsCachedAuthorizationOnCacheHit() + { + // Arrange + var authorization = new OpenIddictAuthorization(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(authorization, It.IsAny())) + .ReturnsAsync("authorization-id"); + store.Setup(store => store.GetApplicationIdAsync(authorization, It.IsAny())) + .ReturnsAsync("application-id"); + store.Setup(store => store.GetSubjectAsync(authorization, It.IsAny())) + .ReturnsAsync("subject"); + + var cache = new OpenIddictAuthorizationCache(options, store.Object); + + await cache.AddAsync(authorization, CancellationToken.None); + + // Act + var result = await cache.FindByIdAsync("authorization-id", CancellationToken.None); + + // Assert + Assert.Same(authorization, result); + store.Verify(store => store.FindByIdAsync("authorization-id", It.IsAny()), Times.Never()); + } + + [Fact] + public async Task FindByIdAsync_QueriesStoreOnCacheMiss() + { + // Arrange + var authorization = new OpenIddictAuthorization(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.FindByIdAsync("authorization-id", It.IsAny())) + .ReturnsAsync(authorization); + store.Setup(store => store.GetIdAsync(authorization, It.IsAny())) + .ReturnsAsync("authorization-id"); + store.Setup(store => store.GetApplicationIdAsync(authorization, It.IsAny())) + .ReturnsAsync("application-id"); + store.Setup(store => store.GetSubjectAsync(authorization, It.IsAny())) + .ReturnsAsync("subject"); + + var cache = new OpenIddictAuthorizationCache(options, store.Object); + + // Act + var result = await cache.FindByIdAsync("authorization-id", CancellationToken.None); + + // Assert + Assert.Same(authorization, result); + store.Verify(store => store.FindByIdAsync("authorization-id", It.IsAny()), Times.Once()); + } + + [Fact] + public async Task FindByIdAsync_ReturnsNullWhenAuthorizationNotFound() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.FindByIdAsync("authorization-id", It.IsAny())) + .ReturnsAsync((OpenIddictAuthorization?) null); + + var cache = new OpenIddictAuthorizationCache(options, store.Object); + + // Act + var result = await cache.FindByIdAsync("authorization-id", CancellationToken.None); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task FindBySubjectAsync_QueriesStoreOnCacheMiss() + { + // Arrange + var authorizations = new[] + { + new OpenIddictAuthorization(), + new OpenIddictAuthorization() + }; + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(authorizations[0], It.IsAny())) + .ReturnsAsync("authorization-id-1"); + store.Setup(store => store.GetApplicationIdAsync(authorizations[0], It.IsAny())) + .ReturnsAsync("application-id-1"); + store.Setup(store => store.GetSubjectAsync(authorizations[0], It.IsAny())) + .ReturnsAsync("subject"); + + store.Setup(store => store.GetIdAsync(authorizations[1], It.IsAny())) + .ReturnsAsync("authorization-id-2"); + store.Setup(store => store.GetApplicationIdAsync(authorizations[1], It.IsAny())) + .ReturnsAsync("application-id-2"); + store.Setup(store => store.GetSubjectAsync(authorizations[1], It.IsAny())) + .ReturnsAsync("subject"); + + store.Setup(store => store.FindBySubjectAsync("subject", It.IsAny())) + .Returns(authorizations.ToAsyncEnumerable()); + + var cache = new OpenIddictAuthorizationCache(options, store.Object); + + // Act + var results = await cache.FindBySubjectAsync("subject", CancellationToken.None).ToListAsync(); + + // Assert + Assert.Equal(2, results.Count); + Assert.Contains(authorizations[0], results); + Assert.Contains(authorizations[1], results); + store.Verify(store => store.FindBySubjectAsync("subject", It.IsAny()), Times.Once()); + } + + [Fact] + public async Task RemoveAsync_ThrowsAnExceptionForNullAuthorization() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictAuthorizationCache(options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => cache.RemoveAsync(authorization: null!, CancellationToken.None).AsTask()); + + Assert.Equal("authorization", exception.ParamName); + } + + [Fact] + public async Task RemoveAsync_InvalidatesCachedEntries() + { + // Arrange + var authorization = new OpenIddictAuthorization(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(authorization, It.IsAny())) + .ReturnsAsync("authorization-id"); + store.Setup(store => store.GetApplicationIdAsync(authorization, It.IsAny())) + .ReturnsAsync("application-id"); + store.Setup(store => store.GetSubjectAsync(authorization, It.IsAny())) + .ReturnsAsync("subject"); + + var cache = new OpenIddictAuthorizationCache(options, store.Object); + + await cache.AddAsync(authorization, CancellationToken.None); + + // Act + await cache.RemoveAsync(authorization, CancellationToken.None); + + var result = await cache.FindByIdAsync("authorization-id", CancellationToken.None); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task RemoveAsync_ThrowsForAuthorizationWithoutId() + { + // Arrange + var authorization = new OpenIddictAuthorization(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(authorization, It.IsAny())) + .ReturnsAsync((string?) null); + + var cache = new OpenIddictAuthorizationCache(options, store.Object); + + // Act and assert + await Assert.ThrowsAsync( + () => cache.RemoveAsync(authorization, CancellationToken.None).AsTask()); + } + + public sealed class OpenIddictAuthorization { } +} diff --git a/test/OpenIddict.Core.Tests/Caches/OpenIddictScopeCacheTests.cs b/test/OpenIddict.Core.Tests/Caches/OpenIddictScopeCacheTests.cs new file mode 100644 index 00000000..e5fbe4bb --- /dev/null +++ b/test/OpenIddict.Core.Tests/Caches/OpenIddictScopeCacheTests.cs @@ -0,0 +1,417 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System.Collections.Immutable; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace OpenIddict.Core.Tests; + +public class OpenIddictScopeCacheTests +{ + [Fact] + public void Constructor_ThrowsAnExceptionForNullOptions() + { + // Arrange + var options = (IOptionsMonitor) null!; + var store = Mock.Of>(); + + // Act and assert + var exception = Assert.Throws(() => new OpenIddictScopeCache(options, store)); + + Assert.Equal("options", exception.ParamName); + } + + [Fact] + public void Constructor_ThrowsAnExceptionForNullStore() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = (IOpenIddictScopeStore) null!; + + // Act and assert + var exception = Assert.Throws(() => new OpenIddictScopeCache(options, store)); + + Assert.Equal("store", exception.ParamName); + } + + [Fact] + public async Task AddAsync_ThrowsAnExceptionForNullScope() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictScopeCache(options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => cache.AddAsync(scope: null!, CancellationToken.None).AsTask()); + + Assert.Equal("scope", exception.ParamName); + } + + [Fact] + public void Dispose_CanBeCalledMultipleTimes() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictScopeCache(options, store); + + // Act and assert + cache.Dispose(); + cache.Dispose(); + } + + [Fact] + public async Task FindByIdAsync_ThrowsAnExceptionForNullIdentifier() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictScopeCache(options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => cache.FindByIdAsync(identifier: null!, CancellationToken.None).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task FindByIdAsync_ThrowsAnExceptionForEmptyIdentifier() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictScopeCache(options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => cache.FindByIdAsync(identifier: string.Empty, CancellationToken.None).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task FindByIdAsync_ReturnsCachedScopeOnCacheHit() + { + // Arrange + var scope = new OpenIddictScope(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(scope, It.IsAny())) + .ReturnsAsync("scope-id"); + store.Setup(store => store.GetNameAsync(scope, It.IsAny())) + .ReturnsAsync("scope-name"); + store.Setup(store => store.GetResourcesAsync(scope, It.IsAny())) + .ReturnsAsync([]); + + var cache = new OpenIddictScopeCache(options, store.Object); + + await cache.AddAsync(scope, CancellationToken.None); + + // Act + var result = await cache.FindByIdAsync("scope-id", CancellationToken.None); + + // Assert + Assert.Same(scope, result); + store.Verify(store => store.FindByIdAsync("scope-id", It.IsAny()), Times.Never()); + } + + [Fact] + public async Task FindByIdAsync_QueriesStoreOnCacheMiss() + { + // Arrange + var scope = new OpenIddictScope(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.FindByIdAsync("scope-id", It.IsAny())) + .ReturnsAsync(scope); + store.Setup(store => store.GetIdAsync(scope, It.IsAny())) + .ReturnsAsync("scope-id"); + store.Setup(store => store.GetNameAsync(scope, It.IsAny())) + .ReturnsAsync("scope-name"); + store.Setup(store => store.GetResourcesAsync(scope, It.IsAny())) + .ReturnsAsync([]); + + var cache = new OpenIddictScopeCache(options, store.Object); + + // Act + var result = await cache.FindByIdAsync("scope-id", CancellationToken.None); + + // Assert + Assert.Same(scope, result); + store.Verify(store => store.FindByIdAsync("scope-id", It.IsAny()), Times.Once()); + } + + [Fact] + public async Task FindByIdAsync_ReturnsNullWhenScopeNotFound() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.FindByIdAsync("scope-id", It.IsAny())) + .ReturnsAsync((OpenIddictScope?) null); + + var cache = new OpenIddictScopeCache(options, store.Object); + + // Act + var result = await cache.FindByIdAsync("scope-id", CancellationToken.None); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task FindByNameAsync_ThrowsAnExceptionForNullName() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictScopeCache(options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => cache.FindByNameAsync(name: null!, CancellationToken.None).AsTask()); + + Assert.Equal("name", exception.ParamName); + } + + [Fact] + public async Task FindByNameAsync_ThrowsAnExceptionForEmptyName() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictScopeCache(options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => cache.FindByNameAsync(name: string.Empty, CancellationToken.None).AsTask()); + + Assert.Equal("name", exception.ParamName); + } + + [Fact] + public async Task FindByNameAsync_ReturnsCachedScopeOnCacheHit() + { + // Arrange + var scope = new OpenIddictScope(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(scope, It.IsAny())) + .ReturnsAsync("scope-id"); + store.Setup(store => store.GetNameAsync(scope, It.IsAny())) + .ReturnsAsync("scope-name"); + store.Setup(store => store.GetResourcesAsync(scope, It.IsAny())) + .ReturnsAsync([]); + + var cache = new OpenIddictScopeCache(options, store.Object); + + await cache.AddAsync(scope, CancellationToken.None); + + // Act + var result = await cache.FindByNameAsync("scope-name", CancellationToken.None); + + // Assert + Assert.Same(scope, result); + store.Verify(store => store.FindByNameAsync("scope-name", It.IsAny()), Times.Never()); + } + + [Fact] + public async Task FindByNameAsync_QueriesStoreOnCacheMiss() + { + // Arrange + var scope = new OpenIddictScope(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.FindByNameAsync("scope-name", It.IsAny())) + .ReturnsAsync(scope); + store.Setup(store => store.GetIdAsync(scope, It.IsAny())) + .ReturnsAsync("scope-id"); + store.Setup(store => store.GetNameAsync(scope, It.IsAny())) + .ReturnsAsync("scope-name"); + store.Setup(store => store.GetResourcesAsync(scope, It.IsAny())) + .ReturnsAsync([]); + + var cache = new OpenIddictScopeCache(options, store.Object); + + // Act + var result = await cache.FindByNameAsync("scope-name", CancellationToken.None); + + // Assert + Assert.Same(scope, result); + store.Verify(store => store.FindByNameAsync("scope-name", It.IsAny()), Times.Once()); + } + + [Fact] + public async Task FindByNamesAsync_QueriesStoreOnCacheMiss() + { + // Arrange + var scopes = new[] + { + new OpenIddictScope(), + new OpenIddictScope() + }; + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(scopes[0], It.IsAny())) + .ReturnsAsync("scope-id-1"); + store.Setup(store => store.GetNameAsync(scopes[0], It.IsAny())) + .ReturnsAsync("scope-name-1"); + store.Setup(store => store.GetResourcesAsync(scopes[0], It.IsAny())) + .ReturnsAsync([]); + + store.Setup(store => store.GetIdAsync(scopes[1], It.IsAny())) + .ReturnsAsync("scope-id-2"); + store.Setup(store => store.GetNameAsync(scopes[1], It.IsAny())) + .ReturnsAsync("scope-name-2"); + store.Setup(store => store.GetResourcesAsync(scopes[1], It.IsAny())) + .ReturnsAsync([]); + + store.Setup(store => store.FindByNamesAsync(It.IsAny>(), It.IsAny())) + .Returns(scopes.ToAsyncEnumerable()); + + var cache = new OpenIddictScopeCache(options, store.Object); + + // Act + var results = await cache.FindByNamesAsync(["scope-name-1", "scope-name-2"], CancellationToken.None).ToListAsync(); + + // Assert + Assert.Equal(2, results.Count); + Assert.Contains(scopes[0], results); + Assert.Contains(scopes[1], results); + } + + [Fact] + public async Task FindByResourceAsync_QueriesStoreOnCacheMiss() + { + // Arrange + var scopes = new[] + { + new OpenIddictScope(), + new OpenIddictScope() + }; + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(scopes[0], It.IsAny())) + .ReturnsAsync("scope-id-1"); + store.Setup(store => store.GetNameAsync(scopes[0], It.IsAny())) + .ReturnsAsync("scope-name-1"); + store.Setup(store => store.GetResourcesAsync(scopes[0], It.IsAny())) + .ReturnsAsync([]); + + store.Setup(store => store.GetIdAsync(scopes[1], It.IsAny())) + .ReturnsAsync("scope-id-2"); + store.Setup(store => store.GetNameAsync(scopes[1], It.IsAny())) + .ReturnsAsync("scope-name-2"); + store.Setup(store => store.GetResourcesAsync(scopes[1], It.IsAny())) + .ReturnsAsync([]); + + store.Setup(store => store.FindByResourceAsync("resource", It.IsAny())) + .Returns(scopes.ToAsyncEnumerable()); + + var cache = new OpenIddictScopeCache(options, store.Object); + + // Act + var results = await cache.FindByResourceAsync("resource", CancellationToken.None).ToListAsync(); + + // Assert + Assert.Equal(2, results.Count); + Assert.Contains(scopes[0], results); + Assert.Contains(scopes[1], results); + store.Verify(store => store.FindByResourceAsync("resource", It.IsAny()), Times.Once()); + } + + [Fact] + public async Task RemoveAsync_ThrowsAnExceptionForNullScope() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictScopeCache(options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => cache.RemoveAsync(scope: null!, CancellationToken.None).AsTask()); + + Assert.Equal("scope", exception.ParamName); + } + + [Fact] + public async Task RemoveAsync_InvalidatesCachedEntries() + { + // Arrange + var scope = new OpenIddictScope(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(scope, It.IsAny())) + .ReturnsAsync("scope-id"); + store.Setup(store => store.GetNameAsync(scope, It.IsAny())) + .ReturnsAsync("scope-name"); + store.Setup(store => store.GetResourcesAsync(scope, It.IsAny())) + .ReturnsAsync([]); + + var cache = new OpenIddictScopeCache(options, store.Object); + + await cache.AddAsync(scope, CancellationToken.None); + + // Act + await cache.RemoveAsync(scope, CancellationToken.None); + + var result = await cache.FindByIdAsync("scope-id", CancellationToken.None); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task RemoveAsync_ThrowsForScopeWithoutId() + { + // Arrange + var scope = new OpenIddictScope(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(scope, It.IsAny())) + .ReturnsAsync((string?) null); + + var cache = new OpenIddictScopeCache(options, store.Object); + + // Act and assert + await Assert.ThrowsAsync( + () => cache.RemoveAsync(scope, CancellationToken.None).AsTask()); + } + + public sealed class OpenIddictScope { } +} diff --git a/test/OpenIddict.Core.Tests/Caches/OpenIddictTokenCacheTests.cs b/test/OpenIddict.Core.Tests/Caches/OpenIddictTokenCacheTests.cs new file mode 100644 index 00000000..21b9eb14 --- /dev/null +++ b/test/OpenIddict.Core.Tests/Caches/OpenIddictTokenCacheTests.cs @@ -0,0 +1,503 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace OpenIddict.Core.Tests; + +public class OpenIddictTokenCacheTests +{ + [Fact] + public void Constructor_ThrowsAnExceptionForNullOptions() + { + // Arrange + var options = (IOptionsMonitor) null!; + var store = Mock.Of>(); + + // Act and assert + var exception = Assert.Throws(() => new OpenIddictTokenCache(options, store)); + + Assert.Equal("options", exception.ParamName); + } + + [Fact] + public void Constructor_ThrowsAnExceptionForNullStore() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = (IOpenIddictTokenStore) null!; + + // Act and assert + var exception = Assert.Throws(() => new OpenIddictTokenCache(options, store)); + + Assert.Equal("store", exception.ParamName); + } + + [Fact] + public async Task AddAsync_ThrowsAnExceptionForNullToken() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictTokenCache(options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => cache.AddAsync(token: null!, CancellationToken.None).AsTask()); + + Assert.Equal("token", exception.ParamName); + } + + [Fact] + public void Dispose_CanBeCalledMultipleTimes() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictTokenCache(options, store); + + // Act and assert + cache.Dispose(); + cache.Dispose(); + } + + [Fact] + public async Task FindByApplicationIdAsync_QueriesStoreOnCacheMiss() + { + // Arrange + var tokens = new[] + { + new OpenIddictToken(), + new OpenIddictToken() + }; + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(tokens[0], It.IsAny())) + .ReturnsAsync("token-id-1"); + store.Setup(store => store.GetReferenceIdAsync(tokens[0], It.IsAny())) + .ReturnsAsync((string?) null); + store.Setup(store => store.GetApplicationIdAsync(tokens[0], It.IsAny())) + .ReturnsAsync("application-id"); + store.Setup(store => store.GetAuthorizationIdAsync(tokens[0], It.IsAny())) + .ReturnsAsync((string?) null); + store.Setup(store => store.GetSubjectAsync(tokens[0], It.IsAny())) + .ReturnsAsync((string?) null); + + store.Setup(store => store.GetIdAsync(tokens[1], It.IsAny())) + .ReturnsAsync("token-id-2"); + store.Setup(store => store.GetReferenceIdAsync(tokens[1], It.IsAny())) + .ReturnsAsync((string?) null); + store.Setup(store => store.GetApplicationIdAsync(tokens[1], It.IsAny())) + .ReturnsAsync("application-id"); + store.Setup(store => store.GetAuthorizationIdAsync(tokens[1], It.IsAny())) + .ReturnsAsync((string?) null); + store.Setup(store => store.GetSubjectAsync(tokens[1], It.IsAny())) + .ReturnsAsync((string?) null); + + store.Setup(store => store.FindByApplicationIdAsync("application-id", It.IsAny())) + .Returns(tokens.ToAsyncEnumerable()); + + var cache = new OpenIddictTokenCache(options, store.Object); + + // Act + var results = await cache.FindByApplicationIdAsync("application-id", CancellationToken.None).ToListAsync(); + + // Assert + Assert.Equal(2, results.Count); + Assert.Contains(tokens[0], results); + Assert.Contains(tokens[1], results); + store.Verify(store => store.FindByApplicationIdAsync("application-id", It.IsAny()), Times.Once()); + } + + [Fact] + public async Task FindByAuthorizationIdAsync_QueriesStoreOnCacheMiss() + { + // Arrange + var tokens = new[] + { + new OpenIddictToken(), + new OpenIddictToken() + }; + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(tokens[0], It.IsAny())) + .ReturnsAsync("token-id-1"); + store.Setup(store => store.GetReferenceIdAsync(tokens[0], It.IsAny())) + .ReturnsAsync((string?) null); + store.Setup(store => store.GetApplicationIdAsync(tokens[0], It.IsAny())) + .ReturnsAsync((string?) null); + store.Setup(store => store.GetAuthorizationIdAsync(tokens[0], It.IsAny())) + .ReturnsAsync("authorization-id"); + store.Setup(store => store.GetSubjectAsync(tokens[0], It.IsAny())) + .ReturnsAsync((string?) null); + + store.Setup(store => store.GetIdAsync(tokens[1], It.IsAny())) + .ReturnsAsync("token-id-2"); + store.Setup(store => store.GetReferenceIdAsync(tokens[1], It.IsAny())) + .ReturnsAsync((string?) null); + store.Setup(store => store.GetApplicationIdAsync(tokens[1], It.IsAny())) + .ReturnsAsync((string?) null); + store.Setup(store => store.GetAuthorizationIdAsync(tokens[1], It.IsAny())) + .ReturnsAsync("authorization-id"); + store.Setup(store => store.GetSubjectAsync(tokens[1], It.IsAny())) + .ReturnsAsync((string?) null); + + store.Setup(store => store.FindByAuthorizationIdAsync("authorization-id", It.IsAny())) + .Returns(tokens.ToAsyncEnumerable()); + + var cache = new OpenIddictTokenCache(options, store.Object); + + // Act + var results = await cache.FindByAuthorizationIdAsync("authorization-id", CancellationToken.None).ToListAsync(); + + // Assert + Assert.Equal(2, results.Count); + Assert.Contains(tokens[0], results); + Assert.Contains(tokens[1], results); + store.Verify(store => store.FindByAuthorizationIdAsync("authorization-id", It.IsAny()), Times.Once()); + } + + [Fact] + public async Task FindByIdAsync_ThrowsAnExceptionForNullIdentifier() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictTokenCache(options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => cache.FindByIdAsync(identifier: null!, CancellationToken.None).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task FindByIdAsync_ThrowsAnExceptionForEmptyIdentifier() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictTokenCache(options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => cache.FindByIdAsync(identifier: string.Empty, CancellationToken.None).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task FindByIdAsync_ReturnsCachedTokenOnCacheHit() + { + // Arrange + var token = new OpenIddictToken(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(token, It.IsAny())) + .ReturnsAsync("token-id"); + store.Setup(store => store.GetReferenceIdAsync(token, It.IsAny())) + .ReturnsAsync((string?) null); + store.Setup(store => store.GetApplicationIdAsync(token, It.IsAny())) + .ReturnsAsync((string?) null); + store.Setup(store => store.GetAuthorizationIdAsync(token, It.IsAny())) + .ReturnsAsync((string?) null); + store.Setup(store => store.GetSubjectAsync(token, It.IsAny())) + .ReturnsAsync((string?) null); + + var cache = new OpenIddictTokenCache(options, store.Object); + + await cache.AddAsync(token, CancellationToken.None); + + // Act + var result = await cache.FindByIdAsync("token-id", CancellationToken.None); + + // Assert + Assert.Same(token, result); + store.Verify(store => store.FindByIdAsync("token-id", It.IsAny()), Times.Never()); + } + + [Fact] + public async Task FindByIdAsync_QueriesStoreOnCacheMiss() + { + // Arrange + var token = new OpenIddictToken(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.FindByIdAsync("token-id", It.IsAny())) + .ReturnsAsync(token); + store.Setup(store => store.GetIdAsync(token, It.IsAny())) + .ReturnsAsync("token-id"); + store.Setup(store => store.GetReferenceIdAsync(token, It.IsAny())) + .ReturnsAsync((string?) null); + store.Setup(store => store.GetApplicationIdAsync(token, It.IsAny())) + .ReturnsAsync((string?) null); + store.Setup(store => store.GetAuthorizationIdAsync(token, It.IsAny())) + .ReturnsAsync((string?) null); + store.Setup(store => store.GetSubjectAsync(token, It.IsAny())) + .ReturnsAsync((string?) null); + + var cache = new OpenIddictTokenCache(options, store.Object); + + // Act + var result = await cache.FindByIdAsync("token-id", CancellationToken.None); + + // Assert + Assert.Same(token, result); + store.Verify(store => store.FindByIdAsync("token-id", It.IsAny()), Times.Once()); + } + + [Fact] + public async Task FindByIdAsync_ReturnsNullWhenTokenNotFound() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.FindByIdAsync("token-id", It.IsAny())) + .ReturnsAsync((OpenIddictToken?) null); + + var cache = new OpenIddictTokenCache(options, store.Object); + + // Act + var result = await cache.FindByIdAsync("token-id", CancellationToken.None); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task FindByReferenceIdAsync_ThrowsAnExceptionForNullIdentifier() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictTokenCache(options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => cache.FindByReferenceIdAsync(identifier: null!, CancellationToken.None).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task FindByReferenceIdAsync_ThrowsAnExceptionForEmptyIdentifier() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictTokenCache(options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => cache.FindByReferenceIdAsync(identifier: string.Empty, CancellationToken.None).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task FindByReferenceIdAsync_ReturnsCachedTokenOnCacheHit() + { + // Arrange + var token = new OpenIddictToken(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(token, It.IsAny())) + .ReturnsAsync("token-id"); + store.Setup(store => store.GetReferenceIdAsync(token, It.IsAny())) + .ReturnsAsync("reference-id"); + store.Setup(store => store.GetApplicationIdAsync(token, It.IsAny())) + .ReturnsAsync((string?) null); + store.Setup(store => store.GetAuthorizationIdAsync(token, It.IsAny())) + .ReturnsAsync((string?) null); + store.Setup(store => store.GetSubjectAsync(token, It.IsAny())) + .ReturnsAsync((string?) null); + + var cache = new OpenIddictTokenCache(options, store.Object); + + await cache.AddAsync(token, CancellationToken.None); + + // Act + var result = await cache.FindByReferenceIdAsync("reference-id", CancellationToken.None); + + // Assert + Assert.Same(token, result); + store.Verify(store => store.FindByReferenceIdAsync("reference-id", It.IsAny()), Times.Never()); + } + + [Fact] + public async Task FindByReferenceIdAsync_QueriesStoreOnCacheMiss() + { + // Arrange + var token = new OpenIddictToken(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.FindByReferenceIdAsync("reference-id", It.IsAny())) + .ReturnsAsync(token); + store.Setup(store => store.GetIdAsync(token, It.IsAny())) + .ReturnsAsync("token-id"); + store.Setup(store => store.GetReferenceIdAsync(token, It.IsAny())) + .ReturnsAsync("reference-id"); + store.Setup(store => store.GetApplicationIdAsync(token, It.IsAny())) + .ReturnsAsync((string?) null); + store.Setup(store => store.GetAuthorizationIdAsync(token, It.IsAny())) + .ReturnsAsync((string?) null); + store.Setup(store => store.GetSubjectAsync(token, It.IsAny())) + .ReturnsAsync((string?) null); + + var cache = new OpenIddictTokenCache(options, store.Object); + + // Act + var result = await cache.FindByReferenceIdAsync("reference-id", CancellationToken.None); + + // Assert + Assert.Same(token, result); + store.Verify(store => store.FindByReferenceIdAsync("reference-id", It.IsAny()), Times.Once()); + } + + [Fact] + public async Task FindBySubjectAsync_QueriesStoreOnCacheMiss() + { + // Arrange + var tokens = new[] + { + new OpenIddictToken(), + new OpenIddictToken() + }; + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(tokens[0], It.IsAny())) + .ReturnsAsync("token-id-1"); + store.Setup(store => store.GetReferenceIdAsync(tokens[0], It.IsAny())) + .ReturnsAsync((string?) null); + store.Setup(store => store.GetApplicationIdAsync(tokens[0], It.IsAny())) + .ReturnsAsync((string?) null); + store.Setup(store => store.GetAuthorizationIdAsync(tokens[0], It.IsAny())) + .ReturnsAsync((string?) null); + store.Setup(store => store.GetSubjectAsync(tokens[0], It.IsAny())) + .ReturnsAsync("subject"); + + store.Setup(store => store.GetIdAsync(tokens[1], It.IsAny())) + .ReturnsAsync("token-id-2"); + store.Setup(store => store.GetReferenceIdAsync(tokens[1], It.IsAny())) + .ReturnsAsync((string?) null); + store.Setup(store => store.GetApplicationIdAsync(tokens[1], It.IsAny())) + .ReturnsAsync((string?) null); + store.Setup(store => store.GetAuthorizationIdAsync(tokens[1], It.IsAny())) + .ReturnsAsync((string?) null); + store.Setup(store => store.GetSubjectAsync(tokens[1], It.IsAny())) + .ReturnsAsync("subject"); + + store.Setup(store => store.FindBySubjectAsync("subject", It.IsAny())) + .Returns(tokens.ToAsyncEnumerable()); + + var cache = new OpenIddictTokenCache(options, store.Object); + + // Act + var results = await cache.FindBySubjectAsync("subject", CancellationToken.None).ToListAsync(); + + // Assert + Assert.Equal(2, results.Count); + Assert.Contains(tokens[0], results); + Assert.Contains(tokens[1], results); + store.Verify(store => store.FindBySubjectAsync("subject", It.IsAny()), Times.Once()); + } + + [Fact] + public async Task RemoveAsync_ThrowsAnExceptionForNullToken() + { + // Arrange + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + var cache = new OpenIddictTokenCache(options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => cache.RemoveAsync(token: null!, CancellationToken.None).AsTask()); + + Assert.Equal("token", exception.ParamName); + } + + [Fact] + public async Task RemoveAsync_InvalidatesCachedEntries() + { + // Arrange + var token = new OpenIddictToken(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(token, It.IsAny())) + .ReturnsAsync("token-id"); + store.Setup(store => store.GetReferenceIdAsync(token, It.IsAny())) + .ReturnsAsync((string?) null); + store.Setup(store => store.GetApplicationIdAsync(token, It.IsAny())) + .ReturnsAsync((string?) null); + store.Setup(store => store.GetAuthorizationIdAsync(token, It.IsAny())) + .ReturnsAsync((string?) null); + store.Setup(store => store.GetSubjectAsync(token, It.IsAny())) + .ReturnsAsync((string?) null); + + var cache = new OpenIddictTokenCache(options, store.Object); + + await cache.AddAsync(token, CancellationToken.None); + + // Act + await cache.RemoveAsync(token, CancellationToken.None); + + var result = await cache.FindByIdAsync("token-id", CancellationToken.None); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task RemoveAsync_ThrowsForTokenWithoutId() + { + // Arrange + var token = new OpenIddictToken(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(token, It.IsAny())) + .ReturnsAsync((string?) null); + + var cache = new OpenIddictTokenCache(options, store.Object); + + // Act and assert + await Assert.ThrowsAsync( + () => cache.RemoveAsync(token, CancellationToken.None).AsTask()); + } + + public sealed class OpenIddictToken { } +} diff --git a/test/OpenIddict.Core.Tests/Managers/OpenIddictApplicationManagerTests.cs b/test/OpenIddict.Core.Tests/Managers/OpenIddictApplicationManagerTests.cs new file mode 100644 index 00000000..b5e5b9bd --- /dev/null +++ b/test/OpenIddict.Core.Tests/Managers/OpenIddictApplicationManagerTests.cs @@ -0,0 +1,3561 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System.Buffers.Binary; +using System.Collections.Immutable; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using Moq; +using Xunit; + +namespace OpenIddict.Core.Tests; + +public class OpenIddictApplicationManagerTests +{ + [Fact] + public void Constructor_ThrowsAnExceptionForNullCache() + { + // Arrange + var cache = (IOpenIddictApplicationCache) null!; + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + // Act and assert + var exception = Assert.Throws( + () => new OpenIddictApplicationManager(cache, logger, options, store)); + + Assert.Equal("cache", exception.ParamName); + } + + [Fact] + public void Constructor_ThrowsAnExceptionForNullLogger() + { + // Arrange + var cache = Mock.Of>(); + var logger = (ILogger>) null!; + var options = Mock.Of>(); + var store = Mock.Of>(); + + // Act and assert + var exception = Assert.Throws( + () => new OpenIddictApplicationManager(cache, logger, options, store)); + + Assert.Equal("logger", exception.ParamName); + } + + [Fact] + public void Constructor_ThrowsAnExceptionForNullOptions() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = (IOptionsMonitor) null!; + var store = Mock.Of>(); + + // Act and assert + var exception = Assert.Throws( + () => new OpenIddictApplicationManager(cache, logger, options, store)); + + Assert.Equal("options", exception.ParamName); + } + + [Fact] + public void Constructor_ThrowsAnExceptionForNullStore() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = (IOpenIddictApplicationStore) null!; + + // Act and assert + var exception = Assert.Throws( + () => new OpenIddictApplicationManager(cache, logger, options, store)); + + Assert.Equal("store", exception.ParamName); + } + + [Fact] + public async Task CountAsync_CallsStoreMethod() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.CountAsync(It.IsAny())) + .ReturnsAsync(42); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var count = await manager.CountAsync(); + + // Assert + Assert.Equal(42, count); + store.Verify(store => store.CountAsync(It.IsAny()), Times.Once()); + } + + [Fact] + public async Task CountAsync_WithQuery_ThrowsAnExceptionForNullQuery() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.CountAsync(query: null!).AsTask()); + + Assert.Equal("query", exception.ParamName); + } + + [Fact] + public async Task CreateAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.CreateAsync(application: null!).AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task CreateAsync_WithSecret_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.CreateAsync(application: null!, secret: "secret").AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task CreateAsync_WithDescriptor_ThrowsAnExceptionForNullDescriptor() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.CreateAsync(descriptor: null!).AsTask()); + + Assert.Equal("descriptor", exception.ParamName); + } + + [Fact] + public async Task DeleteAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.DeleteAsync(application: null!).AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task DeleteAsync_RemovesApplicationFromCache_WhenCachingIsEnabled() + { + // Arrange + var cache = new Mock>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions { DisableEntityCaching = false }); + var store = new Mock>(); + var application = new CustomApplication(); + + var manager = new OpenIddictApplicationManager(cache.Object, logger, options, store.Object); + + // Act + await manager.DeleteAsync(application); + + // Assert + cache.Verify(cache => cache.RemoveAsync(application, It.IsAny()), Times.Once()); + store.Verify(store => store.DeleteAsync(application, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task DeleteAsync_DoesNotRemoveFromCache_WhenCachingIsDisabled() + { + // Arrange + var cache = new Mock>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions { DisableEntityCaching = true }); + var store = new Mock>(); + var application = new CustomApplication(); + + var manager = new OpenIddictApplicationManager(cache.Object, logger, options, store.Object); + + // Act + await manager.DeleteAsync(application); + + // Assert + cache.Verify(cache => cache.RemoveAsync(It.IsAny(), It.IsAny()), Times.Never()); + store.Verify(store => store.DeleteAsync(application, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task FindByClientIdAsync_ThrowsAnExceptionForNullIdentifier() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.FindByClientIdAsync(identifier: null!).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task FindByClientIdAsync_ThrowsAnExceptionForEmptyIdentifier() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.FindByClientIdAsync(identifier: string.Empty).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task FindByClientIdAsync_UsesCache_WhenCachingIsEnabled() + { + // Arrange + var application = new CustomApplication(); + var cache = new Mock>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions { DisableEntityCaching = false }); + var store = new Mock>(); + + cache.Setup(cache => cache.FindByClientIdAsync("client_id", It.IsAny())) + .ReturnsAsync(application); + + store.Setup(store => store.GetClientIdAsync(application, It.IsAny())) + .ReturnsAsync("client_id"); + + var manager = new OpenIddictApplicationManager(cache.Object, logger, options, store.Object); + + // Act + var result = await manager.FindByClientIdAsync("client_id"); + + // Assert + Assert.Same(application, result); + cache.Verify(cache => cache.FindByClientIdAsync("client_id", It.IsAny()), Times.Once()); + store.Verify(store => store.FindByClientIdAsync(It.IsAny(), It.IsAny()), Times.Never()); + } + + [Fact] + public async Task FindByClientIdAsync_UsesStore_WhenCachingIsDisabled() + { + // Arrange + var application = new CustomApplication(); + var cache = new Mock>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions { DisableEntityCaching = true }); + var store = new Mock>(); + + store.Setup(store => store.FindByClientIdAsync("client_id", It.IsAny())) + .ReturnsAsync(application); + + store.Setup(store => store.GetClientIdAsync(application, It.IsAny())) + .ReturnsAsync("client_id"); + + var manager = new OpenIddictApplicationManager(cache.Object, logger, options, store.Object); + + // Act + var result = await manager.FindByClientIdAsync("client_id"); + + // Assert + Assert.Same(application, result); + cache.Verify(cache => cache.FindByClientIdAsync(It.IsAny(), It.IsAny()), Times.Never()); + store.Verify(store => store.FindByClientIdAsync("client_id", It.IsAny()), Times.Once()); + } + + [Fact] + public async Task FindByIdAsync_ThrowsAnExceptionForNullIdentifier() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.FindByIdAsync(identifier: null!).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task FindByIdAsync_ThrowsAnExceptionForEmptyIdentifier() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.FindByIdAsync(identifier: string.Empty).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task FindByIdAsync_UsesCache_WhenCachingIsEnabled() + { + // Arrange + var application = new CustomApplication(); + var cache = new Mock>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions { DisableEntityCaching = false }); + var store = new Mock>(); + + cache.Setup(cache => cache.FindByIdAsync("id", It.IsAny())) + .ReturnsAsync(application); + + store.Setup(store => store.GetIdAsync(application, It.IsAny())) + .ReturnsAsync("id"); + + var manager = new OpenIddictApplicationManager(cache.Object, logger, options, store.Object); + + // Act + var result = await manager.FindByIdAsync("id"); + + // Assert + Assert.Same(application, result); + cache.Verify(cache => cache.FindByIdAsync("id", It.IsAny()), Times.Once()); + store.Verify(store => store.FindByIdAsync(It.IsAny(), It.IsAny()), Times.Never()); + } + + [Fact] + public async Task FindByIdAsync_UsesStore_WhenCachingIsDisabled() + { + // Arrange + var application = new CustomApplication(); + var cache = new Mock>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions { DisableEntityCaching = true }); + var store = new Mock>(); + + store.Setup(store => store.FindByIdAsync("id", It.IsAny())) + .ReturnsAsync(application); + + store.Setup(store => store.GetIdAsync(application, It.IsAny())) + .ReturnsAsync("id"); + + var manager = new OpenIddictApplicationManager(cache.Object, logger, options, store.Object); + + // Act + var result = await manager.FindByIdAsync("id"); + + // Assert + Assert.Same(application, result); + cache.Verify(cache => cache.FindByIdAsync(It.IsAny(), It.IsAny()), Times.Never()); + store.Verify(store => store.FindByIdAsync("id", It.IsAny()), Times.Once()); + } + + [Fact] + public void FindByPostLogoutRedirectUriAsync_ThrowsAnExceptionForNullUri() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = Assert.Throws(() => + manager.FindByPostLogoutRedirectUriAsync(uri: null!)); + + Assert.Equal("uri", exception.ParamName); + } + + [Fact] + public void FindByPostLogoutRedirectUriAsync_ThrowsAnExceptionForEmptyUri() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = Assert.Throws(() => + manager.FindByPostLogoutRedirectUriAsync(uri: string.Empty)); + + Assert.Equal("uri", exception.ParamName); + } + + [Fact] + public async Task FindByPostLogoutRedirectUriAsync_UsesCache_WhenCachingIsEnabled() + { + // Arrange + var applications = new[] { new CustomApplication() }; + var cache = new Mock>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions { DisableEntityCaching = false }); + var store = new Mock>(); + + cache.Setup(cache => cache.FindByPostLogoutRedirectUriAsync("https://www.fabrikam.com/callback/logout", It.IsAny())) + .Returns(applications.ToAsyncEnumerable()); + + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(applications[0], It.IsAny())) + .ReturnsAsync(["https://www.fabrikam.com/callback/logout"]); + + var manager = new OpenIddictApplicationManager(cache.Object, logger, options, store.Object); + + // Act + var result = await manager.FindByPostLogoutRedirectUriAsync("https://www.fabrikam.com/callback/logout").ToListAsync(); + + // Assert + Assert.Same(applications[0], result[0]); + cache.Verify(cache => cache.FindByPostLogoutRedirectUriAsync("https://www.fabrikam.com/callback/logout", It.IsAny()), Times.Once()); + store.Verify(store => store.FindByPostLogoutRedirectUriAsync(It.IsAny(), It.IsAny()), Times.Never()); + } + + [Fact] + public async Task FindByPostLogoutRedirectUriAsync_UsesStore_WhenCachingIsDisabled() + { + // Arrange + var applications = new[] { new CustomApplication() }; + var cache = new Mock>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions { DisableEntityCaching = true }); + var store = new Mock>(); + + store.Setup(store => store.FindByPostLogoutRedirectUriAsync("https://www.fabrikam.com/callback/logout", It.IsAny())) + .Returns(applications.ToAsyncEnumerable()); + + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(applications[0], It.IsAny())) + .ReturnsAsync(["https://www.fabrikam.com/callback/logout"]); + + var manager = new OpenIddictApplicationManager(cache.Object, logger, options, store.Object); + + // Act + var result = await manager.FindByPostLogoutRedirectUriAsync("https://www.fabrikam.com/callback/logout").ToListAsync(); + + // Assert + Assert.Same(applications[0], result[0]); + cache.Verify(cache => cache.FindByPostLogoutRedirectUriAsync(It.IsAny(), It.IsAny()), Times.Never()); + store.Verify(store => store.FindByPostLogoutRedirectUriAsync("https://www.fabrikam.com/callback/logout", It.IsAny()), Times.Once()); + } + + [Fact] + public void FindByRedirectUriAsync_ThrowsAnExceptionForNullUri() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = Assert.Throws(() => + manager.FindByRedirectUriAsync(uri: null!)); + + Assert.Equal("uri", exception.ParamName); + } + + [Fact] + public void FindByRedirectUriAsync_ThrowsAnExceptionForEmptyUri() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = Assert.Throws(() => + manager.FindByRedirectUriAsync(uri: string.Empty)); + + Assert.Equal("uri", exception.ParamName); + } + + [Fact] + public async Task FindByRedirectUriAsync_UsesCache_WhenCachingIsEnabled() + { + // Arrange + var applications = new[] { new CustomApplication() }; + var cache = new Mock>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions { DisableEntityCaching = false }); + var store = new Mock>(); + + cache.Setup(cache => cache.FindByRedirectUriAsync("https://www.fabrikam.com/callback", It.IsAny())) + .Returns(applications.ToAsyncEnumerable()); + + store.Setup(store => store.GetRedirectUrisAsync(applications[0], It.IsAny())) + .ReturnsAsync(["https://www.fabrikam.com/callback"]); + + var manager = new OpenIddictApplicationManager(cache.Object, logger, options, store.Object); + + // Act + var result = await manager.FindByRedirectUriAsync("https://www.fabrikam.com/callback").ToListAsync(); + + // Assert + Assert.Same(applications[0], result[0]); + cache.Verify(cache => cache.FindByRedirectUriAsync("https://www.fabrikam.com/callback", It.IsAny()), Times.Once()); + store.Verify(store => store.FindByRedirectUriAsync(It.IsAny(), It.IsAny()), Times.Never()); + } + + [Fact] + public async Task FindByRedirectUriAsync_UsesStore_WhenCachingIsDisabled() + { + // Arrange + var applications = new[] { new CustomApplication() }; + var cache = new Mock>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions { DisableEntityCaching = true }); + var store = new Mock>(); + + store.Setup(store => store.FindByRedirectUriAsync("https://www.fabrikam.com/callback", It.IsAny())) + .Returns(applications.ToAsyncEnumerable()); + + store.Setup(store => store.GetRedirectUrisAsync(applications[0], It.IsAny())) + .ReturnsAsync(["https://www.fabrikam.com/callback"]); + + var manager = new OpenIddictApplicationManager(cache.Object, logger, options, store.Object); + + // Act + var result = await manager.FindByRedirectUriAsync("https://www.fabrikam.com/callback").ToListAsync(); + + // Assert + Assert.Same(applications[0], result[0]); + cache.Verify(cache => cache.FindByRedirectUriAsync(It.IsAny(), It.IsAny()), Times.Never()); + store.Verify(store => store.FindByRedirectUriAsync("https://www.fabrikam.com/callback", It.IsAny()), Times.Once()); + } + + [Fact] + public async Task GetApplicationTypeAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetApplicationTypeAsync(application: null!).AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task GetAsync_WithQuery_ThrowsAnExceptionForNullQuery() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetAsync(query: null!).AsTask()); + + Assert.Equal("query", exception.ParamName); + } + + [Fact] + public async Task GetAsync_WithQueryAndState_ThrowsAnExceptionForNullQuery() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetAsync(query: null!, state: null!).AsTask()); + + Assert.Equal("query", exception.ParamName); + } + + [Fact] + public async Task GetClientIdAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetClientIdAsync(application: null!).AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task GetClientIdAsync_ReturnsClientIdFromStore() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetClientIdAsync(application, It.IsAny())) + .ReturnsAsync("my-client-id"); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var clientId = await manager.GetClientIdAsync(application); + + // Assert + Assert.Equal("my-client-id", clientId); + store.Verify(store => store.GetClientIdAsync(application, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task GetClientTypeAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetClientTypeAsync(application: null!).AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task GetClientTypeAsync_ReturnsClientTypeFromStore() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync("public"); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var clientType = await manager.GetClientTypeAsync(application); + + // Assert + Assert.Equal("public", clientType); + store.Verify(store => store.GetClientTypeAsync(application, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task GetConsentTypeAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetConsentTypeAsync(application: null!).AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task GetDisplayNameAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetDisplayNameAsync(application: null!).AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task GetDisplayNameAsync_ReturnsDisplayNameFromStore() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetDisplayNameAsync(application, It.IsAny())) + .ReturnsAsync("My Application"); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var displayName = await manager.GetDisplayNameAsync(application); + + // Assert + Assert.Equal("My Application", displayName); + store.Verify(store => store.GetDisplayNameAsync(application, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task GetDisplayNamesAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetDisplayNamesAsync(application: null!).AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task GetIdAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetIdAsync(application: null!).AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task GetIdAsync_ReturnsIdentifierFromStore() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(application, It.IsAny())) + .ReturnsAsync("unique-id"); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var id = await manager.GetIdAsync(application); + + // Assert + Assert.Equal("unique-id", id); + store.Verify(store => store.GetIdAsync(application, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task GetJsonWebKeySetAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetJsonWebKeySetAsync(application: null!).AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task GetLocalizedDisplayNameAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetLocalizedDisplayNameAsync(application: null!).AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task GetLocalizedDisplayNameAsync_WithCulture_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetLocalizedDisplayNameAsync(application: null!, CultureInfo.CurrentCulture).AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task GetPermissionsAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetPermissionsAsync(application: null!).AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task GetPostLogoutRedirectUrisAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetPostLogoutRedirectUrisAsync(application: null!).AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task GetPropertiesAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetPropertiesAsync(application: null!).AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task GetPublicKeyInfrastructureTlsClientAuthenticationPolicyAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + var policy = new X509ChainPolicy(); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetPublicKeyInfrastructureTlsClientAuthenticationPolicyAsync(application: null!, policy).AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task GetRedirectUrisAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetRedirectUrisAsync(application: null!).AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task GetRequirementsAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetRequirementsAsync(application: null!).AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task GetSelfSignedTlsClientAuthenticationPolicyAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + var policy = new X509ChainPolicy(); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetSelfSignedTlsClientAuthenticationPolicyAsync(application: null!, policy).AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task GetSettingsAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetSettingsAsync(application: null!).AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task HasApplicationTypeAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.HasApplicationTypeAsync(application: null!, "web").AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task HasApplicationTypeAsync_ThrowsAnExceptionForNullType() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.HasApplicationTypeAsync(application, type: null!).AsTask()); + + Assert.Equal("type", exception.ParamName); + } + + [Fact] + public async Task HasApplicationTypeAsync_ReturnsTrueWhenTypeMatches() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetApplicationTypeAsync(application, It.IsAny())) + .ReturnsAsync("web"); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var result = await manager.HasApplicationTypeAsync(application, "web"); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task HasClientTypeAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.HasClientTypeAsync(application: null!, "public").AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task HasClientTypeAsync_ThrowsAnExceptionForNullType() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.HasClientTypeAsync(application, type: null!).AsTask()); + + Assert.Equal("type", exception.ParamName); + } + + [Theory] + [InlineData("public", "public", true)] + [InlineData("confidential", "public", false)] + public async Task HasClientTypeAsync_ReturnsExpectedResult(string storedType, string testedType, bool expected) + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(storedType); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var result = await manager.HasClientTypeAsync(application, testedType); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public async Task HasConsentTypeAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.HasConsentTypeAsync(application: null!, "explicit").AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task HasConsentTypeAsync_ThrowsAnExceptionForNullType() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.HasConsentTypeAsync(application, type: null!).AsTask()); + + Assert.Equal("type", exception.ParamName); + } + + [Fact] + public async Task HasConsentTypeAsync_ReturnsTrueWhenTypeMatches() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetConsentTypeAsync(application, It.IsAny())) + .ReturnsAsync("explicit"); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var result = await manager.HasConsentTypeAsync(application, "explicit"); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task HasPermissionAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.HasPermissionAsync(application: null!, "ept:token").AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task HasPermissionAsync_ThrowsAnExceptionForNullPermission() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.HasPermissionAsync(application, permission: null!).AsTask()); + + Assert.Equal("permission", exception.ParamName); + } + + [Fact] + public async Task HasRequirementAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.HasRequirementAsync(application: null!, "requirement").AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task HasRequirementAsync_ThrowsAnExceptionForNullRequirement() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.HasRequirementAsync(application, requirement: null!).AsTask()); + + Assert.Equal("requirement", exception.ParamName); + } + + [Fact] + public async Task HasRequirementAsync_ReturnsTrueWhenRequirementExists() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetRequirementsAsync(application, It.IsAny())) + .ReturnsAsync(["requirement1", "requirement2"]); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var result = await manager.HasRequirementAsync(application, "requirement1"); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task ListAsync_ReturnsAllApplications() + { + // Arrange + var applications = new[] { new CustomApplication(), new CustomApplication() }; + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.ListAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(applications.ToAsyncEnumerable()); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var results = new List(); + await foreach (var app in manager.ListAsync()) + { + results.Add(app); + } + + // Assert + Assert.Equal(2, results.Count); + store.Verify(store => store.ListAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); + } + + [Fact] + public async Task ObfuscateClientSecretAsync_ThrowsAnExceptionForNullSecret() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new CustomApplicationManagerWithProtectedAccess(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.ObfuscateClientSecretAsync(secret: null!).AsTask()); + + Assert.Equal("secret", exception.ParamName); + } + + [Fact] + public async Task ObfuscateClientSecretAsync_ThrowsAnExceptionForEmptySecret() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new CustomApplicationManagerWithProtectedAccess(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.ObfuscateClientSecretAsync(secret: string.Empty).AsTask()); + + Assert.Equal("secret", exception.ParamName); + } + + [Fact] + public async Task ObfuscateClientSecretAsync_ReturnsBase64EncodedHash() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new CustomApplicationManagerWithProtectedAccess(cache, logger, options, store); + + // Act + var hash = await manager.ObfuscateClientSecretAsync("my-secret"); + + var payload = Convert.FromBase64String(hash); + Assert.NotEmpty(payload); + } + + [Fact] + public async Task ObfuscateClientSecretAsync_WritesFormatMarker() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new CustomApplicationManagerWithProtectedAccess(cache, logger, options, store); + + // Act + var hash = await manager.ObfuscateClientSecretAsync("my-secret"); + + var payload = Convert.FromBase64String(hash); + Assert.Equal(0x01, payload[0]); + } + + [Fact] + public async Task ObfuscateClientSecretAsync_UsesSha512ByDefault() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new CustomApplicationManagerWithProtectedAccess(cache, logger, options, store); + + // Act + var hash = await manager.ObfuscateClientSecretAsync("my-secret"); + + var payload = Convert.FromBase64String(hash); + Assert.Equal(2u, BinaryPrimitives.ReadUInt32BigEndian(payload.AsSpan(1, 4))); + } + + [Fact] + public async Task ObfuscateClientSecretAsync_UsesSha256WhenConfigured() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions + { + ClientSecretKeyDerivationHashAlgorithm = HashAlgorithmName.SHA256 + }); + var store = Mock.Of>(); + + var manager = new CustomApplicationManagerWithProtectedAccess(cache, logger, options, store); + + // Act + var hash = await manager.ObfuscateClientSecretAsync("my-secret"); + + var payload = Convert.FromBase64String(hash); + Assert.Equal(1u, BinaryPrimitives.ReadUInt32BigEndian(payload.AsSpan(1, 4))); + } + + [Fact] + public async Task ObfuscateClientSecretAsync_UsesSha1WhenConfigured() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions + { + ClientSecretKeyDerivationHashAlgorithm = HashAlgorithmName.SHA1 + }); + var store = Mock.Of>(); + + var manager = new CustomApplicationManagerWithProtectedAccess(cache, logger, options, store); + + // Act + var hash = await manager.ObfuscateClientSecretAsync("my-secret"); + + var payload = Convert.FromBase64String(hash); + Assert.Equal(0u, BinaryPrimitives.ReadUInt32BigEndian(payload.AsSpan(1, 4))); + } + + [Fact] + public async Task ObfuscateClientSecretAsync_ThrowsForUnsupportedAlgorithm() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions + { + ClientSecretKeyDerivationHashAlgorithm = HashAlgorithmName.SHA384 + }); + var store = Mock.Of>(); + + var manager = new CustomApplicationManagerWithProtectedAccess(cache, logger, options, store); + + // Act and assert + await Assert.ThrowsAsync( + () => manager.ObfuscateClientSecretAsync("my-secret").AsTask()); + } + + [Fact] + public async Task ObfuscateClientSecretAsync_WritesConfiguredIterationCount() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions + { + ClientSecretKeyDerivationIterations = 50_000 + }); + var store = Mock.Of>(); + + var manager = new CustomApplicationManagerWithProtectedAccess(cache, logger, options, store); + + // Act + var hash = await manager.ObfuscateClientSecretAsync("my-secret"); + + // Assert + var payload = Convert.FromBase64String(hash); + Assert.Equal(50_000u, BinaryPrimitives.ReadUInt32BigEndian(payload.AsSpan(5, 4))); + } + + [Fact] + public async Task ObfuscateClientSecretAsync_WritesSaltOfConfiguredLength() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions + { + ClientSecretKeyDerivationSaltLength = 128 // bits → 16 bytes + }); + var store = Mock.Of>(); + + var manager = new CustomApplicationManagerWithProtectedAccess(cache, logger, options, store); + + // Act + var hash = await manager.ObfuscateClientSecretAsync("my-secret"); + + var payload = Convert.FromBase64String(hash); + Assert.Equal(16u, BinaryPrimitives.ReadUInt32BigEndian(payload.AsSpan(9, 4))); + } + + [Fact] + public async Task ObfuscateClientSecretAsync_ProducesDifferentHashesForSameSecret() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new CustomApplicationManagerWithProtectedAccess(cache, logger, options, store); + + var hash1 = await manager.ObfuscateClientSecretAsync("my-secret"); + var hash2 = await manager.ObfuscateClientSecretAsync("my-secret"); + + Assert.NotEqual(hash1, hash2); + } + + [Fact] + public async Task ObfuscateClientSecretAsync_ProducesHashValidatableByValidateClientSecretAsync() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new CustomApplicationManagerWithProtectedAccess(cache, logger, options, store); + + // Act + var hash = await manager.ObfuscateClientSecretAsync("my-secret"); + var (isValid, isRehashRequired) = await manager.ValidateClientSecretAsync("my-secret", hash); + + // Assert + Assert.True(isValid); + Assert.False(isRehashRequired); + } + + [Fact] + public async Task PopulateAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + var descriptor = new OpenIddictApplicationDescriptor(); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.PopulateAsync(application: null!, descriptor).AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task PopulateAsync_ThrowsAnExceptionForNullDescriptor() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.PopulateAsync(application, descriptor: null!).AsTask()); + + Assert.Equal("descriptor", exception.ParamName); + } + + [Fact] + public async Task UpdateAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.UpdateAsync(application: null!).AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task UpdateAsync_WithDescriptor_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + var descriptor = new OpenIddictApplicationDescriptor(); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.UpdateAsync(application: null!, descriptor).AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task UpdateAsync_WithSecret_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.UpdateAsync(application: null!, secret: "secret").AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public void ValidateAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = Assert.Throws( + () => manager.ValidateAsync(application: null!)); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task ValidateAsync_ReturnsErrorWhenClientIdIsEmpty() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetClientIdAsync(application, It.IsAny())) + .ReturnsAsync(string.Empty); + + store.Setup(store => store.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(ClientTypes.Public); + + store.Setup(store => store.GetClientSecretAsync(application, It.IsAny())) + .ReturnsAsync(string.Empty); + + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + store.Setup(store => store.GetRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(application).ToListAsync(); + + // Assert + Assert.Contains(results, result => result.ErrorMessage == SR.GetResourceString(SR.ID2036)); + } + + [Fact] + public async Task ValidateAsync_ReturnsErrorWhenClientIdIsAlreadyUsed() + { + // Arrange + var application = new CustomApplication(); + var other = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetClientIdAsync(application, It.IsAny())) + .ReturnsAsync("client-id"); + + store.Setup(store => store.FindByClientIdAsync("client-id", It.IsAny())) + .ReturnsAsync(other); + + store.Setup(store => store.GetIdAsync(application, It.IsAny())) + .ReturnsAsync("application-id"); + + store.Setup(store => store.GetIdAsync(other, It.IsAny())) + .ReturnsAsync("other-application-id"); + + store.Setup(store => store.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(ClientTypes.Public); + + store.Setup(store => store.GetClientSecretAsync(application, It.IsAny())) + .ReturnsAsync(string.Empty); + + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + store.Setup(store => store.GetRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(application).ToListAsync(); + + // Assert + Assert.Contains(results, result => result.ErrorMessage == SR.GetResourceString(SR.ID2111)); + } + + [Fact] + public async Task ValidateAsync_ReturnsErrorWhenClientTypeIsEmpty() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetClientIdAsync(application, It.IsAny())) + .ReturnsAsync("client-id"); + + store.Setup(store => store.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(string.Empty); + + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + store.Setup(store => store.GetRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(application).ToListAsync(); + + // Assert + Assert.Contains(results, result => result.ErrorMessage == SR.GetResourceString(SR.ID2050)); + } + + [Fact] + public async Task ValidateAsync_ReturnsErrorWhenClientTypeIsNotSupported() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetClientIdAsync(application, It.IsAny())) + .ReturnsAsync("client-id"); + + store.Setup(store => store.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync("unsupported-type"); + + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + store.Setup(store => store.GetRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(application).ToListAsync(); + + // Assert + Assert.Contains(results, result => result.ErrorMessage == SR.GetResourceString(SR.ID2112)); + } + + [Fact] + public async Task ValidateAsync_ReturnsErrorWhenPublicApplicationHasClientSecret() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetClientIdAsync(application, It.IsAny())) + .ReturnsAsync("client-id"); + + store.Setup(store => store.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(ClientTypes.Public); + + store.Setup(store => store.GetClientSecretAsync(application, It.IsAny())) + .ReturnsAsync("some-secret"); + + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + store.Setup(store => store.GetRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(application).ToListAsync(); + + // Assert + Assert.Contains(results, result => result.ErrorMessage == SR.GetResourceString(SR.ID2114)); + } + + [Fact] + public async Task ValidateAsync_ReturnsErrorWhenConfidentialApplicationHasNoSecretAndNoSigningKey() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetClientIdAsync(application, It.IsAny())) + .ReturnsAsync("client-id"); + + store.Setup(store => store.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(ClientTypes.Confidential); + + store.Setup(store => store.GetClientSecretAsync(application, It.IsAny())) + .ReturnsAsync(string.Empty); + + store.Setup(store => store.GetJsonWebKeySetAsync(application, It.IsAny())) + .ReturnsAsync((JsonWebKeySet?) null); + + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + store.Setup(store => store.GetRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(application).ToListAsync(); + + // Assert + Assert.Contains(results, result => result.ErrorMessage == SR.GetResourceString(SR.ID2113)); + } + + [Fact] + public async Task ValidateAsync_ReturnsErrorWhenConfidentialApplicationHasJwksWithoutSigningKey() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetClientIdAsync(application, It.IsAny())) + .ReturnsAsync("client-id"); + + store.Setup(store => store.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(ClientTypes.Confidential); + + store.Setup(store => store.GetClientSecretAsync(application, It.IsAny())) + .ReturnsAsync(string.Empty); + + var jwks = new JsonWebKeySet(); + jwks.Keys.Add(new JsonWebKey { Kty = JsonWebAlgorithmsKeyTypes.Octet, Use = JsonWebKeyUseNames.Sig }); + + store.Setup(store => store.GetJsonWebKeySetAsync(application, It.IsAny())) + .ReturnsAsync(jwks); + + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + store.Setup(store => store.GetRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(application).ToListAsync(); + + // Assert + Assert.Contains(results, result => result.ErrorMessage == SR.GetResourceString(SR.ID2113)); + } + + [Fact] + public async Task ValidateAsync_ReturnsErrorWhenRedirectUriIsEmpty() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetClientIdAsync(application, It.IsAny())) + .ReturnsAsync("client-id"); + + store.Setup(store => store.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(ClientTypes.Confidential); + + store.Setup(store => store.GetClientSecretAsync(application, It.IsAny())) + .ReturnsAsync("hashed-secret"); + + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + store.Setup(store => store.GetRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([string.Empty]); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(application).ToListAsync(); + + // Assert + Assert.Contains(results, result => result.ErrorMessage == SR.GetResourceString(SR.ID2061)); + } + + [Fact] + public async Task ValidateAsync_ReturnsErrorWhenRedirectUriIsNotAbsolute() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetClientIdAsync(application, It.IsAny())) + .ReturnsAsync("client-id"); + + store.Setup(store => store.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(ClientTypes.Confidential); + + store.Setup(store => store.GetClientSecretAsync(application, It.IsAny())) + .ReturnsAsync("hashed-secret"); + + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + store.Setup(store => store.GetRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync(["not-an-absolute-uri"]); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(application).ToListAsync(); + + // Assert + Assert.Contains(results, result => result.ErrorMessage == SR.GetResourceString(SR.ID2062)); + } + + [Fact] + public async Task ValidateAsync_ReturnsErrorWhenRedirectUriContainsFragment() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetClientIdAsync(application, It.IsAny())) + .ReturnsAsync("client-id"); + + store.Setup(store => store.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(ClientTypes.Confidential); + + store.Setup(store => store.GetClientSecretAsync(application, It.IsAny())) + .ReturnsAsync("hashed-secret"); + + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + store.Setup(store => store.GetRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync(["https://client.contoso.com/callback#fragment"]); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(application).ToListAsync(); + + // Assert + Assert.Contains(results, result => result.ErrorMessage == SR.GetResourceString(SR.ID2115)); + } + + [Fact] + public async Task ValidateAsync_ReturnsErrorWhenRedirectUriContainsIssParameter() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetClientIdAsync(application, It.IsAny())) + .ReturnsAsync("client-id"); + + store.Setup(store => store.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(ClientTypes.Confidential); + + store.Setup(store => store.GetClientSecretAsync(application, It.IsAny())) + .ReturnsAsync("hashed-secret"); + + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + store.Setup(store => store.GetRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync(["https://client.contoso.com/callback?iss=https%3A%2F%2Fissuer.contoso.com"]); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(application).ToListAsync(); + + // Assert + Assert.Contains(results, result => result.ErrorMessage == SR.FormatID2134(Parameters.Iss)); + } + + [Fact] + public async Task ValidateAsync_ReturnsErrorWhenPostLogoutRedirectUriContainsFragment() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetClientIdAsync(application, It.IsAny())) + .ReturnsAsync("client-id"); + + store.Setup(store => store.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(ClientTypes.Confidential); + + store.Setup(store => store.GetClientSecretAsync(application, It.IsAny())) + .ReturnsAsync("hashed-secret"); + + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync(["https://client.contoso.com/logout#fragment"]); + + store.Setup(store => store.GetRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(application).ToListAsync(); + + // Assert + Assert.Contains(results, result => result.ErrorMessage == SR.GetResourceString(SR.ID2115)); + } + + [Fact] + public async Task ValidateAsync_ReturnsNoErrorsForValidConfidentialApplicationWithSecret() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetClientIdAsync(application, It.IsAny())) + .ReturnsAsync("client-id"); + + store.Setup(store => store.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(ClientTypes.Confidential); + + store.Setup(store => store.GetClientSecretAsync(application, It.IsAny())) + .ReturnsAsync("hashed-secret"); + + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + store.Setup(store => store.GetRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync(["https://client.contoso.com/callback"]); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(application).ToListAsync(); + + // Assert + Assert.DoesNotContain(results, static result => result != ValidationResult.Success); + } + + [Fact] + public async Task ValidateAsync_ReturnsNoErrorsForValidConfidentialApplicationWithRsaSigningKey() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetClientIdAsync(application, It.IsAny())) + .ReturnsAsync("client-id"); + + store.Setup(store => store.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(ClientTypes.Confidential); + + store.Setup(store => store.GetClientSecretAsync(application, It.IsAny())) + .ReturnsAsync(string.Empty); + + var jwks = new JsonWebKeySet(); + jwks.Keys.Add(new JsonWebKey { Kty = JsonWebAlgorithmsKeyTypes.RSA, Use = JsonWebKeyUseNames.Sig }); + + store.Setup(store => store.GetJsonWebKeySetAsync(application, It.IsAny())) + .ReturnsAsync(jwks); + + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + store.Setup(store => store.GetRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(application).ToListAsync(); + + // Assert + Assert.DoesNotContain(results, static result => result != ValidationResult.Success); + } + + [Fact] + public async Task ValidateAsync_ReturnsNoErrorsForValidPublicApplication() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetClientIdAsync(application, It.IsAny())) + .ReturnsAsync("client-id"); + + store.Setup(store => store.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(ClientTypes.Public); + + store.Setup(store => store.GetClientSecretAsync(application, It.IsAny())) + .ReturnsAsync(string.Empty); + + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + store.Setup(store => store.GetRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync(["https://client.contoso.com/callback"]); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(application).ToListAsync(); + + // Assert + Assert.DoesNotContain(results, static result => result != ValidationResult.Success); + } + + [Fact] + public async Task ValidateClientSecretAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.ValidateClientSecretAsync(application: null!, "secret").AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task ValidateClientSecretAsync_ThrowsAnExceptionForNullSecret() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.ValidateClientSecretAsync(application, secret: null!).AsTask()); + + Assert.Equal("secret", exception.ParamName); + } + + [Fact] + public async Task ValidateClientSecretAsync_ThrowsAnExceptionForEmptySecret() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.ValidateClientSecretAsync(application, secret: string.Empty).AsTask()); + + Assert.Equal("secret", exception.ParamName); + } + + [Fact] + public async Task ValidateClientSecretAsync_ReturnsFalseForPublicClients() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(ClientTypes.Public); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var result = await manager.ValidateClientSecretAsync(application, "any-secret"); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task ValidateClientSecretAsync_ReturnsFalseWhenStoredSecretIsNull() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(ClientTypes.Confidential); + + store.Setup(store => store.GetClientSecretAsync(application, It.IsAny())) + .ReturnsAsync((string?) null); + + store.Setup(store => store.GetClientIdAsync(application, It.IsAny())) + .ReturnsAsync("client-id"); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var result = await manager.ValidateClientSecretAsync(application, "any-secret"); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task ValidateClientSecretAsync_ReturnsFalseWhenStoredSecretIsEmpty() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(ClientTypes.Confidential); + + store.Setup(store => store.GetClientSecretAsync(application, It.IsAny())) + .ReturnsAsync(string.Empty); + + store.Setup(store => store.GetClientIdAsync(application, It.IsAny())) + .ReturnsAsync("client-id"); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var result = await manager.ValidateClientSecretAsync(application, "any-secret"); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task ValidateClientSecretAsync_ReturnsFalseWhenSecretDoesNotMatch() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(ClientTypes.Confidential); + + var hashedSecret = "AQAAAAIAAYagAAAAEOlzsJpxnNJRh9HnCQNEzZMPLqTYYqH5WJ5+DJMwL+BhiLUGjL2SQnfRU8bQv2K2Sg=="; + store.Setup(store => store.GetClientSecretAsync(application, It.IsAny())) + .ReturnsAsync(hashedSecret); + + store.Setup(store => store.GetClientIdAsync(application, It.IsAny())) + .ReturnsAsync("client-id"); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var result = await manager.ValidateClientSecretAsync(application, "wrong-secret"); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task ValidateClientSecretAsync_PerformsRehashWhenRequired() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var store = new Mock>(); + + var storedHash = "AQAAAAEAACcQAAAAIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fMMgkjB4tHCgxxjPemQnSkFqO0dtrtE3xiqISZNQJfWNqm+Flbr/sNlQCAWRy0XrIxw6HU3B+YB5aHDlJhCTw2w=="; + + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions + { + DisableAutomaticClientSecretRehashing = false + }); + + store.Setup(store => store.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(ClientTypes.Confidential); + + store.Setup(store => store.GetClientIdAsync(application, It.IsAny())) + .ReturnsAsync("client-id"); + + store.Setup(store => store.GetClientSecretAsync(application, It.IsAny())) + .ReturnsAsync(storedHash); + + store.Setup(store => store.FindByClientIdAsync("client-id", It.IsAny())) + .ReturnsAsync((CustomApplication?) null); + + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + store.Setup(store => store.GetRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + store.Setup(store => store.UpdateAsync(application, It.IsAny())) + .Returns(ValueTask.CompletedTask); + + store.Setup(store => store.SetClientSecretAsync(application, It.IsAny(), It.IsAny())) + .Returns(ValueTask.CompletedTask); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + await manager.ValidateClientSecretAsync(application, "test-secret"); + + // Assert + store.Verify(store => store.UpdateAsync(application, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task ValidateClientSecretAsync_SkipsRehashWhenDisabled() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions + { + DisableAutomaticClientSecretRehashing = true + }); + var store = new Mock>(); + + store.Setup(store => store.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(ClientTypes.Confidential); + + store.Setup(store => store.GetClientIdAsync(application, It.IsAny())) + .ReturnsAsync("client-id"); + + var oldHash = "AQAAAAEACicQAAAAEJPkfY9tVqKqHQlLPqNT0ksqN8C3LqY4RqBYQaJHhWPQXuYhWFpN9pLkR8vZxQ=="; + store.Setup(store => store.GetClientSecretAsync(application, It.IsAny())) + .ReturnsAsync(oldHash); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var result = await manager.ValidateClientSecretAsync(application, "test-secret"); + + // Assert + store.Verify(store => store.UpdateAsync(application, It.IsAny()), Times.Never()); + } + + [Fact] + public async Task ValidateClientSecretAsync_ContinuesOnRehashFailure() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var store = new Mock>(); + + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions + { + DisableAutomaticClientSecretRehashing = false + }); + + store.Setup(store => store.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(ClientTypes.Confidential); + + store.Setup(store => store.GetClientIdAsync(application, It.IsAny())) + .ReturnsAsync("client-id"); + + store.Setup(store => store.GetClientSecretAsync(application, It.IsAny())) + .ReturnsAsync("AQAAAAEAACcQAAAAIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fMMgkjB4tHCgxxjPemQnSkFqO0dtrtE3xiqISZNQJfWNqm+Flbr/sNlQCAWRy0XrIxw6HU3B+YB5aHDlJhCTw2w=="); + + store.Setup(store => store.FindByClientIdAsync("client-id", It.IsAny())) + .ReturnsAsync((CustomApplication?) null); + + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + store.Setup(store => store.GetRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync([]); + + store.Setup(store => store.UpdateAsync(application, It.IsAny())) + .Returns(new ValueTask(Task.FromException(new InvalidOperationException("Concurrency conflict")))); + + store.Setup(store => store.SetClientSecretAsync(application, It.IsAny(), It.IsAny())) + .Returns(ValueTask.CompletedTask); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var result = await manager.ValidateClientSecretAsync(application, "test-secret"); + + // Assert + Assert.True(result); + store.Verify(store => store.UpdateAsync(application, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task ValidateClientSecretAsync_ProtectedMethod_ThrowsAnExceptionForNullSecret() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new CustomApplicationManagerWithProtectedAccess(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.ValidateClientSecretAsync(secret: null!, "comparand").AsTask()); + + Assert.Equal("secret", exception.ParamName); + } + + [Fact] + public async Task ValidateClientSecretAsync_ProtectedMethod_ThrowsAnExceptionForEmptySecret() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new CustomApplicationManagerWithProtectedAccess(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.ValidateClientSecretAsync(secret: string.Empty, "comparand").AsTask()); + + Assert.Equal("secret", exception.ParamName); + } + + [Fact] + public async Task ValidateClientSecretAsync_ProtectedMethod_ThrowsAnExceptionForNullComparand() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new CustomApplicationManagerWithProtectedAccess(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.ValidateClientSecretAsync("secret", comparand: null!).AsTask()); + + Assert.Equal("comparand", exception.ParamName); + } + + [Fact] + public async Task ValidateClientSecretAsync_ProtectedMethod_ThrowsAnExceptionForEmptyComparand() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new CustomApplicationManagerWithProtectedAccess(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.ValidateClientSecretAsync("secret", comparand: string.Empty).AsTask()); + + Assert.Equal("comparand", exception.ParamName); + } + + [Fact] + public async Task ValidateClientSecretAsync_ProtectedMethod_ReturnsFalseForInvalidBase64Comparand() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new CustomApplicationManagerWithProtectedAccess(cache, logger, options, store); + + // Act + var (isValid, isRehashRequired) = await manager.ValidateClientSecretAsync("secret", "not-valid-base64!!!"); + + // Assert + Assert.False(isValid); + Assert.False(isRehashRequired); + } + + [Fact] + public async Task ValidateClientSecretAsync_ProtectedMethod_ReturnsFalseForEmptyPayload() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new CustomApplicationManagerWithProtectedAccess(cache, logger, options, store); + + var (isValid, isRehashRequired) = await manager.ValidateClientSecretAsync("secret", Convert.ToBase64String([0x01])); + + // Assert + Assert.False(isValid); + Assert.False(isRehashRequired); + } + + [Fact] + public async Task ValidateClientSecretAsync_ProtectedMethod_ReturnsFalseForInvalidFormatMarker() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new CustomApplicationManagerWithProtectedAccess(cache, logger, options, store); + + var payload = new byte[20]; + payload[0] = 0x02; + var (isValid, isRehashRequired) = await manager.ValidateClientSecretAsync("secret", Convert.ToBase64String(payload)); + + // Assert + Assert.False(isValid); + Assert.False(isRehashRequired); + } + + [Fact] + public async Task ValidateClientSecretAsync_ProtectedMethod_ReturnsFalseForUnknownAlgorithmVersion() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new CustomApplicationManagerWithProtectedAccess(cache, logger, options, store); + + var payload = new byte[50]; + payload[0] = 0x01; + BinaryPrimitives.WriteUInt32BigEndian(payload.AsSpan(1, 4), 99); + var (isValid, isRehashRequired) = await manager.ValidateClientSecretAsync("secret", Convert.ToBase64String(payload)); + + // Assert + Assert.False(isValid); + Assert.False(isRehashRequired); + } + + [Fact] + public async Task ValidateClientSecretAsync_ProtectedMethod_ReturnsFalseForTooFewIterations() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new CustomApplicationManagerWithProtectedAccess(cache, logger, options, store); + + var payload = new byte[50]; + payload[0] = 0x01; + BinaryPrimitives.WriteUInt32BigEndian(payload.AsSpan(1, 4), 1); // SHA256 + BinaryPrimitives.WriteUInt32BigEndian(payload.AsSpan(5, 4), 9_999); + var (isValid, isRehashRequired) = await manager.ValidateClientSecretAsync("secret", Convert.ToBase64String(payload)); + + // Assert + Assert.False(isValid); + Assert.False(isRehashRequired); + } + + [Fact] + public async Task ValidateClientSecretAsync_ProtectedMethod_ReturnsFalseForTooManyIterations() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new CustomApplicationManagerWithProtectedAccess(cache, logger, options, store); + + var payload = new byte[50]; + payload[0] = 0x01; + BinaryPrimitives.WriteUInt32BigEndian(payload.AsSpan(1, 4), 1); // SHA256 + BinaryPrimitives.WriteUInt32BigEndian(payload.AsSpan(5, 4), 10_000_001); + var (isValid, isRehashRequired) = await manager.ValidateClientSecretAsync("secret", Convert.ToBase64String(payload)); + + // Assert + Assert.False(isValid); + Assert.False(isRehashRequired); + } + + [Fact] + public async Task ValidateClientSecretAsync_ProtectedMethod_ReturnsFalseForTooShortSalt() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new CustomApplicationManagerWithProtectedAccess(cache, logger, options, store); + + var payload = new byte[50]; + payload[0] = 0x01; + BinaryPrimitives.WriteUInt32BigEndian(payload.AsSpan(1, 4), 1); // SHA256 + BinaryPrimitives.WriteUInt32BigEndian(payload.AsSpan(5, 4), 100_000); + BinaryPrimitives.WriteUInt32BigEndian(payload.AsSpan(9, 4), 15); + var (isValid, isRehashRequired) = await manager.ValidateClientSecretAsync("secret", Convert.ToBase64String(payload)); + + // Assert + Assert.False(isValid); + Assert.False(isRehashRequired); + } + + [Fact] + public async Task ValidateClientSecretAsync_ProtectedMethod_ReturnsFalseWhenSecretDoesNotMatch() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new CustomApplicationManagerWithProtectedAccess(cache, logger, options, store); + + // Act + var (isValid, isRehashRequired) = await manager.ValidateClientSecretAsync("wrong-secret", + "AQAAAAIAAYagAAAAIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fa7sVq4Aib4PoDsWFuIX7V4NFLHw5zf2TM+3M6JSc3S2ae5j2Njof7cWUUqaG4esxvbG6aKTTQMogVSejRPMbhQ=="); + + // Assert + Assert.False(isValid); + Assert.False(isRehashRequired); + } + + [Fact] + public async Task ValidateClientSecretAsync_ProtectedMethod_ReturnsTrueWithoutRehashWhenOptionsMatch() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new CustomApplicationManagerWithProtectedAccess(cache, logger, options, store); + + // Act + var (isValid, isRehashRequired) = await manager.ValidateClientSecretAsync("my-secret", + "AQAAAAIAAYagAAAAIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fhmvJW1GK8OLHa33ut1TfRoG5BojsxFN8Oq8lnF9CTGu/Ti7ItixBhZaDxxb5lejILz/Ob+33P0h2Zax9+FTeWg=="); + + // Assert + Assert.True(isValid); + Assert.False(isRehashRequired); + } + + [Fact] + public async Task ValidateClientSecretAsync_ProtectedMethod_ReturnsTrueWithRehashWhenAlgorithmChanged() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var store = Mock.Of>(); + + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var manager = new CustomApplicationManagerWithProtectedAccess(cache, logger, options, store); + + // Act + var (isValid, isRehashRequired) = await manager.ValidateClientSecretAsync("my-secret", + "AQAAAAEAAYagAAAAIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fcc2TaLOEfZnHMySJlkQmmM1nnq4c6jFhE65SjmqPATGuvpSybeRzlk8584bUg3qjJbxxJ1oALKzppT0V6sPYQQ=="); + + // Assert + Assert.True(isValid); + Assert.True(isRehashRequired); + } + + [Fact] + public async Task ValidateClientSecretAsync_ProtectedMethod_ReturnsTrueWithRehashWhenIterationsChanged() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var store = Mock.Of>(); + + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var manager = new CustomApplicationManagerWithProtectedAccess(cache, logger, options, store); + + // Act + var (isValid, isRehashRequired) = await manager.ValidateClientSecretAsync("my-secret", + "AQAAAAIAAMNQAAAAIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fhUaA0RyMXFhPzsknq1++eBXKuNGp6nPquCMb4NVu9st1Qj6cDZXnnHmS2FXgc/2AUe8+QJ35JGY3SW3gJjzQUA=="); + + // Assert + Assert.True(isValid); + Assert.True(isRehashRequired); + } + + [Fact] + public async Task ValidateClientSecretAsync_ProtectedMethod_ReturnsTrueWithRehashWhenSaltLengthChanged() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var store = Mock.Of>(); + + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var manager = new CustomApplicationManagerWithProtectedAccess(cache, logger, options, store); + + // Act + var (isValid, isRehashRequired) = await manager.ValidateClientSecretAsync("my-secret", + "AQAAAAIAAYagAAAAEAABAgMEBQYHCAkKCwwNDg9/NCTaaTU2IRS086JD6lOexCek5paQ+YtfkDinqaRBpu6sdgeztT00H36B5hWW787rjVDMg9IpYdZ7iPiQZZZu"); + + // Assert + Assert.True(isValid); + Assert.True(isRehashRequired); + } + + [Fact] + public async Task ValidateClientSecretAsync_ProtectedMethod_ReturnsTrueWithRehashWhenOutputLengthChanged() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var store = Mock.Of>(); + + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var manager = new CustomApplicationManagerWithProtectedAccess(cache, logger, options, store); + + // Act + var (isValid, isRehashRequired) = await manager.ValidateClientSecretAsync("my-secret", + "AQAAAAIAAYagAAAAIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fhmvJW1GK8OLHa33ut1TfRg=="); + + // Assert + Assert.True(isValid); + Assert.True(isRehashRequired); + } + + [Fact] + public async Task ValidatePostLogoutRedirectUriAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.ValidatePostLogoutRedirectUriAsync(application: null!, "https://localhost").AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task ValidatePostLogoutRedirectUriAsync_ThrowsAnExceptionForNullUri() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.ValidatePostLogoutRedirectUriAsync(application, uri: null!).AsTask()); + + Assert.Equal("uri", exception.ParamName); + } + + [Fact] + public async Task ValidatePostLogoutRedirectUriAsync_ThrowsAnExceptionForEmptyUri() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.ValidatePostLogoutRedirectUriAsync(application, uri: string.Empty).AsTask()); + + Assert.Equal("uri", exception.ParamName); + } + + [Fact] + public async Task ValidatePostLogoutRedirectUriAsync_ReturnsFalseWhenUriNotRegistered() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync(["https://contoso.com/logout"]); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var result = await manager.ValidatePostLogoutRedirectUriAsync(application, "https://other.com/logout"); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task ValidatePostLogoutRedirectUriAsync_ReturnsTrueWhenUriMatchesExactly() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync(["https://contoso.com/logout"]); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var result = await manager.ValidatePostLogoutRedirectUriAsync(application, "https://contoso.com/logout"); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task ValidatePostLogoutRedirectUriAsync_ReturnsFalseWhenUriDiffersInCase() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync(["https://contoso.com/Logout"]); + + store.Setup(store => store.GetApplicationTypeAsync(application, It.IsAny())) + .ReturnsAsync(ApplicationTypes.Web); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var result = await manager.ValidatePostLogoutRedirectUriAsync(application, "https://contoso.com/logout"); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task ValidatePostLogoutRedirectUriAsync_ReturnsTrueForNativeApplicationWhenLoopbackUriMatchesWithDifferentPort() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync(["http://127.0.0.1/logout"]); + + store.Setup(store => store.GetApplicationTypeAsync(application, It.IsAny())) + .ReturnsAsync(ApplicationTypes.Native); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + var result = await manager.ValidatePostLogoutRedirectUriAsync(application, "http://127.0.0.1:7890/logout"); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task ValidatePostLogoutRedirectUriAsync_ReturnsFalseForNativeApplicationWhenClientUriUsesDefaultPort() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync(["http://127.0.0.1/logout"]); + + store.Setup(store => store.GetApplicationTypeAsync(application, It.IsAny())) + .ReturnsAsync(ApplicationTypes.Native); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + var result = await manager.ValidatePostLogoutRedirectUriAsync(application, "http://127.0.0.1:80/logout"); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task ValidatePostLogoutRedirectUriAsync_ReturnsFalseForNativeApplicationWhenUriIsNotLoopback() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync(["https://contoso.com/logout"]); + + store.Setup(store => store.GetApplicationTypeAsync(application, It.IsAny())) + .ReturnsAsync(ApplicationTypes.Native); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + var result = await manager.ValidatePostLogoutRedirectUriAsync(application, "https://contoso.com:7890/logout"); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task ValidatePostLogoutRedirectUriAsync_ReturnsFalseForNativeApplicationWhenSchemesDiffer() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync(["http://127.0.0.1/logout"]); + + store.Setup(store => store.GetApplicationTypeAsync(application, It.IsAny())) + .ReturnsAsync(ApplicationTypes.Native); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + var result = await manager.ValidatePostLogoutRedirectUriAsync(application, "https://127.0.0.1:7890/logout"); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task ValidatePostLogoutRedirectUriAsync_ReturnsFalseForNativeApplicationWhenPathsDiffer() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync(["http://127.0.0.1/logout"]); + + store.Setup(store => store.GetApplicationTypeAsync(application, It.IsAny())) + .ReturnsAsync(ApplicationTypes.Native); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + var result = await manager.ValidatePostLogoutRedirectUriAsync(application, "http://127.0.0.1:7890/other"); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task ValidatePostLogoutRedirectUriAsync_ReturnsFalseForNonNativeApplicationWhenPortDiffers() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetPostLogoutRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync(["http://127.0.0.1/logout"]); + + store.Setup(store => store.GetApplicationTypeAsync(application, It.IsAny())) + .ReturnsAsync(ApplicationTypes.Web); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var result = await manager.ValidatePostLogoutRedirectUriAsync(application, "http://127.0.0.1:7890/logout"); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task ValidatePublicKeyInfrastructureTlsClientCertificateAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + var certificate = (X509Certificate2)null!; + var policy = new X509ChainPolicy(); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.ValidatePublicKeyInfrastructureTlsClientCertificateAsync(application: null!, certificate, policy).AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task ValidatePublicKeyInfrastructureTlsClientCertificateAsync_ThrowsAnExceptionForNullCertificate() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + var policy = new X509ChainPolicy(); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.ValidatePublicKeyInfrastructureTlsClientCertificateAsync(application, certificate: null!, policy).AsTask()); + + Assert.Equal("certificate", exception.ParamName); + } + + [Fact] + public async Task ValidateRedirectUriAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.ValidateRedirectUriAsync(application: null!, "https://localhost").AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task ValidateRedirectUriAsync_ThrowsAnExceptionForNullUri() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.ValidateRedirectUriAsync(application, uri: null!).AsTask()); + + Assert.Equal("uri", exception.ParamName); + } + + [Fact] + public async Task ValidateRedirectUriAsync_ThrowsAnExceptionForEmptyUri() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.ValidateRedirectUriAsync(application, uri: string.Empty).AsTask()); + + Assert.Equal("uri", exception.ParamName); + } + + [Fact] + public async Task ValidateRedirectUriAsync_ReturnsFalseWhenUriNotRegistered() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync(["https://contoso.com/callback"]); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var result = await manager.ValidateRedirectUriAsync(application, "https://other.com/callback"); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task ValidateRedirectUriAsync_ReturnsTrueWhenUriMatchesExactly() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync(["https://contoso.com/callback"]); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var result = await manager.ValidateRedirectUriAsync(application, "https://contoso.com/callback"); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task ValidateRedirectUriAsync_ReturnsFalseWhenUriDiffersInCase() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync(["https://contoso.com/Callback"]); + + store.Setup(store => store.GetApplicationTypeAsync(application, It.IsAny())) + .ReturnsAsync(ApplicationTypes.Web); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var result = await manager.ValidateRedirectUriAsync(application, "https://contoso.com/callback"); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task ValidateRedirectUriAsync_ReturnsTrueForNativeApplicationWhenLoopbackUriMatchesWithDifferentPort() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync(["http://127.0.0.1/callback"]); + + store.Setup(store => store.GetApplicationTypeAsync(application, It.IsAny())) + .ReturnsAsync(ApplicationTypes.Native); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + var result = await manager.ValidateRedirectUriAsync(application, "http://127.0.0.1:7890/callback"); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task ValidateRedirectUriAsync_ReturnsFalseForNativeApplicationWhenClientUriUsesDefaultPort() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync(["http://127.0.0.1/callback"]); + + store.Setup(store => store.GetApplicationTypeAsync(application, It.IsAny())) + .ReturnsAsync(ApplicationTypes.Native); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + var result = await manager.ValidateRedirectUriAsync(application, "http://127.0.0.1:80/callback"); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task ValidateRedirectUriAsync_ReturnsFalseForNativeApplicationWhenUriIsNotLoopback() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync(["https://contoso.com/callback"]); + + store.Setup(store => store.GetApplicationTypeAsync(application, It.IsAny())) + .ReturnsAsync(ApplicationTypes.Native); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + var result = await manager.ValidateRedirectUriAsync(application, "https://contoso.com:7890/callback"); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task ValidateRedirectUriAsync_ReturnsFalseForNativeApplicationWhenSchemesDiffer() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync(["http://127.0.0.1/callback"]); + + store.Setup(store => store.GetApplicationTypeAsync(application, It.IsAny())) + .ReturnsAsync(ApplicationTypes.Native); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + var result = await manager.ValidateRedirectUriAsync(application, "https://127.0.0.1:7890/callback"); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task ValidateRedirectUriAsync_ReturnsFalseForNativeApplicationWhenPathsDiffer() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync(["http://127.0.0.1/callback"]); + + store.Setup(store => store.GetApplicationTypeAsync(application, It.IsAny())) + .ReturnsAsync(ApplicationTypes.Native); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + var result = await manager.ValidateRedirectUriAsync(application, "http://127.0.0.1:7890/other"); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task ValidateRedirectUriAsync_ReturnsFalseForNonNativeApplicationWhenPortDiffers() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetRedirectUrisAsync(application, It.IsAny())) + .ReturnsAsync(["http://127.0.0.1/callback"]); + + store.Setup(store => store.GetApplicationTypeAsync(application, It.IsAny())) + .ReturnsAsync(ApplicationTypes.Web); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store.Object); + + // Act + var result = await manager.ValidateRedirectUriAsync(application, "http://127.0.0.1:7890/callback"); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task ValidateSelfSignedTlsClientCertificateAsync_ThrowsAnExceptionForNullApplication() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + var certificate = (X509Certificate2)null!; + var policy = new X509ChainPolicy(); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.ValidateSelfSignedTlsClientCertificateAsync(application: null!, certificate, policy).AsTask()); + + Assert.Equal("application", exception.ParamName); + } + + [Fact] + public async Task ValidateSelfSignedTlsClientCertificateAsync_ThrowsAnExceptionForNullCertificate() + { + // Arrange + var application = new CustomApplication(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictApplicationManager(cache, logger, options, store); + var policy = new X509ChainPolicy(); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.ValidateSelfSignedTlsClientCertificateAsync(application, certificate: null!, policy).AsTask()); + + Assert.Equal("certificate", exception.ParamName); + } + + public class CustomApplication { } + + private class CustomApplicationManagerWithProtectedAccess : OpenIddictApplicationManager + { + public CustomApplicationManagerWithProtectedAccess( + IOpenIddictApplicationCache cache, + ILogger> logger, + IOptionsMonitor options, + IOpenIddictApplicationStore store) + : base(cache, logger, options, store) + { + } + + public new ValueTask ObfuscateClientSecretAsync(string secret, CancellationToken cancellationToken = default) + => base.ObfuscateClientSecretAsync(secret, cancellationToken); + + public new ValueTask<(bool IsValid, bool IsRehashRequired)> ValidateClientSecretAsync( + string secret, string comparand, CancellationToken cancellationToken = default) + => base.ValidateClientSecretAsync(secret, comparand, cancellationToken); + } +} diff --git a/test/OpenIddict.Core.Tests/Managers/OpenIddictAuthorizationManagerTests.cs b/test/OpenIddict.Core.Tests/Managers/OpenIddictAuthorizationManagerTests.cs new file mode 100644 index 00000000..4240c99e --- /dev/null +++ b/test/OpenIddict.Core.Tests/Managers/OpenIddictAuthorizationManagerTests.cs @@ -0,0 +1,1290 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System.Collections.Immutable; +using System.ComponentModel.DataAnnotations; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace OpenIddict.Core.Tests; + +public class OpenIddictAuthorizationManagerTests +{ + [Fact] + public void Constructor_ThrowsAnExceptionForNullCache() + { + // Arrange + var cache = (IOpenIddictAuthorizationCache) null!; + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + // Act and assert + var exception = Assert.Throws( + () => new OpenIddictAuthorizationManager(cache, logger, options, store)); + + Assert.Equal("cache", exception.ParamName); + } + + [Fact] + public void Constructor_ThrowsAnExceptionForNullLogger() + { + // Arrange + var cache = Mock.Of>(); + var logger = (ILogger>) null!; + var options = Mock.Of>(); + var store = Mock.Of>(); + + // Act and assert + var exception = Assert.Throws( + () => new OpenIddictAuthorizationManager(cache, logger, options, store)); + + Assert.Equal("logger", exception.ParamName); + } + + [Fact] + public void Constructor_ThrowsAnExceptionForNullOptions() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = (IOptionsMonitor) null!; + var store = Mock.Of>(); + + // Act and assert + var exception = Assert.Throws( + () => new OpenIddictAuthorizationManager(cache, logger, options, store)); + + Assert.Equal("options", exception.ParamName); + } + + [Fact] + public void Constructor_ThrowsAnExceptionForNullStore() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = (IOpenIddictAuthorizationStore) null!; + + // Act and assert + var exception = Assert.Throws( + () => new OpenIddictAuthorizationManager(cache, logger, options, store)); + + Assert.Equal("store", exception.ParamName); + } + + [Fact] + public async Task CountAsync_CallsStoreMethod() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.CountAsync(It.IsAny())) + .ReturnsAsync(42); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store.Object); + + // Act + var count = await manager.CountAsync(); + + // Assert + Assert.Equal(42, count); + store.Verify(store => store.CountAsync(It.IsAny()), Times.Once()); + } + + [Fact] + public async Task CountAsync_WithQuery_ThrowsAnExceptionForNullQuery() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.CountAsync(query: null!).AsTask()); + + Assert.Equal("query", exception.ParamName); + } + + [Fact] + public async Task CreateAsync_ThrowsAnExceptionForNullAuthorization() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.CreateAsync(authorization: null!).AsTask()); + + Assert.Equal("authorization", exception.ParamName); + } + + [Fact] + public async Task DeleteAsync_ThrowsAnExceptionForNullAuthorization() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.DeleteAsync(authorization: null!).AsTask()); + + Assert.Equal("authorization", exception.ParamName); + } + + [Fact] + public async Task DeleteAsync_RemovesAuthorizationFromCache_WhenCachingIsEnabled() + { + // Arrange + var cache = new Mock>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions { DisableEntityCaching = false }); + var store = new Mock>(); + var authorization = new CustomAuthorization(); + + var manager = new OpenIddictAuthorizationManager(cache.Object, logger, options, store.Object); + + // Act + await manager.DeleteAsync(authorization); + + // Assert + cache.Verify(cache => cache.RemoveAsync(authorization, It.IsAny()), Times.Once()); + store.Verify(store => store.DeleteAsync(authorization, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task DeleteAsync_DoesNotRemoveFromCache_WhenCachingIsDisabled() + { + // Arrange + var cache = new Mock>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions { DisableEntityCaching = true }); + var store = new Mock>(); + var authorization = new CustomAuthorization(); + + var manager = new OpenIddictAuthorizationManager(cache.Object, logger, options, store.Object); + + // Act + await manager.DeleteAsync(authorization); + + // Assert + cache.Verify(cache => cache.RemoveAsync(It.IsAny(), It.IsAny()), Times.Never()); + store.Verify(store => store.DeleteAsync(authorization, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task FindAsync_FiltersOutAuthorizationsWithNonMatchingSubject() + { + // Arrange + var authorizations = new[] { new CustomAuthorization(), new CustomAuthorization() }; + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions { DisableEntityCaching = true }); + var store = new Mock>(); + + store.Setup(store => store.FindAsync("alice", null, null, null, null, It.IsAny())) + .Returns(authorizations.ToAsyncEnumerable()); + + store.Setup(store => store.GetSubjectAsync(authorizations[0], It.IsAny())) + .ReturnsAsync("alice"); + + store.Setup(store => store.GetSubjectAsync(authorizations[1], It.IsAny())) + .ReturnsAsync("bob"); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store.Object); + + // Act + var results = await manager.FindAsync("alice", null, null, null, null).ToListAsync(); + + // Assert + Assert.Single(results); + Assert.Same(authorizations[0], results[0]); + } + + [Fact] + public async Task FindAsync_FiltersOutAuthorizationsWithNonMatchingScopes() + { + // Arrange + var authorizations = new[] { new CustomAuthorization(), new CustomAuthorization() }; + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions { DisableEntityCaching = true }); + var store = new Mock>(); + + store.Setup(store => store.FindAsync(null, null, null, null, ImmutableArray.Create("openid"), It.IsAny())) + .Returns(authorizations.ToAsyncEnumerable()); + + store.Setup(store => store.GetSubjectAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((string?) null); + + store.Setup(store => store.GetScopesAsync(authorizations[0], It.IsAny())) + .ReturnsAsync(["openid", "profile"]); + + store.Setup(store => store.GetScopesAsync(authorizations[1], It.IsAny())) + .ReturnsAsync(["profile"]); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store.Object); + + // Act + var results = await manager.FindAsync(null, null, null, null, ImmutableArray.Create("openid")).ToListAsync(); + + // Assert + Assert.Single(results); + Assert.Same(authorizations[0], results[0]); + } + + [Fact] + public void FindByApplicationIdAsync_ThrowsAnExceptionForNullIdentifier() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = Assert.Throws( + () => manager.FindByApplicationIdAsync(identifier: null!)); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public void FindByApplicationIdAsync_ThrowsAnExceptionForEmptyIdentifier() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = Assert.Throws( + () => manager.FindByApplicationIdAsync(identifier: string.Empty)); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task FindByIdAsync_ThrowsAnExceptionForNullIdentifier() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.FindByIdAsync(identifier: null!).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task FindByIdAsync_ThrowsAnExceptionForEmptyIdentifier() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.FindByIdAsync(identifier: string.Empty).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task FindByIdAsync_UsesCache_WhenCachingIsEnabled() + { + // Arrange + var authorization = new CustomAuthorization(); + var cache = new Mock>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions { DisableEntityCaching = false }); + var store = new Mock>(); + + cache.Setup(cache => cache.FindByIdAsync("id", It.IsAny())) + .ReturnsAsync(authorization); + + store.Setup(store => store.GetIdAsync(authorization, It.IsAny())) + .ReturnsAsync("id"); + + var manager = new OpenIddictAuthorizationManager(cache.Object, logger, options, store.Object); + + // Act + var result = await manager.FindByIdAsync("id"); + + // Assert + Assert.Same(authorization, result); + cache.Verify(cache => cache.FindByIdAsync("id", It.IsAny()), Times.Once()); + store.Verify(store => store.FindByIdAsync(It.IsAny(), It.IsAny()), Times.Never()); + } + + [Fact] + public async Task FindByIdAsync_UsesStore_WhenCachingIsDisabled() + { + // Arrange + var authorization = new CustomAuthorization(); + var cache = new Mock>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions { DisableEntityCaching = true }); + var store = new Mock>(); + + store.Setup(store => store.FindByIdAsync("id", It.IsAny())) + .ReturnsAsync(authorization); + + store.Setup(store => store.GetIdAsync(authorization, It.IsAny())) + .ReturnsAsync("id"); + + var manager = new OpenIddictAuthorizationManager(cache.Object, logger, options, store.Object); + + // Act + var result = await manager.FindByIdAsync("id"); + + // Assert + Assert.Same(authorization, result); + cache.Verify(cache => cache.FindByIdAsync(It.IsAny(), It.IsAny()), Times.Never()); + store.Verify(store => store.FindByIdAsync("id", It.IsAny()), Times.Once()); + } + + [Fact] + public void FindBySubjectAsync_ThrowsAnExceptionForNullSubject() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = Assert.Throws( + () => manager.FindBySubjectAsync(subject: null!)); + + Assert.Equal("subject", exception.ParamName); + } + + [Fact] + public void FindBySubjectAsync_ThrowsAnExceptionForEmptySubject() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = Assert.Throws( + () => manager.FindBySubjectAsync(subject: string.Empty)); + + Assert.Equal("subject", exception.ParamName); + } + + [Fact] + public async Task GetApplicationIdAsync_ThrowsAnExceptionForNullAuthorization() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetApplicationIdAsync(authorization: null!).AsTask()); + + Assert.Equal("authorization", exception.ParamName); + } + + [Fact] + public async Task GetAsync_WithQuery_ThrowsAnExceptionForNullQuery() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetAsync(query: null!).AsTask()); + + Assert.Equal("query", exception.ParamName); + } + + [Fact] + public async Task GetAsync_WithQueryAndState_ThrowsAnExceptionForNullQuery() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetAsync(query: null!, state: null!).AsTask()); + + Assert.Equal("query", exception.ParamName); + } + + [Fact] + public async Task GetCreationDateAsync_ThrowsAnExceptionForNullAuthorization() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetCreationDateAsync(authorization: null!).AsTask()); + + Assert.Equal("authorization", exception.ParamName); + } + + [Fact] + public async Task GetIdAsync_ThrowsAnExceptionForNullAuthorization() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetIdAsync(authorization: null!).AsTask()); + + Assert.Equal("authorization", exception.ParamName); + } + + [Fact] + public async Task GetIdAsync_ReturnsIdentifierFromStore() + { + // Arrange + var authorization = new CustomAuthorization(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(authorization, It.IsAny())) + .ReturnsAsync("unique-auth-id"); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store.Object); + + // Act + var id = await manager.GetIdAsync(authorization); + + // Assert + Assert.Equal("unique-auth-id", id); + store.Verify(store => store.GetIdAsync(authorization, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task GetPropertiesAsync_ThrowsAnExceptionForNullAuthorization() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetPropertiesAsync(authorization: null!).AsTask()); + + Assert.Equal("authorization", exception.ParamName); + } + + [Fact] + public async Task GetScopesAsync_ThrowsAnExceptionForNullAuthorization() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetScopesAsync(authorization: null!).AsTask()); + + Assert.Equal("authorization", exception.ParamName); + } + + [Fact] + public async Task GetStatusAsync_ThrowsAnExceptionForNullAuthorization() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetStatusAsync(authorization: null!).AsTask()); + + Assert.Equal("authorization", exception.ParamName); + } + + [Fact] + public async Task GetSubjectAsync_ThrowsAnExceptionForNullAuthorization() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetSubjectAsync(authorization: null!).AsTask()); + + Assert.Equal("authorization", exception.ParamName); + } + + [Fact] + public async Task GetTypeAsync_ThrowsAnExceptionForNullAuthorization() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetTypeAsync(authorization: null!).AsTask()); + + Assert.Equal("authorization", exception.ParamName); + } + + [Fact] + public async Task HasScopesAsync_ThrowsAnExceptionForNullAuthorization() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.HasScopesAsync(authorization: null!, ["scope"]).AsTask()); + + Assert.Equal("authorization", exception.ParamName); + } + + [Theory] + [InlineData(new[] { "scope1", "scope2", "scope3" }, new[] { "scope1", "scope2" }, true)] + [InlineData(new[] { "scope1" }, new[] { "scope1", "scope2" }, false)] + public async Task HasScopesAsync_ReturnsExpectedResult(string[] storedScopes, string[] testedScopes, bool expected) + { + // Arrange + var authorization = new CustomAuthorization(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetScopesAsync(authorization, It.IsAny())) + .ReturnsAsync([.. storedScopes]); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store.Object); + + // Act + var result = await manager.HasScopesAsync(authorization, [.. testedScopes]); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public async Task HasStatusAsync_ThrowsAnExceptionForNullAuthorization() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.HasStatusAsync(authorization: null!, "valid").AsTask()); + + Assert.Equal("authorization", exception.ParamName); + } + + [Fact] + public async Task HasStatusAsync_ThrowsAnExceptionForNullStatus() + { + // Arrange + var authorization = new CustomAuthorization(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.HasStatusAsync(authorization, status: null!).AsTask()); + + Assert.Equal("status", exception.ParamName); + } + + [Theory] + [InlineData(Statuses.Valid, Statuses.Valid, true )] + [InlineData(Statuses.Valid, Statuses.Revoked, false)] + [InlineData(Statuses.Revoked, "REVOKED", false)] + public async Task HasStatusAsync_ReturnsExpectedResult(string storedStatus, string testedStatus, bool expected) + { + // Arrange + var authorization = new CustomAuthorization(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetStatusAsync(authorization, It.IsAny())) + .ReturnsAsync(storedStatus); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store.Object); + + // Act + var result = await manager.HasStatusAsync(authorization, testedStatus); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public async Task HasTypeAsync_ThrowsAnExceptionForNullAuthorization() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.HasTypeAsync(authorization: null!, "permanent").AsTask()); + + Assert.Equal("authorization", exception.ParamName); + } + + [Fact] + public async Task HasTypeAsync_ThrowsAnExceptionForNullType() + { + // Arrange + var authorization = new CustomAuthorization(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.HasTypeAsync(authorization, type: null!).AsTask()); + + Assert.Equal("type", exception.ParamName); + } + + [Theory] + [InlineData(AuthorizationTypes.Permanent, AuthorizationTypes.Permanent, true )] + [InlineData(AuthorizationTypes.Permanent, AuthorizationTypes.AdHoc, false)] + [InlineData(AuthorizationTypes.Permanent, "PERMANENT", false)] + public async Task HasTypeAsync_ReturnsExpectedResult(string storedType, string testedType, bool expected) + { + // Arrange + var authorization = new CustomAuthorization(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetTypeAsync(authorization, It.IsAny())) + .ReturnsAsync(storedType); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store.Object); + + // Act + var result = await manager.HasTypeAsync(authorization, testedType); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public async Task ListAsync_ReturnsAllAuthorizations() + { + // Arrange + var authorizations = new[] { new CustomAuthorization(), new CustomAuthorization() }; + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.ListAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(authorizations.ToAsyncEnumerable()); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store.Object); + + // Act + var results = new List(); + await foreach (var auth in manager.ListAsync()) + { + results.Add(auth); + } + + // Assert + Assert.Equal(2, results.Count); + store.Verify(store => store.ListAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); + } + + [Fact] + public async Task PopulateAsync_ThrowsAnExceptionForNullAuthorization() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + var descriptor = new OpenIddictAuthorizationDescriptor(); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.PopulateAsync(authorization: null!, descriptor).AsTask()); + + Assert.Equal("authorization", exception.ParamName); + } + + [Fact] + public async Task PopulateAsync_ThrowsAnExceptionForNullDescriptor() + { + // Arrange + var authorization = new CustomAuthorization(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.PopulateAsync(authorization, descriptor: null!).AsTask()); + + Assert.Equal("descriptor", exception.ParamName); + } + + [Fact] + public async Task PruneAsync_CallsStoreMethod() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.PruneAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(42); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store.Object); + + // Act + var count = await manager.PruneAsync(DateTimeOffset.UtcNow); + + // Assert + Assert.Equal(42, count); + store.Verify(store => store.PruneAsync(It.IsAny(), It.IsAny()), Times.Once()); + } + + [Fact] + public async Task RevokeAsync_ReturnsZeroWhenNoAuthorizationsFound() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.FindAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Returns((string? subject, string? client, string? status, string? type, ImmutableArray? scopes, CancellationToken cancellationToken) => + { + return Enumerable.Empty().ToAsyncEnumerable(); + }); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store.Object); + + // Act + var count = await manager.RevokeAsync("subject", "client", "status", "type"); + + // Assert + Assert.Equal(0, count); + } + + [Fact] + public async Task RevokeByApplicationIdAsync_ThrowsAnExceptionForNullIdentifier() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.RevokeByApplicationIdAsync(identifier: null!).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task RevokeByApplicationIdAsync_ThrowsAnExceptionForEmptyIdentifier() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.RevokeByApplicationIdAsync(identifier: string.Empty).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task RevokeBySubjectAsync_ThrowsAnExceptionForNullSubject() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.RevokeBySubjectAsync(subject: null!).AsTask()); + + Assert.Equal("subject", exception.ParamName); + } + + [Fact] + public async Task RevokeBySubjectAsync_ThrowsAnExceptionForEmptySubject() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.RevokeBySubjectAsync(subject: string.Empty).AsTask()); + + Assert.Equal("subject", exception.ParamName); + } + + [Fact] + public async Task TryRevokeAsync_ThrowsAnExceptionForNullAuthorization() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.TryRevokeAsync(authorization: null!).AsTask()); + + Assert.Equal("authorization", exception.ParamName); + } + + [Fact] + public async Task TryRevokeAsync_ReturnsTrueImmediatelyWhenAlreadyRevoked() + { + // Arrange + var authorization = new CustomAuthorization(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetStatusAsync(authorization, It.IsAny())) + .ReturnsAsync(Statuses.Revoked); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store.Object); + + // Act + var result = await manager.TryRevokeAsync(authorization); + + // Assert + Assert.True(result); + store.Verify(store => store.SetStatusAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task TryRevokeAsync_ReturnsFalseWhenStatusIsNotRevoked_CaseSensitive() + { + // Arrange + var authorization = new CustomAuthorization(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetStatusAsync(authorization, It.IsAny())) + .ReturnsAsync("REVOKED"); + + store.Setup(store => store.GetIdAsync(authorization, It.IsAny())) + .ReturnsAsync("id"); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store.Object); + + // Act + await manager.TryRevokeAsync(authorization); + + store.Verify(store => store.SetStatusAsync(authorization, Statuses.Revoked, It.IsAny()), Times.Once); + } + + [Fact] + public async Task UpdateAsync_ThrowsAnExceptionForNullAuthorization() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.UpdateAsync(authorization: null!).AsTask()); + + Assert.Equal("authorization", exception.ParamName); + } + + [Fact] + public async Task UpdateAsync_WithDescriptor_ThrowsAnExceptionForNullAuthorization() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + var descriptor = new OpenIddictAuthorizationDescriptor(); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.UpdateAsync(authorization: null!, descriptor).AsTask()); + + Assert.Equal("authorization", exception.ParamName); + } + + [Fact] + public void ValidateAsync_ThrowsAnExceptionForNullAuthorization() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store); + + // Act and assert + var exception = Assert.Throws( + () => manager.ValidateAsync(authorization: null!)); + + Assert.Equal("authorization", exception.ParamName); + } + + [Fact] + public async Task ValidateAsync_ReturnsErrorWhenTypeIsEmpty() + { + // Arrange + var authorization = new CustomAuthorization(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetTypeAsync(authorization, It.IsAny())) + .ReturnsAsync(string.Empty); + + store.Setup(store => store.GetStatusAsync(authorization, It.IsAny())) + .ReturnsAsync(Statuses.Valid); + + store.Setup(store => store.GetScopesAsync(authorization, It.IsAny())) + .ReturnsAsync([]); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(authorization).ToListAsync(); + + // Assert + Assert.Contains(results, result => result.ErrorMessage == SR.GetResourceString(SR.ID2116)); + } + + [Fact] + public async Task ValidateAsync_ReturnsErrorWhenTypeIsNotSupported() + { + // Arrange + var authorization = new CustomAuthorization(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetTypeAsync(authorization, It.IsAny())) + .ReturnsAsync("unsupported-type"); + + store.Setup(store => store.GetStatusAsync(authorization, It.IsAny())) + .ReturnsAsync(Statuses.Valid); + + store.Setup(store => store.GetScopesAsync(authorization, It.IsAny())) + .ReturnsAsync([]); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(authorization).ToListAsync(); + + // Assert + Assert.Contains(results, result => result.ErrorMessage == SR.GetResourceString(SR.ID2117)); + } + + [Fact] + public async Task ValidateAsync_ReturnsErrorWhenStatusIsEmpty() + { + // Arrange + var authorization = new CustomAuthorization(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetTypeAsync(authorization, It.IsAny())) + .ReturnsAsync(AuthorizationTypes.Permanent); + + store.Setup(store => store.GetStatusAsync(authorization, It.IsAny())) + .ReturnsAsync(string.Empty); + + store.Setup(store => store.GetScopesAsync(authorization, It.IsAny())) + .ReturnsAsync([]); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(authorization).ToListAsync(); + + // Assert + Assert.Contains(results, result => result.ErrorMessage == SR.GetResourceString(SR.ID2038)); + } + + [Fact] + public async Task ValidateAsync_ReturnsErrorWhenScopeIsEmpty() + { + // Arrange + var authorization = new CustomAuthorization(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetTypeAsync(authorization, It.IsAny())) + .ReturnsAsync(AuthorizationTypes.Permanent); + + store.Setup(store => store.GetStatusAsync(authorization, It.IsAny())) + .ReturnsAsync(Statuses.Valid); + + store.Setup(store => store.GetScopesAsync(authorization, It.IsAny())) + .ReturnsAsync([string.Empty]); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(authorization).ToListAsync(); + + // Assert + Assert.Contains(results, result => result.ErrorMessage == SR.GetResourceString(SR.ID2039)); + } + + [Fact] + public async Task ValidateAsync_ReturnsErrorWhenScopeContainsSpace() + { + // Arrange + var authorization = new CustomAuthorization(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetTypeAsync(authorization, It.IsAny())) + .ReturnsAsync(AuthorizationTypes.Permanent); + + store.Setup(store => store.GetStatusAsync(authorization, It.IsAny())) + .ReturnsAsync(Statuses.Valid); + + store.Setup(store => store.GetScopesAsync(authorization, It.IsAny())) + .ReturnsAsync(["scope with space"]); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(authorization).ToListAsync(); + + // Assert + Assert.Contains(results, result => result.ErrorMessage == SR.GetResourceString(SR.ID2042)); + } + + [Fact] + public async Task ValidateAsync_ReturnsNoErrorsForValidAuthorization() + { + // Arrange + var authorization = new CustomAuthorization(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetTypeAsync(authorization, It.IsAny())) + .ReturnsAsync(AuthorizationTypes.Permanent); + + store.Setup(store => store.GetStatusAsync(authorization, It.IsAny())) + .ReturnsAsync(Statuses.Valid); + + store.Setup(store => store.GetScopesAsync(authorization, It.IsAny())) + .ReturnsAsync(["openid", "profile"]); + + var manager = new OpenIddictAuthorizationManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(authorization).ToListAsync(); + + // Assert + Assert.DoesNotContain(results, static result => result != ValidationResult.Success); + } + + public class CustomAuthorization { } +} diff --git a/test/OpenIddict.Core.Tests/Managers/OpenIddictScopeManagerTests.cs b/test/OpenIddict.Core.Tests/Managers/OpenIddictScopeManagerTests.cs new file mode 100644 index 00000000..a67d529a --- /dev/null +++ b/test/OpenIddict.Core.Tests/Managers/OpenIddictScopeManagerTests.cs @@ -0,0 +1,977 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System.Collections.Immutable; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace OpenIddict.Core.Tests; + +public class OpenIddictScopeManagerTests +{ + [Fact] + public void Constructor_ThrowsAnExceptionForNullCache() + { + // Arrange + var cache = (IOpenIddictScopeCache) null!; + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + // Act and assert + var exception = Assert.Throws( + () => new OpenIddictScopeManager(cache, logger, options, store)); + + Assert.Equal("cache", exception.ParamName); + } + + [Fact] + public void Constructor_ThrowsAnExceptionForNullLogger() + { + // Arrange + var cache = Mock.Of>(); + var logger = (ILogger>) null!; + var options = Mock.Of>(); + var store = Mock.Of>(); + + // Act and assert + var exception = Assert.Throws( + () => new OpenIddictScopeManager(cache, logger, options, store)); + + Assert.Equal("logger", exception.ParamName); + } + + [Fact] + public void Constructor_ThrowsAnExceptionForNullOptions() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = (IOptionsMonitor) null!; + var store = Mock.Of>(); + + // Act and assert + var exception = Assert.Throws( + () => new OpenIddictScopeManager(cache, logger, options, store)); + + Assert.Equal("options", exception.ParamName); + } + + [Fact] + public void Constructor_ThrowsAnExceptionForNullStore() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = (IOpenIddictScopeStore) null!; + + // Act and assert + var exception = Assert.Throws( + () => new OpenIddictScopeManager(cache, logger, options, store)); + + Assert.Equal("store", exception.ParamName); + } + + [Fact] + public async Task CountAsync_CallsStoreMethod() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.CountAsync(It.IsAny())) + .ReturnsAsync(42); + + var manager = new OpenIddictScopeManager(cache, logger, options, store.Object); + + // Act + var count = await manager.CountAsync(); + + // Assert + Assert.Equal(42, count); + store.Verify(store => store.CountAsync(It.IsAny()), Times.Once()); + } + + [Fact] + public async Task CountAsync_WithQuery_ThrowsAnExceptionForNullQuery() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictScopeManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.CountAsync(query: null!).AsTask()); + + Assert.Equal("query", exception.ParamName); + } + + [Fact] + public async Task CreateAsync_ThrowsAnExceptionForNullScope() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new OpenIddictScopeManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.CreateAsync(scope: null!).AsTask()); + + Assert.Equal("scope", exception.ParamName); + } + + [Fact] + public async Task DeleteAsync_ThrowsAnExceptionForNullScope() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new OpenIddictScopeManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.DeleteAsync(scope: null!).AsTask()); + + Assert.Equal("scope", exception.ParamName); + } + + [Fact] + public async Task DeleteAsync_RemovesScopeFromCache_WhenCachingIsEnabled() + { + // Arrange + var cache = new Mock>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions { DisableEntityCaching = false }); + var store = new Mock>(); + var scope = new CustomScope(); + + var manager = new OpenIddictScopeManager(cache.Object, logger, options, store.Object); + + // Act + await manager.DeleteAsync(scope); + + // Assert + cache.Verify(cache => cache.RemoveAsync(scope, It.IsAny()), Times.Once()); + store.Verify(store => store.DeleteAsync(scope, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task DeleteAsync_DoesNotRemoveFromCache_WhenCachingIsDisabled() + { + // Arrange + var cache = new Mock>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions { DisableEntityCaching = true }); + var store = new Mock>(); + var scope = new CustomScope(); + + var manager = new OpenIddictScopeManager(cache.Object, logger, options, store.Object); + + // Act + await manager.DeleteAsync(scope); + + // Assert + cache.Verify(cache => cache.RemoveAsync(It.IsAny(), It.IsAny()), Times.Never()); + store.Verify(store => store.DeleteAsync(scope, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task FindByIdAsync_ThrowsAnExceptionForNullIdentifier() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictScopeManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.FindByIdAsync(identifier: null!).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task FindByIdAsync_ThrowsAnExceptionForEmptyIdentifier() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictScopeManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.FindByIdAsync(identifier: string.Empty).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task FindByIdAsync_UsesCache_WhenCachingIsEnabled() + { + // Arrange + var scope = new CustomScope(); + var cache = new Mock>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions { DisableEntityCaching = false }); + var store = new Mock>(); + + cache.Setup(cache => cache.FindByIdAsync("id", It.IsAny())) + .ReturnsAsync(scope); + + store.Setup(store => store.GetIdAsync(scope, It.IsAny())) + .ReturnsAsync("id"); + + var manager = new OpenIddictScopeManager(cache.Object, logger, options, store.Object); + + // Act + var result = await manager.FindByIdAsync("id"); + + // Assert + Assert.Same(scope, result); + cache.Verify(cache => cache.FindByIdAsync("id", It.IsAny()), Times.Once()); + store.Verify(store => store.FindByIdAsync(It.IsAny(), It.IsAny()), Times.Never()); + } + + [Fact] + public async Task FindByIdAsync_UsesStore_WhenCachingIsDisabled() + { + // Arrange + var scope = new CustomScope(); + var cache = new Mock>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions { DisableEntityCaching = true }); + var store = new Mock>(); + + store.Setup(store => store.FindByIdAsync("id", It.IsAny())) + .ReturnsAsync(scope); + + store.Setup(store => store.GetIdAsync(scope, It.IsAny())) + .ReturnsAsync("id"); + + var manager = new OpenIddictScopeManager(cache.Object, logger, options, store.Object); + + // Act + var result = await manager.FindByIdAsync("id"); + + // Assert + Assert.Same(scope, result); + cache.Verify(cache => cache.FindByIdAsync(It.IsAny(), It.IsAny()), Times.Never()); + store.Verify(store => store.FindByIdAsync("id", It.IsAny()), Times.Once()); + } + + [Fact] + public async Task FindByNameAsync_ThrowsAnExceptionForNullName() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictScopeManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.FindByNameAsync(name: null!).AsTask()); + + Assert.Equal("name", exception.ParamName); + } + + [Fact] + public async Task FindByNameAsync_ThrowsAnExceptionForEmptyName() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictScopeManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.FindByNameAsync(name: string.Empty).AsTask()); + + Assert.Equal("name", exception.ParamName); + } + + [Fact] + public async Task FindByNamesAsync_ReturnsEmptyWhenNoScopesMatch() + { + // Arrange + var cache = new Mock>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + cache.Setup(cache => cache.FindByNamesAsync(It.IsAny>(), It.IsAny())) + .Returns((ImmutableArray names, CancellationToken cancellationToken) => + { + return Enumerable.Empty().ToAsyncEnumerable(); + }); + + var manager = new OpenIddictScopeManager(cache.Object, logger, options, store); + + // Act + var results = new List(); + await foreach (var scope in manager.FindByNamesAsync(["scope1", "scope2"])) + { + results.Add(scope); + } + + // Assert + Assert.Empty(results); + } + + [Fact] + public void FindByResourceAsync_ThrowsAnExceptionForNullResource() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictScopeManager(cache, logger, options, store); + + // Act and assert + var exception = Assert.Throws( + () => manager.FindByResourceAsync(resource: null!)); + + Assert.Equal("resource", exception.ParamName); + } + + [Fact] + public void FindByResourceAsync_ThrowsAnExceptionForEmptyResource() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictScopeManager(cache, logger, options, store); + + // Act and assert + var exception = Assert.Throws( + () => manager.FindByResourceAsync(resource: string.Empty)); + + Assert.Equal("resource", exception.ParamName); + } + + [Fact] + public async Task GetAsync_WithQuery_ThrowsAnExceptionForNullQuery() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictScopeManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetAsync(query: null!).AsTask()); + + Assert.Equal("query", exception.ParamName); + } + + [Fact] + public async Task GetAsync_WithQueryAndState_ThrowsAnExceptionForNullQuery() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictScopeManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetAsync(query: null!, state: null!).AsTask()); + + Assert.Equal("query", exception.ParamName); + } + + [Fact] + public async Task GetDescriptionAsync_ThrowsAnExceptionForNullScope() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictScopeManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetDescriptionAsync(scope: null!).AsTask()); + + Assert.Equal("scope", exception.ParamName); + } + + [Fact] + public async Task GetDescriptionsAsync_ThrowsAnExceptionForNullScope() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictScopeManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetDescriptionsAsync(scope: null!).AsTask()); + + Assert.Equal("scope", exception.ParamName); + } + + [Fact] + public async Task GetDisplayNameAsync_ThrowsAnExceptionForNullScope() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictScopeManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetDisplayNameAsync(scope: null!).AsTask()); + + Assert.Equal("scope", exception.ParamName); + } + + [Fact] + public async Task GetDisplayNamesAsync_ThrowsAnExceptionForNullScope() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictScopeManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetDisplayNamesAsync(scope: null!).AsTask()); + + Assert.Equal("scope", exception.ParamName); + } + + [Fact] + public async Task GetIdAsync_ThrowsAnExceptionForNullScope() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictScopeManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetIdAsync(scope: null!).AsTask()); + + Assert.Equal("scope", exception.ParamName); + } + + [Fact] + public async Task GetIdAsync_ReturnsIdentifierFromStore() + { + // Arrange + var scope = new CustomScope(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(scope, It.IsAny())) + .ReturnsAsync("unique-scope-id"); + + var manager = new OpenIddictScopeManager(cache, logger, options, store.Object); + + // Act + var id = await manager.GetIdAsync(scope); + + // Assert + Assert.Equal("unique-scope-id", id); + store.Verify(store => store.GetIdAsync(scope, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task GetLocalizedDescriptionAsync_ThrowsAnExceptionForNullScope() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictScopeManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetLocalizedDescriptionAsync(scope: null!, CultureInfo.InvariantCulture).AsTask()); + + Assert.Equal("scope", exception.ParamName); + } + + [Fact] + public async Task GetLocalizedDescriptionAsync_ReturnsDescriptionForMatchingCulture() + { + // Arrange + var scope = new CustomScope(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetDescriptionsAsync(scope, It.IsAny())) + .ReturnsAsync(ImmutableDictionary.Create() + .Add(CultureInfo.GetCultureInfo("en-US"), "English description") + .Add(CultureInfo.GetCultureInfo("fr-FR"), "Description fran�aise")); + + var manager = new OpenIddictScopeManager(cache, logger, options, store.Object); + + // Act + var description = await manager.GetLocalizedDescriptionAsync(scope, CultureInfo.GetCultureInfo("fr-FR")); + + // Assert + Assert.Equal("Description fran�aise", description); + } + + [Fact] + public async Task GetLocalizedDescriptionAsync_FallsBackToNonLocalizedDescriptionWhenNoCultureMatches() + { + // Arrange + var scope = new CustomScope(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetDescriptionsAsync(scope, It.IsAny())) + .ReturnsAsync(ImmutableDictionary.Create() + .Add(CultureInfo.GetCultureInfo("en-US"), "English description")); + + store.Setup(store => store.GetDescriptionAsync(scope, It.IsAny())) + .ReturnsAsync("Default description"); + + var manager = new OpenIddictScopeManager(cache, logger, options, store.Object); + + // Act + var description = await manager.GetLocalizedDescriptionAsync(scope, CultureInfo.GetCultureInfo("ja-JP")); + + // Assert + Assert.Equal("Default description", description); + } + + [Fact] + public async Task GetLocalizedDisplayNameAsync_ThrowsAnExceptionForNullScope() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictScopeManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetLocalizedDisplayNameAsync(scope: null!, CultureInfo.InvariantCulture).AsTask()); + + Assert.Equal("scope", exception.ParamName); + } + + [Fact] + public async Task GetLocalizedDisplayNameAsync_ReturnsDisplayNameForMatchingCulture() + { + // Arrange + var scope = new CustomScope(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetDisplayNamesAsync(scope, It.IsAny())) + .ReturnsAsync(ImmutableDictionary.Create() + .Add(CultureInfo.GetCultureInfo("en-US"), "English name") + .Add(CultureInfo.GetCultureInfo("fr-FR"), "Nom fran�ais")); + + var manager = new OpenIddictScopeManager(cache, logger, options, store.Object); + + // Act + var name = await manager.GetLocalizedDisplayNameAsync(scope, CultureInfo.GetCultureInfo("fr-FR")); + + // Assert + Assert.Equal("Nom fran�ais", name); + } + + [Fact] + public async Task GetNameAsync_ThrowsAnExceptionForNullScope() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictScopeManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetNameAsync(scope: null!).AsTask()); + + Assert.Equal("scope", exception.ParamName); + } + + [Fact] + public async Task GetPropertiesAsync_ThrowsAnExceptionForNullScope() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictScopeManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetPropertiesAsync(scope: null!).AsTask()); + + Assert.Equal("scope", exception.ParamName); + } + + [Fact] + public async Task GetResourcesAsync_ThrowsAnExceptionForNullScope() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictScopeManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetResourcesAsync(scope: null!).AsTask()); + + Assert.Equal("scope", exception.ParamName); + } + + [Fact] + public async Task ListAsync_ReturnsAllScopes() + { + // Arrange + var scopes = new[] { new CustomScope(), new CustomScope() }; + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.ListAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(scopes.ToAsyncEnumerable()); + + var manager = new OpenIddictScopeManager(cache, logger, options, store.Object); + + // Act + var results = new List(); + await foreach (var scp in manager.ListAsync()) + { + results.Add(scp); + } + + // Assert + Assert.Equal(2, results.Count); + store.Verify(store => store.ListAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); + } + + [Fact] + public async Task ListResourcesAsync_ReturnsDeduplicatedResourcesFromMatchingScopes() + { + // Arrange + var scopes = new[] { new CustomScope(), new CustomScope() }; + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions { DisableEntityCaching = true }); + var store = new Mock>(); + + store.Setup(store => store.FindByNamesAsync(It.IsAny>(), It.IsAny())) + .Returns(scopes.ToAsyncEnumerable()); + + store.Setup(store => store.GetNameAsync(scopes[0], It.IsAny())) + .ReturnsAsync("openid"); + + store.Setup(store => store.GetNameAsync(scopes[1], It.IsAny())) + .ReturnsAsync("profile"); + + store.Setup(store => store.GetResourcesAsync(scopes[0], It.IsAny())) + .ReturnsAsync(["resource1", "resource2"]); + + store.Setup(store => store.GetResourcesAsync(scopes[1], It.IsAny())) + .ReturnsAsync(["resource2", "resource3"]); + + var manager = new OpenIddictScopeManager(cache, logger, options, store.Object); + + // Act + var resources = await manager.ListResourcesAsync(["openid", "profile"]).ToListAsync(); + + // Assert + Assert.Equal(3, resources.Count); + Assert.Contains("resource1", resources); + Assert.Contains("resource2", resources); + Assert.Contains("resource3", resources); + } + + [Fact] + public async Task PopulateAsync_ThrowsAnExceptionForNullScope() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictScopeManager(cache, logger, options, store); + var descriptor = new OpenIddictScopeDescriptor(); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.PopulateAsync(scope: null!, descriptor).AsTask()); + + Assert.Equal("scope", exception.ParamName); + } + + [Fact] + public async Task PopulateAsync_ThrowsAnExceptionForNullDescriptor() + { + // Arrange + var scope = new CustomScope(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictScopeManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.PopulateAsync(scope, descriptor: null!).AsTask()); + + Assert.Equal("descriptor", exception.ParamName); + } + + [Fact] + public async Task UpdateAsync_ThrowsAnExceptionForNullScope() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new OpenIddictScopeManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.UpdateAsync(scope: null!).AsTask()); + + Assert.Equal("scope", exception.ParamName); + } + + [Fact] + public async Task UpdateAsync_WithDescriptor_ThrowsAnExceptionForNullScope() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new OpenIddictScopeManager(cache, logger, options, store); + var descriptor = new OpenIddictScopeDescriptor(); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.UpdateAsync(scope: null!, descriptor).AsTask()); + + Assert.Equal("scope", exception.ParamName); + } + + [Fact] + public void ValidateAsync_ThrowsAnExceptionForNullScope() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictScopeManager(cache, logger, options, store); + + // Act and assert + var exception = Assert.Throws( + () => manager.ValidateAsync(scope: null!)); + + Assert.Equal("scope", exception.ParamName); + } + + [Fact] + public async Task ValidateAsync_ReturnsErrorWhenNameIsEmpty() + { + // Arrange + var scope = new CustomScope(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetNameAsync(scope, It.IsAny())) + .ReturnsAsync(string.Empty); + + var manager = new OpenIddictScopeManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(scope).ToListAsync(); + + // Assert + Assert.Contains(results, result => result.ErrorMessage == SR.GetResourceString(SR.ID2044)); + } + + [Fact] + public async Task ValidateAsync_ReturnsErrorWhenNameContainsSpace() + { + // Arrange + var scope = new CustomScope(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetNameAsync(scope, It.IsAny())) + .ReturnsAsync("scope name with space"); + + var manager = new OpenIddictScopeManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(scope).ToListAsync(); + + // Assert + Assert.Contains(results, result => result.ErrorMessage == SR.GetResourceString(SR.ID2045)); + } + + [Fact] + public async Task ValidateAsync_ReturnsErrorWhenNameIsAlreadyUsed() + { + // Arrange + var scope = new CustomScope(); + var other = new CustomScope(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetNameAsync(scope, It.IsAny())) + .ReturnsAsync("openid"); + + store.Setup(store => store.FindByNameAsync("openid", It.IsAny())) + .ReturnsAsync(other); + + store.Setup(store => store.GetIdAsync(scope, It.IsAny())) + .ReturnsAsync("scope-id"); + + store.Setup(store => store.GetIdAsync(other, It.IsAny())) + .ReturnsAsync("other-scope-id"); + + var manager = new OpenIddictScopeManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(scope).ToListAsync(); + + // Assert + Assert.Contains(results, result => result.ErrorMessage == SR.GetResourceString(SR.ID2060)); + } + + [Fact] + public async Task ValidateAsync_ReturnsNoErrorsForValidScope() + { + // Arrange + var scope = new CustomScope(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetNameAsync(scope, It.IsAny())) + .ReturnsAsync("openid"); + + store.Setup(store => store.FindByNameAsync("openid", It.IsAny())) + .ReturnsAsync((CustomScope?) null); + + var manager = new OpenIddictScopeManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(scope).ToListAsync(); + + // Assert + Assert.DoesNotContain(results, static result => result != ValidationResult.Success); + } + + public class CustomScope { } +} diff --git a/test/OpenIddict.Core.Tests/Managers/OpenIddictTokenManagerTests.cs b/test/OpenIddict.Core.Tests/Managers/OpenIddictTokenManagerTests.cs new file mode 100644 index 00000000..779634bb --- /dev/null +++ b/test/OpenIddict.Core.Tests/Managers/OpenIddictTokenManagerTests.cs @@ -0,0 +1,1343 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System.Collections.Immutable; +using System.ComponentModel.DataAnnotations; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace OpenIddict.Core.Tests; + +public class OpenIddictTokenManagerTests +{ + [Fact] + public void Constructor_ThrowsAnExceptionForNullCache() + { + // Arrange + var cache = (IOpenIddictTokenCache) null!; + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + // Act and assert + var exception = Assert.Throws( + () => new OpenIddictTokenManager(cache, logger, options, store)); + + Assert.Equal("cache", exception.ParamName); + } + + [Fact] + public void Constructor_ThrowsAnExceptionForNullLogger() + { + // Arrange + var cache = Mock.Of>(); + var logger = (ILogger>) null!; + var options = Mock.Of>(); + var store = Mock.Of>(); + + // Act and assert + var exception = Assert.Throws( + () => new OpenIddictTokenManager(cache, logger, options, store)); + + Assert.Equal("logger", exception.ParamName); + } + + [Fact] + public void Constructor_ThrowsAnExceptionForNullOptions() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = (IOptionsMonitor) null!; + var store = Mock.Of>(); + + // Act and assert + var exception = Assert.Throws( + () => new OpenIddictTokenManager(cache, logger, options, store)); + + Assert.Equal("options", exception.ParamName); + } + + [Fact] + public void Constructor_ThrowsAnExceptionForNullStore() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = (IOpenIddictTokenStore) null!; + + // Act and assert + var exception = Assert.Throws( + () => new OpenIddictTokenManager(cache, logger, options, store)); + + Assert.Equal("store", exception.ParamName); + } + + [Fact] + public async Task CountAsync_CallsStoreMethod() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.CountAsync(It.IsAny())) + .ReturnsAsync(42); + + var manager = new OpenIddictTokenManager(cache, logger, options, store.Object); + + // Act + var count = await manager.CountAsync(); + + // Assert + Assert.Equal(42, count); + store.Verify(store => store.CountAsync(It.IsAny()), Times.Once()); + } + + [Fact] + public async Task CountAsync_WithQuery_ThrowsAnExceptionForNullQuery() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.CountAsync(query: null!).AsTask()); + + Assert.Equal("query", exception.ParamName); + } + + [Fact] + public async Task CreateAsync_ThrowsAnExceptionForNullToken() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.CreateAsync(token: null!).AsTask()); + + Assert.Equal("token", exception.ParamName); + } + + [Fact] + public async Task DeleteAsync_ThrowsAnExceptionForNullToken() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.DeleteAsync(token: null!).AsTask()); + + Assert.Equal("token", exception.ParamName); + } + + [Fact] + public async Task DeleteAsync_RemovesTokenFromCache_WhenCachingIsEnabled() + { + // Arrange + var cache = new Mock>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions { DisableEntityCaching = false }); + var store = new Mock>(); + var token = new CustomToken(); + + var manager = new OpenIddictTokenManager(cache.Object, logger, options, store.Object); + + // Act + await manager.DeleteAsync(token); + + // Assert + cache.Verify(cache => cache.RemoveAsync(token, It.IsAny()), Times.Once()); + store.Verify(store => store.DeleteAsync(token, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task DeleteAsync_DoesNotRemoveFromCache_WhenCachingIsDisabled() + { + // Arrange + var cache = new Mock>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions { DisableEntityCaching = true }); + var store = new Mock>(); + var token = new CustomToken(); + + var manager = new OpenIddictTokenManager(cache.Object, logger, options, store.Object); + + // Act + await manager.DeleteAsync(token); + + // Assert + cache.Verify(cache => cache.RemoveAsync(It.IsAny(), It.IsAny()), Times.Never()); + store.Verify(store => store.DeleteAsync(token, It.IsAny()), Times.Once()); + } + + [Fact] + public void FindByApplicationIdAsync_ThrowsAnExceptionForNullIdentifier() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = Assert.Throws( + () => manager.FindByApplicationIdAsync(identifier: null!)); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public void FindByApplicationIdAsync_ThrowsAnExceptionForEmptyIdentifier() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = Assert.Throws( + () => manager.FindByApplicationIdAsync(identifier: string.Empty)); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public void FindByAuthorizationIdAsync_ThrowsAnExceptionForNullIdentifier() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = Assert.Throws( + () => manager.FindByAuthorizationIdAsync(identifier: null!)); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public void FindByAuthorizationIdAsync_ThrowsAnExceptionForEmptyIdentifier() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = Assert.Throws( + () => manager.FindByAuthorizationIdAsync(identifier: string.Empty)); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task FindByIdAsync_ThrowsAnExceptionForNullIdentifier() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.FindByIdAsync(identifier: null!).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task FindByIdAsync_ThrowsAnExceptionForEmptyIdentifier() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.FindByIdAsync(identifier: string.Empty).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task FindByIdAsync_UsesCache_WhenCachingIsEnabled() + { + // Arrange + var token = new CustomToken(); + var cache = new Mock>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions { DisableEntityCaching = false }); + var store = new Mock>(); + + cache.Setup(cache => cache.FindByIdAsync("id", It.IsAny())) + .ReturnsAsync(token); + + store.Setup(store => store.GetIdAsync(token, It.IsAny())) + .ReturnsAsync("id"); + + var manager = new OpenIddictTokenManager(cache.Object, logger, options, store.Object); + + // Act + var result = await manager.FindByIdAsync("id"); + + // Assert + Assert.Same(token, result); + cache.Verify(cache => cache.FindByIdAsync("id", It.IsAny()), Times.Once()); + store.Verify(store => store.FindByIdAsync(It.IsAny(), It.IsAny()), Times.Never()); + } + + [Fact] + public async Task FindByIdAsync_UsesStore_WhenCachingIsDisabled() + { + // Arrange + var token = new CustomToken(); + var cache = new Mock>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions { DisableEntityCaching = true }); + var store = new Mock>(); + + store.Setup(store => store.FindByIdAsync("id", It.IsAny())) + .ReturnsAsync(token); + + store.Setup(store => store.GetIdAsync(token, It.IsAny())) + .ReturnsAsync("id"); + + var manager = new OpenIddictTokenManager(cache.Object, logger, options, store.Object); + + // Act + var result = await manager.FindByIdAsync("id"); + + // Assert + Assert.Same(token, result); + cache.Verify(cache => cache.FindByIdAsync(It.IsAny(), It.IsAny()), Times.Never()); + store.Verify(store => store.FindByIdAsync("id", It.IsAny()), Times.Once()); + } + + [Fact] + public async Task FindByReferenceIdAsync_ThrowsAnExceptionForNullIdentifier() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.FindByReferenceIdAsync(identifier: null!).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task FindByReferenceIdAsync_ThrowsAnExceptionForEmptyIdentifier() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.FindByReferenceIdAsync(identifier: string.Empty).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public void FindBySubjectAsync_ThrowsAnExceptionForNullSubject() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = Assert.Throws( + () => manager.FindBySubjectAsync(subject: null!)); + + Assert.Equal("subject", exception.ParamName); + } + + [Fact] + public void FindBySubjectAsync_ThrowsAnExceptionForEmptySubject() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = Assert.Throws( + () => manager.FindBySubjectAsync(subject: string.Empty)); + + Assert.Equal("subject", exception.ParamName); + } + + [Fact] + public async Task GetApplicationIdAsync_ThrowsAnExceptionForNullToken() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetApplicationIdAsync(token: null!).AsTask()); + + Assert.Equal("token", exception.ParamName); + } + + [Fact] + public async Task GetAsync_WithQuery_ThrowsAnExceptionForNullQuery() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetAsync(query: null!).AsTask()); + + Assert.Equal("query", exception.ParamName); + } + + [Fact] + public async Task GetAsync_WithQueryAndState_ThrowsAnExceptionForNullQuery() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetAsync(query: null!, state: null!).AsTask()); + + Assert.Equal("query", exception.ParamName); + } + + [Fact] + public async Task GetAuthorizationIdAsync_ThrowsAnExceptionForNullToken() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetAuthorizationIdAsync(token: null!).AsTask()); + + Assert.Equal("token", exception.ParamName); + } + + [Fact] + public async Task GetCreationDateAsync_ThrowsAnExceptionForNullToken() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetCreationDateAsync(token: null!).AsTask()); + + Assert.Equal("token", exception.ParamName); + } + + [Fact] + public async Task GetExpirationDateAsync_ThrowsAnExceptionForNullToken() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetExpirationDateAsync(token: null!).AsTask()); + + Assert.Equal("token", exception.ParamName); + } + + [Fact] + public async Task GetIdAsync_ThrowsAnExceptionForNullToken() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetIdAsync(token: null!).AsTask()); + + Assert.Equal("token", exception.ParamName); + } + + [Fact] + public async Task GetIdAsync_ReturnsIdentifierFromStore() + { + // Arrange + var token = new CustomToken(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetIdAsync(token, It.IsAny())) + .ReturnsAsync("unique-token-id"); + + var manager = new OpenIddictTokenManager(cache, logger, options, store.Object); + + // Act + var id = await manager.GetIdAsync(token); + + // Assert + Assert.Equal("unique-token-id", id); + store.Verify(store => store.GetIdAsync(token, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task GetPayloadAsync_ThrowsAnExceptionForNullToken() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetPayloadAsync(token: null!).AsTask()); + + Assert.Equal("token", exception.ParamName); + } + + [Fact] + public async Task GetPropertiesAsync_ThrowsAnExceptionForNullToken() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetPropertiesAsync(token: null!).AsTask()); + + Assert.Equal("token", exception.ParamName); + } + + [Fact] + public async Task GetRedemptionDateAsync_ThrowsAnExceptionForNullToken() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetRedemptionDateAsync(token: null!).AsTask()); + + Assert.Equal("token", exception.ParamName); + } + + [Fact] + public async Task GetReferenceIdAsync_ThrowsAnExceptionForNullToken() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetReferenceIdAsync(token: null!).AsTask()); + + Assert.Equal("token", exception.ParamName); + } + + [Fact] + public async Task GetStatusAsync_ThrowsAnExceptionForNullToken() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetStatusAsync(token: null!).AsTask()); + + Assert.Equal("token", exception.ParamName); + } + + [Fact] + public async Task GetSubjectAsync_ThrowsAnExceptionForNullToken() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetSubjectAsync(token: null!).AsTask()); + + Assert.Equal("token", exception.ParamName); + } + + [Fact] + public async Task GetTypeAsync_ThrowsAnExceptionForNullToken() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.GetTypeAsync(token: null!).AsTask()); + + Assert.Equal("token", exception.ParamName); + } + + [Fact] + public async Task HasStatusAsync_ThrowsAnExceptionForNullToken() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.HasStatusAsync(token: null!, "valid").AsTask()); + + Assert.Equal("token", exception.ParamName); + } + + [Fact] + public async Task HasStatusAsync_ThrowsAnExceptionForNullStatus() + { + // Arrange + var token = new CustomToken(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.HasStatusAsync(token, status: null!).AsTask()); + + Assert.Equal("status", exception.ParamName); + } + + [Theory] + [InlineData(Statuses.Valid, Statuses.Valid, true )] + [InlineData(Statuses.Valid, Statuses.Revoked, false)] + [InlineData(Statuses.Revoked, "REVOKED", false)] + public async Task HasStatusAsync_ReturnsExpectedResult(string storedStatus, string testedStatus, bool expected) + { + // Arrange + var token = new CustomToken(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetStatusAsync(token, It.IsAny())) + .ReturnsAsync(storedStatus); + + var manager = new OpenIddictTokenManager(cache, logger, options, store.Object); + + // Act + var result = await manager.HasStatusAsync(token, testedStatus); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public async Task HasTypeAsync_ThrowsAnExceptionForNullToken() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.HasTypeAsync(token: null!, "type").AsTask()); + + Assert.Equal("token", exception.ParamName); + } + + [Fact] + public async Task HasTypeAsync_ThrowsAnExceptionForEmptyType() + { + // Arrange + var token = new CustomToken(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.HasTypeAsync(token, type: string.Empty).AsTask()); + + Assert.Equal("type", exception.ParamName); + } + + [Theory] + [InlineData(TokenTypeHints.AccessToken, TokenTypeIdentifiers.AccessToken, true )] + [InlineData(TokenTypeHints.AccessToken, TokenTypeIdentifiers.RefreshToken, false)] + [InlineData(TokenTypeHints.AccessToken, "urn:ietf:params:oauth:token-type:ACCESS_TOKEN", false)] + public async Task HasTypeAsync_ReturnsExpectedResult(string storedType, string testedType, bool expected) + { + // Arrange + var token = new CustomToken(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetTypeAsync(token, It.IsAny())) + .ReturnsAsync(storedType); + + var manager = new OpenIddictTokenManager(cache, logger, options, store.Object); + + // Act + var result = await manager.HasTypeAsync(token, testedType); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public async Task HasTypeAsync_WithArray_ThrowsAnExceptionForNullToken() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.HasTypeAsync(token: null!, ImmutableArray.Create(TokenTypeHints.AccessToken)).AsTask()); + + Assert.Equal("token", exception.ParamName); + } + + [Theory] + [InlineData(null, new[] { TokenTypeHints.AccessToken }, false)] + [InlineData(TokenTypeHints.RefreshToken, new[] { TokenTypeIdentifiers.AccessToken, TokenTypeIdentifiers.RefreshToken }, true )] + [InlineData("authorization_code", new[] { TokenTypeIdentifiers.AccessToken, TokenTypeIdentifiers.RefreshToken }, false)] + [InlineData(TokenTypeHints.AccessToken, new[] { "ACCESS_TOKEN" }, false)] + public async Task HasTypeAsync_WithArray_ReturnsExpectedResult(string? type, string[] types, bool expected) + { + // Arrange + var token = new CustomToken(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = new Mock>(); + + store.Setup(store => store.GetTypeAsync(token, It.IsAny())) + .ReturnsAsync(type); + + var manager = new OpenIddictTokenManager(cache, logger, options, store.Object); + + // Act + var result = await manager.HasTypeAsync(token, [.. types]); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public async Task ListAsync_ReturnsAllTokens() + { + // Arrange + var tokens = new[] { new CustomToken(), new CustomToken() }; + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.ListAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(tokens.ToAsyncEnumerable()); + + var manager = new OpenIddictTokenManager(cache, logger, options, store.Object); + + // Act + var results = new List(); + await foreach (var tkn in manager.ListAsync()) + { + results.Add(tkn); + } + + // Assert + Assert.Equal(2, results.Count); + store.Verify(store => store.ListAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); + } + + [Fact] + public async Task PopulateAsync_ThrowsAnExceptionForNullToken() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + var descriptor = new OpenIddictTokenDescriptor(); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.PopulateAsync(token: null!, descriptor).AsTask()); + + Assert.Equal("token", exception.ParamName); + } + + [Fact] + public async Task PopulateAsync_ThrowsAnExceptionForNullDescriptor() + { + // Arrange + var token = new CustomToken(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.PopulateAsync(token, descriptor: null!).AsTask()); + + Assert.Equal("descriptor", exception.ParamName); + } + + [Fact] + public async Task PruneAsync_CallsStoreMethod() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.PruneAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(42); + + var manager = new OpenIddictTokenManager(cache, logger, options, store.Object); + + // Act + var count = await manager.PruneAsync(DateTimeOffset.UtcNow); + + // Assert + Assert.Equal(42, count); + store.Verify(store => store.PruneAsync(It.IsAny(), It.IsAny()), Times.Once()); + } + + [Fact] + public async Task RevokeAsync_CallsStoreMethod() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.RevokeAsync("alice", "client-id", Statuses.Valid, TokenTypeHints.AccessToken, It.IsAny())) + .ReturnsAsync(5); + + var manager = new OpenIddictTokenManager(cache, logger, options, store.Object); + + // Act + var count = await manager.RevokeAsync("alice", "client-id", Statuses.Valid, TokenTypeHints.AccessToken); + + // Assert + Assert.Equal(5, count); + store.Verify(store => store.RevokeAsync("alice", "client-id", Statuses.Valid, TokenTypeHints.AccessToken, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task RevokeByApplicationIdAsync_ThrowsAnExceptionForNullIdentifier() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.RevokeByApplicationIdAsync(identifier: null!).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task RevokeByApplicationIdAsync_ThrowsAnExceptionForEmptyIdentifier() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.RevokeByApplicationIdAsync(identifier: string.Empty).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task RevokeByAuthorizationIdAsync_ThrowsAnExceptionForNullIdentifier() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.RevokeByAuthorizationIdAsync(identifier: null!).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task RevokeByAuthorizationIdAsync_ThrowsAnExceptionForEmptyIdentifier() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.RevokeByAuthorizationIdAsync(identifier: string.Empty).AsTask()); + + Assert.Equal("identifier", exception.ParamName); + } + + [Fact] + public async Task RevokeBySubjectAsync_ThrowsAnExceptionForNullSubject() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.RevokeBySubjectAsync(subject: null!).AsTask()); + + Assert.Equal("subject", exception.ParamName); + } + + [Fact] + public async Task RevokeBySubjectAsync_ThrowsAnExceptionForEmptySubject() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.RevokeBySubjectAsync(subject: string.Empty).AsTask()); + + Assert.Equal("subject", exception.ParamName); + } + + [Fact] + public async Task TryRedeemAsync_ThrowsAnExceptionForNullToken() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.TryRedeemAsync(token: null!).AsTask()); + + Assert.Equal("token", exception.ParamName); + } + + [Fact] + public async Task TryRejectAsync_ThrowsAnExceptionForNullToken() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.TryRejectAsync(token: null!).AsTask()); + + Assert.Equal("token", exception.ParamName); + } + + [Fact] + public async Task TryRevokeAsync_ThrowsAnExceptionForNullToken() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.TryRevokeAsync(token: null!).AsTask()); + + Assert.Equal("token", exception.ParamName); + } + + [Fact] + public async Task UpdateAsync_ThrowsAnExceptionForNullToken() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.UpdateAsync(token: null!).AsTask()); + + Assert.Equal("token", exception.ParamName); + } + + [Fact] + public async Task UpdateAsync_WithDescriptor_ThrowsAnExceptionForNullToken() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + var descriptor = new OpenIddictTokenDescriptor(); + + // Act and assert + var exception = await Assert.ThrowsAsync( + () => manager.UpdateAsync(token: null!, descriptor).AsTask()); + + Assert.Equal("token", exception.ParamName); + } + + [Fact] + public void ValidateAsync_ThrowsAnExceptionForNullToken() + { + // Arrange + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>(); + var store = Mock.Of>(); + + var manager = new OpenIddictTokenManager(cache, logger, options, store); + + // Act and assert + var exception = Assert.Throws( + () => manager.ValidateAsync(token: null!)); + + Assert.Equal("token", exception.ParamName); + } + + [Fact] + public async Task ValidateAsync_ReturnsErrorWhenReferenceIdIsAlreadyUsed() + { + // Arrange + var token = new CustomToken(); + var other = new CustomToken(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetReferenceIdAsync(token, It.IsAny())) + .ReturnsAsync("reference-id"); + + store.Setup(store => store.FindByReferenceIdAsync("reference-id", It.IsAny())) + .ReturnsAsync(other); + + store.Setup(store => store.GetIdAsync(token, It.IsAny())) + .ReturnsAsync("token-id"); + + store.Setup(store => store.GetIdAsync(other, It.IsAny())) + .ReturnsAsync("other-token-id"); + + store.Setup(store => store.GetTypeAsync(token, It.IsAny())) + .ReturnsAsync(TokenTypeHints.AccessToken); + + store.Setup(store => store.GetStatusAsync(token, It.IsAny())) + .ReturnsAsync(Statuses.Valid); + + var manager = new OpenIddictTokenManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(token).ToListAsync(); + + // Assert + Assert.Contains(results, result => result.ErrorMessage == SR.GetResourceString(SR.ID2085)); + } + + [Fact] + public async Task ValidateAsync_ReturnsErrorWhenTypeIsEmpty() + { + // Arrange + var token = new CustomToken(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetReferenceIdAsync(token, It.IsAny())) + .ReturnsAsync(string.Empty); + + store.Setup(store => store.GetTypeAsync(token, It.IsAny())) + .ReturnsAsync(string.Empty); + + store.Setup(store => store.GetStatusAsync(token, It.IsAny())) + .ReturnsAsync(Statuses.Valid); + + var manager = new OpenIddictTokenManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(token).ToListAsync(); + + // Assert + Assert.Contains(results, result => result.ErrorMessage == SR.GetResourceString(SR.ID2086)); + } + + [Fact] + public async Task ValidateAsync_ReturnsErrorWhenStatusIsEmpty() + { + // Arrange + var token = new CustomToken(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetReferenceIdAsync(token, It.IsAny())) + .ReturnsAsync(string.Empty); + + store.Setup(store => store.GetTypeAsync(token, It.IsAny())) + .ReturnsAsync(TokenTypeHints.AccessToken); + + store.Setup(store => store.GetStatusAsync(token, It.IsAny())) + .ReturnsAsync(string.Empty); + + var manager = new OpenIddictTokenManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(token).ToListAsync(); + + // Assert + Assert.Contains(results, result => result.ErrorMessage == SR.GetResourceString(SR.ID2038)); + } + + [Fact] + public async Task ValidateAsync_ReturnsNoErrorsForValidToken() + { + // Arrange + var token = new CustomToken(); + var cache = Mock.Of>(); + var logger = Mock.Of>>(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictCoreOptions()); + var store = new Mock>(); + + store.Setup(store => store.GetReferenceIdAsync(token, It.IsAny())) + .ReturnsAsync(string.Empty); + + store.Setup(store => store.GetTypeAsync(token, It.IsAny())) + .ReturnsAsync(TokenTypeHints.AccessToken); + + store.Setup(store => store.GetStatusAsync(token, It.IsAny())) + .ReturnsAsync(Statuses.Valid); + + var manager = new OpenIddictTokenManager(cache, logger, options, store.Object); + + // Act + var results = await manager.ValidateAsync(token).ToListAsync(); + + // Assert + Assert.DoesNotContain(results, static result => result != ValidationResult.Success); + } + + public class CustomToken { } +} diff --git a/test/OpenIddict.Core.Tests/OpenIddict.Core.Tests.csproj b/test/OpenIddict.Core.Tests/OpenIddict.Core.Tests.csproj index a7428b12..9856eda4 100644 --- a/test/OpenIddict.Core.Tests/OpenIddict.Core.Tests.csproj +++ b/test/OpenIddict.Core.Tests/OpenIddict.Core.Tests.csproj @@ -13,6 +13,10 @@ + + + + diff --git a/test/OpenIddict.Core.Tests/OpenIddictCoreBuilderTests.cs b/test/OpenIddict.Core.Tests/OpenIddictCoreBuilderTests.cs index cfb4a8c6..c67d2d71 100644 --- a/test/OpenIddict.Core.Tests/OpenIddictCoreBuilderTests.cs +++ b/test/OpenIddict.Core.Tests/OpenIddictCoreBuilderTests.cs @@ -1,4 +1,4 @@ -/* +/* * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) * See https://github.com/openiddict/openiddict-core for more information concerning * the license and the contributors participating to this project. @@ -26,28 +26,28 @@ public class OpenIddictCoreBuilderTests } [Fact] - public void ReplaceApplicationManager_ThrowsAnExceptionForInvalidManager() + public void ReplaceApplicationManager_ThrowsAnExceptionForClosedSourceManager() { // Arrange var services = CreateServices(); var builder = CreateBuilder(services); // Act and assert - var exception = Assert.Throws(() => builder.ReplaceApplicationManager(typeof(object))); + var exception = Assert.Throws(() => builder.ReplaceApplicationManager(typeof(ClosedGenericApplicationManager))); Assert.Equal("type", exception.ParamName); Assert.StartsWith(SR.GetResourceString(SR.ID0232), exception.Message); } [Fact] - public void ReplaceApplicationManager_ThrowsAnExceptionForClosedSourceManager() + public void ReplaceApplicationManager_ThrowsAnExceptionForInvalidManager() { // Arrange var services = CreateServices(); var builder = CreateBuilder(services); // Act and assert - var exception = Assert.Throws(() => builder.ReplaceApplicationManager(typeof(ClosedGenericApplicationManager))); + var exception = Assert.Throws(() => builder.ReplaceApplicationManager(typeof(object))); Assert.Equal("type", exception.ParamName); Assert.StartsWith(SR.GetResourceString(SR.ID0232), exception.Message); @@ -71,28 +71,28 @@ public class OpenIddictCoreBuilderTests } [Fact] - public void ReplaceAuthorizationManager_ThrowsAnExceptionForInvalidManager() + public void ReplaceAuthorizationManager_ThrowsAnExceptionForClosedSourceManager() { // Arrange var services = CreateServices(); var builder = CreateBuilder(services); // Act and assert - var exception = Assert.Throws(() => builder.ReplaceAuthorizationManager(typeof(object))); + var exception = Assert.Throws(() => builder.ReplaceAuthorizationManager(typeof(ClosedGenericAuthorizationManager))); Assert.Equal("type", exception.ParamName); Assert.StartsWith(SR.GetResourceString(SR.ID0232), exception.Message); } [Fact] - public void ReplaceAuthorizationManager_ThrowsAnExceptionForClosedSourceManager() + public void ReplaceAuthorizationManager_ThrowsAnExceptionForInvalidManager() { // Arrange var services = CreateServices(); var builder = CreateBuilder(services); // Act and assert - var exception = Assert.Throws(() => builder.ReplaceAuthorizationManager(typeof(ClosedGenericAuthorizationManager))); + var exception = Assert.Throws(() => builder.ReplaceAuthorizationManager(typeof(object))); Assert.Equal("type", exception.ParamName); Assert.StartsWith(SR.GetResourceString(SR.ID0232), exception.Message); @@ -116,28 +116,28 @@ public class OpenIddictCoreBuilderTests } [Fact] - public void ReplaceScopeManager_ThrowsAnExceptionForInvalidManager() + public void ReplaceScopeManager_ThrowsAnExceptionForClosedSourceManager() { // Arrange var services = CreateServices(); var builder = CreateBuilder(services); // Act and assert - var exception = Assert.Throws(() => builder.ReplaceScopeManager(typeof(object))); + var exception = Assert.Throws(() => builder.ReplaceScopeManager(typeof(ClosedGenericScopeManager))); Assert.Equal("type", exception.ParamName); Assert.StartsWith(SR.GetResourceString(SR.ID0232), exception.Message); } [Fact] - public void ReplaceScopeManager_ThrowsAnExceptionForClosedSourceManager() + public void ReplaceScopeManager_ThrowsAnExceptionForInvalidManager() { // Arrange var services = CreateServices(); var builder = CreateBuilder(services); // Act and assert - var exception = Assert.Throws(() => builder.ReplaceScopeManager(typeof(ClosedGenericScopeManager))); + var exception = Assert.Throws(() => builder.ReplaceScopeManager(typeof(object))); Assert.Equal("type", exception.ParamName); Assert.StartsWith(SR.GetResourceString(SR.ID0232), exception.Message); @@ -161,28 +161,28 @@ public class OpenIddictCoreBuilderTests } [Fact] - public void ReplaceTokenManager_ThrowsAnExceptionForInvalidManager() + public void ReplaceTokenManager_ThrowsAnExceptionForClosedSourceManager() { // Arrange var services = CreateServices(); var builder = CreateBuilder(services); // Act and assert - var exception = Assert.Throws(() => builder.ReplaceTokenManager(typeof(object))); + var exception = Assert.Throws(() => builder.ReplaceTokenManager(typeof(ClosedGenericTokenManager))); Assert.Equal("type", exception.ParamName); Assert.StartsWith(SR.GetResourceString(SR.ID0232), exception.Message); } [Fact] - public void ReplaceTokenManager_ThrowsAnExceptionForClosedSourceManager() + public void ReplaceTokenManager_ThrowsAnExceptionForInvalidManager() { // Arrange var services = CreateServices(); var builder = CreateBuilder(services); // Act and assert - var exception = Assert.Throws(() => builder.ReplaceTokenManager(typeof(ClosedGenericTokenManager))); + var exception = Assert.Throws(() => builder.ReplaceTokenManager(typeof(object))); Assert.Equal("type", exception.ParamName); Assert.StartsWith(SR.GetResourceString(SR.ID0232), exception.Message); @@ -240,71 +240,169 @@ public class OpenIddictCoreBuilderTests } [Fact] - public void SetDefaultApplicationEntity_ReplacesUntypedManager() + public void DisableAutomaticClientSecretRehashing_RehashingIsCorrectlyDisabled() { // Arrange var services = CreateServices(); var builder = CreateBuilder(services); // Act - builder.SetDefaultApplicationEntity(); + builder.DisableAutomaticClientSecretRehashing(); // Assert - Assert.Contains(services, service => - service.Lifetime == ServiceLifetime.Scoped && - service.ServiceType == typeof(IOpenIddictApplicationManager) && - service.ImplementationFactory is not null); + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().CurrentValue; + + Assert.True(options.DisableAutomaticClientSecretRehashing); } - [Fact] - public void SetDefaultAuthorizationEntity_ReplacesUntypedManager() + [Theory] + [InlineData("MD5")] + [InlineData("SHA384")] + [InlineData("Invalid")] + public void SetClientSecretKeyDerivationHashAlgorithm_ThrowsAnExceptionForInvalidAlgorithm(string algorithmName) { // Arrange var services = CreateServices(); var builder = CreateBuilder(services); + var algorithm = new System.Security.Cryptography.HashAlgorithmName(algorithmName); + + // Act and assert + var exception = Assert.Throws(() => builder.SetClientSecretKeyDerivationHashAlgorithm(algorithm)); + + Assert.Equal("algorithm", exception.ParamName); + } + + [Theory] + [InlineData("SHA1")] + [InlineData("SHA256")] + [InlineData("SHA512")] + public void SetClientSecretKeyDerivationHashAlgorithm_AlgorithmIsCorrectlySet(string algorithmName) + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + var algorithm = new System.Security.Cryptography.HashAlgorithmName(algorithmName); // Act - builder.SetDefaultAuthorizationEntity(); + builder.SetClientSecretKeyDerivationHashAlgorithm(algorithm); // Assert - Assert.Contains(services, service => - service.Lifetime == ServiceLifetime.Scoped && - service.ServiceType == typeof(IOpenIddictAuthorizationManager) && - service.ImplementationFactory is not null); + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().CurrentValue; + + Assert.Equal(algorithm, options.ClientSecretKeyDerivationHashAlgorithm); } - [Fact] - public void SetDefaultScopeEntity_ReplacesUntypedManager() + [Theory] + [InlineData(-1)] + [InlineData(0)] + [InlineData(9_999)] + [InlineData(10_000_001)] + public void SetClientSecretKeyDerivationIterations_ThrowsAnExceptionForInvalidIterations(int iterations) + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.SetClientSecretKeyDerivationIterations(iterations)); + + Assert.Equal("iterations", exception.ParamName); + } + + [Theory] + [InlineData(10_000)] + [InlineData(50_000)] + [InlineData(10_000_000)] + public void SetClientSecretKeyDerivationIterations_IterationsAreCorrectlySet(int iterations) { // Arrange var services = CreateServices(); var builder = CreateBuilder(services); // Act - builder.SetDefaultScopeEntity(); + builder.SetClientSecretKeyDerivationIterations(iterations); // Assert - Assert.Contains(services, service => - service.Lifetime == ServiceLifetime.Scoped && - service.ServiceType == typeof(IOpenIddictScopeManager) && - service.ImplementationFactory is not null); + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().CurrentValue; + + Assert.Equal(iterations, options.ClientSecretKeyDerivationIterations); } - [Fact] - public void SetDefaultTokenEntity_ReplacesUntypedManager() + [Theory] + [InlineData(-1)] + [InlineData(0)] + [InlineData(255)] + [InlineData(2049)] + public void SetClientSecretKeyDerivationOutputLength_ThrowsAnExceptionForInvalidLength(int length) + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.SetClientSecretKeyDerivationOutputLength(length)); + + Assert.Equal("length", exception.ParamName); + } + + [Theory] + [InlineData(256)] + [InlineData(512)] + [InlineData(2048)] + public void SetClientSecretKeyDerivationOutputLength_LengthIsCorrectlySet(int length) { // Arrange var services = CreateServices(); var builder = CreateBuilder(services); // Act - builder.SetDefaultTokenEntity(); + builder.SetClientSecretKeyDerivationOutputLength(length); // Assert - Assert.Contains(services, service => - service.Lifetime == ServiceLifetime.Scoped && - service.ServiceType == typeof(IOpenIddictTokenManager) && - service.ImplementationFactory is not null); + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().CurrentValue; + + Assert.Equal(length, options.ClientSecretKeyDerivationOutputLength); + } + + [Theory] + [InlineData(-1)] + [InlineData(0)] + [InlineData(127)] + [InlineData(1025)] + public void SetClientSecretKeyDerivationSaltLength_ThrowsAnExceptionForInvalidLength(int length) + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.SetClientSecretKeyDerivationSaltLength(length)); + + Assert.Equal("length", exception.ParamName); + } + + [Theory] + [InlineData(128)] + [InlineData(256)] + [InlineData(1024)] + public void SetClientSecretKeyDerivationSaltLength_LengthIsCorrectlySet(int length) + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.SetClientSecretKeyDerivationSaltLength(length); + + // Assert + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().CurrentValue; + + Assert.Equal(length, options.ClientSecretKeyDerivationSaltLength); } [Theory] @@ -340,7 +438,74 @@ public class OpenIddictCoreBuilderTests Assert.Equal(42, options.EntityCacheLimit); } - private static OpenIddictCoreBuilder CreateBuilder(IServiceCollection services) + [Fact] + public void SetDefaultApplicationEntity_ReplacesUntypedManager() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.SetDefaultApplicationEntity(); + + // Assert + Assert.Contains(services, service => + service.Lifetime == ServiceLifetime.Scoped && + service.ServiceType == typeof(IOpenIddictApplicationManager) && + service.ImplementationFactory is not null); + } + + [Fact] + public void SetDefaultAuthorizationEntity_ReplacesUntypedManager() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.SetDefaultAuthorizationEntity(); + + // Assert + Assert.Contains(services, service => + service.Lifetime == ServiceLifetime.Scoped && + service.ServiceType == typeof(IOpenIddictAuthorizationManager) && + service.ImplementationFactory is not null); + } + + [Fact] + public void SetDefaultScopeEntity_ReplacesUntypedManager() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.SetDefaultScopeEntity(); + + // Assert + Assert.Contains(services, service => + service.Lifetime == ServiceLifetime.Scoped && + service.ServiceType == typeof(IOpenIddictScopeManager) && + service.ImplementationFactory is not null); + } + + [Fact] + public void SetDefaultTokenEntity_ReplacesUntypedManager() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.SetDefaultTokenEntity(); + + // Assert + Assert.Contains(services, service => + service.Lifetime == ServiceLifetime.Scoped && + service.ServiceType == typeof(IOpenIddictTokenManager) && + service.ImplementationFactory is not null); + } +private static OpenIddictCoreBuilder CreateBuilder(IServiceCollection services) => services.AddOpenIddict().AddCore(); private static ServiceCollection CreateServices() diff --git a/test/OpenIddict.Core.Tests/OpenIddictCoreConfigurationTests.cs b/test/OpenIddict.Core.Tests/OpenIddictCoreConfigurationTests.cs new file mode 100644 index 00000000..c5dbdb3e --- /dev/null +++ b/test/OpenIddict.Core.Tests/OpenIddictCoreConfigurationTests.cs @@ -0,0 +1,303 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System.Security.Cryptography; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace OpenIddict.Core.Tests; + +public class OpenIddictCoreConfigurationTests +{ + [Fact] + public void Constructor_ThrowsAnExceptionForNullProvider() + { + // Arrange + var provider = (IServiceProvider) null!; + + // Act and assert + var exception = Assert.Throws(() => new OpenIddictCoreConfiguration(provider)); + + Assert.Equal("provider", exception.ParamName); + } + + [Fact] + public void PostConfigure_ThrowsAnExceptionForNullOptions() + { + // Arrange + var services = new ServiceCollection(); + var provider = services.BuildServiceProvider(); + var configuration = new OpenIddictCoreConfiguration(provider); + + // Act and assert + var exception = Assert.Throws(() => configuration.PostConfigure(null, null!)); + + Assert.Equal("options", exception.ParamName); + } + + [Fact] + public void PostConfigure_SetsTimeProviderToSystemWhenNotRegistered() + { + // Arrange + var services = new ServiceCollection(); + var provider = services.BuildServiceProvider(); + var configuration = new OpenIddictCoreConfiguration(provider); + var options = new OpenIddictCoreOptions(); + + // Act + configuration.PostConfigure(null, options); + + // Assert + Assert.Same(TimeProvider.System, options.TimeProvider); + } + + [Fact] + public void PostConfigure_UsesRegisteredTimeProvider() + { + // Arrange + var customTimeProvider = new CustomTimeProvider(); + var services = new ServiceCollection(); + services.AddSingleton(customTimeProvider); + var provider = services.BuildServiceProvider(); + var configuration = new OpenIddictCoreConfiguration(provider); + var options = new OpenIddictCoreOptions(); + + // Act + configuration.PostConfigure(null, options); + + // Assert + Assert.Same(customTimeProvider, options.TimeProvider); + } + + [Fact] + public void PostConfigure_DoesNotOverrideExplicitlySetTimeProvider() + { + // Arrange + var explicitTimeProvider = new CustomTimeProvider(); + var registeredTimeProvider = new CustomTimeProvider(); + var services = new ServiceCollection(); + services.AddSingleton(registeredTimeProvider); + var provider = services.BuildServiceProvider(); + var configuration = new OpenIddictCoreConfiguration(provider); + var options = new OpenIddictCoreOptions + { + TimeProvider = explicitTimeProvider + }; + + // Act + configuration.PostConfigure(null, options); + + // Assert + Assert.Same(explicitTimeProvider, options.TimeProvider); + } + + [Fact] + public void Validate_ThrowsAnExceptionForNullOptions() + { + // Arrange + var services = new ServiceCollection(); + var provider = services.BuildServiceProvider(); + var configuration = new OpenIddictCoreConfiguration(provider); + + // Act and assert + var exception = Assert.Throws(() => configuration.Validate(null, null!)); + + Assert.Equal("options", exception.ParamName); + } + + [Fact] + public void Validate_SucceedsForDefaultOptions() + { + // Arrange + var services = new ServiceCollection(); + var provider = services.BuildServiceProvider(); + var configuration = new OpenIddictCoreConfiguration(provider); + var options = new OpenIddictCoreOptions(); + + // Act + var result = configuration.Validate(null, options); + + // Assert + Assert.True(result.Succeeded); + } + + [Theory] + [InlineData(nameof(HashAlgorithmName.SHA1))] + [InlineData(nameof(HashAlgorithmName.SHA256))] + [InlineData(nameof(HashAlgorithmName.SHA512))] + public void Validate_SucceedsForValidHashAlgorithm(string algorithmName) + { + // Arrange + var services = new ServiceCollection(); + var provider = services.BuildServiceProvider(); + var configuration = new OpenIddictCoreConfiguration(provider); + var options = new OpenIddictCoreOptions + { + ClientSecretKeyDerivationHashAlgorithm = new HashAlgorithmName(algorithmName) + }; + + // Act + var result = configuration.Validate(null, options); + + // Assert + Assert.True(result.Succeeded); + } + + [Fact] + public void Validate_FailsForInvalidHashAlgorithm() + { + // Arrange + var services = new ServiceCollection(); + var provider = services.BuildServiceProvider(); + var configuration = new OpenIddictCoreConfiguration(provider); + var options = new OpenIddictCoreOptions + { + ClientSecretKeyDerivationHashAlgorithm = new HashAlgorithmName("MD5") + }; + + // Act + var result = configuration.Validate(null, options); + + // Assert + Assert.True(result.Failed); + Assert.Contains(SR.FormatID0217("MD5"), result.Failures); + } + + [Theory] + [InlineData(9_999)] + [InlineData(10_000_001)] + public void Validate_FailsForInvalidIterationCount(int iterations) + { + // Arrange + var services = new ServiceCollection(); + var provider = services.BuildServiceProvider(); + var configuration = new OpenIddictCoreConfiguration(provider); + var options = new OpenIddictCoreOptions + { + ClientSecretKeyDerivationIterations = iterations + }; + + // Act + var result = configuration.Validate(null, options); + + // Assert + Assert.True(result.Failed); + Assert.Contains(SR.FormatID0518(10_000, 10_000_000), result.Failures); + } + + [Theory] + [InlineData(10_000)] + [InlineData(100_000)] + [InlineData(10_000_000)] + public void Validate_SucceedsForValidIterationCount(int iterations) + { + // Arrange + var services = new ServiceCollection(); + var provider = services.BuildServiceProvider(); + var configuration = new OpenIddictCoreConfiguration(provider); + var options = new OpenIddictCoreOptions + { + ClientSecretKeyDerivationIterations = iterations + }; + + // Act + var result = configuration.Validate(null, options); + + // Assert + Assert.True(result.Succeeded); + } + + [Theory] + [InlineData(127)] + [InlineData(1025)] + public void Validate_FailsForInvalidSaltLength(int saltLength) + { + // Arrange + var services = new ServiceCollection(); + var provider = services.BuildServiceProvider(); + var configuration = new OpenIddictCoreConfiguration(provider); + var options = new OpenIddictCoreOptions + { + ClientSecretKeyDerivationSaltLength = saltLength + }; + + // Act + var result = configuration.Validate(null, options); + + // Assert + Assert.True(result.Failed); + Assert.Contains(SR.FormatID0519(128, 1024), result.Failures); + } + + [Theory] + [InlineData(128)] + [InlineData(256)] + [InlineData(1024)] + public void Validate_SucceedsForValidSaltLength(int saltLength) + { + // Arrange + var services = new ServiceCollection(); + var provider = services.BuildServiceProvider(); + var configuration = new OpenIddictCoreConfiguration(provider); + var options = new OpenIddictCoreOptions + { + ClientSecretKeyDerivationSaltLength = saltLength + }; + + // Act + var result = configuration.Validate(null, options); + + // Assert + Assert.True(result.Succeeded); + } + + [Theory] + [InlineData(255)] + [InlineData(2049)] + public void Validate_FailsForInvalidOutputLength(int outputLength) + { + // Arrange + var services = new ServiceCollection(); + var provider = services.BuildServiceProvider(); + var configuration = new OpenIddictCoreConfiguration(provider); + var options = new OpenIddictCoreOptions + { + ClientSecretKeyDerivationOutputLength = outputLength + }; + + // Act + var result = configuration.Validate(null, options); + + // Assert + Assert.True(result.Failed); + Assert.Contains(SR.FormatID0520(256, 2048), result.Failures); + } + + [Theory] + [InlineData(256)] + [InlineData(512)] + [InlineData(2048)] + public void Validate_SucceedsForValidOutputLength(int outputLength) + { + // Arrange + var services = new ServiceCollection(); + var provider = services.BuildServiceProvider(); + var configuration = new OpenIddictCoreConfiguration(provider); + var options = new OpenIddictCoreOptions + { + ClientSecretKeyDerivationOutputLength = outputLength + }; + + // Act + var result = configuration.Validate(null, options); + + // Assert + Assert.True(result.Succeeded); + } + private class CustomTimeProvider : TimeProvider + { + } +} diff --git a/test/OpenIddict.Core.Tests/OpenIddictCoreExtensionsTests.cs b/test/OpenIddict.Core.Tests/OpenIddictCoreExtensionsTests.cs index 50df67f7..80dcc397 100644 --- a/test/OpenIddict.Core.Tests/OpenIddictCoreExtensionsTests.cs +++ b/test/OpenIddict.Core.Tests/OpenIddictCoreExtensionsTests.cs @@ -1,4 +1,4 @@ -/* +/* * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) * See https://github.com/openiddict/openiddict-core for more information concerning * the license and the contributors participating to this project. @@ -173,9 +173,4 @@ public class OpenIddictCoreExtensionsTests Assert.Equal(SR.GetResourceString(SR.ID0472), exception.Message); } - - public class OpenIddictApplication { } - public class OpenIddictAuthorization { } - public class OpenIddictScope { } - public class OpenIddictToken { } } diff --git a/test/OpenIddict.Core.Tests/OpenIddictCoreOptionsTests.cs b/test/OpenIddict.Core.Tests/OpenIddictCoreOptionsTests.cs new file mode 100644 index 00000000..ac8814c5 --- /dev/null +++ b/test/OpenIddict.Core.Tests/OpenIddictCoreOptionsTests.cs @@ -0,0 +1,152 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System.Security.Cryptography; +using Xunit; + +namespace OpenIddict.Core.Tests; + +public class OpenIddictCoreOptionsTests +{ + [Fact] + public void Constructor_SetsDefaultValues() + { + // Act + var options = new OpenIddictCoreOptions(); + + // Assert + Assert.Equal(HashAlgorithmName.SHA512, options.ClientSecretKeyDerivationHashAlgorithm); + Assert.Equal(100_000, options.ClientSecretKeyDerivationIterations); + Assert.Equal(512, options.ClientSecretKeyDerivationOutputLength); + Assert.Equal(256, options.ClientSecretKeyDerivationSaltLength); + Assert.False(options.DisableAdditionalFiltering); + Assert.False(options.DisableAutomaticClientSecretRehashing); + Assert.False(options.DisableEntityCaching); + Assert.Equal(250, options.EntityCacheLimit); + } + + [Fact] + public void ClientSecretKeyDerivationHashAlgorithm_CanBeSet() + { + // Arrange + var options = new OpenIddictCoreOptions(); + + // Act + options.ClientSecretKeyDerivationHashAlgorithm = HashAlgorithmName.SHA256; + + // Assert + Assert.Equal(HashAlgorithmName.SHA256, options.ClientSecretKeyDerivationHashAlgorithm); + } + + [Fact] + public void ClientSecretKeyDerivationIterations_CanBeSet() + { + // Arrange + var options = new OpenIddictCoreOptions(); + + // Act + options.ClientSecretKeyDerivationIterations = 50_000; + + // Assert + Assert.Equal(50_000, options.ClientSecretKeyDerivationIterations); + } + + [Fact] + public void ClientSecretKeyDerivationOutputLength_CanBeSet() + { + // Arrange + var options = new OpenIddictCoreOptions(); + + // Act + options.ClientSecretKeyDerivationOutputLength = 1024; + + // Assert + Assert.Equal(1024, options.ClientSecretKeyDerivationOutputLength); + } + + [Fact] + public void ClientSecretKeyDerivationSaltLength_CanBeSet() + { + // Arrange + var options = new OpenIddictCoreOptions(); + + // Act + options.ClientSecretKeyDerivationSaltLength = 512; + + // Assert + Assert.Equal(512, options.ClientSecretKeyDerivationSaltLength); + } + + [Fact] + public void DisableAdditionalFiltering_CanBeSet() + { + // Arrange + var options = new OpenIddictCoreOptions(); + + // Act + options.DisableAdditionalFiltering = true; + + // Assert + Assert.True(options.DisableAdditionalFiltering); + } + + [Fact] + public void DisableAutomaticClientSecretRehashing_CanBeSet() + { + // Arrange + var options = new OpenIddictCoreOptions(); + + // Act + options.DisableAutomaticClientSecretRehashing = true; + + // Assert + Assert.True(options.DisableAutomaticClientSecretRehashing); + } + + [Fact] + public void DisableEntityCaching_CanBeSet() + { + // Arrange + var options = new OpenIddictCoreOptions(); + + // Act + options.DisableEntityCaching = true; + + // Assert + Assert.True(options.DisableEntityCaching); + } + + [Fact] + public void EntityCacheLimit_CanBeSet() + { + // Arrange + var options = new OpenIddictCoreOptions(); + + // Act + options.EntityCacheLimit = 500; + + // Assert + Assert.Equal(500, options.EntityCacheLimit); + } + + [Fact] + public void TimeProvider_CanBeSet() + { + // Arrange + var options = new OpenIddictCoreOptions(); + var customTimeProvider = new CustomTimeProvider(); + + // Act + options.TimeProvider = customTimeProvider; + + // Assert + Assert.Same(customTimeProvider, options.TimeProvider); + } + + private class CustomTimeProvider : TimeProvider + { + } +} diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddict.Server.IntegrationTests.csproj b/test/OpenIddict.Server.IntegrationTests/OpenIddict.Server.IntegrationTests.csproj index 0cd569c1..cecbccc8 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddict.Server.IntegrationTests.csproj +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddict.Server.IntegrationTests.csproj @@ -21,17 +21,12 @@ - - - - + diff --git a/test/OpenIddict.Validation.IntegrationTests/OpenIddict.Validation.IntegrationTests.csproj b/test/OpenIddict.Validation.IntegrationTests/OpenIddict.Validation.IntegrationTests.csproj index fbac1241..5e05022d 100644 --- a/test/OpenIddict.Validation.IntegrationTests/OpenIddict.Validation.IntegrationTests.csproj +++ b/test/OpenIddict.Validation.IntegrationTests/OpenIddict.Validation.IntegrationTests.csproj @@ -20,17 +20,12 @@ - - - - +