Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<Entities.ClientProperty>(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ApiResource, Entities.ApiResource>()
.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<Entities.ApiResource, ApiResource>()
.Verify(entity => entity.ToModel());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Client, Entities.Client>()
.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<Entities.Client, Client>()
.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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,24 @@ public void CanMapIdentityResources()
Assert.NotNull(mappedModel);
Assert.NotNull(mappedEntity);
}

[Fact]
public void ToEntity_maps_all_properties()
{
new MappingVerifier<IdentityResource, Entities.IdentityResource>()
.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<Entities.IdentityResource, IdentityResource>()
.Verify(entity => entity.ToModel());
}
}
241 changes: 241 additions & 0 deletions src/EntityFramework.Storage/test/UnitTests/Mappers/MappingVerifier.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Reflection-based helper that verifies all properties on a destination type are
/// populated by a mapping function, without maintaining explicit per-property assertions.
/// <para>
/// 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.
/// </para>
/// </summary>
internal sealed class MappingVerifier<TSource, TDest>
where TSource : new()
where TDest : new()
{
private readonly HashSet<string> _excludedDestProperties = [];
private readonly List<Action<TSource>> _customPopulators = [];

/// <summary>
/// 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.
/// </summary>
public MappingVerifier<TSource, TDest> ExcludeDestinationProperties(params string[] properties)
{
foreach (var p in properties)
_excludedDestProperties.Add(p);
return this;
}

/// <summary>
/// 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.
/// </summary>
public MappingVerifier<TSource, TDest> WithCustomPopulator(Action<TSource> populator)
{
_customPopulators.Add(populator);
return this;
}

/// <summary>
/// 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.
/// </summary>
public void Verify(Func<TSource, TDest> 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)}");
}

/// <summary>
/// Populates each property of <paramref name="target"/> with a non-default test value,
/// using <paramref name="defaults"/> to determine what the default value is.
/// </summary>
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<string, string> dict)
{
dict[$"test_key_{prop.Name}"] = $"test_val_{prop.Name}";
return;
}

// Mutable string collection: add a test item
if (targetValue is ICollection<string> 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<T> 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<object>().ToList();
return values.FirstOrDefault(v => !v.Equals(defaultValue)) ?? defaultValue;
}

return null;
}

/// <summary>
/// Sets primitive-typed properties on <paramref name="item"/> to test values.
/// Used when creating instances of complex collection item types.
/// </summary>
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<object>().Count();
var countB = (b as IEnumerable)?.Cast<object>().Count() ?? -1;
return countA == countB;
}

return a.Equals(b);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}

[Fact]
public void ToEntity_maps_all_properties()
{
new MappingVerifier<PersistedGrant, Entities.PersistedGrant>()
.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<Entities.PersistedGrant, PersistedGrant>()
.Verify(entity => entity.ToModel());
}
}
Loading
Loading