diff --git a/src/Core/ExistForAll.SimpleSettings.Extensions.Binders/ConfigurationBinder.cs b/src/Core/ExistForAll.SimpleSettings.Extensions.Binders/ConfigurationBinder.cs index 119747b..88d9a6a 100644 --- a/src/Core/ExistForAll.SimpleSettings.Extensions.Binders/ConfigurationBinder.cs +++ b/src/Core/ExistForAll.SimpleSettings.Extensions.Binders/ConfigurationBinder.cs @@ -1,4 +1,5 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; +using ExistForAll.SimpleSettings.Core.Reflection; using Microsoft.Extensions.Configuration; namespace ExistForAll.SimpleSettings.Binders @@ -29,12 +30,50 @@ public void BindPropertySettings(BindingContext context) // capturing lambda would build a fresh delegate on every call and eat most of the win. var section = _sections.GetOrAdd(context.Section, static (name, self) => self.ResolveSection(name), this); + var isCollection = context.PropertyType.IsCollectionShape(); + + if (isCollection && TrySetChildSequence(section, context)) + return; + var value = section[context.Key]; + if (isCollection) + { + if (!string.IsNullOrWhiteSpace(value)) + context.SetNewValue(value); + + return; + } + if (value != null) context.SetNewValue(value); } + // Child-section sequence path (COLL-03): a collection property may be an indexed sub-section + // (Key:0, Key:1, ...) rather than a delimited scalar. Enumerate the live config view ONCE (a second + // pass could race a reload), collecting non-empty element values into a string[] so the element + // converter chain runs per item. Empty entries are dropped for parity with the comma-scalar split + // (RemoveEmptyEntries). Children win over the scalar; an all-empty sequence falls through to the scalar + // path. No element value is placed in any exception (S1). + private static bool TrySetChildSequence(IConfigurationSection section, BindingContext context) + { + var childSection = section.GetSection(context.Key); + + List? values = null; + foreach (var child in childSection.GetChildren()) + { + var childValue = child.Value; + if (!string.IsNullOrEmpty(childValue)) + (values ??= new List()).Add(childValue); + } + + if (values is null) + return false; + + context.SetNewValue(values.ToArray()); + return true; + } + private IConfigurationSection ResolveSection(string section) { return _configuration.GetSection(string.IsNullOrWhiteSpace(RootSection) ? section : $"{RootSection}:{section}"); diff --git a/src/Core/ExistForAll.SimpleSettings/Conversion/CollectionTypeConverter.cs b/src/Core/ExistForAll.SimpleSettings/Conversion/CollectionTypeConverter.cs index 302a068..d03276a 100644 --- a/src/Core/ExistForAll.SimpleSettings/Conversion/CollectionTypeConverter.cs +++ b/src/Core/ExistForAll.SimpleSettings/Conversion/CollectionTypeConverter.cs @@ -22,7 +22,7 @@ protected CollectionTypeConverter(SettingsOptions settingsOptions, TypeConverter // The element type to convert each item to: the array's element type, or the IEnumerable argument. protected abstract Type GetElementType(Type settingsType); - public object Convert(object value, Type settingsType) + public virtual object Convert(object value, Type settingsType) { var elementType = GetElementType(settingsType); var source = AsArray(value); diff --git a/src/Core/ExistForAll.SimpleSettings/Conversion/ListTypeConverter.cs b/src/Core/ExistForAll.SimpleSettings/Conversion/ListTypeConverter.cs new file mode 100644 index 0000000..b6b8a93 --- /dev/null +++ b/src/Core/ExistForAll.SimpleSettings/Conversion/ListTypeConverter.cs @@ -0,0 +1,56 @@ +using System.Collections.Concurrent; +using System.Reflection; +using ExistForAll.SimpleSettings.Core.Reflection; + +namespace ExistForAll.SimpleSettings.Conversion +{ + // Handles the List family (List/IList/ICollection/IReadOnlyList/IReadOnlyCollection) by reusing the + // base array-build path and copying the result into a List. Materialization uses a cached per-element + // delegate (built once, invoked on every populate) so the warm path carries no MakeGenericType/Activator + // reflection. + internal class ListTypeConverter : CollectionTypeConverter + { + private static readonly MethodInfo CreateListMethod = + typeof(ListTypeConverter).GetMethod(nameof(CreateList), BindingFlags.NonPublic | BindingFlags.Static)!; + + private static readonly ConcurrentDictionary> ListFactories = new(); + + public ListTypeConverter(SettingsOptions settingsOptions, TypeConvertersCollections converters) + : base(settingsOptions, converters) + { + } + + public override bool CanConvert(Type settingsType) + { + return settingsType.IsListLike(); + } + + protected override Type GetElementType(Type settingsType) + { + return settingsType.GetTypeInfo().GetGenericArguments()[0]; + } + + public override object Convert(object value, Type settingsType) + { + var elementType = GetElementType(settingsType); + var builtArray = (Array)base.Convert(value, settingsType); + var factory = ListFactories.GetOrAdd(elementType, BuildFactory); + + return factory(builtArray); + } + + private static Func BuildFactory(Type elementType) + { + var closed = CreateListMethod.MakeGenericMethod(elementType); + + return (Func)closed.CreateDelegate(typeof(Func)); + } + + // The source is the freshly built T[] (ICollection), so new List(source) copies into a + // right-sized buffer rather than aliasing the array. + private static object CreateList(Array source) + { + return new List((TElement[])source); + } + } +} diff --git a/src/Core/ExistForAll.SimpleSettings/Conversion/PropertyConversion.cs b/src/Core/ExistForAll.SimpleSettings/Conversion/PropertyConversion.cs index 30232e6..3d544dc 100644 --- a/src/Core/ExistForAll.SimpleSettings/Conversion/PropertyConversion.cs +++ b/src/Core/ExistForAll.SimpleSettings/Conversion/PropertyConversion.cs @@ -30,12 +30,19 @@ public PropertyConversion(ISettingsTypeConverter converter, { if (value == null) { + // AllowEmpty=false rejects a missing required value, value-free. if (_throwOnNull) throw new SettingsPropertyNullException(_propertyName); - return _nullResult; + // A list null-result is a factory so each bind gets a fresh mutable List; arrays / + // IEnumerable / value-type defaults stay on the shared cached instance. + return _nullResult is Func listFactory ? listFactory() : _nullResult; } + // AllowEmpty=false also rejects empty/whitespace strings, not just null. + if (_throwOnNull && value is string s && string.IsNullOrWhiteSpace(s)) + throw new SettingsPropertyNullException(_propertyName); + return _converter.Convert(value, _strippedType); } } diff --git a/src/Core/ExistForAll.SimpleSettings/Conversion/TypeConvertersCollections.cs b/src/Core/ExistForAll.SimpleSettings/Conversion/TypeConvertersCollections.cs index d64747f..bb74968 100644 --- a/src/Core/ExistForAll.SimpleSettings/Conversion/TypeConvertersCollections.cs +++ b/src/Core/ExistForAll.SimpleSettings/Conversion/TypeConvertersCollections.cs @@ -8,6 +8,7 @@ public TypeConvertersCollections(SettingsOptions settingsOptions) AddLast(new UriTypeConvertor()); AddLast(new ArrayTypeConverter(settingsOptions, this)); AddLast(new EnumerableTypeConverter(settingsOptions, this)); + AddLast(new ListTypeConverter(settingsOptions, this)); AddLast(new EnumTypeConverter()); AddLast(new DefaultTypeConverter()); } diff --git a/src/Core/ExistForAll.SimpleSettings/Core/Reflection/TypeConverter.cs b/src/Core/ExistForAll.SimpleSettings/Core/Reflection/TypeConverter.cs index 663338f..f9778a1 100644 --- a/src/Core/ExistForAll.SimpleSettings/Core/Reflection/TypeConverter.cs +++ b/src/Core/ExistForAll.SimpleSettings/Core/Reflection/TypeConverter.cs @@ -22,17 +22,34 @@ public PropertyConversion CreateConversion(PropertyInfo propertyInfo, SettingsPr return new PropertyConversion(converter, strippedType, throwOnNull, nullResult, propertyInfo.Name); } - // The value a null bound value converts to: an empty sequence for IEnumerable, a default instance - // for a value type, otherwise null. Constant per property, so it is materialized once at plan build. + private static readonly MethodInfo EmptyListFactoryMethod = + typeof(TypeConverter).GetMethod(nameof(CreateEmptyList), BindingFlags.NonPublic | BindingFlags.Static)!; + + // The value a null bound value converts to per shape: a shared empty array for arrays and IEnumerable, + // a fresh-per-bind empty List for the List family (a mutable list must not be shared), a + // default instance for a value type, otherwise null. Resolved once at plan build; the list branch bakes a + // factory delegate (cold reflection here, plain new List() per call) rather than a shared instance. private static object? CreateNullResult(Type propertyType) { - if (!propertyType.IsEnumerable()) - return propertyType.GetTypeInfo().IsValueType ? Activator.CreateInstance(propertyType) : null; + if (propertyType.IsArray) + return Array.CreateInstance(propertyType.GetElementType()!, 0); + + if (propertyType.IsEnumerable()) + return Array.CreateInstance(propertyType.GetTypeInfo().GetGenericArguments()[0], 0); + + if (propertyType.IsListLike()) + { + var elementType = propertyType.GetTypeInfo().GetGenericArguments()[0]; + return (Func)EmptyListFactoryMethod.MakeGenericMethod(elementType) + .CreateDelegate(typeof(Func)); + } + + return propertyType.GetTypeInfo().IsValueType ? Activator.CreateInstance(propertyType) : null; + } - // An empty IEnumerable is just an empty T[] (arrays implement IEnumerable). Array.CreateInstance - // replaces the old Enumerable.Empty() built via GetMethod("Empty").MakeGenericMethod().Invoke(). - var elementType = propertyType.GetTypeInfo().GetGenericArguments()[0]; - return Array.CreateInstance(elementType, 0); + private static object CreateEmptyList() + { + return new List(); } private static ISettingsTypeConverter GetConverter(Type strippedType, diff --git a/src/Core/ExistForAll.SimpleSettings/Core/Reflection/TypeExtensions.cs b/src/Core/ExistForAll.SimpleSettings/Core/Reflection/TypeExtensions.cs index 1d6db31..3fd4f67 100644 --- a/src/Core/ExistForAll.SimpleSettings/Core/Reflection/TypeExtensions.cs +++ b/src/Core/ExistForAll.SimpleSettings/Core/Reflection/TypeExtensions.cs @@ -24,5 +24,27 @@ public static bool IsEnumerable(this Type type) return info.IsGenericType && info.GetGenericTypeDefinition() == typeof(IEnumerable<>); } + + // Disjoint from IsEnumerable/IsArray: matches only the List family the ListTypeConverter claims. + public static bool IsListLike(this Type type) + { + var info = type.GetTypeInfo(); + + if (!info.IsGenericType) + return false; + + var definition = info.GetGenericTypeDefinition(); + + return definition == typeof(List<>) + || definition == typeof(IList<>) + || definition == typeof(ICollection<>) + || definition == typeof(IReadOnlyList<>) + || definition == typeof(IReadOnlyCollection<>); + } + + public static bool IsCollectionShape(this Type type) + { + return type.IsArray || type.IsEnumerable() || type.IsListLike(); + } } } \ No newline at end of file diff --git a/src/Core/ExistForAll.SimpleSettings/Info.cs b/src/Core/ExistForAll.SimpleSettings/Info.cs index 4f8341b..75c27cd 100644 --- a/src/Core/ExistForAll.SimpleSettings/Info.cs +++ b/src/Core/ExistForAll.SimpleSettings/Info.cs @@ -2,3 +2,4 @@ [assembly:InternalsVisibleTo("ExistForAll.SimpleSettings.UnitTests")] [assembly:InternalsVisibleTo("ExistForAll.SimpleSettings.Benchmark")] +[assembly:InternalsVisibleTo("ExistForAll.SimpleSettings.Binders")] diff --git a/src/Core/ExistForAll.SimpleSettings/Resources.cs b/src/Core/ExistForAll.SimpleSettings/Resources.cs index 69f9dd6..caf1366 100644 --- a/src/Core/ExistForAll.SimpleSettings/Resources.cs +++ b/src/Core/ExistForAll.SimpleSettings/Resources.cs @@ -1,4 +1,6 @@ using System.Reflection; +using System.Text; +using ExistForAll.SimpleSettings.Validations; namespace ExistForAll.SimpleSettings { @@ -40,7 +42,7 @@ public static string SettingsPropertiesExtractionMessage(Type type) => all properties from type [{type.FullName}]"; public static string PropertyNotAllowNullMessage(string propertyName) => - $@"[{propertyName}] is marked as Null not allowed, yet the value is null. please provide value via binder or attribute"; + $@"[{propertyName}] is marked as Null not allowed, yet the value is missing (null, empty, or whitespace). please provide value via binder or attribute"; public static string TypeIsNotInterface(string typeName) => $@"[{typeName}] is not an interface, SimpleSettings supports only interfaces"; @@ -48,5 +50,27 @@ public static string TypeIsNotInterface(string typeName) => public static string SettingsOptionAttributeTypeMessage(Type type) => $"SimpleSettings support Attribute indication of interfaces, the type provided [{type.FullName}] is not an attribute."; + // Composed ONLY from author-supplied ValidationError text (SettingsName + ErrorMessage). The inspected + // settings values are deliberately never embedded — a validator may run against a secret, and this + // message reaches logs via Exception.ToString(). See D-12/S1 and SettingsValidationException. + public static string SettingsValidationExceptionMessage(IReadOnlyList errors) + { + var builder = new StringBuilder(); + builder.Append("Settings validation failed with ").Append(errors.Count).Append(" error(s):"); + + for (var i = 0; i < errors.Count; i++) + { + var error = errors[i]; + builder.Append(Environment.NewLine).Append(" - [").Append(error.SettingsName).Append("] ").Append(error.ErrorMessage); + } + + return builder.ToString(); + } + + // Value-free like the rest of the family: names only the validator and the failure type, never the + // settings value the validator inspected (which may be a secret). See S1. + public static string SettingsValidatorInvocationExceptionMessage(Type validatorType, Type failureType) => + $"Validator [{validatorType.Name}] threw [{failureType.Name}] while validating. The settings value is omitted to avoid leaking secrets into logs."; + } } \ No newline at end of file diff --git a/src/Core/ExistForAll.SimpleSettings/SettingsPlan.cs b/src/Core/ExistForAll.SimpleSettings/SettingsPlan.cs index 0dad595..0509e85 100644 --- a/src/Core/ExistForAll.SimpleSettings/SettingsPlan.cs +++ b/src/Core/ExistForAll.SimpleSettings/SettingsPlan.cs @@ -13,11 +13,24 @@ internal sealed class SettingsPlan private readonly SettingsOptions _options; private string? _sectionName; - public SettingsPlan(Type settingsType, SettingsOptions options, PropertyPlan[] properties) + public SettingsPlan(Type settingsType, SettingsOptions options, PropertyPlan[] properties, Type? objectValidatorType) { _settingsType = settingsType; _options = options; Properties = properties; + ObjectValidatorType = objectValidatorType; + HasValidators = objectValidatorType is not null || AnyPropertyHasValidator(properties); + } + + private static bool AnyPropertyHasValidator(PropertyPlan[] properties) + { + for (var i = 0; i < properties.Length; i++) + { + if (properties[i].ValidatorType is not null) + return true; + } + + return false; } // Resolved lazily and cached: a builder with no binders (e.g. the cold assembly scan) never reads it, @@ -26,18 +39,27 @@ public SettingsPlan(Type settingsType, SettingsOptions options, PropertyPlan[] p public string SectionName => _sectionName ??= _settingsType.GetSectionName(_options); public PropertyPlan[] Properties { get; } + + // The object-level validator ([SettingsSection].ValidatorType) declared on the settings interface, + // resolved once at plan build (null when none is declared). + public Type? ObjectValidatorType { get; } + + // Computed once at plan build so the post-populate hook can short-circuit with a single field read + // before allocating anything on the validator-free warm path. + public bool HasValidators { get; } } // A readonly struct held inline in SettingsPlan.Properties: the whole per-type plan is then one array // allocation plus the section string, rather than an object per property (matters at scan scale). internal readonly struct PropertyPlan { - public PropertyPlan(PropertyInfo property, string key, object? defaultValue, PropertyConversion conversion) + public PropertyPlan(PropertyInfo property, string key, object? defaultValue, PropertyConversion conversion, Type? validatorType) { Property = property; Key = key; DefaultValue = defaultValue; Conversion = conversion; + ValidatorType = validatorType; } public PropertyInfo Property { get; } @@ -47,5 +69,8 @@ public PropertyPlan(PropertyInfo property, string key, object? defaultValue, Pro public object? DefaultValue { get; } public PropertyConversion Conversion { get; } + + // The [SettingsProperty(ValidatorType)] validator for this property, resolved once at plan build. + public Type? ValidatorType { get; } } } diff --git a/src/Core/ExistForAll.SimpleSettings/SettingsSectionAttribute.cs b/src/Core/ExistForAll.SimpleSettings/SettingsSectionAttribute.cs index 50de319..472ce22 100644 --- a/src/Core/ExistForAll.SimpleSettings/SettingsSectionAttribute.cs +++ b/src/Core/ExistForAll.SimpleSettings/SettingsSectionAttribute.cs @@ -5,11 +5,12 @@ public class SettingsSectionAttribute : Attribute { public string? Name { get; set; } + public Type? ValidatorType { get; set; } + public SettingsSectionAttribute() { - } - + public SettingsSectionAttribute(string name) { Name = name; diff --git a/src/Core/ExistForAll.SimpleSettings/SettingsValidationException.cs b/src/Core/ExistForAll.SimpleSettings/SettingsValidationException.cs new file mode 100644 index 0000000..eded80b --- /dev/null +++ b/src/Core/ExistForAll.SimpleSettings/SettingsValidationException.cs @@ -0,0 +1,34 @@ +using ExistForAll.SimpleSettings.Validations; + +namespace ExistForAll.SimpleSettings +{ + // Aggregates every ValidationError produced by the declared validators into one exception. Like the rest of + // the family it embeds NO bound value: the message is composed only from author-supplied ValidationError text + // (SettingsName + ErrorMessage), never from the settings values the validators inspected (which may be + // secrets). See D-12/S1 and SettingsPropertyValueException. The aggregate-and-throw is centralized in + // ThrowIfAny so the core populate path and the DI-resolved path share one thrown contract. + public class SettingsValidationException : SimpleSettingsException + { + public SettingsValidationException(IReadOnlyList errors) + : base(Resources.SettingsValidationExceptionMessage(errors)) + { + Errors = errors; + } + + public IReadOnlyList Errors { get; } + + // The single aggregate-and-throw entry point shared by the core populate path (Plan 03) and the + // DI-resolved runner (Plan 04): throws one aggregated exception when any error is present, otherwise + // returns. Centralizing it keeps the thrown contract (type + Errors + value-free message) identical + // across both paths. + public static void ThrowIfAny(IReadOnlyList errors) + { + if (errors == null) throw new ArgumentNullException(nameof(errors)); + + if (errors.Count == 0) + return; + + throw new SettingsValidationException(errors); + } + } +} diff --git a/src/Core/ExistForAll.SimpleSettings/SettingsValidatorInvocationException.cs b/src/Core/ExistForAll.SimpleSettings/SettingsValidatorInvocationException.cs new file mode 100644 index 0000000..28049e3 --- /dev/null +++ b/src/Core/ExistForAll.SimpleSettings/SettingsValidatorInvocationException.cs @@ -0,0 +1,19 @@ +namespace ExistForAll.SimpleSettings +{ + // A declared validator threw instead of returning a ValidationResult. Value-free like the rest of the + // family: carries only the validator and failure types — never the inspected settings value (which may be a + // secret) and never the chained inner. See S1 and SettingsValidationException. + public class SettingsValidatorInvocationException : SimpleSettingsException + { + public SettingsValidatorInvocationException(Type validatorType, Type failureType) + : base(Resources.SettingsValidatorInvocationExceptionMessage(validatorType, failureType)) + { + ValidatorType = validatorType; + FailureType = failureType; + } + + public Type ValidatorType { get; } + + public Type FailureType { get; } + } +} diff --git a/src/Core/ExistForAll.SimpleSettings/Validations/ISettingValidation.cs b/src/Core/ExistForAll.SimpleSettings/Validations/ISettingValidation.cs index 293f9e1..362d817 100644 --- a/src/Core/ExistForAll.SimpleSettings/Validations/ISettingValidation.cs +++ b/src/Core/ExistForAll.SimpleSettings/Validations/ISettingValidation.cs @@ -1,7 +1,12 @@ -namespace ExistForAll.SimpleSettings.Validations -{ - public interface ISettingValidation : ISettingsValidator - { - Task Validate(ValidationContext context); - } -} \ No newline at end of file +namespace ExistForAll.SimpleSettings.Validations +{ + public interface ISettingValidation : ISettingsValidator + { + ValidationResult Validate(ValidationContext context); + + // Bridges the non-generic base call to the generic overload so callers dispatch via a plain + // ISettingsValidator.Validate cast — no reflection. Authors implement only the generic Validate. + ValidationResult ISettingsValidator.Validate(ValidationContext context) + => Validate(new ValidationContext((T)context.Settings!)); + } +} diff --git a/src/Core/ExistForAll.SimpleSettings/Validations/ISettingsValidator.cs b/src/Core/ExistForAll.SimpleSettings/Validations/ISettingsValidator.cs index c4f6953..5dd9859 100644 --- a/src/Core/ExistForAll.SimpleSettings/Validations/ISettingsValidator.cs +++ b/src/Core/ExistForAll.SimpleSettings/Validations/ISettingsValidator.cs @@ -1,7 +1,7 @@ -namespace ExistForAll.SimpleSettings.Validations -{ - public interface ISettingsValidator - { - Task Validate(ValidationContext context); - } -} \ No newline at end of file +namespace ExistForAll.SimpleSettings.Validations +{ + public interface ISettingsValidator + { + ValidationResult Validate(ValidationContext context); + } +} diff --git a/src/Core/ExistForAll.SimpleSettings/Validations/ValidationContext.cs b/src/Core/ExistForAll.SimpleSettings/Validations/ValidationContext.cs index 97c4e2d..b60f217 100644 --- a/src/Core/ExistForAll.SimpleSettings/Validations/ValidationContext.cs +++ b/src/Core/ExistForAll.SimpleSettings/Validations/ValidationContext.cs @@ -1,7 +1,12 @@ -namespace ExistForAll.SimpleSettings.Validations -{ - public class ValidationContext - { - public object? Settings { get; } - } -} \ No newline at end of file +namespace ExistForAll.SimpleSettings.Validations +{ + public class ValidationContext + { + public ValidationContext(object? settings) + { + Settings = settings; + } + + public object? Settings { get; } + } +} diff --git a/src/Core/ExistForAll.SimpleSettings/Validations/ValidationContextOfT.cs b/src/Core/ExistForAll.SimpleSettings/Validations/ValidationContextOfT.cs index ffe8878..3e61f6f 100644 --- a/src/Core/ExistForAll.SimpleSettings/Validations/ValidationContextOfT.cs +++ b/src/Core/ExistForAll.SimpleSettings/Validations/ValidationContextOfT.cs @@ -1,7 +1,13 @@ -namespace ExistForAll.SimpleSettings.Validations -{ - public class ValidationContext : ValidationContext - { - public new T? Settings { get; } - } -} \ No newline at end of file +namespace ExistForAll.SimpleSettings.Validations +{ + public class ValidationContext : ValidationContext + { + public ValidationContext(T settings) + : base(settings) + { + Settings = settings; + } + + public new T? Settings { get; } + } +} diff --git a/src/Core/ExistForAll.SimpleSettings/ValuesPopulator.cs b/src/Core/ExistForAll.SimpleSettings/ValuesPopulator.cs index 9d366bd..a39dff4 100644 --- a/src/Core/ExistForAll.SimpleSettings/ValuesPopulator.cs +++ b/src/Core/ExistForAll.SimpleSettings/ValuesPopulator.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Reflection; using ExistForAll.SimpleSettings.Core.Reflection; +using ExistForAll.SimpleSettings.Validations; namespace ExistForAll.SimpleSettings { @@ -62,6 +63,64 @@ public void PopulateInstanceWithValues(object instance, var propertyValue = ConvertPropertyValue(settings, tempValue, propertyPlan); propertyPlan.Property.SetValue(instance, propertyValue); } + + // Runs AFTER the property-set loop so cross-property rules see the fully-populated instance. + RunValidators(instance, plan); + } + + // Zero-allocation short-circuit for validator-free types: a single field read returns before anything is + // allocated, keeping the warm populate path byte-identical to the pre-validation build. The error list + // is allocated lazily only after a validator actually produces an error. + private static void RunValidators(object instance, SettingsPlan plan) + { + if (!plan.HasValidators) + return; + + List? errors = null; + + if (plan.ObjectValidatorType is not null) + errors = InvokeValidator(plan.ObjectValidatorType, instance, errors); + + foreach (var propertyPlan in plan.Properties) + { + if (propertyPlan.ValidatorType is null) + continue; + + var propertyValue = propertyPlan.Property.GetValue(instance); + errors = InvokeValidator(propertyPlan.ValidatorType, propertyValue, errors); + } + + // The single aggregate-and-throw entry point shared with the DI-resolved runner: the + // thrown contract cannot drift between the two paths. + if (errors is not null) + SettingsValidationException.ThrowIfAny(errors); + } + + private static List? InvokeValidator(Type validatorType, object? target, List? errors) + { + ValidationResult result; + try + { + // The validator type comes from a compile-time attribute; instantiate it and dispatch through the + // non-generic ISettingsValidator.Validate. ISettingValidation's default implementation forwards + // to the author's generic overload, so no reflection over the Validate methods is needed. + var validator = (ISettingsValidator)Activator.CreateInstance(validatorType)!; + result = validator.Validate(new ValidationContext(target)); + } + catch (Exception e) + { + // A validator threw instead of returning a result. Surface value-free: the inner may embed a + // secret the validator read, so it is never chained and the value never enters the message. See S1. + throw new SettingsValidatorInvocationException(validatorType, e.GetType()); + } + + foreach (var error in result.Errors) + { + errors ??= new List(); + errors.Add(error); + } + + return errors; } private SettingsPlan GetOrBuildPlan(Type settings, SettingsOptions options) @@ -90,7 +149,8 @@ private SettingsPlan GetOrBuildPlan(Type settings, SettingsOptions options) propertyPlans[i] = new PropertyPlan(property, key, attribute?.DefaultValue, - _typeConverter.CreateConversion(property, attribute, options)); + _typeConverter.CreateConversion(property, attribute, options), + attribute?.ValidatorType); } catch (Exception e) { @@ -102,7 +162,12 @@ private SettingsPlan GetOrBuildPlan(Type settings, SettingsOptions options) } } - var plan = new SettingsPlan(settings, options, propertyPlans); + // Read the object-level validator ([SettingsSection].ValidatorType) once per type at plan build, + // mirroring the per-property attribute read above — never on the hot populate path. + var objectValidatorType = settings.GetTypeInfo() + .GetCustomAttribute(inherit: true)?.ValidatorType; + + var plan = new SettingsPlan(settings, options, propertyPlans, objectValidatorType); // Concurrent builds would produce equivalent plans, so last-writer-wins is harmless. A build that // throws is never cached (we don't reach here), so the next call retries — matching the old diff --git a/src/SimpleSettings.slnx b/src/SimpleSettings.slnx index e96c1d8..8c36805 100644 --- a/src/SimpleSettings.slnx +++ b/src/SimpleSettings.slnx @@ -14,6 +14,7 @@ + diff --git a/src/Tests/ExistForAll.SimpleSettings.UnitTests.Fixtures/ExistForAll.SimpleSettings.UnitTests.Fixtures.csproj b/src/Tests/ExistForAll.SimpleSettings.UnitTests.Fixtures/ExistForAll.SimpleSettings.UnitTests.Fixtures.csproj new file mode 100644 index 0000000..84edc35 --- /dev/null +++ b/src/Tests/ExistForAll.SimpleSettings.UnitTests.Fixtures/ExistForAll.SimpleSettings.UnitTests.Fixtures.csproj @@ -0,0 +1,9 @@ + + + net8.0;net10.0 + false + + + + + diff --git a/src/Tests/ExistForAll.SimpleSettings.UnitTests.Fixtures/ValidationFixtures.cs b/src/Tests/ExistForAll.SimpleSettings.UnitTests.Fixtures/ValidationFixtures.cs new file mode 100644 index 0000000..300e44d --- /dev/null +++ b/src/Tests/ExistForAll.SimpleSettings.UnitTests.Fixtures/ValidationFixtures.cs @@ -0,0 +1,136 @@ +using ExistForAll.SimpleSettings.Validations; + +namespace ExistForAll.SimpleSettings.UnitTests.Fixtures +{ + // Separate, never-scanned assembly so these deliberately-failing validators dodge the whole-assembly ScanAssemblies tests. + + [SettingsSection(ValidatorType = typeof(FailingObjectValidator))] + public interface IObjectValidated + { + string Name { get; set; } + } + + [SettingsSection(ValidatorType = typeof(PassingObjectValidator))] + public interface IObjectValid + { + string Name { get; set; } + } + + public interface IPropertyValidated + { + [SettingsProperty(ValidatorType = typeof(PositivePortValidator))] + int Port { get; set; } + } + + [SettingsSection(ValidatorType = typeof(TwoErrorValidator))] + public interface ITwoErrors + { + string Name { get; set; } + } + + [SettingsSection(ValidatorType = typeof(BothObjectValidator))] + public interface IBothValidated + { + [SettingsProperty(ValidatorType = typeof(PositivePortValidator))] + int Port { get; set; } + } + + [SettingsSection(ValidatorType = typeof(CrossPropertyValidator))] + public interface ICrossProperty + { + int Min { get; set; } + int Max { get; set; } + } + + [SettingsSection(ValidatorType = typeof(RedactionValidator))] + public interface IRedaction + { + string ApiKey { get; set; } + } + + [SettingsSection(ValidatorType = typeof(ThrowingValidator))] + public interface IThrowing + { + string ApiKey { get; set; } + } + + public interface INoValidators + { + string Name { get; set; } + } + + public class FailingObjectValidator : ISettingValidation + { + public ValidationResult Validate(ValidationContext context) + { + var result = new ValidationResult(); + result.AddError(new ValidationError(nameof(IObjectValidated.Name), "name is invalid")); + return result; + } + } + + public class PassingObjectValidator : ISettingValidation + { + public ValidationResult Validate(ValidationContext context) => new(); + } + + public class PositivePortValidator : ISettingValidation + { + public ValidationResult Validate(ValidationContext context) + { + var result = new ValidationResult(); + if (context.Settings <= 0) + result.AddError(new ValidationError("Port", "port must be positive")); + return result; + } + } + + public class TwoErrorValidator : ISettingValidation + { + public ValidationResult Validate(ValidationContext context) + { + var result = new ValidationResult(); + result.AddError(new ValidationError("Name", "first failure")); + result.AddError(new ValidationError("Name", "second failure")); + return result; + } + } + + public class BothObjectValidator : ISettingValidation + { + public ValidationResult Validate(ValidationContext context) + { + var result = new ValidationResult(); + result.AddError(new ValidationError("Object", "object-level failure")); + return result; + } + } + + public class CrossPropertyValidator : ISettingValidation + { + public ValidationResult Validate(ValidationContext context) + { + var result = new ValidationResult(); + var settings = context.Settings!; + if (settings.Min > settings.Max) + result.AddError(new ValidationError("Min", "Min must not exceed Max")); + return result; + } + } + + public class RedactionValidator : ISettingValidation + { + public ValidationResult Validate(ValidationContext context) + { + var result = new ValidationResult(); + result.AddError(new ValidationError("ApiKey", "ApiKey failed policy")); + return result; + } + } + + public class ThrowingValidator : ISettingValidation + { + public ValidationResult Validate(ValidationContext context) + => throw new InvalidOperationException($"boom with {context.Settings!.ApiKey}"); + } +} diff --git a/src/Tests/ExistForAll.SimpleSettings.UnitTests/ConfigBuilderConfigurationBinderIntegrationTests.cs b/src/Tests/ExistForAll.SimpleSettings.UnitTests/ConfigBuilderConfigurationBinderIntegrationTests.cs index 94bb1ff..a1fde01 100644 --- a/src/Tests/ExistForAll.SimpleSettings.UnitTests/ConfigBuilderConfigurationBinderIntegrationTests.cs +++ b/src/Tests/ExistForAll.SimpleSettings.UnitTests/ConfigBuilderConfigurationBinderIntegrationTests.cs @@ -1,3 +1,4 @@ +using System.Linq; using ExistForAll.SimpleSettings.Binder; using ExistForAll.SimpleSettings.Binders; using Microsoft.Extensions.Configuration; @@ -78,6 +79,123 @@ public async Task Build_WhenMemoryBinderSetValues_ShouldSetProperData() await Assert.That(settings.Value).IsEqualTo(value); } + [Test] + public async Task Bind_ChildSequence_ToStringArray_BindsEachElementInOrder() + { + var settings = BindSequence(new Dictionary + { + ["SequenceStringSetting:Values:0"] = "a", + ["SequenceStringSetting:Values:1"] = "b", + ["SequenceStringSetting:Values:2"] = "c", + }); + + await Assert.That(settings.Values.SequenceEqual(new[] { "a", "b", "c" })).IsTrue(); + } + + [Test] + public async Task Bind_ChildSequence_ToIntArray_BindsEachElementInOrder() + { + var settings = BindSequence(new Dictionary + { + ["SequenceIntSetting:Numbers:0"] = "1", + ["SequenceIntSetting:Numbers:1"] = "2", + ["SequenceIntSetting:Numbers:2"] = "3", + }); + + await Assert.That(settings.Numbers.SequenceEqual(new[] { 1, 2, 3 })).IsTrue(); + } + + [Test] + public async Task Bind_ChildSequence_WithEmptyElements_DropsThemLikeCommaScalar() + { + // Empty entries are dropped for parity with the comma-scalar RemoveEmptyEntries, so an interspersed + // empty element does not crash the int[] bind (review MED-2). + var settings = BindSequence(new Dictionary + { + ["SequenceIntSetting:Numbers:0"] = "1", + ["SequenceIntSetting:Numbers:1"] = "", + ["SequenceIntSetting:Numbers:2"] = "3", + }); + + await Assert.That(settings.Numbers.SequenceEqual(new[] { 1, 3 })).IsTrue(); + } + + [Test] + public async Task Bind_ChildSequence_ToStringList_BindsEachElementInOrder() + { + var settings = BindSequence(new Dictionary + { + ["SequenceStringListSetting:Items:0"] = "x", + ["SequenceStringListSetting:Items:1"] = "y", + }); + + await Assert.That(settings.Items.SequenceEqual(new List { "x", "y" })).IsTrue(); + } + + [Test] + public async Task Bind_ScalarAndChildren_ChildrenWin() + { + var settings = BindSequence(new Dictionary + { + ["SequenceStringSetting:Values"] = "scalar-should-lose", + ["SequenceStringSetting:Values:0"] = "a", + ["SequenceStringSetting:Values:1"] = "b", + }); + + await Assert.That(settings.Values.SequenceEqual(new[] { "a", "b" })).IsTrue(); + } + + [Test] + public async Task Bind_CommaScalar_NoChildren_StillBinds() + { + var settings = BindSequence(new Dictionary + { + ["SequenceStringSetting:Values"] = "a,b,c", + }); + + await Assert.That(settings.Values.SequenceEqual(new[] { "a", "b", "c" })).IsTrue(); + } + + [Test] + public async Task Bind_WhitespaceScalar_ToStringArray_BindsEmpty() + { + var settings = BindSequence(new Dictionary + { + ["SequenceStringSetting:Values"] = " ", + }); + + await Assert.That(settings.Values.Length).IsEqualTo(0); + } + + [Test] + public async Task Bind_EmptySequence_BindsEmpty() + { + var settings = BindSequence(new Dictionary()); + + await Assert.That(settings.Values.Length).IsEqualTo(0); + } + + [Test] + public async Task Bind_ChildSequence_WithRootSection_ResolvesUnderPrefix() + { + var settings = BindSequence(new Dictionary + { + ["MyRoot:SequenceStringSetting:Values:0"] = "a", + ["MyRoot:SequenceStringSetting:Values:1"] = "b", + }, "MyRoot"); + + await Assert.That(settings.Values.SequenceEqual(new[] { "a", "b" })).IsTrue(); + } + + private static T BindSequence(Dictionary data, string? rootSection = null) where T : class + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection(data).Build(); + + return SettingsBuilder + .CreateBuilder(x => x.AddSectionBinder(new ConfigurationBinder(configuration, rootSection))) + .GetSettings(); + } + private IConfiguration GetConfiguration() { return new ConfigurationBuilder() @@ -97,5 +215,20 @@ private static SettingsBuilder BuildSutWithBinder(params ISectionBinder[] binder return sut; } + + public interface ISequenceStringSetting + { + string[] Values { get; set; } + } + + public interface ISequenceIntSetting + { + int[] Numbers { get; set; } + } + + public interface ISequenceStringListSetting + { + List Items { get; set; } + } } } diff --git a/src/Tests/ExistForAll.SimpleSettings.UnitTests/Conversion/CollectionConversionTests.cs b/src/Tests/ExistForAll.SimpleSettings.UnitTests/Conversion/CollectionConversionTests.cs index 163bfe4..ef84b43 100644 --- a/src/Tests/ExistForAll.SimpleSettings.UnitTests/Conversion/CollectionConversionTests.cs +++ b/src/Tests/ExistForAll.SimpleSettings.UnitTests/Conversion/CollectionConversionTests.cs @@ -86,6 +86,59 @@ public async Task Convert_UnboundEnumerable_NoDefault_YieldsEmptyArray() await Assert.That(result.Values is int[]).IsTrue(); } + [Test] + public async Task Convert_DelimitedString_ToIntList_MaterializesList() + { + var result = Build("IntList", nameof(IIntList.Values), "1,2,3"); + + await Assert.That(result.Values is List).IsTrue(); + await Assert.That(result.Values.SequenceEqual([1, 2, 3])).IsTrue(); + } + + [Test] + public async Task Convert_DelimitedString_ToIntIList_MaterializesList() + { + var result = Build("IntIList", nameof(IIntIList.Values), "1,2,3"); + + await Assert.That(result.Values is List).IsTrue(); + await Assert.That(result.Values.SequenceEqual([1, 2, 3])).IsTrue(); + } + + [Test] + public async Task Convert_DelimitedString_ToIntICollection_MaterializesList() + { + var result = Build("IntICollection", nameof(IIntICollection.Values), "4,5,6"); + + await Assert.That(result.Values is List).IsTrue(); + await Assert.That(result.Values.SequenceEqual([4, 5, 6])).IsTrue(); + } + + [Test] + public async Task Convert_DelimitedString_ToIntReadOnlyList_MaterializesList() + { + var result = Build("IntReadOnlyList", nameof(IIntReadOnlyList.Values), "7,8,9"); + + await Assert.That(result.Values is List).IsTrue(); + await Assert.That(result.Values.SequenceEqual([7, 8, 9])).IsTrue(); + } + + [Test] + public async Task Convert_DelimitedString_ToIntReadOnlyCollection_MaterializesList() + { + var result = Build("IntReadOnlyCollection", nameof(IIntReadOnlyCollection.Values), "10,20,30"); + + await Assert.That(result.Values is List).IsTrue(); + await Assert.That(result.Values.SequenceEqual([10, 20, 30])).IsTrue(); + } + + [Test] + public async Task Convert_DelimitedString_ToDayOfWeekList_ParsesEachElement() + { + var result = Build("DayOfWeekList", nameof(IDayOfWeekList.Values), "Monday,Friday"); + + await Assert.That(result.Values.SequenceEqual(new[] { DayOfWeek.Monday, DayOfWeek.Friday })).IsTrue(); + } + [Test] public async Task Convert_DelimitedString_ToEnumArray_ParsesEachElement() { @@ -176,5 +229,35 @@ public interface IUriArray { Uri[] Values { get; set; } } + + public interface IIntList + { + List Values { get; set; } + } + + public interface IIntIList + { + IList Values { get; set; } + } + + public interface IIntICollection + { + ICollection Values { get; set; } + } + + public interface IIntReadOnlyList + { + IReadOnlyList Values { get; set; } + } + + public interface IIntReadOnlyCollection + { + IReadOnlyCollection Values { get; set; } + } + + public interface IDayOfWeekList + { + List Values { get; set; } + } } } diff --git a/src/Tests/ExistForAll.SimpleSettings.UnitTests/Conversion/ExceptionRedactionTests.cs b/src/Tests/ExistForAll.SimpleSettings.UnitTests/Conversion/ExceptionRedactionTests.cs index 3d1a1b3..fdab3cb 100644 --- a/src/Tests/ExistForAll.SimpleSettings.UnitTests/Conversion/ExceptionRedactionTests.cs +++ b/src/Tests/ExistForAll.SimpleSettings.UnitTests/Conversion/ExceptionRedactionTests.cs @@ -1,5 +1,7 @@ using ExistForAll.SimpleSettings.Binder; +using ExistForAll.SimpleSettings.Binders; using ExistForAll.SimpleSettings.Conversion; +using Microsoft.Extensions.Configuration; namespace ExistForAll.SimpleSettings.UnitTests.Conversion { @@ -54,6 +56,58 @@ public async Task Convert_CustomConverterLeakingValue_IsStillRedacted() await AssertRedacted(ex, nameof(ILeakyConverterSetting.Value), nameof(String)); } + [Test] + public async Task Convert_SecretInSequenceElement_DoesNotLeakValue() + { + // D-06/S1 on the ConfigurationBinder GetChildren() sequence path: an int[] element that fails to + // convert is wrapped value-free — the secret in a LATER element never reaches the ToString() chain. + var ex = CaptureSequenceConversionFailure(nameof(IIntArraySetting.Values), "1", Secret); + await AssertRedacted(ex, nameof(IIntArraySetting.Values), nameof(Int32)); + } + + [Test] + public async Task Convert_SecretInFirstSequenceElement_DoesNotLeakValue() + { + // S-6: element position must not matter — the throw site is the shared per-element convert, so a + // secret in the FIRST element is redacted identically. + var ex = CaptureSequenceConversionFailure(nameof(IIntArraySetting.Values), Secret, "2"); + await AssertRedacted(ex, nameof(IIntArraySetting.Values), nameof(Int32)); + } + + [Test] + public async Task Convert_SecretInListSequenceElement_DoesNotLeakValue() + { + // S-6: the List converter wraps into a List only AFTER the element-convert throw site, so the + // redaction contract holds identically for the List shape as for the array shape. + var ex = CaptureSequenceConversionFailure(nameof(IIntListSetting.Values), "1", Secret); + await AssertRedacted(ex, nameof(IIntListSetting.Values), "List"); + } + + private static SettingsPropertyValueException CaptureSequenceConversionFailure(string key, params string[] elements) + where T : class + { + var data = new Dictionary(); + var section = SectionOf(); + for (var i = 0; i < elements.Length; i++) + { + data[$"{section}:{key}:{i}"] = elements[i]; + } + + var configuration = new ConfigurationBuilder().AddInMemoryCollection(data).Build(); + var builder = SettingsBuilder.CreateBuilder(x => x.AddSectionBinder(new ConfigurationBinder(configuration))); + + try + { + builder.GetSettings(); + } + catch (SettingsPropertyValueException e) + { + return e; + } + + throw new Exception($"Expected a SettingsPropertyValueException for [{typeof(T).Name}], but none was thrown."); + } + private static SettingsPropertyValueException CaptureConversionFailure(string key, string value) where T : class { @@ -104,6 +158,16 @@ public interface IUriSetting Uri Endpoint { get; set; } } + public interface IIntArraySetting + { + int[] Values { get; set; } + } + + public interface IIntListSetting + { + List Values { get; set; } + } + public interface ILeakyConverterSetting { [SettingsProperty(ConverterType = typeof(LeakyConverter))] diff --git a/src/Tests/ExistForAll.SimpleSettings.UnitTests/Core/TypeConverterTests.cs b/src/Tests/ExistForAll.SimpleSettings.UnitTests/Core/TypeConverterTests.cs index 093cfe2..6165439 100644 --- a/src/Tests/ExistForAll.SimpleSettings.UnitTests/Core/TypeConverterTests.cs +++ b/src/Tests/ExistForAll.SimpleSettings.UnitTests/Core/TypeConverterTests.cs @@ -51,6 +51,54 @@ public async Task Convert_WhenConverterTypeSetOnCollectionProperty_BypassesColle await Assert.That(((int[])result!).SequenceEqual(new[] { -1 })).IsTrue(); } + [Test] + public async Task Convert_NullForArrayProperty_ReturnsEmptyArray() + { + var conversion = ResolveConversion(nameof(IIntArrayProp.Values)); + + var result = conversion.Convert(null); + + await Assert.That(result is int[]).IsTrue(); + await Assert.That(((int[])result!).Length).IsEqualTo(0); + } + + [Test] + public async Task Convert_NullForListProperty_ReturnsEmptyList() + { + var conversion = ResolveConversion(nameof(IIntListProp.Values)); + + var result = conversion.Convert(null); + + await Assert.That(result is List).IsTrue(); + await Assert.That(((List)result!).Count).IsEqualTo(0); + } + + [Test] + public async Task Convert_NullForEnumerableProperty_ReturnsEmptyArray() + { + var conversion = ResolveConversion(nameof(IIntEnumerableProp.Values)); + + var result = conversion.Convert(null); + + await Assert.That(result is int[]).IsTrue(); + await Assert.That(((int[])result!).Length).IsEqualTo(0); + } + + [Test] + public async Task Convert_NullForListProperty_YieldsFreshInstancePerBind() + { + var conversion = ResolveConversion(nameof(IIntListProp.Values)); + + var first = (List)conversion.Convert(null)!; + var second = (List)conversion.Convert(null)!; + + await Assert.That(ReferenceEquals(first, second)).IsFalse(); + + first.Add(99); + + await Assert.That(second.Count).IsEqualTo(0); + } + private static PropertyConversion ResolveConversion(string propertyName) { var options = new SettingsOptions(); // Converters auto-seeded: DateTime,Uri,Array,Enumerable,Enum,Default @@ -72,6 +120,21 @@ public interface IWithConverterOverride IEnumerable Values { get; set; } } + public interface IIntArrayProp + { + int[] Values { get; set; } + } + + public interface IIntListProp + { + List Values { get; set; } + } + + public interface IIntEnumerableProp + { + IEnumerable Values { get; set; } + } + // Returns a distinctive sentinel so a passing test proves the attribute ConverterType was chosen // instead of CollectionTypeConverter. private class SentinelConverter : ISettingsTypeConverter diff --git a/src/Tests/ExistForAll.SimpleSettings.UnitTests/ExistForAll.SimpleSettings.UnitTests.csproj b/src/Tests/ExistForAll.SimpleSettings.UnitTests/ExistForAll.SimpleSettings.UnitTests.csproj index 0804179..9bf98dc 100644 --- a/src/Tests/ExistForAll.SimpleSettings.UnitTests/ExistForAll.SimpleSettings.UnitTests.csproj +++ b/src/Tests/ExistForAll.SimpleSettings.UnitTests/ExistForAll.SimpleSettings.UnitTests.csproj @@ -18,5 +18,6 @@ + diff --git a/src/Tests/ExistForAll.SimpleSettings.UnitTests/SimpleSettings/SettingsPropertyTests.cs b/src/Tests/ExistForAll.SimpleSettings.UnitTests/SimpleSettings/SettingsPropertyTests.cs index b84f878..c713cb7 100644 --- a/src/Tests/ExistForAll.SimpleSettings.UnitTests/SimpleSettings/SettingsPropertyTests.cs +++ b/src/Tests/ExistForAll.SimpleSettings.UnitTests/SimpleSettings/SettingsPropertyTests.cs @@ -1,3 +1,5 @@ +using ExistForAll.SimpleSettings.Binder; + namespace ExistForAll.SimpleSettings.UnitTests.SimpleSettings { public class SettingsPropertyTests @@ -15,10 +17,80 @@ public async Task Build_WhenAllowEmptyIsFalse_ShouldThrowException() await Assert.That(exception!.Message.Contains(nameof(IWithNonNullInterface.Value))).IsTrue(); } + [Test] + public async Task Build_WhenAllowEmptyIsFalse_EmptyString_ShouldThrowNullException() + { + var sut = BuildWith(nameof(IRejectsEmpty.Value), ""); + + await Assert.That(() => sut.GetSettings()) + .Throws(); + } + + [Test] + public async Task Build_WhenAllowEmptyIsFalse_WhitespaceString_ShouldThrowNullException() + { + var sut = BuildWith(nameof(IRejectsEmpty.Value), " "); + + await Assert.That(() => sut.GetSettings()) + .Throws(); + } + + [Test] + public async Task Build_WhenAllowEmptyIsFalse_EmptyString_MessageIsValueFreeWithPropertyName() + { + var sut = BuildWith(nameof(IRejectsEmpty.Value), " "); + + var exception = await Assert.That(() => sut.GetSettings()) + .Throws(); + + await Assert.That(exception!.Message.Contains(nameof(IRejectsEmpty.Value))).IsTrue(); + await Assert.That(exception!.Message.Contains(" ")).IsFalse(); + } + + [Test] + public async Task Build_WhenAllowEmptyIsTrue_EmptyString_BindsValue() + { + var sut = BuildWith(nameof(IAllowsEmpty.Value), ""); + + var settings = sut.GetSettings(); + + await Assert.That(settings.Value).IsEqualTo(""); + } + + [Test] + public async Task Build_WhenAllowEmptyIsTrue_WhitespaceString_BindsValue() + { + var sut = BuildWith(nameof(IAllowsEmpty.Value), " "); + + var settings = sut.GetSettings(); + + await Assert.That(settings.Value).IsEqualTo(" "); + } + + private static SettingsBuilder BuildWith(string key, string value) + { + var collection = new InMemoryCollection(); + collection.Add("RejectsEmpty", key, value); + collection.Add("AllowsEmpty", key, value); + + return SettingsBuilder.CreateBuilder(x => x.AddSectionBinder(new InMemoryBinder(collection))); + } + public interface IWithNonNullInterface { [SettingsProperty(AllowEmpty = false)] int Value { get; set; } } + + public interface IRejectsEmpty + { + [SettingsProperty(AllowEmpty = false)] + string Value { get; set; } + } + + public interface IAllowsEmpty + { + string Value { get; set; } + } } } diff --git a/src/Tests/ExistForAll.SimpleSettings.UnitTests/SimpleSettings/SettingsValidationTests.cs b/src/Tests/ExistForAll.SimpleSettings.UnitTests/SimpleSettings/SettingsValidationTests.cs new file mode 100644 index 0000000..ddab35a --- /dev/null +++ b/src/Tests/ExistForAll.SimpleSettings.UnitTests/SimpleSettings/SettingsValidationTests.cs @@ -0,0 +1,152 @@ +using ExistForAll.SimpleSettings.Binder; +using ExistForAll.SimpleSettings.UnitTests.Fixtures; + +namespace ExistForAll.SimpleSettings.UnitTests.SimpleSettings +{ + // Fixtures live in the never-scanned ExistForAll.SimpleSettings.UnitTests.Fixtures project; resolved via GetSettings(). + public class SettingsValidationTests + { + [Test] + public async Task ObjectValidator_WhenItAddsError_ThrowsSettingsValidationException() + { + await Assert.That(() => Build()).Throws(); + } + + [Test] + public async Task ObjectValidator_WhenValid_DoesNotThrow() + { + var result = Build(); + + await Assert.That(result).IsNotNull(); + } + + [Test] + public async Task PropertyValidator_WhenValueInvalid_Throws() + { + // Bind an explicitly invalid value (-5) so the failure is distinct from the unbound default (0) — + // this proves the validator observed the BOUND value, not just the default. + var collection = new InMemoryCollection(); + collection.Add("PropertyValidated", nameof(IPropertyValidated.Port), "-5"); + + await Assert.That(() => Build(collection)).Throws(); + } + + [Test] + public async Task PropertyValidator_WhenValueValid_DoesNotThrow() + { + var collection = new InMemoryCollection(); + collection.Add("PropertyValidated", nameof(IPropertyValidated.Port), "8080"); + + var result = Build(collection); + + await Assert.That(result.Port).IsEqualTo(8080); + } + + [Test] + public async Task MultipleErrors_AggregateIntoOneExceptionWithAllErrors() + { + var exception = await Assert.That(() => Build()) + .Throws(); + + await Assert.That(exception!.Errors.Count).IsEqualTo(2); + } + + [Test] + public async Task ObjectAndPropertyValidators_BothFail_AggregateWithObjectErrorFirst() + { + // A type carrying BOTH an object-level and a property-level validator, each failing, aggregates + // into ONE exception; the object error is accumulated before the property error (runs object-first). + var collection = new InMemoryCollection(); + collection.Add("BothValidated", nameof(IBothValidated.Port), "-1"); + + var exception = await Assert.That(() => Build(collection)) + .Throws(); + + await Assert.That(exception!.Errors.Count).IsEqualTo(2); + await Assert.That(exception.Errors[0].SettingsName).IsEqualTo("Object"); + await Assert.That(exception.Errors[1].SettingsName).IsEqualTo("Port"); + } + + [Test] + public async Task CrossPropertyValidator_ObservesEveryPropertySet() + { + var collection = new InMemoryCollection(); + collection.Add("CrossProperty", nameof(ICrossProperty.Min), "5"); + collection.Add("CrossProperty", nameof(ICrossProperty.Max), "10"); + + var result = Build(collection); + + await Assert.That(result.Min).IsEqualTo(5); + await Assert.That(result.Max).IsEqualTo(10); + } + + [Test] + public async Task CrossPropertyValidator_WhenRuleViolated_Throws() + { + var collection = new InMemoryCollection(); + collection.Add("CrossProperty", nameof(ICrossProperty.Min), "10"); + collection.Add("CrossProperty", nameof(ICrossProperty.Max), "5"); + + await Assert.That(() => Build(collection)).Throws(); + } + + [Test] + public async Task Exception_CarriesAuthorMessage_AndNoBoundValue() + { + var collection = new InMemoryCollection(); + collection.Add("Redaction", nameof(IRedaction.ApiKey), Sentinel); + + var exception = await Assert.That(() => Build(collection)) + .Throws(); + + var text = exception!.ToString(); + await Assert.That(text.Contains("ApiKey failed policy")).IsTrue(); + await Assert.That(text.Contains(Sentinel)).IsFalse(); + } + + [Test] + public async Task ThrowingValidator_IsWrappedValueFree_NotLeaked() + { + // A validator that throws (rather than returning a result) with the secret in its message must be + // surfaced value-free: the invocation is wrapped in SettingsValidatorInvocationException, whose + // ToString() carries no bound value and does not chain the leaking inner. + var collection = new InMemoryCollection(); + collection.Add("Throwing", nameof(IThrowing.ApiKey), Sentinel); + + var exception = await Assert.That(() => Build(collection)) + .Throws(); + + await Assert.That(exception!.ToString().Contains(Sentinel)).IsFalse(); + } + + [Test] + public async Task NoValidators_PopulatesAndReturnsWithoutThrowing() + { + var result = Build(); + + await Assert.That(result).IsNotNull(); + } + + [Test] + public async Task ScanAssemblies_WhenSectionCarriesValidator_RunsItAndSurfacesFailure() + { + // Guards the scan path for the merged [SettingsSection(ValidatorType=...)]: scanning the Fixtures + // assembly (whose discovered sections carry failing validators) must surface a validation failure. + var builder = SettingsBuilder.CreateBuilder(); + + await Assert.That(() => builder.ScanAssemblies(typeof(IObjectValidated).Assembly)) + .Throws(); + } + + private const string Sentinel = "SUPER_SECRET_API_KEY_VALUE"; + + private static T Build(InMemoryCollection? collection = null) where T : class + { + var builder = collection == null + ? SettingsBuilder.CreateBuilder() + : SettingsBuilder.CreateBuilder(x => x.AddSectionBinder(new InMemoryBinder(collection))); + + return builder.GetSettings(); + } + } +}