V15: Add MNTP serverside validation (#18526)

* Add amount validator

* Add ObjectTypeValidator to MNTP

* Move validate startnode to helper method

* Validate allowed type

* Fix tests

* Added some XML header comments and resolved nit-picky warnings.

* Further XML comments.

* Fix null validation case

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
This commit is contained in:
Mole
2025-03-04 16:06:05 +01:00
committed by GitHub
parent 85883cee85
commit a99c581ab5
10 changed files with 709 additions and 71 deletions

View File

@@ -114,13 +114,16 @@ Mange hilsner fra Umbraco robotten
<key alias="entriesExceed"><![CDATA[Maksimum %0% element(er), <strong>%1%</strong> for mange.]]></key>
<key alias="entriesAreasMismatch">Ét eller flere områder lever ikke op til kravene for antal indholdselementer.</key>
<key alias="invalidMediaType">Den valgte medie type er ugyldig.</key>
<key alias="invalidContentType">Det valgte indhold er af en ugyldig type.</key>
<key alias="missingContent">Det valgte indhold eksistere ikke.</key>
<key alias="multipleMediaNotAllowed">Det er kun tilladt at vælge ét medie.</key>
<key alias="invalidStartNode">Valgt medie kommer fra en ugyldig mappe.</key>
<key alias="invalidStartNode">Valgt indhold kommer fra en ugyldig mappe.</key>
<key alias="outOfRangeMinimum">Værdien %0% er mindre end det tilladte minimum af %1%.</key>
<key alias="outOfRangeMaximum">Værdien %0% er større end det tilladte maksimum af %1%.</key>
<key alias="invalidStep">Værdien %0% passer ikke med den konfigureret trin værdi af %1% og mindste værdi af %2%.</key>
<key alias="unexpectedRange">Værdien %0% forventes ikke at indeholde et spænd.</key>
<key alias="invalidRange">Værdien %0% forventes at have en værdi der er større end fra værdien.</key>
<key alias="invalidObjectType">Det valgte indhold er af den forkerte type.</key>
<key alias="notOneOfOptions">"Værdien '%0%' er ikke en af de tilgængelige valgmuligheder.</key>
<key alias="invalidColor">"Den valgte farve '%0%' er ikke en af de tilgængelige valgmuligheder.</key>
</area>

View File

@@ -393,10 +393,14 @@
<key alias="unexpectedRange">The value %0% is not expected to contain a range</key>
<key alias="invalidRange">The value %0% is not expected to have a to value less than the from value</key>
<key alias="invalidMediaType">The chosen media type is invalid.</key>
<key alias="invalidContentType">The chosen content is of invalid type.</key>
<key alias="missingContent">The chosen content does not exist.</key>
<key alias="multipleMediaNotAllowed">Multiple selected media is not allowed.</key>
<key alias="invalidStartNode">The selected media is from the wrong folder.</key>
<key alias="notOneOfOptions">The value '%0%' is not one of the available options.</key>
<key alias="invalidColor">"The selected colour '%0%' is not one of the available options.</key>
<key alias="invalidStartNode">The selected item is from the wrong folder.</key>
<key alias="invalidObjectType">The selected item is of the wrong type.</key>
<key alias="notOneOfOptions">"The value '%0%' is not one of the available options.</key>
</area>
<area alias="healthcheck">
<!-- The following keys get these tokens passed in:

View File

@@ -394,9 +394,12 @@
<key alias="entriesExceed"><![CDATA[Maximum %0% entries, <strong>%1%</strong> too many.]]></key>
<key alias="entriesAreasMismatch">The content amount requirements are not met for one or more areas.</key>
<key alias="invalidMediaType">The chosen media type is invalid.</key>
<key alias="invalidContentType">The chosen content is of invalid type.</key>
<key alias="missingContent">The chosen content does not exist.</key>
<key alias="multipleMediaNotAllowed">Multiple selected media is not allowed.</key>
<key alias="invalidStartNode">The selected media is from the wrong folder.</key>
<key alias="notOneOfOptions">The value '%0%' is not one of the available options.</key>
<key alias="invalidStartNode">The selected item is from the wrong folder.</key>
<key alias="invalidObjectType">The selected item is of the wrong type.</key>
<key alias="notOneOfOptions">"The value '%0%' is not one of the available options.</key>
<key alias="invalidColor">The selected color '%0%' is not one of the available options.</key>
</area>
<area alias="healthcheck">

View File

@@ -17,6 +17,9 @@ public class MultiNodePickerConfiguration : IIgnoreUserStartNodesConfig
[ConfigurationField("maxNumber")]
public int MaxNumber { get; set; }
[ConfigurationField("filter")]
public string? Filter { get; set; }
[ConfigurationField(Constants.DataTypes.ReservedPreValueKeys.IgnoreUserStartNodes)]
public bool IgnoreUserStartNodes { get; set; }
}

View File

