From db4fb6fc370e57e46d9eb1dac8b19dc0d4e9b0fe Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Sat, 1 Aug 2026 10:27:36 +0200 Subject: [PATCH 1/5] Nullable improvments for StringExtensions. --- .../src/Extensions/StringsExtensions.cs | 29 ++++++++++--------- .../Utility/InternalStringExtensions.cs | 5 ++-- .../src/Extensions/StringsExtensions.cs | 8 +++-- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/src/Open.IdentityServer/src/Extensions/StringsExtensions.cs b/src/Open.IdentityServer/src/Extensions/StringsExtensions.cs index 31f86870b..d12d60a53 100644 --- a/src/Open.IdentityServer/src/Extensions/StringsExtensions.cs +++ b/src/Open.IdentityServer/src/Extensions/StringsExtensions.cs @@ -7,10 +7,13 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text; using System.Text.Encodings.Web; +#nullable enable + namespace Open.IdentityServer.Extensions; internal static class StringExtensions @@ -47,7 +50,7 @@ public static IEnumerable FromSpaceSeparatedString(this string input) return input.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries).ToList(); } - public static List ParseScopesString(this string scopes) + public static List? ParseScopesString(this string? scopes) { if (scopes.IsMissing()) { @@ -67,13 +70,13 @@ public static List ParseScopesString(this string scopes) } [DebuggerStepThrough] - public static bool IsMissing(this string value) + public static bool IsMissing([NotNullWhen(false)] this string? value) { return string.IsNullOrWhiteSpace(value); } [DebuggerStepThrough] - public static bool IsMissingOrTooLong(this string value, int maxLength) + public static bool IsMissingOrTooLong(this string? value, int maxLength) { if (string.IsNullOrWhiteSpace(value)) { @@ -89,13 +92,13 @@ public static bool IsMissingOrTooLong(this string value, int maxLength) } [DebuggerStepThrough] - public static bool IsPresent(this string value) + public static bool IsPresent([NotNullWhen(true)] this string? value) { return !string.IsNullOrWhiteSpace(value); } [DebuggerStepThrough] - public static string EnsureLeadingSlash(this string url) + public static string? EnsureLeadingSlash(this string? url) { if (url != null && !url.StartsWith("/")) { @@ -106,7 +109,7 @@ public static string EnsureLeadingSlash(this string url) } [DebuggerStepThrough] - public static string EnsureTrailingSlash(this string url) + public static string? EnsureTrailingSlash(this string? url) { if (url != null && !url.EndsWith("/")) { @@ -117,7 +120,7 @@ public static string EnsureTrailingSlash(this string url) } [DebuggerStepThrough] - public static string RemoveLeadingSlash(this string url) + public static string? RemoveLeadingSlash(this string? url) { if (url != null && url.StartsWith("/")) { @@ -128,7 +131,7 @@ public static string RemoveLeadingSlash(this string url) } [DebuggerStepThrough] - public static string RemoveTrailingSlash(this string url) + public static string? RemoveTrailingSlash(this string? url) { if (url != null && url.EndsWith("/")) { @@ -139,9 +142,9 @@ public static string RemoveTrailingSlash(this string url) } [DebuggerStepThrough] - public static string CleanUrlPath(this string url) + public static string CleanUrlPath(this string? url) { - if (String.IsNullOrWhiteSpace(url)) url = "/"; + if (string.IsNullOrWhiteSpace(url)) url = "/"; if (url != "/" && url.EndsWith("/")) { @@ -153,7 +156,7 @@ public static string CleanUrlPath(this string url) [DebuggerStepThrough] // Clone of UrlHelperBase.CheckIsLocalUrl from https://github.com/dotnet/aspnetcore/blob/3f1acb59718cadf111a0a796681e3d3509bb3381/src/Mvc/Mvc.Core/src/Routing/UrlHelperBase.cs - public static bool IsLocalUrl(this string url) + public static bool IsLocalUrl(this string? url) { if (string.IsNullOrEmpty(url)) { @@ -246,7 +249,7 @@ public static string AddHashFragment(this string url, string query) } [DebuggerStepThrough] - public static NameValueCollection ReadQueryStringAsNameValueCollection(this string url) + public static NameValueCollection ReadQueryStringAsNameValueCollection(this string? url) { if (url != null) { @@ -266,7 +269,7 @@ public static NameValueCollection ReadQueryStringAsNameValueCollection(this stri return new NameValueCollection(); } - public static string GetOrigin(this string url) + public static string? GetOrigin(this string? url) { if (url != null) { diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Utility/InternalStringExtensions.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Utility/InternalStringExtensions.cs index 06fc17f4d..c3fce0580 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Utility/InternalStringExtensions.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Utility/InternalStringExtensions.cs @@ -1,4 +1,5 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Modified by Rock Solid Knowledge Ltd. Copyright in modifications 2026, Rock Solid Knowledge Ltd. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. #nullable enable @@ -11,13 +12,13 @@ namespace IdentityServer.IntegrationTests.Utility; internal static class InternalStringExtensions { [DebuggerStepThrough] - public static bool IsMissing(this string value) + public static bool IsMissing([NotNullWhen(false)] this string? value) { return string.IsNullOrWhiteSpace(value); } [DebuggerStepThrough] - public static bool IsPresent(this string value) + public static bool IsPresent([NotNullWhen(true)] this string? value) { return !(value.IsMissing()); } diff --git a/src/Storage/src/Extensions/StringsExtensions.cs b/src/Storage/src/Extensions/StringsExtensions.cs index 4aec2bf9b..8c46339be 100644 --- a/src/Storage/src/Extensions/StringsExtensions.cs +++ b/src/Storage/src/Extensions/StringsExtensions.cs @@ -1,21 +1,25 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Modified by Rock Solid Knowledge Ltd. Copyright in modifications 2026, Rock Solid Knowledge Ltd. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +#nullable enable namespace Open.IdentityServer.Extensions; internal static class StringExtensions { [DebuggerStepThrough] - public static bool IsMissing(this string value) + public static bool IsMissing([NotNullWhen(false)] this string? value) { return string.IsNullOrWhiteSpace(value); } [DebuggerStepThrough] - public static bool IsPresent(this string value) + public static bool IsPresent([NotNullWhen(true)] this string? value) { return !string.IsNullOrWhiteSpace(value); } From 2b783499e9d2973ccee89a0344a9d94ec8bce0b5 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Thu, 6 Aug 2026 10:41:04 +0200 Subject: [PATCH 2/5] Small refactoring to DataProtectedIdentityServerKeyMaterialConverter more readable and parts of it more re-usable. --- .../src/Configuration/CryptoHelper.cs | 37 +++++++++ ...ectedIdentityServerKeyMaterialConverter.cs | 15 +--- .../Configuration/CryptoHelperTests.cs | 79 +++++++++++++++++++ .../Open.IdentityServer.UnitTests.csproj | 16 ++-- 4 files changed, 128 insertions(+), 19 deletions(-) create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/CryptoHelperTests.cs diff --git a/src/Open.IdentityServer/src/Configuration/CryptoHelper.cs b/src/Open.IdentityServer/src/Configuration/CryptoHelper.cs index dee7b712d..936cc7b3d 100644 --- a/src/Open.IdentityServer/src/Configuration/CryptoHelper.cs +++ b/src/Open.IdentityServer/src/Configuration/CryptoHelper.cs @@ -97,6 +97,43 @@ public static HashAlgorithm GetHashAlgorithmForSigningAlgorithm(string signingAl }; } + /// + /// Returns if the algorithm is an RSA algorithm (RSxxx or PSxxx) + /// + /// The algorithm to check. + /// if the algorithm is an RSA algorithm; otherwise, . + public static bool IsRsaAlgorithm(this string algorithm) + { + return algorithm.StartsWith('R') || algorithm.StartsWith('P'); + } + + /// + /// Returns if the algorithm is an EC algorithm (Exxx) + /// + /// The algorithm to check. + /// if the algorithm is an EC algorithm; otherwise, . + public static bool IsEcAlgorithm(this string algorithm) + { + return algorithm.StartsWith('E'); + } + + /// + /// Returns the matching named curve for a given algorithm + /// + /// The algorithm to get the curve name for. + /// The name of the curve corresponding to the algorithm. + /// + public static string? GetCurveNameForAlgorithm(this string algorithm) + { + return algorithm switch + { + "ES256" => "P-256", + "ES384" => "P-384", + "ES512" => "P-521", + _ => throw new ArgumentOutOfRangeException(nameof(algorithm), "Unexpected algorithm value for EC Curve") + }; + } + /// /// Returns the matching named curve for RFC 7518 crv value /// diff --git a/src/Open.IdentityServer/src/DataProtection/DataProtectedIdentityServerKeyMaterialConverter.cs b/src/Open.IdentityServer/src/DataProtection/DataProtectedIdentityServerKeyMaterialConverter.cs index 44e2950c1..d586a93cd 100644 --- a/src/Open.IdentityServer/src/DataProtection/DataProtectedIdentityServerKeyMaterialConverter.cs +++ b/src/Open.IdentityServer/src/DataProtection/DataProtectedIdentityServerKeyMaterialConverter.cs @@ -45,8 +45,7 @@ public SigningKey Convert(IdentityServerKeyMaterial keyMaterial) dataProtector.Unprotect(keyMaterial.Data) : keyMaterial.Data; - if (!keyMaterial.IsX509Certificate && - (keyMaterial.Algorithm.StartsWith('R') || keyMaterial.Algorithm.StartsWith('P'))) + if (!keyMaterial.IsX509Certificate && keyMaterial.Algorithm.IsRsaAlgorithm()) { var keyData = JsonSerializer.Deserialize(unprotectedData, Settings); @@ -54,18 +53,12 @@ public SigningKey Convert(IdentityServerKeyMaterial keyMaterial) signingKey.Credentials = new SigningCredentials(new RsaSecurityKey(keyData.Parameters) { KeyId = keyData.Id }, keyData.Algorithm); } - if (!keyMaterial.IsX509Certificate && keyMaterial.Algorithm.StartsWith('E')) + if (!keyMaterial.IsX509Certificate && keyMaterial.Algorithm.IsEcAlgorithm()) { var keyData = JsonSerializer.Deserialize(unprotectedData, Settings); - ECCurve curve = keyMaterial.Algorithm switch - { - "ES256" => CryptoHelper.GetCurveFromCrvValue("P-256"), - "ES384" => CryptoHelper.GetCurveFromCrvValue("P-384"), - "ES521" => CryptoHelper.GetCurveFromCrvValue("P-521"), - _ => throw new ArgumentOutOfRangeException(nameof(keyMaterial.Algorithm), "Unexpected algorithm value for EC Curve") - }; - + ECCurve curve = CryptoHelper.GetCurveFromCrvValue( + keyMaterial.Algorithm.GetCurveNameForAlgorithm()); var ecdsa = ECDsa.Create(new ECParameters { Curve = curve, D = keyData.D, Q = keyData.Q }); signingKey.Created = keyData.Created; diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/CryptoHelperTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/CryptoHelperTests.cs new file mode 100644 index 000000000..4f2deb377 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/CryptoHelperTests.cs @@ -0,0 +1,79 @@ +using AwesomeAssertions; +using Open.IdentityServer.Configuration; +using System; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Configuration; + +public class CryptoHelperTests +{ + [Theory] + [InlineData("RS256")] + [InlineData("RS384")] + [InlineData("RS512")] + [InlineData("PS256")] + [InlineData("PS384")] + [InlineData("PS512")] + public void IsRsaAlgorithm_ShouldReturnTrueForRsaAlgorithms(string algorithm) + { + algorithm.IsRsaAlgorithm().Should().BeTrue(); + } + + [Theory] + [InlineData("ES256")] + [InlineData("ES384")] + [InlineData("ES512")] + [InlineData("HS256")] + [InlineData("AES256")] + public void IsRsaAlgorithm_ShouldReturnFalseForNonRsaAlgorithms(string algorithm) + { + algorithm.IsRsaAlgorithm().Should().BeFalse(); + } + + [Theory] + [InlineData("ES256")] + [InlineData("ES384")] + [InlineData("ES512")] + public void IsEcAlgorithm_ShouldReturnTrueForEcAlgorithms(string algorithm) + { + algorithm.IsEcAlgorithm().Should().BeTrue(); + } + + [Theory] + [InlineData("RS256")] + [InlineData("RS384")] + [InlineData("RS512")] + [InlineData("PS256")] + [InlineData("PS384")] + [InlineData("PS512")] + [InlineData("HS256")] + [InlineData("AES256")] + public void IsEcAlgorithm_ShouldReturnFalseForNonEcAlgorithms(string algorithm) + { + algorithm.IsEcAlgorithm().Should().BeFalse(); + } + + [Theory] + [InlineData("ES256", "P-256")] + [InlineData("ES384", "P-384")] + [InlineData("ES512", "P-521")] + public void GetCurveNameForAlgorithm_ShouldReturnCorrectCurveNameForEcAlgorithms(string algorithm, string expectedCurveName) + { + algorithm.GetCurveNameForAlgorithm().Should().Be(expectedCurveName); + } + + [Theory] + [InlineData("RS256")] + [InlineData("RS384")] + [InlineData("RS512")] + [InlineData("PS256")] + [InlineData("PS384")] + [InlineData("PS512")] + [InlineData("HS256")] + [InlineData("AES256")] + public void GetCurveNameForAlgorithm_ShouldThrowArgumentOutOfRangeExceptionForNonEcAlgorithms(string algorithm) + { + Action act = () => algorithm.GetCurveNameForAlgorithm(); + act.Should().Throw(); + } +} diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Open.IdentityServer.UnitTests.csproj b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Open.IdentityServer.UnitTests.csproj index 46644c63d..50baae4c4 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Open.IdentityServer.UnitTests.csproj +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Open.IdentityServer.UnitTests.csproj @@ -12,18 +12,18 @@ - + - + - - - - - + + + + + @@ -36,6 +36,6 @@ - + From 0ef90718007bad3fc9d3a5d02c75eb5460043e6e Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Thu, 6 Aug 2026 10:47:42 +0200 Subject: [PATCH 3/5] Fixed typo. --- src/Open.IdentityServer/src/Configuration/CryptoHelper.cs | 2 +- .../Configuration/CryptoHelperTests.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Open.IdentityServer/src/Configuration/CryptoHelper.cs b/src/Open.IdentityServer/src/Configuration/CryptoHelper.cs index 936cc7b3d..bd52067ca 100644 --- a/src/Open.IdentityServer/src/Configuration/CryptoHelper.cs +++ b/src/Open.IdentityServer/src/Configuration/CryptoHelper.cs @@ -129,7 +129,7 @@ public static bool IsEcAlgorithm(this string algorithm) { "ES256" => "P-256", "ES384" => "P-384", - "ES512" => "P-521", + "ES521" => "P-521", _ => throw new ArgumentOutOfRangeException(nameof(algorithm), "Unexpected algorithm value for EC Curve") }; } diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/CryptoHelperTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/CryptoHelperTests.cs index 4f2deb377..282643c71 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/CryptoHelperTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/CryptoHelperTests.cs @@ -33,7 +33,7 @@ public void IsRsaAlgorithm_ShouldReturnFalseForNonRsaAlgorithms(string algorithm [Theory] [InlineData("ES256")] [InlineData("ES384")] - [InlineData("ES512")] + [InlineData("ES521")] public void IsEcAlgorithm_ShouldReturnTrueForEcAlgorithms(string algorithm) { algorithm.IsEcAlgorithm().Should().BeTrue(); @@ -56,7 +56,7 @@ public void IsEcAlgorithm_ShouldReturnFalseForNonEcAlgorithms(string algorithm) [Theory] [InlineData("ES256", "P-256")] [InlineData("ES384", "P-384")] - [InlineData("ES512", "P-521")] + [InlineData("ES521", "P-521")] public void GetCurveNameForAlgorithm_ShouldReturnCorrectCurveNameForEcAlgorithms(string algorithm, string expectedCurveName) { algorithm.GetCurveNameForAlgorithm().Should().Be(expectedCurveName); From 45ac31d62f9d0859a1d54b7d0a1b2884c1ac3f47 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Thu, 6 Aug 2026 10:55:41 +0200 Subject: [PATCH 4/5] Wrong nullable annotation. --- src/Open.IdentityServer/src/Configuration/CryptoHelper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Open.IdentityServer/src/Configuration/CryptoHelper.cs b/src/Open.IdentityServer/src/Configuration/CryptoHelper.cs index bd52067ca..f3e4c45be 100644 --- a/src/Open.IdentityServer/src/Configuration/CryptoHelper.cs +++ b/src/Open.IdentityServer/src/Configuration/CryptoHelper.cs @@ -123,7 +123,7 @@ public static bool IsEcAlgorithm(this string algorithm) /// The algorithm to get the curve name for. /// The name of the curve corresponding to the algorithm. /// - public static string? GetCurveNameForAlgorithm(this string algorithm) + public static string GetCurveNameForAlgorithm(this string algorithm) { return algorithm switch { From 566534bb693b262b219e61691307f464a9e87f4b Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Tue, 25 Aug 2026 18:17:20 +0200 Subject: [PATCH 5/5] Generic mapping tests. --- .../src/Mappers/ClientMappingExtensions.cs | 1 + .../Mappers/ApiResourceMappersTests.cs | 21 ++ .../UnitTests/Mappers/ClientMappersTests.cs | 42 +++ .../Mappers/IdentityResourcesMappersTests.cs | 20 ++ .../test/UnitTests/Mappers/MappingVerifier.cs | 241 ++++++++++++++++++ .../Mappers/PersistedGrantMappersTests.cs | 19 +- .../UnitTests/Mappers/ScopeMappersTests.cs | 21 ++ 7 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 src/EntityFramework.Storage/test/UnitTests/Mappers/MappingVerifier.cs diff --git a/src/EntityFramework.Storage/src/Mappers/ClientMappingExtensions.cs b/src/EntityFramework.Storage/src/Mappers/ClientMappingExtensions.cs index 1567c8ddc..ac9d823fc 100644 --- a/src/EntityFramework.Storage/src/Mappers/ClientMappingExtensions.cs +++ b/src/EntityFramework.Storage/src/Mappers/ClientMappingExtensions.cs @@ -177,6 +177,7 @@ public Entities.Client ToEntity() ClientClaimsPrefix = clientModel.ClientClaimsPrefix, PairWiseSubjectSalt = clientModel.PairWiseSubjectSalt, UserSsoLifetime = clientModel.UserSsoLifetime, + UserCodeType = clientModel.UserCodeType, DeviceCodeLifetime = clientModel.DeviceCodeLifetime, AllowedCorsOrigins = clientModel.AllowedCorsOrigins?.Select(x => new ClientCorsOrigin { Origin = x }).ToList() ?? [], Properties = clientModel.Properties.ToEntityList(), diff --git a/src/EntityFramework.Storage/test/UnitTests/Mappers/ApiResourceMappersTests.cs b/src/EntityFramework.Storage/test/UnitTests/Mappers/ApiResourceMappersTests.cs index d9d30a467..35d37aa3f 100644 --- a/src/EntityFramework.Storage/test/UnitTests/Mappers/ApiResourceMappersTests.cs +++ b/src/EntityFramework.Storage/test/UnitTests/Mappers/ApiResourceMappersTests.cs @@ -139,4 +139,25 @@ public void EntitiesApiResourceToModel_MissingValues_ShouldUseDefaults() var model = entity.ToModel(); model.ApiSecrets.First().Type.Should().Be(def.ApiSecrets.First().Type); } + + [Fact] + public void ToEntity_maps_all_properties() + { + new MappingVerifier() + .ExcludeDestinationProperties( + // Database-assigned or entity-managed fields not sourced from the model + nameof(Entities.ApiResource.Id), + nameof(Entities.ApiResource.Created), + nameof(Entities.ApiResource.Updated), + nameof(Entities.ApiResource.LastAccessed), + nameof(Entities.ApiResource.NonEditable)) + .Verify(model => model.ToEntity()); + } + + [Fact] + public void ToModel_maps_all_properties() + { + new MappingVerifier() + .Verify(entity => entity.ToModel()); + } } \ No newline at end of file diff --git a/src/EntityFramework.Storage/test/UnitTests/Mappers/ClientMappersTests.cs b/src/EntityFramework.Storage/test/UnitTests/Mappers/ClientMappersTests.cs index 7bd78c542..c419b6303 100644 --- a/src/EntityFramework.Storage/test/UnitTests/Mappers/ClientMappersTests.cs +++ b/src/EntityFramework.Storage/test/UnitTests/Mappers/ClientMappersTests.cs @@ -97,4 +97,46 @@ public void missing_values_should_use_defaults() model.ProtocolType.Should().Be(def.ProtocolType); model.ClientSecrets.First().Type.Should().Be(def.ClientSecrets.First().Type); } + + [Fact] + public void ToEntity_maps_all_properties() + { + new MappingVerifier() + .ExcludeDestinationProperties( + // Database-assigned or entity-managed fields not sourced from the model + nameof(Entities.Client.Id), + nameof(Entities.Client.Created), + nameof(Entities.Client.Updated), + nameof(Entities.Client.LastAccessed), + nameof(Entities.Client.NonEditable), + // Compatibility properties intentionally not mapped + nameof(Entities.Client.CibaLifetime), + nameof(Entities.Client.PollingInterval), + nameof(Entities.Client.CoordinateLifetimeWithUserSession), + nameof(Entities.Client.InitiateLoginUri), + nameof(Entities.Client.DPoPClockSkew), + nameof(Entities.Client.DPoPValidationMode), + nameof(Entities.Client.RequireDPoP), + nameof(Entities.Client.PushedAuthorizationLifetime), + nameof(Entities.Client.RequirePushedAuthorization)) + .Verify(model => model.ToEntity()); + } + + [Fact] + public void ToModel_maps_all_properties() + { + new MappingVerifier() + .ExcludeDestinationProperties( + // Compatibility properties intentionally not mapped + nameof(Client.CibaLifetime), + nameof(Client.PollingInterval), + nameof(Client.CoordinateLifetimeWithUserSession), + nameof(Client.InitiateLoginUri), + nameof(Client.DPoPClockSkew), + nameof(Client.DPoPValidationMode), + nameof(Client.RequireDPoP), + nameof(Client.PushedAuthorizationLifetime), + nameof(Client.RequirePushedAuthorization)) + .Verify(entity => entity.ToModel()); + } } \ No newline at end of file diff --git a/src/EntityFramework.Storage/test/UnitTests/Mappers/IdentityResourcesMappersTests.cs b/src/EntityFramework.Storage/test/UnitTests/Mappers/IdentityResourcesMappersTests.cs index fc1c72da2..795f3e488 100644 --- a/src/EntityFramework.Storage/test/UnitTests/Mappers/IdentityResourcesMappersTests.cs +++ b/src/EntityFramework.Storage/test/UnitTests/Mappers/IdentityResourcesMappersTests.cs @@ -22,4 +22,24 @@ public void CanMapIdentityResources() Assert.NotNull(mappedModel); Assert.NotNull(mappedEntity); } + + [Fact] + public void ToEntity_maps_all_properties() + { + new MappingVerifier() + .ExcludeDestinationProperties( + // Database-assigned or entity-managed fields not sourced from the model + nameof(Entities.IdentityResource.Id), + nameof(Entities.IdentityResource.Created), + nameof(Entities.IdentityResource.Updated), + nameof(Entities.IdentityResource.NonEditable)) + .Verify(model => model.ToEntity()); + } + + [Fact] + public void ToModel_maps_all_properties() + { + new MappingVerifier() + .Verify(entity => entity.ToModel()); + } } \ No newline at end of file diff --git a/src/EntityFramework.Storage/test/UnitTests/Mappers/MappingVerifier.cs b/src/EntityFramework.Storage/test/UnitTests/Mappers/MappingVerifier.cs new file mode 100644 index 000000000..aafdd8c43 --- /dev/null +++ b/src/EntityFramework.Storage/test/UnitTests/Mappers/MappingVerifier.cs @@ -0,0 +1,241 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using AwesomeAssertions; + +namespace Open.IdentityServer.EntityFramework.UnitTests.Mappers; + +/// +/// Reflection-based helper that verifies all properties on a destination type are +/// populated by a mapping function, without maintaining explicit per-property assertions. +/// +/// It works by creating both a default and a fully-populated source instance, mapping +/// each, and asserting that every non-excluded destination property differs between +/// the two results. A property that is the same in both results was not mapped. +/// +/// +internal sealed class MappingVerifier + where TSource : new() + where TDest : new() +{ + private readonly HashSet _excludedDestProperties = []; + private readonly List> _customPopulators = []; + + /// + /// Excludes the specified destination properties from the mapping check. + /// Use this for properties intentionally not mapped, e.g. database-assigned keys, + /// audit timestamps, and compatibility properties. + /// + public MappingVerifier ExcludeDestinationProperties(params string[] properties) + { + foreach (var p in properties) + _excludedDestProperties.Add(p); + return this; + } + + /// + /// Adds a custom action that runs after the generic reflection-based population. + /// Use this for source properties whose types reflection cannot handle generically, + /// such as collections of types without parameterless constructors. + /// + public MappingVerifier WithCustomPopulator(Action populator) + { + _customPopulators.Add(populator); + return this; + } + + /// + /// Verifies the mapping. Populates a source instance with non-default test values, + /// maps it alongside a default source, then asserts that every non-excluded + /// destination property differs between the two mapped results. + /// + public void Verify(Func mapper) + { + var defaultSource = new TSource(); + var populatedSource = new TSource(); + PopulateWithTestValues(populatedSource, defaultSource); + foreach (var populator in _customPopulators) + populator(populatedSource); + + var defaultDest = mapper(defaultSource); + var populatedDest = mapper(populatedSource); + + var notMapped = typeof(TDest) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.CanRead && !_excludedDestProperties.Contains(p.Name)) + .Where(p => AreEquivalent(p.GetValue(defaultDest), p.GetValue(populatedDest))) + .Select(p => p.Name) + .ToList(); + + notMapped.Should().BeEmpty( + $"because these destination properties appear not to be mapped from the source: " + + $"{string.Join(", ", notMapped)}"); + } + + /// + /// Populates each property of with a non-default test value, + /// using to determine what the default value is. + /// + private static void PopulateWithTestValues(object target, object defaults) + { + foreach (var prop in target.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (!prop.CanRead) continue; + try + { + var defaultValue = prop.GetValue(defaults); + var targetValue = prop.GetValue(target); + ApplyTestValue(prop, target, targetValue, defaultValue); + } + catch + { + // Skip properties that cannot be safely read or written + } + } + } + + private static void ApplyTestValue(PropertyInfo prop, object target, object? targetValue, object? defaultValue) + { + var type = prop.PropertyType; + + // Mutable string-keyed dictionary: add a test entry + if (targetValue is IDictionary dict) + { + dict[$"test_key_{prop.Name}"] = $"test_val_{prop.Name}"; + return; + } + + // Mutable string collection: add a test item + if (targetValue is ICollection strColl) + { + strColl.Add($"test_{prop.Name}"); + return; + } + + // Collection of complex types with a parameterless constructor + if (TryGetCollectionItemType(type, out var itemType) && itemType!.IsClass && itemType != typeof(string)) + { + if (itemType.GetConstructor(Type.EmptyTypes) is not null) + { + var item = Activator.CreateInstance(itemType)!; + PopulateSimpleProperties(item); + + if (targetValue is not null) + { + // Try to add to the existing collection via reflection + var addMethod = targetValue.GetType().GetMethod("Add", [itemType]); + if (addMethod is not null) + { + addMethod.Invoke(targetValue, [item]); + return; + } + } + + // Collection is null or has no Add method: create a new List and assign it + if (prop.CanWrite) + { + var newList = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(itemType))!; + newList.Add(item); + prop.SetValue(target, newList); + } + return; + } + } + + // Scalar types: compute a new value and assign + if (!prop.CanWrite) return; + var newValue = ComputeScalarTestValue(type, prop.Name, defaultValue); + if (newValue is not null) + prop.SetValue(target, newValue); + } + + private static object? ComputeScalarTestValue(Type type, string name, object? defaultValue) + { + if (type == typeof(bool)) + return !(bool)(defaultValue ?? false); + + if (type == typeof(int)) + return (int)(defaultValue ?? 0) + 1000; + + if (type == typeof(int?)) + return (defaultValue as int? ?? 0) + 1000; + + if (type == typeof(string)) + return $"test_{name}"; + + if (type == typeof(DateTime)) + return DateTime.UtcNow.AddYears(10); + + if (type == typeof(DateTime?)) + return (DateTime?)DateTime.UtcNow.AddYears(10); + + if (type == typeof(TimeSpan)) + return TimeSpan.FromHours(99); + + if (type == typeof(TimeSpan?)) + return (TimeSpan?)TimeSpan.FromHours(99); + + if (type.IsEnum) + { + var values = Enum.GetValues(type).Cast().ToList(); + return values.FirstOrDefault(v => !v.Equals(defaultValue)) ?? defaultValue; + } + + return null; + } + + /// + /// Sets primitive-typed properties on to test values. + /// Used when creating instances of complex collection item types. + /// + private static void PopulateSimpleProperties(object item) + { + foreach (var prop in item.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (!prop.CanWrite) continue; + try + { + if (prop.PropertyType == typeof(string)) + prop.SetValue(item, $"test_{prop.Name}"); + else if (prop.PropertyType == typeof(int)) + prop.SetValue(item, 99); + else if (prop.PropertyType == typeof(bool)) + prop.SetValue(item, true); + } + catch { /* skip */ } + } + } + + private static bool TryGetCollectionItemType( + Type type, + [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Type? itemType) + { + itemType = null; + if (!type.IsGenericType) return false; + var def = type.GetGenericTypeDefinition(); + if (def != typeof(ICollection<>) && def != typeof(HashSet<>) && def != typeof(List<>)) return false; + itemType = type.GetGenericArguments()[0]; + return true; + } + + private static bool AreEquivalent(object? a, object? b) + { + if (a is null && b is null) return true; + if (a is null || b is null) return false; + + // Compare collections by item count + if (a is IEnumerable enumA && a is not string) + { + var countA = enumA.Cast().Count(); + var countB = (b as IEnumerable)?.Cast().Count() ?? -1; + return countA == countB; + } + + return a.Equals(b); + } +} diff --git a/src/EntityFramework.Storage/test/UnitTests/Mappers/PersistedGrantMappersTests.cs b/src/EntityFramework.Storage/test/UnitTests/Mappers/PersistedGrantMappersTests.cs index 50e148b1b..11c3e9adf 100644 --- a/src/EntityFramework.Storage/test/UnitTests/Mappers/PersistedGrantMappersTests.cs +++ b/src/EntityFramework.Storage/test/UnitTests/Mappers/PersistedGrantMappersTests.cs @@ -30,4 +30,21 @@ public void CanMap() mappedModel.ConsumedTime.Should().NotBeNull(); mappedModel.ConsumedTime.Value.Should().Be(new System.DateTime(2020, 02, 03, 4, 5, 6)); } -} \ No newline at end of file + + [Fact] + public void ToEntity_maps_all_properties() + { + new MappingVerifier() + .ExcludeDestinationProperties( + // Database-assigned or entity-managed fields not sourced from the model + nameof(Entities.PersistedGrant.Id)) + .Verify(model => model.ToEntity()); + } + + [Fact] + public void ToModel_maps_all_properties() + { + new MappingVerifier() + .Verify(entity => entity.ToModel()); + } +} diff --git a/src/EntityFramework.Storage/test/UnitTests/Mappers/ScopeMappersTests.cs b/src/EntityFramework.Storage/test/UnitTests/Mappers/ScopeMappersTests.cs index a58f19245..b53af4969 100644 --- a/src/EntityFramework.Storage/test/UnitTests/Mappers/ScopeMappersTests.cs +++ b/src/EntityFramework.Storage/test/UnitTests/Mappers/ScopeMappersTests.cs @@ -65,4 +65,25 @@ public void Properties_Map() mappedModel.Properties["x"].Should().Be("xx"); mappedModel.Properties["y"].Should().Be("yy"); } + + [Fact] + public void ToEntity_maps_all_properties() + { + new MappingVerifier() + .ExcludeDestinationProperties( + // Database-assigned or entity-managed fields not sourced from the model + nameof(Entities.ApiScope.Id), + nameof(Entities.ApiScope.Created), + nameof(Entities.ApiScope.Updated), + nameof(Entities.ApiScope.LastAccessed), + nameof(Entities.ApiScope.NonEditable)) + .Verify(model => model.ToEntity()); + } + + [Fact] + public void ToModel_maps_all_properties() + { + new MappingVerifier() + .Verify(entity => entity.ToModel()); + } } \ No newline at end of file