@@ -20,13 +20,22 @@ public class TypedJsonValidatorRunner<TValue, TConfiguration> : IValueValidator
private readonly IJsonSerializer _jsonSerializer;
private readonly ITypedJsonValidator<TValue, TConfiguration>[] _validators;
/// <summary>
/// Initializes a new instance of the <see cref="TypedJsonValidatorRunner{TValue, TConfiguration}"/> class.
/// </summary>
/// <param name="jsonSerializer">The JSON serializer.</param>
/// <param name="validators">The collection of validators to run.</param>
public TypedJsonValidatorRunner(IJsonSerializer jsonSerializer, params ITypedJsonValidator<TValue, TConfiguration>[] validators)
{
_jsonSerializer = jsonSerializer;
_validators = validators;
}
public IEnumerable<ValidationResult> Validate(object? value, string? valueType, object? dataTypeConfiguration,
/// <inheritdoc/>
public IEnumerable<ValidationResult> Validate(
object? value,
string? valueType,
object? dataTypeConfiguration,
PropertyValidationContext validationContext)
{
var validationResults = new List<ValidationResult>();
@@ -36,7 +45,8 @@ public class TypedJsonValidatorRunner<TValue, TConfiguration> : IValueValidator
return validationResults;
}
if (value is null || _jsonSerializer.TryDeserialize(value, out TValue? deserializedValue) is false)
TValue? deserializedValue = null;
if (value is not null && _jsonSerializer.TryDeserialize(value, out deserializedValue) is false)
{
return validationResults;
}

View File

@@ -1,3 +1,5 @@
using Umbraco.Cms.Core.Services.Navigation;
namespace Umbraco.Cms.Core.PropertyEditors.Validation;
/// <summary>
@@ -26,4 +28,49 @@ public static class ValidationHelper
return (value - min) % step == 0;
}
/// <summary>
/// Checks if all provided entities has the start node as an ancestor.
/// </summary>
/// <param name="entityKeys">Keys to check.</param>
/// <param name="startNode">The configured start node.</param>
/// <param name="navigationQueryService">The navigation query service to use for the checks.</param>
/// <returns>True if the startnode key is in the ancestry tree.</returns>
public static bool HasValidStartNode(IEnumerable<Guid> entityKeys, Guid startNode, INavigationQueryService navigationQueryService)
{
List<Guid> uniqueParentKeys = [];
foreach (Guid distinctMediaKey in entityKeys.Distinct())
{
if (navigationQueryService.TryGetParentKey(distinctMediaKey, out Guid? parentKey) is false)
{
continue;
}
// If there is a start node, the media must have a parent.
if (parentKey is null)
{
return false;
}
uniqueParentKeys.Add(parentKey.Value);
}
IEnumerable<Guid> parentKeysNotInStartNode = uniqueParentKeys.Where(x => x != startNode);
foreach (Guid parentKey in parentKeysNotInStartNode)
{
if (navigationQueryService.TryGetAncestorsKeys(parentKey, out IEnumerable<Guid> foundAncestorKeys) is false)
{
// We couldn't find the parent node, so we fail.
return false;
}
Guid[] ancestorKeys = foundAncestorKeys.ToArray();
if (ancestorKeys.Length == 0 || ancestorKeys.Contains(startNode) is false)
{
return false;
}
}
return true;
}
}

View File

@@ -18,7 +18,7 @@ using Umbraco.Extensions;
namespace Umbraco.Cms.Core.PropertyEditors;
/// <summary>
/// Represents a media picker property editor.
/// Represents a media picker property editor.
/// </summary>
[DataEditor(
Constants.PropertyEditors.Aliases.MediaPicker3,
@@ -38,6 +38,7 @@ public class MediaPicker3PropertyEditor : DataEditor
SupportsReadOnly = true;
}
/// <inheritdoc />
public override IPropertyIndexValueFactory PropertyIndexValueFactory { get; } = new NoopPropertyIndexValueFactory();
/// <inheritdoc />
@@ -48,8 +49,9 @@ public class MediaPicker3PropertyEditor : DataEditor
protected override IDataValueEditor CreateValueEditor() =>
DataValueEditorFactory.Create<MediaPicker3PropertyValueEditor>(Attribute!);
/// <summary>
/// Defines the value editor for the media picker property editor.
/// </summary>
internal class MediaPicker3PropertyValueEditor : DataValueEditor, IDataValueReference
{
private readonly IDataTypeConfigurationCache _dataTypeReadCache;
@@ -60,6 +62,13 @@ public class MediaPicker3PropertyEditor : DataEditor
private readonly IScopeProvider _scopeProvider;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="MediaPicker3PropertyValueEditor"/> class.
/// </summary>
/// <remarks>
/// Note on FromEditor() and ToEditor() methods.
/// We do not want to transform the way the data is stored in the DB and would like to keep a raw JSON string.
/// </remarks>
public MediaPicker3PropertyValueEditor(
IShortStringHelper shortStringHelper,
IJsonSerializer jsonSerializer,
@@ -92,10 +101,7 @@ public class MediaPicker3PropertyEditor : DataEditor
Validators.Add(validators);
}
/// <remarks>
/// Note: no FromEditor() and ToEditor() methods
/// We do not want to transform the way the data is stored in the DB and would like to keep a raw JSON string
/// </remarks>
/// <inheritdoc/>
public IEnumerable<UmbracoEntityReference> GetReferences(object? value)
{
foreach (MediaWithCropsDto dto in Deserialize(_jsonSerializer, value))
@@ -104,6 +110,7 @@ public class MediaPicker3PropertyEditor : DataEditor
}
}
/// <inheritdoc/>
public override object ToEditor(IProperty property, string? culture = null, string? segment = null)
{
var value = property.GetValue(culture, segment);
@@ -111,7 +118,7 @@ public class MediaPicker3PropertyEditor : DataEditor
var dtos = Deserialize(_jsonSerializer, value).ToList();
dtos = UpdateMediaTypeAliases(dtos);
var configuration = _dataTypeReadCache.GetConfigurationAs<MediaPicker3Configuration>(property.PropertyType.DataTypeKey);
MediaPicker3Configuration? configuration = _dataTypeReadCache.GetConfigurationAs<MediaPicker3Configuration>(property.PropertyType.DataTypeKey);
if (configuration is not null)
{
foreach (MediaWithCropsDto dto in dtos)
@@ -123,6 +130,7 @@ public class MediaPicker3PropertyEditor : DataEditor
return dtos;
}
/// <inheritdoc/>
public override object? FromEditor(ContentPropertyData editorValue, object? currentValue)
{
if (editorValue.Value is null ||
@@ -145,6 +153,9 @@ public class MediaPicker3PropertyEditor : DataEditor
return _jsonSerializer.Serialize(mediaWithCropsDtos);
}
/// <summary>
/// Deserializes the provided JSON value into a list of <see cref="MediaWithCropsDto"/>.
/// </summary>
internal static IEnumerable<MediaWithCropsDto> Deserialize(IJsonSerializer jsonSerializer, object? value)
{
var rawJson = value is string str ? str : value?.ToString();
@@ -248,14 +259,29 @@ public class MediaPicker3PropertyEditor : DataEditor
/// </summary>
internal class MediaWithCropsDto
{
/// <summary>
/// Gets or sets the key.
/// </summary>
public Guid Key { get; set; }
/// <summary>
/// Gets or sets the media key.
/// </summary>
public Guid MediaKey { get; set; }
/// <summary>
/// Gets or sets the media type alias.
/// </summary>
public string MediaTypeAlias { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the crops.
/// </summary>
public IEnumerable<ImageCropperValue.ImageCropperCrop>? Crops { get; set; }
/// <summary>
/// Gets or sets the focal point.
/// </summary>
public ImageCropperValue.ImageCropperFocalPoint? FocalPoint { get; set; }
/// <summary>
@@ -309,12 +335,19 @@ public class MediaPicker3PropertyEditor : DataEditor
}
}
/// <summary>
/// Validates the min/max configuration for the media picker property editor.
/// </summary>
internal class MinMaxValidator : ITypedJsonValidator<List<MediaWithCropsDto>, MediaPicker3Configuration>
{
private readonly ILocalizedTextService _localizedTextService;
/// <summary>
/// Initializes a new instance of the <see cref="MinMaxValidator"/> class.
/// </summary>
public MinMaxValidator(ILocalizedTextService localizedTextService) => _localizedTextService = localizedTextService;
/// <inheritdoc/>
public IEnumerable<ValidationResult> Validate(
List<MediaWithCropsDto>? mediaWithCropsDtos,
MediaPicker3Configuration? mediaPickerConfiguration,
@@ -363,12 +396,18 @@ public class MediaPicker3PropertyEditor : DataEditor
}
}
/// <summary>
/// Validates the allowed type configuration for the media picker property editor.
/// </summary>
internal class AllowedTypeValidator : ITypedJsonValidator<List<MediaWithCropsDto>, MediaPicker3Configuration>
{
private readonly ILocalizedTextService _localizedTextService;
private readonly IMediaTypeService _mediaTypeService;
private readonly IMediaService _mediaService;
/// <summary>
/// Initializes a new instance of the <see cref="AllowedTypeValidator"/> class.
/// </summary>
public AllowedTypeValidator(ILocalizedTextService localizedTextService, IMediaTypeService mediaTypeService, IMediaService mediaService)
{
_localizedTextService = localizedTextService;
@@ -376,6 +415,7 @@ public class MediaPicker3PropertyEditor : DataEditor
_mediaService = mediaService;
}
/// <inheritdoc/>
public IEnumerable<ValidationResult> Validate(
List<MediaWithCropsDto>? value,
MediaPicker3Configuration? configuration,
@@ -429,11 +469,17 @@ public class MediaPicker3PropertyEditor : DataEditor
}
}
/// <summary>
/// Validates the start node configuration for the media picker property editor.
/// </summary>
internal class StartNodeValidator : ITypedJsonValidator<List<MediaWithCropsDto>, MediaPicker3Configuration>
{
private readonly ILocalizedTextService _localizedTextService;
private readonly IMediaNavigationQueryService _mediaNavigationQueryService;
/// <summary>
/// Initializes a new instance of the <see cref="StartNodeValidator"/> class.
/// </summary>
public StartNodeValidator(
ILocalizedTextService localizedTextService,
IMediaNavigationQueryService mediaNavigationQueryService)
@@ -442,6 +488,7 @@ public class MediaPicker3PropertyEditor : DataEditor
_mediaNavigationQueryService = mediaNavigationQueryService;
}
/// <inheritdoc/>
public IEnumerable<ValidationResult> Validate(
List<MediaWithCropsDto>? value,
MediaPicker3Configuration? configuration,
@@ -453,53 +500,14 @@ public class MediaPicker3PropertyEditor : DataEditor
return [];
}
List<Guid> uniqueParentKeys = [];
foreach (Guid distinctMediaKey in value.DistinctBy(x => x.MediaKey).Select(x => x.MediaKey))
if (ValidationHelper.HasValidStartNode(value.Select(x => x.MediaKey), configuration.StartNodeId.Value, _mediaNavigationQueryService) is false)
{
if (_mediaNavigationQueryService.TryGetParentKey(distinctMediaKey, out Guid? parentKey) is false)
{
continue;
}
// If there is a start node, the media must have a parent.
if (parentKey is null)
{
return
[
new ValidationResult(
_localizedTextService.Localize("validation", "invalidStartNode"),
["value"])
];
}
uniqueParentKeys.Add(parentKey.Value);
}
IEnumerable<Guid> parentKeysNotInStartNode = uniqueParentKeys.Where(x => x != configuration.StartNodeId.Value);
foreach (Guid parentKey in parentKeysNotInStartNode)
{
if (_mediaNavigationQueryService.TryGetAncestorsKeys(parentKey, out IEnumerable<Guid> foundAncestorKeys) is false)
{
// We couldn't find the parent node, so we fail.
return
[
new ValidationResult(
_localizedTextService.Localize("validation", "invalidStartNode"),
["value"])
];
}
Guid[] ancestorKeys = foundAncestorKeys.ToArray();
if (ancestorKeys.Length == 0 || ancestorKeys.Contains(configuration.StartNodeId.Value) is false)
{
return
[
new ValidationResult(
_localizedTextService.Localize("validation", "invalidStartNode"),
["value"])
];
}
return
[
new ValidationResult(
_localizedTextService.Localize("validation", "invalidStartNode"),
["value"])
];
}
return [];

View File

@@ -1,16 +1,27 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Nodes;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Editors;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Models.Validation;
using Umbraco.Cms.Core.PropertyEditors.Validation;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Strings;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.PropertyEditors;
/// <summary>
/// Represents a multi-node tree picker property editor.
/// </summary>
[DataEditor(
Constants.PropertyEditors.Aliases.MultiNodeTreePicker,
ValueType = ValueTypes.Text,
@@ -19,6 +30,9 @@ public class MultiNodeTreePickerPropertyEditor : DataEditor
{
private readonly IIOHelper _ioHelper;
/// <summary>
/// Initializes a new instance of the <see cref="MultiNodeTreePickerPropertyEditor"/> class.
/// </summary>
public MultiNodeTreePickerPropertyEditor(IDataValueEditorFactory dataValueEditorFactory, IIOHelper ioHelper)
: base(dataValueEditorFactory)
{
@@ -26,32 +40,75 @@ public class MultiNodeTreePickerPropertyEditor : DataEditor
SupportsReadOnly = true;
}
/// <inheritdoc/>
protected override IConfigurationEditor CreateConfigurationEditor() =>
new MultiNodePickerConfigurationEditor(_ioHelper);
/// <inheritdoc/>
protected override IDataValueEditor CreateValueEditor() =>
DataValueEditorFactory.Create<MultiNodeTreePickerPropertyValueEditor>(Attribute!);
/// <summary>
/// Defines the value editor for the media picker property editor.
/// </summary>
/// <remarks>
/// At first glance, the fromEditor and toEditor methods might seem strange.
/// This is because we wanted to stop the leaking of UDI's to the frontend while not having to do database migrations
/// so we opted to, for now, translate the udi string in the database into a structured format unique to the client
/// At first glance, the FromEditor and ToEditor methods might seem strange.
/// This is because we wanted to stop the leaking of UDIs to the frontend while not having to do database migrations
/// so we opted to, for now, translate the UDI string in the database into a structured format unique to the client.
/// This way, for now, no migration is needed and no changes outside of the editor logic needs to be touched to stop the leaking.
/// </remarks>
public class MultiNodeTreePickerPropertyValueEditor : DataValueEditor, IDataValueReference
{
private readonly IJsonSerializer _jsonSerializer;
/// <summary>
/// Initializes a new instance of the <see cref="MultiNodeTreePickerPropertyValueEditor"/> class.
/// </summary>
public MultiNodeTreePickerPropertyValueEditor(
IShortStringHelper shortStringHelper,
IJsonSerializer jsonSerializer,
IIOHelper ioHelper,
DataEditorAttribute attribute,
ILocalizedTextService localizedTextService,
IEntityService entityService,
ICoreScopeProvider coreScopeProvider,
IContentService contentService,
IMediaService mediaService,
IMemberService memberService)
: base(shortStringHelper, jsonSerializer, ioHelper, attribute)
{
_jsonSerializer = jsonSerializer;
Validators.Add(new TypedJsonValidatorRunner<EditorEntityReference[], MultiNodePickerConfiguration>(
jsonSerializer,
new MinMaxValidator(localizedTextService),
new ObjectTypeValidator(localizedTextService, coreScopeProvider, entityService),
new ContentTypeValidator(localizedTextService, coreScopeProvider, contentService, mediaService, memberService)));
}
/// <summary>
/// Initializes a new instance of the <see cref="MultiNodeTreePickerPropertyValueEditor"/> class.
/// </summary>
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 17.")]
public MultiNodeTreePickerPropertyValueEditor(
IShortStringHelper shortStringHelper,
IJsonSerializer jsonSerializer,
IIOHelper ioHelper,
DataEditorAttribute attribute)
: base(shortStringHelper, jsonSerializer, ioHelper, attribute)
: this(
shortStringHelper,
jsonSerializer,
ioHelper,
attribute,
StaticServiceProvider.Instance.GetRequiredService<ILocalizedTextService>(),
StaticServiceProvider.Instance.GetRequiredService<IEntityService>(),
StaticServiceProvider.Instance.GetRequiredService<ICoreScopeProvider>(),
StaticServiceProvider.Instance.GetRequiredService<IContentService>(),
StaticServiceProvider.Instance.GetRequiredService<IMediaService>(),
StaticServiceProvider.Instance.GetRequiredService<IMemberService>())
{
_jsonSerializer = jsonSerializer;
}
/// <inheritdoc/>
public IEnumerable<UmbracoEntityReference> GetReferences(object? value)
{
var asString = value == null ? string.Empty : value is string str ? str : value.ToString();
@@ -66,12 +123,13 @@ public class MultiNodeTreePickerPropertyEditor : DataEditor
}
}
/// <inheritdoc/>
public override object? FromEditor(ContentPropertyData editorValue, object? currentValue)
=> editorValue.Value is JsonArray jsonArray
? EntityReferencesToUdis(_jsonSerializer.Deserialize<IEnumerable<EditorEntityReference>>(jsonArray.ToJsonString()) ?? Enumerable.Empty<EditorEntityReference>())
: null;
/// <inheritdoc/>
public override object? ToEditor(IProperty property, string? culture = null, string? segment = null)
{
var value = property.GetValue(culture, segment);
@@ -80,7 +138,7 @@ public class MultiNodeTreePickerPropertyEditor : DataEditor
: null;
}
private IEnumerable<EditorEntityReference> UdisToEntityReferences(IEnumerable<string> stringUdis)
private static IEnumerable<EditorEntityReference> UdisToEntityReferences(IEnumerable<string> stringUdis)
{
foreach (var stringUdi in stringUdis)
{
@@ -93,14 +151,277 @@ public class MultiNodeTreePickerPropertyEditor : DataEditor
}
}
private string EntityReferencesToUdis(IEnumerable<EditorEntityReference> nodeReferences)
private static string EntityReferencesToUdis(IEnumerable<EditorEntityReference> nodeReferences)
=> string.Join(",", nodeReferences.Select(entityReference => Udi.Create(entityReference.Type, entityReference.Unique).ToString()));
/// <summary>
/// Describes and editor entity reference.
/// </summary>
public class EditorEntityReference
{
/// <summary>
/// Gets or sets the entity object type.
/// </summary>
public required string Type { get; set; }
/// <summary>
/// Gets or sets the entity unique identifier.
/// </summary>
public required Guid Unique { get; set; }
}
/// <summary>
/// Gets the name of the configured object type for documents.
/// </summary>
internal const string DocumentObjectType = "content";
/// <summary>
/// Gets the name of the configured object type for media.
/// </summary>
internal const string MediaObjectType = "media";
/// <summary>
/// Gets the name of the configured object type for members.
/// </summary>
internal const string MemberObjectType = "member";
/// <inheritdoc/>
/// <summary>
/// Validates the min/max configuration for the multi-node tree picker property editor.
/// </summary>
internal class MinMaxValidator : ITypedJsonValidator<EditorEntityReference[], MultiNodePickerConfiguration>
{
private readonly ILocalizedTextService _localizedTextService;
/// <summary>
/// Initializes a new instance of the <see cref="MinMaxValidator"/> class.
/// </summary>
public MinMaxValidator(ILocalizedTextService localizedTextService) => _localizedTextService = localizedTextService;
/// <inheritdoc/>
public IEnumerable<ValidationResult> Validate(
EditorEntityReference[]? entityReferences,
MultiNodePickerConfiguration? configuration,
string? valueType,
PropertyValidationContext validationContext)
{
var validationResults = new List<ValidationResult>();
if (configuration is null)
{
return validationResults;
}
if (configuration.MinNumber > 0 && (entityReferences is null || entityReferences.Length < configuration.MinNumber))
{
validationResults.Add(new ValidationResult(
_localizedTextService.Localize(
"validation",
"entriesShort",
[configuration.MinNumber.ToString(), (configuration.MinNumber - (entityReferences?.Length ?? 0)).ToString()
]),
["value"]));
}
if (entityReferences is null)
{
return validationResults;
}
if (configuration.MaxNumber > 0 && entityReferences.Length > configuration.MaxNumber)
{
validationResults.Add(new ValidationResult(
_localizedTextService.Localize(
"validation",
"entriesExceed",
[configuration.MaxNumber.ToString(), (entityReferences.Length - configuration.MaxNumber).ToString()
]),
["value"]));
}
return validationResults;
}
}
/// <inheritdoc/>
/// <summary>
/// Validates the selected object type for the multi-node tree picker property editor.
/// </summary>
internal class ObjectTypeValidator : ITypedJsonValidator<EditorEntityReference[], MultiNodePickerConfiguration>
{
private readonly ILocalizedTextService _localizedTextService;
private readonly ICoreScopeProvider _coreScopeProvider;
private readonly IEntityService _entityService;
/// <summary>
/// Initializes a new instance of the <see cref="ObjectTypeValidator"/> class.
/// </summary>
public ObjectTypeValidator(
ILocalizedTextService localizedTextService,
ICoreScopeProvider coreScopeProvider,
IEntityService entityService)
{
_localizedTextService = localizedTextService;
_coreScopeProvider = coreScopeProvider;
_entityService = entityService;
}
/// <inheritdoc/>
public IEnumerable<ValidationResult> Validate(
EditorEntityReference[]? entityReferences,
MultiNodePickerConfiguration? configuration,
string? valueType,
PropertyValidationContext validationContext)
{
var validationResults = new List<ValidationResult>();
if (entityReferences is null || configuration?.TreeSource?.ObjectType is null)
{
return validationResults;
}
Guid[] uniqueKeys = entityReferences.DistinctBy(x => x.Unique).Select(x => x.Unique).ToArray();
if (uniqueKeys.Length == 0)
{
return validationResults;
}
Guid? allowedObjectType = GetObjectType(configuration.TreeSource.ObjectType);
if (allowedObjectType is null)
{
return
[
// Some invalid object type was sent.
new ValidationResult(
_localizedTextService.Localize(
"validation",
"invalidObjectType"),
["value"])
];
}
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
foreach (Guid key in uniqueKeys)
{
IEntitySlim? entity = _entityService.Get(key);
if (entity is not null && entity.NodeObjectType != allowedObjectType)
{
validationResults.Add(new ValidationResult(
_localizedTextService.Localize(
"validation",
"invalidObjectType"),
["value"]));
}
}
scope.Complete();
return validationResults;
}
private static Guid? GetObjectType(string objectType) =>
objectType switch
{
DocumentObjectType => Constants.ObjectTypes.Document,
MediaObjectType => Constants.ObjectTypes.Media,
MemberObjectType => Constants.ObjectTypes.Member,
_ => null,
};
}
/// <inheritdoc/>
/// <summary>
/// Validates the selected content type for the multi-node tree picker property editor.
/// </summary>
internal class ContentTypeValidator : ITypedJsonValidator<EditorEntityReference[], MultiNodePickerConfiguration>
{
private readonly ILocalizedTextService _localizedTextService;
private readonly ICoreScopeProvider _coreScopeProvider;
private readonly IContentService _contentService;
private readonly IMediaService _mediaService;
private readonly IMemberService _memberService;
/// <summary>
/// Initializes a new instance of the <see cref="ContentTypeValidator"/> class.
/// </summary>
public ContentTypeValidator(
ILocalizedTextService localizedTextService,
ICoreScopeProvider coreScopeProvider,
IContentService contentService,
IMediaService mediaService,
IMemberService memberService)
{
_localizedTextService = localizedTextService;
_coreScopeProvider = coreScopeProvider;
_contentService = contentService;
_mediaService = mediaService;
_memberService = memberService;
}
/// <inheritdoc/>
public IEnumerable<ValidationResult> Validate(
EditorEntityReference[]? entityReferences,
MultiNodePickerConfiguration? configuration,
string? valueType,
PropertyValidationContext validationContext)
{
var validationResults = new List<ValidationResult>();
Guid[] allowedTypes = configuration?.Filter?.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries).Select(Guid.Parse).ToArray() ?? [];
// We can't validate if there is no object type, and we don't need to if there's no filter.
if (entityReferences is null || allowedTypes.Length == 0 || configuration?.TreeSource?.ObjectType is null)
{
return validationResults;
}
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
Guid?[] uniqueContentTypeKeys = entityReferences
.Select(x => x.Unique)
.Distinct()
.Select(x => GetContent(configuration.TreeSource.ObjectType, x))
.Select(x => x?.ContentType.Key)
.Distinct()
.ToArray();
scope.Complete();
foreach (Guid? key in uniqueContentTypeKeys)
{
if (key is null)
{
validationResults.Add(new ValidationResult(
_localizedTextService.Localize(
"validation",
"missingContent"),
["value"]));
continue;
}
if (allowedTypes.Contains(key.Value) is false)
{
validationResults.Add(new ValidationResult(
_localizedTextService.Localize(
"validation",
"invalidObjectType"),
["value"]));
}
}
return validationResults;
}
private IContentBase? GetContent(string objectType, Guid key) =>
objectType switch
{
DocumentObjectType => _contentService.GetById(key),
MediaObjectType => _mediaService.GetById(key),
MemberObjectType => _memberService.GetById(key),
_ => null,
};
}
}
}

View File

@@ -1,13 +1,16 @@
using System.Text.Json.Nodes;
using System.Data;
using System.Text.Json.Nodes;
using Moq;
using NUnit.Framework;
using Org.BouncyCastle.Asn1.X500;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Editors;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Strings;
using Umbraco.Cms.Infrastructure.Serialization;
@@ -244,11 +247,30 @@ public class MultiNodeTreePickerTests
private static MultiNodeTreePickerPropertyEditor.MultiNodeTreePickerPropertyValueEditor CreateValueEditor(
IJsonSerializer? jsonSerializer = null)
{
var mockScope = new Mock<IScope>();
var mockScopeProvider = new Mock<ICoreScopeProvider>();
mockScopeProvider
.Setup(x => x.CreateCoreScope(
It.IsAny<IsolationLevel>(),
It.IsAny<RepositoryCacheMode>(),
It.IsAny<IEventDispatcher>(),
It.IsAny<IScopedNotificationPublisher>(),
It.IsAny<bool?>(),
It.IsAny<bool>(),
It.IsAny<bool>()))
.Returns(mockScope.Object);
var valueEditor = new MultiNodeTreePickerPropertyEditor.MultiNodeTreePickerPropertyValueEditor(
Mock.Of<IShortStringHelper>(),
jsonSerializer ?? Mock.Of<IJsonSerializer>(),
Mock.Of<IIOHelper>(),
new DataEditorAttribute("alias"));
new DataEditorAttribute("alias"),
Mock.Of<ILocalizedTextService>(),
Mock.Of<IEntityService>(),
mockScopeProvider.Object,
Mock.Of<IContentService>(),
Mock.Of<IMediaService>(),
Mock.Of<IMemberService>());
return valueEditor;
}

View File

@@ -0,0 +1,217 @@
using System.ComponentModel.DataAnnotations;
using System.Data;
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Models.Validation;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Strings;
using Umbraco.Cms.Infrastructure.Serialization;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors;
[TestFixture]
public class MultiNodeTreePickerValidationTests
{
// Remember 0 = no limit
[TestCase(0, true, "[{\"type\":\"document\",\"unique\":\"86eb02a7-793f-4406-9152-9736b6b64bee\"}]")]
[TestCase(1, true, "[{\"type\":\"document\",\"unique\":\"86eb02a7-793f-4406-9152-9736b6b64bee\"}]")]
[TestCase(2, true, "[{\"type\":\"document\",\"unique\":\"86eb02a7-793f-4406-9152-9736b6b64bee\"},{\"type\":\"document\",\"unique\":\"25ef6fd2-db48-450a-8c48-df3ad75adf4b\"}]")]
[TestCase(3, false, "[{\"type\":\"document\",\"unique\":\"86eb02a7-793f-4406-9152-9736b6b64bee\"},{\"type\":\"document\",\"unique\":\"86eb02a7-793f-4406-9152-9736b6b64bee\"}]")]
[TestCase(2, false, "[{\"type\":\"document\",\"unique\":\"86eb02a7-793f-4406-9152-9736b6b64bee\"}]")]
[TestCase(1, false, null)]
[TestCase(0, true, null)]
public void Validates_Minimum_Entries(int min, bool shouldSucceed, string? value)
{
var (valueEditor, _, _, _, _) = CreateValueEditor();
valueEditor.ConfigurationObject = new MultiNodePickerConfiguration { MinNumber = min};
var result = valueEditor.Validate(value, false, null, PropertyValidationContext.Empty());
TestShouldSucceed(shouldSucceed, result);
}
private static void TestShouldSucceed(bool shouldSucceed, IEnumerable<ValidationResult> result)
{
if (shouldSucceed)
{
Assert.IsEmpty(result);
}
else
{
Assert.IsNotEmpty(result);
}
}
[TestCase(0, true, "[]")]
[TestCase(1, true, "[{\"type\":\"document\",\"unique\":\"86eb02a7-793f-4406-9152-9736b6b64bee\"}]")]
[TestCase(0, true, "[{\"type\":\"document\",\"unique\":\"86eb02a7-793f-4406-9152-9736b6b64bee\"}]")]
[TestCase(1, false, "[{\"type\":\"document\",\"unique\":\"86eb02a7-793f-4406-9152-9736b6b64bee\"},{\"type\":\"document\",\"unique\":\"25ef6fd2-db48-450a-8c48-df3ad75adf4b\"}]")]
[TestCase(3, true, "[{\"type\":\"document\",\"unique\":\"86eb02a7-793f-4406-9152-9736b6b64bee\"},{\"type\":\"document\",\"unique\":\"86eb02a7-793f-4406-9152-9736b6b64bee\"}]")]
[TestCase(2, true, "[{\"type\":\"document\",\"unique\":\"86eb02a7-793f-4406-9152-9736b6b64bee\"}]")]
public void Validates_Maximum_Entries(int max, bool shouldSucceed, string value)
{
var (valueEditor, _, _, _, _) = CreateValueEditor();
valueEditor.ConfigurationObject = new MultiNodePickerConfiguration { MaxNumber = max };
var result = valueEditor.Validate(value, false, null, PropertyValidationContext.Empty());
TestShouldSucceed(shouldSucceed, result);
}
private readonly Dictionary<Guid, Guid> _entityTypeMap = new()
{
{ Constants.ObjectTypes.Document, Guid.Parse("08035A7E-AE9C-4D36-BA2E-63F639005758") },
{ Constants.ObjectTypes.Media, Guid.Parse("AAF97C7D-A586-45CC-AC7F-CE0A80BCFEE3") },
{ Constants.ObjectTypes.Member, Guid.Parse("E477804E-C903-470B-B7EC-67DCAF71E37C") },
};
private class ObjectTypeTestSetup
{
public ObjectTypeTestSetup(string expectedObjectType, bool shouldSucceed, string value)
{
ExpectedObjectType = expectedObjectType;
ShouldSucceed = shouldSucceed;
Value = value;
}
public string ExpectedObjectType { get; }
public bool ShouldSucceed { get; }
public string Value { get; }
}
private void SetupEntityServiceForObjectTypeTest(Mock<IEntityService> entityServiceMock)
{
foreach (var objectTypeEntity in _entityTypeMap)
{
var entity = new Mock<IEntitySlim>();
entity.Setup(x => x.NodeObjectType).Returns(objectTypeEntity.Key);
entityServiceMock.Setup(x => x.Get(objectTypeEntity.Value)).Returns(entity.Object);
}
}
private IEnumerable<ObjectTypeTestSetup> GetObjectTypeTestSetup() =>
[
new(MultiNodeTreePickerPropertyEditor.MultiNodeTreePickerPropertyValueEditor.DocumentObjectType, true, "[]"),
new(MultiNodeTreePickerPropertyEditor.MultiNodeTreePickerPropertyValueEditor.DocumentObjectType, true, $"[{{\"type\":\"document\",\"unique\":\"{_entityTypeMap[Constants.ObjectTypes.Document]}\"}}]"),
new(MultiNodeTreePickerPropertyEditor.MultiNodeTreePickerPropertyValueEditor.DocumentObjectType, false, $"[{{\"type\":\"document\",\"unique\":\"{_entityTypeMap[Constants.ObjectTypes.Media]}\"}}]"),
new(MultiNodeTreePickerPropertyEditor.MultiNodeTreePickerPropertyValueEditor.DocumentObjectType, false, $"[{{\"type\":\"document\",\"unique\":\"{_entityTypeMap[Constants.ObjectTypes.Member]}\"}}]"),
new(MultiNodeTreePickerPropertyEditor.MultiNodeTreePickerPropertyValueEditor.MediaObjectType, false, $"[{{\"type\":\"document\",\"unique\":\"{_entityTypeMap[Constants.ObjectTypes.Document]}\"}}]"),
new(MultiNodeTreePickerPropertyEditor.MultiNodeTreePickerPropertyValueEditor.MemberObjectType, false, $"[{{\"type\":\"document\",\"unique\":\"{_entityTypeMap[Constants.ObjectTypes.Document]}\"}}]"),
new(MultiNodeTreePickerPropertyEditor.MultiNodeTreePickerPropertyValueEditor.DocumentObjectType, false, $"[{{\"type\":\"document\",\"unique\":\"{_entityTypeMap[Constants.ObjectTypes.Document]}\"}}, {{\"type\":\"document\",\"unique\":\"{_entityTypeMap[Constants.ObjectTypes.Media]}\"}}]"),
new(MultiNodeTreePickerPropertyEditor.MultiNodeTreePickerPropertyValueEditor.MediaObjectType, false, $"[{{\"type\":\"document\",\"unique\":\"{_entityTypeMap[Constants.ObjectTypes.Document]}\"}}]"),
new(MultiNodeTreePickerPropertyEditor.MultiNodeTreePickerPropertyValueEditor.MediaObjectType, false, $"[{{\"type\":\"document\",\"unique\":\"{_entityTypeMap[Constants.ObjectTypes.Member]}\"}}]"),
new(MultiNodeTreePickerPropertyEditor.MultiNodeTreePickerPropertyValueEditor.MemberObjectType, false, $"[{{\"type\":\"document\",\"unique\":\"{_entityTypeMap[Constants.ObjectTypes.Document]}\"}}]"),
new(MultiNodeTreePickerPropertyEditor.MultiNodeTreePickerPropertyValueEditor.MemberObjectType, false, $"[{{\"type\":\"document\",\"unique\":\"{_entityTypeMap[Constants.ObjectTypes.Media]}\"}}]"),
];
[Test]
public void Validates_Object_Type()
{
var setups = GetObjectTypeTestSetup();
foreach (var setup in setups)
{
var (valueEditor, entityServiceMock, _, _, _) = CreateValueEditor();
SetupEntityServiceForObjectTypeTest(entityServiceMock);
valueEditor.ConfigurationObject = new MultiNodePickerConfiguration { TreeSource = new MultiNodePickerConfigurationTreeSource() { ObjectType = setup.ExpectedObjectType } };
var result = valueEditor.Validate(setup.Value, false, null, PropertyValidationContext.Empty());
TestShouldSucceed(setup.ShouldSucceed, result);
}
}
[TestCase(true, true, true, MultiNodeTreePickerPropertyEditor.MultiNodeTreePickerPropertyValueEditor.DocumentObjectType)]
[TestCase(true, true, true, MultiNodeTreePickerPropertyEditor.MultiNodeTreePickerPropertyValueEditor.MediaObjectType)]
[TestCase(true, true, true, MultiNodeTreePickerPropertyEditor.MultiNodeTreePickerPropertyValueEditor.MemberObjectType)]
[TestCase(false, false, true, MultiNodeTreePickerPropertyEditor.MultiNodeTreePickerPropertyValueEditor.DocumentObjectType)]
[TestCase(false, false, true, MultiNodeTreePickerPropertyEditor.MultiNodeTreePickerPropertyValueEditor.MediaObjectType)]
[TestCase(false, false, true, MultiNodeTreePickerPropertyEditor.MultiNodeTreePickerPropertyValueEditor.MemberObjectType)]
[TestCase(false, true, false, MultiNodeTreePickerPropertyEditor.MultiNodeTreePickerPropertyValueEditor.DocumentObjectType)]
[TestCase(false, true, false, MultiNodeTreePickerPropertyEditor.MultiNodeTreePickerPropertyValueEditor.MediaObjectType)]
[TestCase(false, true, false, MultiNodeTreePickerPropertyEditor.MultiNodeTreePickerPropertyValueEditor.MemberObjectType)]
public void Validates_Allowed_Type(bool shouldSucceed, bool hasAllowedType, bool findsContent, string objectType)
{
var (valueEditor, _, contentService, mediaService, memberService) = CreateValueEditor();
var expectedEntityKey = Guid.NewGuid();
var allowedTypeKey = Guid.NewGuid();
valueEditor.ConfigurationObject = new MultiNodePickerConfiguration()
{
Filter = $"{allowedTypeKey}",
TreeSource = new MultiNodePickerConfigurationTreeSource { ObjectType = objectType },
};
var contentTypeMock = new Mock<ISimpleContentType>();
contentTypeMock.Setup(x => x.Key).Returns(() => hasAllowedType ? allowedTypeKey : Guid.NewGuid());
var contentMock = new Mock<IContent>();
contentMock.Setup(x => x.ContentType).Returns(contentTypeMock.Object);
contentService.Setup(x => x.GetById(expectedEntityKey)).Returns(contentMock.Object);
var mediaMock = new Mock<IMedia>();
mediaMock.Setup(x => x.ContentType).Returns(contentTypeMock.Object);
mediaService.Setup(x => x.GetById(expectedEntityKey)).Returns(mediaMock.Object);
var memberMock = new Mock<IMember>();
memberMock.Setup(x => x.ContentType).Returns(contentTypeMock.Object);
memberService.Setup(x => x.GetById(expectedEntityKey)).Returns(memberMock.Object);
var actualkey = findsContent ? expectedEntityKey : Guid.NewGuid();
var value = $"[{{\"type\":\"document\",\"unique\":\"{actualkey}\"}}]";
var result = valueEditor.Validate(value, false, null, PropertyValidationContext.Empty());
TestShouldSucceed(shouldSucceed, result);
}
private static (MultiNodeTreePickerPropertyEditor.MultiNodeTreePickerPropertyValueEditor ValueEditor,
Mock<IEntityService> EntityService,
Mock<IContentService> ContentService,
Mock<IMediaService> MediaService,
Mock<IMemberService> MemberService) CreateValueEditor()
{
var entityServiceMock = new Mock<IEntityService>();
var contentServiceMock = new Mock<IContentService>();
var mediaServiceMock = new Mock<IMediaService>();
var memberServiceMock = new Mock<IMemberService>();
var mockScope = new Mock<ICoreScope>();
var mockScopeProvider = new Mock<ICoreScopeProvider>();
mockScopeProvider
.Setup(x => x.CreateCoreScope(
It.IsAny<IsolationLevel>(),
It.IsAny<RepositoryCacheMode>(),
It.IsAny<IEventDispatcher>(),
It.IsAny<IScopedNotificationPublisher>(),
It.IsAny<bool?>(),
It.IsAny<bool>(),
It.IsAny<bool>()))
.Returns(mockScope.Object);
var valueEditor = new MultiNodeTreePickerPropertyEditor.MultiNodeTreePickerPropertyValueEditor(
Mock.Of<IShortStringHelper>(),
new SystemTextJsonSerializer(),
Mock.Of<IIOHelper>(),
new DataEditorAttribute("alias"),
Mock.Of<ILocalizedTextService>(),
entityServiceMock.Object,
mockScopeProvider.Object,
contentServiceMock.Object,
mediaServiceMock.Object,
memberServiceMock.Object)
{
ConfigurationObject = new MultiNodePickerConfiguration(),
};
return (valueEditor, entityServiceMock, contentServiceMock, mediaServiceMock, memberServiceMock);
}
}